mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 17:30:31 +00:00
Remove protocol thread cwd from panel state and operations. Require the immutable vault root wherever path presentation needs a workspace base.
303 lines
12 KiB
TypeScript
303 lines
12 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import type { ThreadActivationSnapshot } from "../../../../../src/domain/threads/activation";
|
|
import { activeThreadId, activeThreadState } from "../../../../../src/features/chat/application/state/root-reducer";
|
|
import { createChatStateStore } from "../../../../../src/features/chat/application/state/store";
|
|
import { createEphemeralThreadLifecycle } from "../../../../../src/features/chat/application/threads/ephemeral-thread-lifecycle";
|
|
import type { EphemeralThreadTransport } from "../../../../../src/features/chat/application/threads/ephemeral-thread-transport";
|
|
import { deferred } from "../../../../support/async";
|
|
|
|
describe("ephemeral thread lifecycle", () => {
|
|
it("activates an ephemeral fork without adding it to the thread list", async () => {
|
|
const store = createChatStateStore();
|
|
store.dispatch({
|
|
type: "thread-list/applied",
|
|
threads: [
|
|
{
|
|
id: "source",
|
|
preview: "Source",
|
|
name: null,
|
|
archived: false,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
provenance: { kind: "interactive" },
|
|
},
|
|
],
|
|
});
|
|
const transport = transportMock();
|
|
const lifecycle = createEphemeralThreadLifecycle({
|
|
stateStore: store,
|
|
transport,
|
|
ensureConnected: vi.fn().mockResolvedValue(true),
|
|
addSystemMessage: vi.fn(),
|
|
notifyActiveThreadIdentityChanged: vi.fn(),
|
|
interruptTurn: vi.fn().mockResolvedValue(true),
|
|
});
|
|
|
|
await expect(lifecycle.open({ sourceThreadId: "source", sourceThreadTitle: "Source" })).resolves.toBe(true);
|
|
|
|
expect(transport.forkEphemeralThread).toHaveBeenCalledWith("source");
|
|
expect(activeThreadState(store.getState())).toMatchObject({
|
|
id: "side",
|
|
lifetime: { kind: "ephemeral", sourceThreadId: "source", sourceThreadTitle: "Source" },
|
|
});
|
|
expect(store.getState().threadList.listedThreads.map((thread) => thread.id)).toEqual(["source"]);
|
|
});
|
|
|
|
it("unsubscribes an idle ephemeral thread before persistent navigation", async () => {
|
|
const store = createChatStateStore();
|
|
const transport = transportMock();
|
|
const lifecycle = createEphemeralThreadLifecycle({
|
|
stateStore: store,
|
|
transport,
|
|
ensureConnected: vi.fn().mockResolvedValue(true),
|
|
addSystemMessage: vi.fn(),
|
|
notifyActiveThreadIdentityChanged: vi.fn(),
|
|
interruptTurn: vi.fn().mockResolvedValue(true),
|
|
});
|
|
await lifecycle.open({ sourceThreadId: "source", sourceThreadTitle: null });
|
|
|
|
await expect(lifecycle.prepareForPersistentNavigation()).resolves.toBe(true);
|
|
|
|
expect(transport.unsubscribeEphemeralThread).toHaveBeenCalledWith("side");
|
|
expect(activeThreadId(store.getState())).toBeNull();
|
|
});
|
|
|
|
it("does not clear a newer panel target after side-chat unsubscribe completes", async () => {
|
|
const store = createChatStateStore();
|
|
const unsubscribe = deferred<boolean>();
|
|
const transport = transportMock();
|
|
transport.unsubscribeEphemeralThread = vi.fn(() => unsubscribe.promise);
|
|
const notifyActiveThreadIdentityChanged = vi.fn();
|
|
const lifecycle = createEphemeralThreadLifecycle({
|
|
stateStore: store,
|
|
transport,
|
|
ensureConnected: vi.fn().mockResolvedValue(true),
|
|
addSystemMessage: vi.fn(),
|
|
notifyActiveThreadIdentityChanged,
|
|
interruptTurn: vi.fn().mockResolvedValue(true),
|
|
});
|
|
await lifecycle.open({ sourceThreadId: "source", sourceThreadTitle: null });
|
|
notifyActiveThreadIdentityChanged.mockClear();
|
|
|
|
const preparing = lifecycle.prepareForPersistentNavigation();
|
|
await vi.waitFor(() => expect(transport.unsubscribeEphemeralThread).toHaveBeenCalledWith("side"));
|
|
store.dispatch({
|
|
type: "active-thread/resumed",
|
|
approvalPolicyKnown: true,
|
|
sandboxPolicyKnown: true,
|
|
permissionProfileKnown: true,
|
|
approvalPolicy: null,
|
|
sandboxPolicy: null,
|
|
activePermissionProfile: null,
|
|
thread: { ...activationFixture().thread, id: "other" },
|
|
model: null,
|
|
reasoningEffort: null,
|
|
serviceTier: null,
|
|
approvalsReviewer: null,
|
|
});
|
|
unsubscribe.resolve(true);
|
|
|
|
await expect(preparing).resolves.toBe(false);
|
|
expect(activeThreadId(store.getState())).toBe("other");
|
|
expect(notifyActiveThreadIdentityChanged).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("interrupts a running side turn before close cleanup", async () => {
|
|
const store = createChatStateStore();
|
|
const transport = transportMock();
|
|
const interruptTurn = vi.fn().mockResolvedValue(true);
|
|
const lifecycle = createEphemeralThreadLifecycle({
|
|
stateStore: store,
|
|
transport,
|
|
ensureConnected: vi.fn().mockResolvedValue(true),
|
|
addSystemMessage: vi.fn(),
|
|
notifyActiveThreadIdentityChanged: vi.fn(),
|
|
interruptTurn,
|
|
});
|
|
await lifecycle.open({ sourceThreadId: "source", sourceThreadTitle: null });
|
|
store.dispatch({ type: "turn/started", threadId: "side", turnId: "turn" });
|
|
|
|
await lifecycle.dispose();
|
|
|
|
expect(interruptTurn).toHaveBeenCalledWith("side", "turn");
|
|
expect(transport.unsubscribeEphemeralThread).toHaveBeenCalledWith("side");
|
|
});
|
|
|
|
it("unsubscribes a fork that resolves after the lifecycle is disposed without activating it", async () => {
|
|
const store = createChatStateStore();
|
|
let resolveFork!: (value: Awaited<ReturnType<EphemeralThreadTransport["forkEphemeralThread"]>>) => void;
|
|
const transport = transportMock();
|
|
transport.forkEphemeralThread = vi.fn(
|
|
() =>
|
|
new Promise<Awaited<ReturnType<EphemeralThreadTransport["forkEphemeralThread"]>>>((resolve) => {
|
|
resolveFork = resolve;
|
|
}),
|
|
);
|
|
const lifecycle = createEphemeralThreadLifecycle({
|
|
stateStore: store,
|
|
transport,
|
|
ensureConnected: vi.fn().mockResolvedValue(true),
|
|
addSystemMessage: vi.fn(),
|
|
notifyActiveThreadIdentityChanged: vi.fn(),
|
|
interruptTurn: vi.fn().mockResolvedValue(true),
|
|
});
|
|
|
|
const opening = lifecycle.open({ sourceThreadId: "source", sourceThreadTitle: "Source" });
|
|
await Promise.resolve();
|
|
await lifecycle.dispose();
|
|
resolveFork({
|
|
kind: "completed-current",
|
|
value: { kind: "ready", sourceThreadId: "source", activation: activationFixture() },
|
|
});
|
|
|
|
await expect(opening).resolves.toBe(false);
|
|
expect(transport.unsubscribeEphemeralThread).toHaveBeenCalledWith("side");
|
|
expect(activeThreadId(store.getState())).toBeNull();
|
|
});
|
|
|
|
it("unsubscribes a cleanup-required fork that resolves after disposal", async () => {
|
|
const store = createChatStateStore();
|
|
const fork = deferred<Awaited<ReturnType<EphemeralThreadTransport["forkEphemeralThread"]>>>();
|
|
const transport = transportMock();
|
|
transport.forkEphemeralThread = vi.fn(() => fork.promise);
|
|
const lifecycle = createEphemeralThreadLifecycle({
|
|
stateStore: store,
|
|
transport,
|
|
ensureConnected: vi.fn().mockResolvedValue(true),
|
|
addSystemMessage: vi.fn(),
|
|
notifyActiveThreadIdentityChanged: vi.fn(),
|
|
interruptTurn: vi.fn().mockResolvedValue(true),
|
|
});
|
|
|
|
const opening = lifecycle.open({ sourceThreadId: "source", sourceThreadTitle: null });
|
|
await Promise.resolve();
|
|
await lifecycle.dispose();
|
|
fork.resolve({ kind: "completed-current", value: { kind: "cleanup-required", threadId: "side" } });
|
|
|
|
await expect(opening).resolves.toBe(false);
|
|
expect(transport.unsubscribeEphemeralThread).toHaveBeenCalledWith("side");
|
|
});
|
|
|
|
it("still unsubscribes the side chat when interrupting its running turn fails", async () => {
|
|
const store = createChatStateStore();
|
|
const transport = transportMock();
|
|
const lifecycle = createEphemeralThreadLifecycle({
|
|
stateStore: store,
|
|
transport,
|
|
ensureConnected: vi.fn().mockResolvedValue(true),
|
|
addSystemMessage: vi.fn(),
|
|
notifyActiveThreadIdentityChanged: vi.fn(),
|
|
interruptTurn: vi.fn().mockRejectedValue(new Error("interrupt failed")),
|
|
});
|
|
await lifecycle.open({ sourceThreadId: "source", sourceThreadTitle: null });
|
|
store.dispatch({ type: "turn/started", threadId: "side", turnId: "turn" });
|
|
|
|
await lifecycle.dispose();
|
|
|
|
expect(transport.unsubscribeEphemeralThread).toHaveBeenCalledWith("side");
|
|
});
|
|
|
|
it("continues close cleanup when interrupting a running side turn does not settle", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const store = createChatStateStore();
|
|
const transport = transportMock();
|
|
const lifecycle = createEphemeralThreadLifecycle({
|
|
stateStore: store,
|
|
transport,
|
|
ensureConnected: vi.fn().mockResolvedValue(true),
|
|
addSystemMessage: vi.fn(),
|
|
notifyActiveThreadIdentityChanged: vi.fn(),
|
|
interruptTurn: vi.fn(() => new Promise<boolean>(() => undefined)),
|
|
});
|
|
await lifecycle.open({ sourceThreadId: "source", sourceThreadTitle: null });
|
|
store.dispatch({ type: "turn/started", threadId: "side", turnId: "turn" });
|
|
|
|
const disposal = lifecycle.dispose();
|
|
await vi.advanceTimersByTimeAsync(1_000);
|
|
await disposal;
|
|
|
|
expect(transport.unsubscribeEphemeralThread).toHaveBeenCalledWith("side");
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("keeps the side chat active when unsubscribe fails before navigation", async () => {
|
|
const store = createChatStateStore();
|
|
const transport = transportMock();
|
|
transport.unsubscribeEphemeralThread = vi.fn().mockResolvedValue(false);
|
|
const addSystemMessage = vi.fn();
|
|
const lifecycle = createEphemeralThreadLifecycle({
|
|
stateStore: store,
|
|
transport,
|
|
ensureConnected: vi.fn().mockResolvedValue(true),
|
|
addSystemMessage,
|
|
notifyActiveThreadIdentityChanged: vi.fn(),
|
|
interruptTurn: vi.fn().mockResolvedValue(true),
|
|
});
|
|
await lifecycle.open({ sourceThreadId: "source", sourceThreadTitle: null });
|
|
|
|
await expect(lifecycle.prepareForPersistentNavigation()).resolves.toBe(false);
|
|
|
|
expect(activeThreadId(store.getState())).toBe("side");
|
|
expect(addSystemMessage).toHaveBeenCalledWith("Could not discard the side chat. Try again before switching threads.");
|
|
});
|
|
|
|
it("retries cleanup-required forks when the side view is disposed", async () => {
|
|
const store = createChatStateStore();
|
|
const transport = transportMock();
|
|
transport.forkEphemeralThread = vi
|
|
.fn()
|
|
.mockResolvedValue({ kind: "completed-current", value: { kind: "cleanup-required", threadId: "side" } });
|
|
const addSystemMessage = vi.fn();
|
|
const lifecycle = createEphemeralThreadLifecycle({
|
|
stateStore: store,
|
|
transport,
|
|
ensureConnected: vi.fn().mockResolvedValue(true),
|
|
addSystemMessage,
|
|
notifyActiveThreadIdentityChanged: vi.fn(),
|
|
interruptTurn: vi.fn().mockResolvedValue(true),
|
|
});
|
|
|
|
await expect(lifecycle.open({ sourceThreadId: "source", sourceThreadTitle: null })).resolves.toBe(false);
|
|
await lifecycle.dispose();
|
|
|
|
expect(addSystemMessage).toHaveBeenCalledWith("Could not prepare the side chat. Cleanup will be retried when this view closes.");
|
|
expect(transport.unsubscribeEphemeralThread).toHaveBeenCalledWith("side");
|
|
});
|
|
});
|
|
|
|
function transportMock(): EphemeralThreadTransport {
|
|
return {
|
|
forkEphemeralThread: vi.fn().mockResolvedValue({
|
|
kind: "completed-current",
|
|
value: { kind: "ready", sourceThreadId: "source", activation: activationFixture() },
|
|
}),
|
|
unsubscribeEphemeralThread: vi.fn().mockResolvedValue(true),
|
|
};
|
|
}
|
|
|
|
function activationFixture(): ThreadActivationSnapshot {
|
|
return {
|
|
thread: {
|
|
id: "side",
|
|
preview: "",
|
|
name: null,
|
|
archived: false,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
provenance: { kind: "interactive" },
|
|
},
|
|
model: "gpt-5.5",
|
|
serviceTier: null,
|
|
approvalsReviewer: null,
|
|
reasoningEffort: null,
|
|
approvalPolicyKnown: true,
|
|
sandboxPolicyKnown: true,
|
|
permissionProfileKnown: true,
|
|
approvalPolicy: "never",
|
|
sandboxPolicy: { type: "readOnly", networkAccess: false },
|
|
activePermissionProfile: null,
|
|
};
|
|
}
|