murashit_codex-panel/tests/features/chat/host/view-thread-state.test.ts

345 lines
13 KiB
TypeScript

// @vitest-environment jsdom
import { describe, expect, it, vi } from "vitest";
import type { ServerNotification } from "../../../../src/app-server/connection/rpc-messages";
import { deferred, waitForAsyncWork } from "../../../support/async";
import {
chatHost,
chatView,
completedTurn,
composerElement,
composerPlaceholder,
connectedClient,
connectionMockState,
expectRequestTimes,
panelThread,
requestMethods,
resumedThread,
setupViewConnectionHarness,
turnWithUserMessage,
} from "./view-connection-harness";
describe("CodexChatView thread state", () => {
setupViewConnectionHarness();
it("requests a workspace layout save after resuming a thread", async () => {
const requestSaveLayout = vi.fn();
const client = connectedClient();
connectionMockState().client = client;
const view = await chatView({ requestSaveLayout });
await view.surface.openThread("thread-1");
expect(view.getState()).toEqual({ version: 1, threadId: "thread-1", threadTitle: "Restored thread" });
expect(requestSaveLayout).toHaveBeenCalledTimes(1);
});
it("resumes another persistent thread before unsubscribing a running subagent", async () => {
const client = connectedClient({
"thread/resume": vi.fn((params: unknown) => {
const threadId = (params as { threadId: string }).threadId;
return Promise.resolve(
threadId === "child"
? resumedThread(threadId, { parentThreadId: "parent", threadSource: "subAgentThreadSpawn" })
: resumedThread(threadId),
);
}),
"thread/unsubscribe": vi.fn().mockResolvedValue({ status: "unsubscribed" }),
});
connectionMockState().client = client;
const view = await chatView();
await view.surface.openThread("child");
connectionMockState().onNotification?.({
method: "turn/started",
params: {
threadId: "child",
turn: {
id: "turn-child",
status: "inProgress",
startedAt: 1,
completedAt: null,
durationMs: null,
error: null,
itemsView: "full",
items: [],
},
},
} satisfies Extract<ServerNotification, { method: "turn/started" }>);
await view.surface.openThread("other");
const unsubscribeCall = client.request.mock.calls.findIndex(([method]) => method === "thread/unsubscribe");
const otherResumeCall = client.request.mock.calls.findIndex(
([method, params]) => method === "thread/resume" && (params as { threadId: string }).threadId === "other",
);
expect(unsubscribeCall).toBeGreaterThanOrEqual(0);
expect(unsubscribeCall).toBeGreaterThan(otherResumeCall);
expect(client.request).not.toHaveBeenCalledWith("turn/interrupt", expect.anything());
expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: "other" });
});
it("keeps a running subagent subscribed when openThread cannot resume the target", async () => {
const client = connectedClient({
"thread/resume": vi.fn((params: unknown) => {
const threadId = (params as { threadId: string }).threadId;
return Promise.resolve(
threadId === "child" ? resumedThread(threadId, { parentThreadId: "parent", threadSource: "subAgentThreadSpawn" }) : null,
);
}),
"thread/unsubscribe": vi.fn().mockResolvedValue({ status: "unsubscribed" }),
});
connectionMockState().client = client;
const view = await chatView();
await view.surface.openThread("child");
connectionMockState().onNotification?.({
method: "turn/started",
params: {
threadId: "child",
turn: {
id: "turn-child",
status: "inProgress",
startedAt: 1,
completedAt: null,
durationMs: null,
error: null,
itemsView: "full",
items: [],
},
},
} satisfies Extract<ServerNotification, { method: "turn/started" }>);
await view.surface.openThread("other");
expect(requestMethods(client).filter((method) => method === "thread/unsubscribe")).toEqual([]);
expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: "child", turnBusy: true });
});
it("resets to an unstarted empty chat without starting a thread", async () => {
const requestSaveLayout = vi.fn();
const client = connectedClient();
connectionMockState().client = client;
const view = await chatView({ requestSaveLayout });
await view.surface.openThread("thread-1");
await view.surface.startNewThread();
expect(requestMethods(client)).not.toContain("thread/start");
expect(view.getState()).toEqual({ version: 1 });
expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: null, turnBusy: false, hasComposerDraft: false });
expect(requestSaveLayout).toHaveBeenCalledTimes(2);
});
it("restores an unavailable side-chat tab as a normal empty chat", async () => {
const view = await chatView();
await view.setState({ version: 2, ephemeralSource: { threadId: "source", title: "Source" } }, {} as never);
expect(view.getState()).toEqual({ version: 1 });
expect(view.getDisplayText()).not.toBe("Side chat");
expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: null, turnBusy: false, hasComposerDraft: false });
expect(view.containerEl.textContent).not.toContain("This side conversation is no longer available.");
});
it("focuses the composer after panel thread actions", async () => {
const client = connectedClient();
connectionMockState().client = client;
const view = await chatView();
await view.onOpen();
const focus = vi.spyOn(HTMLTextAreaElement.prototype, "focus").mockImplementation(() => undefined);
await view.surface.openThread("thread-1");
await view.surface.focusThread("thread-1");
await view.surface.startNewThread();
expect(focus).toHaveBeenCalledTimes(3);
expect(focus).toHaveBeenCalledWith({ preventScroll: true });
});
it("clears the active thread when another view archives it", async () => {
const requestSaveLayout = vi.fn();
const client = connectedClient();
connectionMockState().client = client;
const view = await chatView({ requestSaveLayout });
await view.surface.openThread("thread-1");
view.surface.applyThreadArchived("thread-1");
expect(view.getState()).toEqual({ version: 1 });
expect(requestSaveLayout).toHaveBeenCalledTimes(2);
});
it("updates restored panel title from shared rename notifications", async () => {
const view = await chatView();
await view.setState({ threadId: "thread-1", threadTitle: "Before rename" }, {} as never);
view.surface.applyThreadRenamed("thread-1", "After rename");
expect(view.getDisplayText()).toBe("Codex: After rename");
expect(view.getState()).toEqual({ version: 1, threadId: "thread-1", threadTitle: "After rename" });
});
it("does not use restored thread identity as a composer name before the thread becomes active", async () => {
const host = chatHost();
const view = await chatView({ host });
await view.setState({ threadId: "thread-1", threadTitle: "Restored title" }, {} as never);
await view.onOpen();
expect(composerPlaceholder(view)).toBe("Ask Codex...");
host.receiveActiveThreads([panelThread({ id: "thread-1", name: "Explicit name" })]);
await waitForAsyncWork(() => {
expect(composerPlaceholder(view)).toBe("Ask Codex...");
});
view.surface.applyThreadRenamed("thread-1", "Explicit name");
await waitForAsyncWork(() => {
expect(composerPlaceholder(view)).toBe("Ask Codex...");
});
});
it("keeps composer draft and selection while updating the placeholder", async () => {
const client = connectedClient();
connectionMockState().client = client;
const host = chatHost();
const view = await chatView({ host });
await view.onOpen();
await view.surface.openThread("thread-1");
view.surface.setComposerText("keep this draft");
const composer = composerElement(view);
await waitForAsyncWork(() => {
expect(composer.value).toBe("keep this draft");
});
composer.setSelectionRange(5, 9);
host.receiveActiveThreads([panelThread({ id: "thread-1", name: "Renamed thread" })]);
view.surface.applyThreadRenamed("thread-1", "Renamed thread");
await waitForAsyncWork(() => {
expect(composer.value).toBe("keep this draft");
expect(composer.selectionStart).toBe(5);
expect(composer.selectionEnd).toBe(9);
expect(composer.getAttribute("placeholder")).toBe("Ask Codex in “Renamed thread”...");
});
});
it("renders resumed thread metadata before history hydration completes", async () => {
const history = deferred<{ data: unknown[]; nextCursor: null }>();
const client = connectedClient({
"thread/turns/list": vi.fn(() => history.promise),
});
connectionMockState().client = client;
const view = await chatView();
const opening = view.surface.openThread("thread-1");
await waitForAsyncWork(() => {
expect(client.request).toHaveBeenCalledWith(
"thread/turns/list",
expect.objectContaining({ threadId: "thread-1", cursor: null, limit: 20 }),
);
});
expect(view.getState()).toEqual({ version: 1, threadId: "thread-1", threadTitle: "Restored thread" });
expect(view.containerEl.textContent).not.toContain("Loading thread...");
history.resolve({ data: [], nextCursor: null });
await opening;
});
it("hydrates resumed threads from the initial turns page without a second history request", async () => {
const client = connectedClient({
"thread/resume": vi.fn().mockResolvedValue({
...resumedThread("thread-1"),
initialTurnsPage: {
data: [completedTurn("turn-1")],
nextCursor: "older-cursor",
backwardsCursor: null,
},
}),
"thread/turns/list": vi.fn().mockResolvedValue({ data: [turnWithUserMessage("fallback prompt")], nextCursor: null }),
});
connectionMockState().client = client;
const view = await chatView();
await view.onOpen();
await view.surface.openThread("thread-1");
expect(client.request).toHaveBeenCalledWith("thread/resume", expect.objectContaining({ threadId: "thread-1", cwd: "/vault" }));
expect(requestMethods(client)).not.toContain("thread/turns/list");
await waitForAsyncWork(() => {
expect(view.containerEl.textContent).toContain("hello");
expect(view.containerEl.textContent).toContain("done");
});
});
it("ignores stale resume results when another thread is opened first", async () => {
const firstResume = deferred<ReturnType<typeof resumedThread>>();
const secondResume = deferred<ReturnType<typeof resumedThread>>();
const client = connectedClient({
"thread/resume": vi.fn((params: unknown) =>
(params as { threadId: string }).threadId === "thread-1" ? firstResume.promise : secondResume.promise,
),
});
connectionMockState().client = client;
const view = await chatView();
const firstOpen = view.surface.openThread("thread-1");
await waitForAsyncWork(() => {
expect(client.request).toHaveBeenCalledWith("thread/resume", expect.objectContaining({ threadId: "thread-1", cwd: "/vault" }));
});
const secondOpen = view.surface.openThread("thread-2");
await waitForAsyncWork(() => {
expect(client.request).toHaveBeenCalledWith("thread/resume", expect.objectContaining({ threadId: "thread-2", cwd: "/vault" }));
});
secondResume.resolve(resumedThread("thread-2"));
await secondOpen;
firstResume.resolve(resumedThread("thread-1"));
await firstOpen;
expect(view.getState()).toEqual({ version: 1, threadId: "thread-2", threadTitle: "Restored thread" });
expectRequestTimes(client, "thread/turns/list", 1);
expect(client.request).toHaveBeenCalledWith(
"thread/turns/list",
expect.objectContaining({ threadId: "thread-2", cursor: null, limit: 20 }),
);
});
it("invalidates stale history hydration when a second resume starts", async () => {
const firstHistory = deferred<{ data: unknown[]; nextCursor: null }>();
const client = connectedClient({
"thread/resume": vi.fn((params: unknown) => Promise.resolve(resumedThread((params as { threadId: string }).threadId))),
"thread/turns/list": vi.fn((params: unknown) =>
(params as { threadId: string }).threadId === "thread-1" ? firstHistory.promise : Promise.resolve({ data: [], nextCursor: null }),
),
});
connectionMockState().client = client;
const view = await chatView();
const firstOpen = view.surface.openThread("thread-1");
await waitForAsyncWork(() => {
expect(client.request).toHaveBeenCalledWith(
"thread/turns/list",
expect.objectContaining({ threadId: "thread-1", cursor: null, limit: 20 }),
);
});
const secondOpen = view.surface.openThread("thread-2");
await waitForAsyncWork(() => {
expect(client.request).toHaveBeenCalledWith(
"thread/turns/list",
expect.objectContaining({ threadId: "thread-2", cursor: null, limit: 20 }),
);
});
firstHistory.resolve({ data: [turnWithUserMessage("first prompt")], nextCursor: null });
await firstOpen;
await secondOpen;
expect(view.getState()).toEqual({ version: 1, threadId: "thread-2", threadTitle: "Restored thread" });
expect(view.containerEl.textContent).not.toContain("first prompt");
});
});