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

482 lines
20 KiB
TypeScript

// @vitest-environment jsdom
import { describe, expect, it, vi } from "vitest";
import type { ServerNotification } from "../../../../src/app-server/connection/rpc-messages";
import { createServerDiagnostics } from "../../../../src/domain/server/diagnostics";
import type { ChatPanelSession } from "../../../../src/features/chat/host/session";
import { deferred, waitForAsyncWork } from "../../../support/async";
import { runtimeConfigFixture } from "../../../support/runtime-config";
import {
chatHost,
chatView,
composerElement,
connectedClient,
connectionMockState,
expectRequestTimes,
panelThread,
requestMethods,
requiredButton,
resumedThread,
setupViewConnectionHarness,
submitComposerByEnter,
threadFixture,
} from "./view-connection-harness";
describe("CodexChatView workspace restoration", () => {
setupViewConnectionHarness();
it("restores workspace thread state without hydrating it automatically", async () => {
vi.useFakeTimers();
const client = connectedClient({
"thread/resume": vi.fn().mockResolvedValue(resumedThread("thread-1")),
});
connectionMockState().client = client;
const view = await chatView();
await view.setState({ threadId: "thread-1", threadTitle: "Restored thread" }, {} as never);
await view.onOpen();
expect(view.getDisplayText()).toBe("Codex: Restored thread");
expect(view.getState()).toEqual({ version: 1, threadId: "thread-1", threadTitle: "Restored thread" });
expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: "thread-1" });
expect(connectionMockState().connectCalls).toBe(0);
expect(requestMethods(client)).not.toContain("thread/resume");
await vi.advanceTimersByTimeAsync(0);
expect(connectionMockState().connectCalls).toBe(1);
expectRequestTimes(client, "config/read", 1);
expectRequestTimes(client, "thread/list", 1);
expect(requestMethods(client)).not.toContain("thread/resume");
expect(requestMethods(client)).not.toContain("thread/resume");
expect(requestMethods(client)).not.toContain("thread/turns/list");
});
it("keeps workspace state readable while detached from an execution runtime", async () => {
const view = await chatView();
await view.setState({ threadId: "thread-1", threadTitle: "Restored thread" }, {} as never);
view.detachRuntime();
expect(view.getState()).toEqual({ version: 1, threadId: "thread-1", threadTitle: "Restored thread" });
expect(view.getDisplayText()).toBe("Restored thread");
expect(view.isRuntimeAttached()).toBe(false);
expect(() => view.surface).toThrow("not attached");
});
it("closes the session even when runtime snapshot capture fails", async () => {
const view = await chatView();
const session = view.surface;
const close = vi.spyOn(session, "close").mockResolvedValue(undefined);
vi.spyOn(session as ChatPanelSession, "runtimeSnapshot").mockImplementation(() => {
throw new Error("snapshot failed");
});
expect(() => view.detachRuntime()).toThrow("snapshot failed");
expect(close).toHaveBeenCalledOnce();
expect(view.isRuntimeAttached()).toBe(false);
});
it("formats the panel title from listed thread metadata", async () => {
const host = chatHost();
const view = await chatView({ host });
await view.setState({ threadId: "thread-named" }, {} as never);
await view.onOpen();
host.receiveActiveThreads([panelThread({ id: "thread-named", name: "作業メモ" })]);
expect(view.getDisplayText()).toBe("Codex: 作業メモ");
host.receiveActiveThreads([panelThread({ id: "thread-named", name: null, preview: "初回依頼" })]);
expect(view.getDisplayText()).toBe("Codex: 初回依頼");
await view.setState({ threadId: "019e061e-0000-7000-8000-000000000001" }, {} as never);
host.receiveActiveThreads([]);
expect(view.getDisplayText()).toBe("Codex: 019e061e");
});
it("keeps late workspace thread state restored until explicit focus", async () => {
vi.useFakeTimers();
const client = connectedClient();
connectionMockState().client = client;
const view = await chatView();
await view.onOpen();
await vi.advanceTimersByTimeAsync(0);
expect(connectionMockState().connectCalls).toBe(1);
expectRequestTimes(client, "config/read", 1);
expectRequestTimes(client, "thread/list", 1);
expect(requestMethods(client)).not.toContain("thread/resume");
await view.setState({ threadId: "thread-1", threadTitle: "Restored thread" }, {} as never);
expect(view.getDisplayText()).toBe("Codex: Restored thread");
expect(view.getState()).toEqual({ version: 1, threadId: "thread-1", threadTitle: "Restored thread" });
expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: "thread-1" });
expect(requestMethods(client)).not.toContain("thread/resume");
expect(requestMethods(client)).not.toContain("thread/turns/list");
await view.surface.focusThread("thread-1");
expect(client.request).toHaveBeenCalledWith("thread/resume", expect.objectContaining({ threadId: "thread-1", cwd: "/vault" }));
expect(client.request).toHaveBeenCalledWith(
"thread/turns/list",
expect.objectContaining({ threadId: "thread-1", cursor: null, limit: 20 }),
);
});
it("replaces active thread-scoped state when late workspace state restores another thread", async () => {
const client = connectedClient();
connectionMockState().client = client;
const view = await chatView();
await view.onOpen();
await view.surface.openThread("thread-1");
view.surface.setComposerText("stale draft");
await view.setState({ threadId: "thread-2", threadTitle: "Restored thread 2" }, {} as never);
expect(view.getDisplayText()).toBe("Codex: Restored thread 2");
expect(view.getState()).toEqual({ version: 1, threadId: "thread-2", threadTitle: "Restored thread 2" });
expect(view.surface.openPanelSnapshot()).toMatchObject({
threadId: "thread-2",
turnBusy: false,
hasComposerDraft: false,
});
expect(composerElement(view).value).toBe("");
});
it("warms app-server metadata for an empty restored panel after the shell is open", async () => {
vi.useFakeTimers();
const client = connectedClient({
"thread/list": vi.fn().mockResolvedValue({ data: [threadFixture("thread-1")], nextCursor: null }),
});
const fetchModels = vi.fn().mockResolvedValue([]);
connectionMockState().client = client;
const view = await chatView({ host: chatHost({ fetchModels }) });
await view.onOpen();
expect(connectionMockState().connectCalls).toBe(0);
await vi.advanceTimersByTimeAsync(0);
expect(connectionMockState().connectCalls).toBe(1);
expectRequestTimes(client, "config/read", 1);
expect(fetchModels).toHaveBeenCalledOnce();
expectRequestTimes(client, "skills/list", 1);
expectRequestTimes(client, "permissionProfile/list", 1);
expectRequestTimes(client, "account/rateLimits/read", 1);
expect(client.request).toHaveBeenCalledWith("thread/list", {
cwd: "/vault",
archived: false,
sortKey: "recency_at",
sortDirection: "desc",
});
requiredButton(view.containerEl, '[aria-label="Show thread list"]').click();
await waitForAsyncWork(() => {
expect(view.containerEl.textContent).toContain("Restored thread");
});
});
it("applies cached shared thread list and metadata when opened", async () => {
const cachedThread = threadFixture("thread-cached");
const view = await chatView({
host: chatHost({
activeThreadsSnapshot: vi.fn(() => [cachedThread] as never[]),
appServerMetadataSnapshot: vi.fn(
() =>
({
runtimeConfig: { ...runtimeConfigFixture(), model: "gpt-cached" },
availableSkills: [{ name: "writer", enabled: true }],
availablePermissionProfiles: [],
rateLimit: null,
serverDiagnostics: createServerDiagnostics(),
}) as never,
),
}),
});
await view.onOpen();
requiredButton(view.containerEl, '[aria-label="Show thread list"]').click();
await waitForAsyncWork(() => {
expect(view.containerEl.textContent).toContain("Restored thread");
});
requiredButton(view.containerEl, '[aria-label="Show status"]').click();
await waitForAsyncWork(() => {
expect(view.containerEl.textContent).toContain("gpt-cached");
});
});
it("hydrates a focused restored thread immediately", async () => {
vi.useFakeTimers();
const client = connectedClient();
connectionMockState().client = client;
const view = await chatView();
await view.setState({ threadId: "thread-1", threadTitle: "Restored thread" }, {} as never);
await view.onOpen();
expect(requestMethods(client)).not.toContain("thread/resume");
await view.surface.focusThread("thread-1");
expect(client.request).toHaveBeenCalledWith("thread/resume", expect.objectContaining({ threadId: "thread-1", cwd: "/vault" }));
expect(client.request).toHaveBeenCalledWith(
"thread/turns/list",
expect.objectContaining({ threadId: "thread-1", cursor: null, limit: 20 }),
);
});
it("starts fresh hydration when the same restored view state is reapplied", async () => {
const resume = deferred<ReturnType<typeof resumedThread>>();
const client = connectedClient({
"thread/resume": vi.fn(() => resume.promise),
});
connectionMockState().client = client;
const view = await chatView();
await view.setState({ threadId: "thread-1", threadTitle: "Restored thread" }, {} as never);
await view.onOpen();
const firstHydration = view.surface.focusThread("thread-1");
await waitForAsyncWork(() => {
expectRequestTimes(client, "thread/resume", 1);
});
await view.setState({ threadId: "thread-1", threadTitle: "Restored thread" }, {} as never);
const secondHydration = view.surface.focusThread("thread-1");
await waitForAsyncWork(() => {
expectRequestTimes(client, "thread/resume", 2);
});
resume.resolve(resumedThread("thread-1"));
await Promise.all([firstHydration, secondHydration]);
expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: "thread-1" });
});
it("resumes a restored thread before sending the first message", async () => {
const client = connectedClient();
connectionMockState().client = client;
const view = await chatView();
await view.setState({ threadId: "thread-1", threadTitle: "Restored thread" }, {} as never);
await view.onOpen();
view.surface.setComposerText("hello");
await submitComposerByEnter(view);
await waitForAsyncWork(() => {
expect(client.request).toHaveBeenCalledWith("thread/resume", expect.objectContaining({ threadId: "thread-1", cwd: "/vault" }));
expect(client.request).toHaveBeenCalledWith("turn/start", {
threadId: "thread-1",
cwd: "/vault",
input: [{ type: "text", text: "hello", text_elements: [] }],
clientUserMessageId: expect.stringMatching(/^local-user-\d+-[A-Za-z0-9_-]+-[a-z0-9]+$/),
});
});
expect(view.surface.openPanelSnapshot()).toMatchObject({ turnBusy: true });
expect(view.getState()).toEqual({ version: 1, threadId: "thread-1", threadTitle: "Restored thread" });
connectionMockState().onNotification?.({
method: "turn/started",
params: {
threadId: "thread-1",
turn: {
id: "turn-1",
status: "inProgress",
startedAt: 1,
completedAt: null,
durationMs: null,
error: null,
itemsView: "full",
items: [],
},
},
} satisfies Extract<ServerNotification, { method: "turn/started" }>);
expect(view.surface.openPanelSnapshot()).toMatchObject({ turnBusy: true });
});
it("notifies Threads only when panel activity changes", async () => {
const notifyPanelActivityChanged = vi.fn();
const client = connectedClient();
connectionMockState().client = client;
const view = await chatView({ host: chatHost({ notifyPanelActivityChanged }) });
await view.onOpen();
expect(notifyPanelActivityChanged).toHaveBeenCalledOnce();
await view.surface.openThread("thread-1");
notifyPanelActivityChanged.mockClear();
view.surface.setComposerText("hello");
expect(notifyPanelActivityChanged).not.toHaveBeenCalled();
await submitComposerByEnter(view);
expect(notifyPanelActivityChanged).toHaveBeenCalledOnce();
expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: "thread-1", turnBusy: true, pending: false });
notifyPanelActivityChanged.mockClear();
connectionMockState().onNotification?.({
method: "turn/started",
params: {
threadId: "thread-1",
turn: {
id: "turn-1",
status: "inProgress",
startedAt: 1,
completedAt: null,
durationMs: null,
error: null,
itemsView: "full",
items: [],
},
},
} satisfies Extract<ServerNotification, { method: "turn/started" }>);
expect(notifyPanelActivityChanged).not.toHaveBeenCalled();
connectionMockState().onServerRequest?.(
{
id: 7,
method: "item/tool/requestUserInput",
params: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "input-1",
questions: [{ id: "note", header: "Note", question: "What now?", isOther: false, isSecret: false, options: null }],
autoResolutionMs: null,
},
},
{ respond: vi.fn(), reject: vi.fn() },
);
expect(notifyPanelActivityChanged).toHaveBeenCalledOnce();
expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: "thread-1", turnBusy: true, pending: true });
notifyPanelActivityChanged.mockClear();
connectionMockState().onNotification?.({
method: "turn/completed",
params: {
threadId: "thread-1",
turn: {
id: "turn-1",
status: "completed",
startedAt: 1,
completedAt: 2,
durationMs: 1,
error: null,
itemsView: "full",
items: [],
},
},
} satisfies Extract<ServerNotification, { method: "turn/completed" }>);
expect(notifyPanelActivityChanged).toHaveBeenCalledOnce();
expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: "thread-1", turnBusy: false, pending: true });
});
it("publishes a replacement panel activity only after its operation finishes", async () => {
const notifyPanelActivityChanged = vi.fn();
connectionMockState().client = connectedClient({
"thread/resume": vi.fn((params?: unknown) => Promise.resolve(resumedThread((params as { threadId: string }).threadId))),
});
const view = await chatView({ host: chatHost({ notifyPanelActivityChanged }) });
await view.onOpen();
await view.surface.openThread("source");
notifyPanelActivityChanged.mockClear();
const publication = (view.surface as ChatPanelSession).beginPanelActivityPublication("replacement");
await view.surface.openThread("replacement");
expect(view.surface.openPanelSnapshot()).toMatchObject({
threadId: "replacement",
publishedActivity: { threadId: "source" },
});
expect(notifyPanelActivityChanged).not.toHaveBeenCalled();
const commit = vi.fn();
publication.publish(commit);
expect(view.surface.openPanelSnapshot()).toMatchObject({ publishedActivity: { threadId: "replacement" } });
expect(commit).toHaveBeenCalledOnce();
expect(notifyPanelActivityChanged).toHaveBeenCalledOnce();
});
it("holds overlapping activity commits until the panel has no remaining publication", async () => {
const notifyPanelActivityChanged = vi.fn();
connectionMockState().client = connectedClient({
"thread/resume": vi.fn((params?: unknown) => Promise.resolve(resumedThread((params as { threadId: string }).threadId))),
});
const view = await chatView({ host: chatHost({ notifyPanelActivityChanged }) });
await view.onOpen();
await view.surface.openThread("source");
notifyPanelActivityChanged.mockClear();
const session = view.surface as ChatPanelSession;
const first = session.beginPanelActivityPublication("replacement");
const second = session.beginPanelActivityPublication("replacement");
await view.surface.openThread("replacement");
const firstCommit = vi.fn();
const secondCommit = vi.fn();
second.publish(secondCommit);
expect(firstCommit).not.toHaveBeenCalled();
expect(secondCommit).not.toHaveBeenCalled();
expect(view.surface.openPanelSnapshot()).toMatchObject({ publishedActivity: { threadId: "source" } });
first.publish(firstCommit);
expect(firstCommit).toHaveBeenCalledOnce();
expect(secondCommit).toHaveBeenCalledOnce();
expect(firstCommit.mock.invocationCallOrder[0]).toBeLessThan(secondCommit.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY);
expect(view.surface.openPanelSnapshot()).toMatchObject({ publishedActivity: { threadId: "replacement" } });
expect(notifyPanelActivityChanged).toHaveBeenCalledOnce();
});
it("publishes a newer unrelated navigation without releasing the held list commit", async () => {
const notifyPanelActivityChanged = vi.fn();
connectionMockState().client = connectedClient({
"thread/resume": vi.fn((params?: unknown) => Promise.resolve(resumedThread((params as { threadId: string }).threadId))),
});
const view = await chatView({ host: chatHost({ notifyPanelActivityChanged }) });
await view.onOpen();
await view.surface.openThread("source");
notifyPanelActivityChanged.mockClear();
const publication = (view.surface as ChatPanelSession).beginPanelActivityPublication("replacement");
await view.surface.openThread("replacement");
await view.surface.openThread("newer");
expect(view.surface.openPanelSnapshot()).toMatchObject({
threadId: "newer",
publishedActivity: { threadId: "newer" },
});
expect(notifyPanelActivityChanged).toHaveBeenCalledOnce();
const commit = vi.fn();
publication.publish(commit);
expect(commit).toHaveBeenCalledOnce();
expect(notifyPanelActivityChanged).toHaveBeenCalledOnce();
});
it("keeps a later replacement held while an earlier superseded commit is unfinished", async () => {
const notifyPanelActivityChanged = vi.fn();
connectionMockState().client = connectedClient({
"thread/resume": vi.fn((params?: unknown) => Promise.resolve(resumedThread((params as { threadId: string }).threadId))),
});
const view = await chatView({ host: chatHost({ notifyPanelActivityChanged }) });
await view.onOpen();
await view.surface.openThread("source");
const session = view.surface as ChatPanelSession;
const first = session.beginPanelActivityPublication("first-replacement");
await view.surface.openThread("first-replacement");
await view.surface.openThread("unrelated");
notifyPanelActivityChanged.mockClear();
const second = session.beginPanelActivityPublication("second-replacement");
await view.surface.openThread("second-replacement");
const firstCommit = vi.fn();
const secondCommit = vi.fn();
second.publish(secondCommit);
expect(secondCommit).not.toHaveBeenCalled();
expect(view.surface.openPanelSnapshot()).toMatchObject({ publishedActivity: { threadId: "unrelated" } });
expect(notifyPanelActivityChanged).not.toHaveBeenCalled();
first.publish(firstCommit);
expect(firstCommit.mock.invocationCallOrder[0]).toBeLessThan(secondCommit.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY);
expect(view.surface.openPanelSnapshot()).toMatchObject({ publishedActivity: { threadId: "second-replacement" } });
expect(notifyPanelActivityChanged).toHaveBeenCalledOnce();
});
});