mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
1565 lines
59 KiB
TypeScript
1565 lines
59 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { DEFAULT_SETTINGS } from "../../../src/settings/model";
|
|
import type { CodexChatHost } from "../../../src/features/chat/host/runtime";
|
|
import { StaleAppServerSharedQueryContextError } from "../../../src/app-server/query/shared-queries";
|
|
import { modelMetadataFromCatalogModels } from "../../../src/app-server/protocol/catalog";
|
|
import type { ObservedDataResult } from "../../../src/domain/observed-data";
|
|
import { createServerDiagnostics } from "../../../src/domain/server/diagnostics";
|
|
import type { Thread } from "../../../src/domain/threads/model";
|
|
import type { ModelMetadata } from "../../../src/domain/catalog/metadata";
|
|
import type { SharedServerMetadata } from "../../../src/domain/server/metadata";
|
|
import { emptyRuntimeConfigSnapshot } from "../../../src/domain/runtime/config";
|
|
import type { ThreadRecord } from "../../../src/app-server/protocol/thread";
|
|
import type { ServerNotification } from "../../../src/app-server/connection/rpc-messages";
|
|
import type { ThreadCatalogSnapshotWriter } from "../../../src/workspace/thread-catalog";
|
|
import { notices } from "../../mocks/obsidian";
|
|
import { deferred, waitForAsyncWork } from "../../support/async";
|
|
import { installObsidianDomShims } from "../../support/dom";
|
|
|
|
const ESTIMATED_MESSAGE_BLOCK_HEIGHT = 96;
|
|
type TestCodexChatHost = CodexChatHost & {
|
|
threadCatalog: CodexChatHost["threadCatalog"] & ThreadCatalogSnapshotWriter;
|
|
};
|
|
|
|
const connectionMock = vi.hoisted(() => {
|
|
const state = {
|
|
client: null as Record<string, unknown> | null,
|
|
connectCalls: 0,
|
|
connected: false,
|
|
onNotification: null as ((notification: ServerNotification) => void) | null,
|
|
onExit: null as (() => void) | null,
|
|
};
|
|
|
|
return {
|
|
state,
|
|
reset(): void {
|
|
state.client = null;
|
|
state.connectCalls = 0;
|
|
state.connected = false;
|
|
state.onNotification = null;
|
|
state.onExit = null;
|
|
},
|
|
};
|
|
});
|
|
|
|
vi.mock("../../../src/app-server/connection/connection-manager", () => {
|
|
class StaleConnectionError extends Error {}
|
|
|
|
class ConnectionManager {
|
|
private handlers: {
|
|
onNotification: (notification: ServerNotification) => void;
|
|
onServerRequest: (request: unknown) => void;
|
|
onLog: (message: string) => void;
|
|
onExit: () => void;
|
|
} | null;
|
|
|
|
constructor(_codexPath: () => string, _cwd: string) {
|
|
this.handlers = null;
|
|
}
|
|
|
|
connect(handlers: {
|
|
onNotification: (notification: ServerNotification) => void;
|
|
onServerRequest: (request: unknown) => void;
|
|
onLog: (message: string) => void;
|
|
onExit: () => void;
|
|
}): Promise<unknown> {
|
|
this.handlers = handlers;
|
|
this.publishHandlers(handlers);
|
|
connectionMock.state.connectCalls += 1;
|
|
connectionMock.state.connected = true;
|
|
return Promise.resolve({
|
|
userAgent: "codex-test",
|
|
codexHome: "/tmp/codex",
|
|
platformFamily: "unix",
|
|
platformOs: "macos",
|
|
});
|
|
}
|
|
|
|
currentClient(): unknown {
|
|
return connectionMock.state.connected ? connectionMock.state.client : null;
|
|
}
|
|
|
|
isConnected(): boolean {
|
|
return connectionMock.state.connected;
|
|
}
|
|
|
|
resetConnection(): void {
|
|
this.disconnect();
|
|
}
|
|
|
|
disconnect(): void {
|
|
connectionMock.state.connected = false;
|
|
}
|
|
|
|
exit(): void {
|
|
connectionMock.state.connected = false;
|
|
this.handlers?.onExit();
|
|
}
|
|
|
|
private publishHandlers(handlers: NonNullable<ConnectionManager["handlers"]>): void {
|
|
connectionMock.state.onNotification = handlers.onNotification;
|
|
connectionMock.state.onExit = handlers.onExit;
|
|
}
|
|
}
|
|
|
|
return { ConnectionManager, StaleConnectionError };
|
|
});
|
|
|
|
installObsidianDomShims();
|
|
|
|
describe("CodexChatView connection lifecycle", () => {
|
|
let restoreDefaultMessageViewportMetrics: (() => void) | null = null;
|
|
|
|
beforeEach(() => {
|
|
vi.useRealTimers();
|
|
notices.length = 0;
|
|
connectionMock.reset();
|
|
restoreDefaultMessageViewportMetrics = mockMessageViewportOffsetMetrics({ clientHeight: 320, clientWidth: 240 });
|
|
});
|
|
|
|
afterEach(() => {
|
|
restoreDefaultMessageViewportMetrics?.();
|
|
restoreDefaultMessageViewportMetrics = null;
|
|
document.body.replaceChildren();
|
|
});
|
|
|
|
it("shares post-initialize metadata loading across concurrent connect calls", async () => {
|
|
const client = connectedClient();
|
|
const fetchModels = vi.fn().mockResolvedValue([]);
|
|
connectionMock.state.client = client;
|
|
const view = await chatView({ host: chatHost({ fetchModels }) });
|
|
|
|
await Promise.all([view.surface.connect(), view.surface.connect()]);
|
|
|
|
expect(connectionMock.state.connectCalls).toBe(1);
|
|
expect(client.readEffectiveConfig).toHaveBeenCalledTimes(1);
|
|
expect(fetchModels).toHaveBeenCalledTimes(1);
|
|
expect(client.listSkills).toHaveBeenCalledTimes(1);
|
|
expect(client.readAccountRateLimits).toHaveBeenCalledTimes(1);
|
|
expect(client.listThreads).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("keeps connect calls joined while metadata is still loading", async () => {
|
|
const config = deferred<unknown>();
|
|
const client = connectedClient({
|
|
readEffectiveConfig: vi.fn(() => config.promise),
|
|
});
|
|
connectionMock.state.client = client;
|
|
const view = await chatView();
|
|
|
|
const firstConnect = view.surface.connect();
|
|
await waitForAsyncWork(() => {
|
|
expect(client.readEffectiveConfig).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
let secondResolved = false;
|
|
const secondConnect = view.surface.connect().then(() => {
|
|
secondResolved = true;
|
|
});
|
|
await Promise.resolve();
|
|
|
|
expect(connectionMock.state.connected).toBe(true);
|
|
expect(secondResolved).toBe(false);
|
|
|
|
config.resolve({});
|
|
await Promise.all([firstConnect, secondConnect]);
|
|
|
|
expect(client.readEffectiveConfig).toHaveBeenCalledOnce();
|
|
expect(client.listThreads).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("refreshes shared thread lists after connecting", async () => {
|
|
const refresh = vi
|
|
.fn()
|
|
.mockResolvedValue([{ id: "thread-1", preview: "Restored thread", name: null, archived: false, createdAt: 1, updatedAt: 1 }]);
|
|
const client = connectedClient({
|
|
listThreads: vi.fn(),
|
|
});
|
|
connectionMock.state.client = client;
|
|
const view = await chatView({
|
|
host: chatHost({ refreshActive: refresh }),
|
|
});
|
|
|
|
await view.onOpen();
|
|
await view.surface.connect();
|
|
|
|
expect(refresh).toHaveBeenCalledOnce();
|
|
expect(client.listThreads).not.toHaveBeenCalled();
|
|
requiredButton(view.containerEl, '[aria-label="Show thread list"]').click();
|
|
await waitForAsyncWork(() => {
|
|
expect(view.containerEl.textContent).toContain("Restored thread");
|
|
});
|
|
});
|
|
|
|
it("ignores stale shared thread refreshes after connecting", async () => {
|
|
const refresh = vi.fn().mockRejectedValue(new StaleAppServerSharedQueryContextError());
|
|
connectionMock.state.client = connectedClient({
|
|
listThreads: vi.fn(),
|
|
});
|
|
const view = await chatView({
|
|
host: chatHost({ refreshActive: refresh }),
|
|
});
|
|
|
|
await view.onOpen();
|
|
await view.surface.connect();
|
|
|
|
expect(refresh).toHaveBeenCalledOnce();
|
|
expect(view.surface.openPanelSnapshot()).toMatchObject({ connected: true });
|
|
expect(notices).toEqual([]);
|
|
requiredButton(view.containerEl, '[aria-label="Show thread list"]').click();
|
|
expect(view.containerEl.textContent).not.toContain("stale");
|
|
});
|
|
|
|
it("loads app-server metadata after connecting", async () => {
|
|
connectionMock.state.client = connectedClient();
|
|
const view = await chatView();
|
|
|
|
await view.surface.connect();
|
|
|
|
expect(connectionMock.state.client["readEffectiveConfig"]).toHaveBeenCalledOnce();
|
|
expect(view.surface.openPanelSnapshot()).toMatchObject({ connected: true });
|
|
});
|
|
|
|
it("resets the app-server connection only when settings change the app-server context", async () => {
|
|
connectionMock.state.client = connectedClient();
|
|
const host = chatHost();
|
|
const view = await chatView({ host });
|
|
|
|
await view.onOpen();
|
|
await view.surface.connect();
|
|
expect(view.surface.openPanelSnapshot()).toMatchObject({ connected: true });
|
|
|
|
host.settingsRef.settings.showToolbar = false;
|
|
view.surface.refreshSettings();
|
|
expect(view.surface.openPanelSnapshot()).toMatchObject({ connected: true });
|
|
|
|
host.settingsRef.settings.codexPath = "codex-next";
|
|
view.surface.refreshSettings();
|
|
expect(view.surface.openPanelSnapshot()).toMatchObject({ connected: false });
|
|
});
|
|
|
|
it("starts an empty thread when saving a toolbar goal from a blank panel", async () => {
|
|
vi.useFakeTimers();
|
|
const client = connectedClient({
|
|
setThreadGoal: vi.fn().mockResolvedValue({
|
|
goal: {
|
|
threadId: "thread-new",
|
|
objective: "Ship the feature",
|
|
status: "active",
|
|
tokenBudget: null,
|
|
tokensUsed: 0,
|
|
timeUsedSeconds: 0,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
},
|
|
}),
|
|
});
|
|
connectionMock.state.client = client;
|
|
const view = await chatView();
|
|
await view.onOpen();
|
|
|
|
view.containerEl.querySelector<HTMLButtonElement>('[aria-label="Show chat actions"]')?.click();
|
|
await waitForAsyncWork(() => {
|
|
expect(view.containerEl.textContent).toContain("Set goal...");
|
|
});
|
|
[...view.containerEl.querySelectorAll<HTMLElement>(".codex-panel__chat-actions-panel-item")]
|
|
.find((item) => item.textContent === "Set goal...")
|
|
?.click();
|
|
await waitForAsyncWork(() => {
|
|
expect(view.containerEl.querySelector(".codex-panel__goal-objective-input")).not.toBeNull();
|
|
});
|
|
const input = requiredTextArea(view.containerEl, ".codex-panel__goal-objective-input");
|
|
input.value = "Ship the feature";
|
|
input.dispatchEvent(new InputEvent("input", { bubbles: true }));
|
|
await waitForAsyncWork(() => {
|
|
const save = view.containerEl.querySelector<HTMLButtonElement>('[aria-label="Save goal"]');
|
|
expect(save?.disabled).toBe(false);
|
|
});
|
|
const save = requiredButton(view.containerEl, '[aria-label="Save goal"]');
|
|
save.click();
|
|
|
|
await waitForAsyncWork(() => {
|
|
expect(client.startThread).toHaveBeenCalledWith({ cwd: "/vault", serviceTier: undefined });
|
|
expect(client.setThreadGoal).toHaveBeenCalledWith("thread-new", {
|
|
objective: "Ship the feature",
|
|
status: "active",
|
|
tokenBudget: null,
|
|
});
|
|
});
|
|
await waitForAsyncWork(() => {
|
|
expect(client.injectThreadItems).toHaveBeenCalledWith("thread-new", [
|
|
{
|
|
type: "message",
|
|
role: "user",
|
|
content: [{ type: "input_text", text: "Ship the feature" }],
|
|
},
|
|
]);
|
|
});
|
|
expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: "thread-new" });
|
|
expect(view.containerEl.textContent).toContain("Ship the feature");
|
|
});
|
|
|
|
it("ignores stale connection work after the view closes", async () => {
|
|
let resolveConfig!: (value: unknown) => void;
|
|
const client = connectedClient({
|
|
readEffectiveConfig: vi.fn(
|
|
() =>
|
|
new Promise((resolve) => {
|
|
resolveConfig = resolve;
|
|
}),
|
|
),
|
|
});
|
|
connectionMock.state.client = client;
|
|
const view = await chatView();
|
|
|
|
const connecting = view.surface.connect();
|
|
await Promise.resolve();
|
|
await view.onClose();
|
|
resolveConfig({});
|
|
await connecting;
|
|
|
|
expect(notices).toEqual([]);
|
|
expect(client.listThreads).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("ignores stale connection work after the app-server exits during metadata loading", async () => {
|
|
const config = deferred<unknown>();
|
|
const client = connectedClient({
|
|
readEffectiveConfig: vi.fn(() => config.promise),
|
|
});
|
|
connectionMock.state.client = client;
|
|
const view = await chatView();
|
|
|
|
const connecting = view.surface.connect();
|
|
await waitForAsyncWork(() => {
|
|
expect(client.readEffectiveConfig).toHaveBeenCalledOnce();
|
|
});
|
|
connectionMock.state.connected = false;
|
|
connectionMock.state.onExit?.();
|
|
|
|
config.resolve({});
|
|
await connecting;
|
|
|
|
expect(client.listThreads).not.toHaveBeenCalled();
|
|
expect(view.surface.openPanelSnapshot()).toMatchObject({ connected: false });
|
|
});
|
|
|
|
it("restores workspace thread state without hydrating it automatically", async () => {
|
|
vi.useFakeTimers();
|
|
const client = connectedClient({
|
|
resumeThread: vi.fn().mockResolvedValue(resumedThread("thread-1")),
|
|
});
|
|
connectionMock.state.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(connectionMock.state.connectCalls).toBe(0);
|
|
expect(client.resumeThread).not.toHaveBeenCalled();
|
|
expect(view.containerEl.textContent).not.toContain("Thread restored. Send a message to resume it.");
|
|
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
|
|
expect(connectionMock.state.connectCalls).toBe(1);
|
|
expect(client.readEffectiveConfig).toHaveBeenCalledOnce();
|
|
expect(client.listThreads).toHaveBeenCalledOnce();
|
|
expect(client.resumeThread).not.toHaveBeenCalled();
|
|
|
|
await vi.advanceTimersByTimeAsync(1_500);
|
|
|
|
expect(client.resumeThread).not.toHaveBeenCalled();
|
|
expect(client.threadTurnsList).not.toHaveBeenCalled();
|
|
});
|
|
|
|
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.threadCatalog.replaceActiveThreadsSnapshot([panelThread({ id: "thread-named", name: "作業メモ" })]);
|
|
expect(view.getDisplayText()).toBe("Codex: 作業メモ");
|
|
|
|
host.threadCatalog.replaceActiveThreadsSnapshot([panelThread({ id: "thread-named", name: null, preview: "初回依頼" })]);
|
|
expect(view.getDisplayText()).toBe("Codex: 初回依頼");
|
|
|
|
await view.setState({ threadId: "019e061e-0000-7000-8000-000000000001" }, {} as never);
|
|
host.threadCatalog.replaceActiveThreadsSnapshot([]);
|
|
expect(view.getDisplayText()).toBe("Codex: 019e061e");
|
|
});
|
|
|
|
it("keeps late workspace thread state restored until explicit focus", async () => {
|
|
vi.useFakeTimers();
|
|
const client = connectedClient();
|
|
connectionMock.state.client = client;
|
|
const view = await chatView();
|
|
|
|
await view.onOpen();
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
expect(connectionMock.state.connectCalls).toBe(1);
|
|
expect(client.readEffectiveConfig).toHaveBeenCalledOnce();
|
|
expect(client.listThreads).toHaveBeenCalledOnce();
|
|
expect(client.resumeThread).not.toHaveBeenCalled();
|
|
|
|
await view.setState({ threadId: "thread-1", threadTitle: "Restored thread" }, {} as never);
|
|
await vi.advanceTimersByTimeAsync(1_500);
|
|
|
|
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(client.resumeThread).not.toHaveBeenCalled();
|
|
expect(client.threadTurnsList).not.toHaveBeenCalled();
|
|
|
|
await view.surface.focusThread("thread-1");
|
|
|
|
expect(client.resumeThread).toHaveBeenCalledWith("thread-1", "/vault");
|
|
expect(client.threadTurnsList).toHaveBeenCalledWith("thread-1", null, 20);
|
|
});
|
|
|
|
it("warms app-server metadata for an empty restored panel after the shell is open", async () => {
|
|
vi.useFakeTimers();
|
|
const client = connectedClient({
|
|
listThreads: vi.fn().mockResolvedValue({ data: [threadFixture("thread-1")], nextCursor: null }),
|
|
});
|
|
const fetchModels = vi.fn().mockResolvedValue([]);
|
|
connectionMock.state.client = client;
|
|
const view = await chatView({ host: chatHost({ fetchModels }) });
|
|
|
|
await view.onOpen();
|
|
|
|
expect(connectionMock.state.connectCalls).toBe(0);
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
|
|
expect(connectionMock.state.connectCalls).toBe(1);
|
|
expect(client.readEffectiveConfig).toHaveBeenCalledOnce();
|
|
expect(fetchModels).toHaveBeenCalledOnce();
|
|
expect(client.listSkills).toHaveBeenCalledOnce();
|
|
expect(client.readAccountRateLimits).toHaveBeenCalledOnce();
|
|
expect(client.listThreads).toHaveBeenCalledWith("/vault", { archived: false, cursor: null, limit: 100 });
|
|
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({
|
|
activeSnapshot: vi.fn(() => [cachedThread] as never[]),
|
|
appServerMetadataSnapshot: vi.fn(
|
|
() =>
|
|
({
|
|
runtimeConfig: { ...emptyRuntimeConfigSnapshot(), model: "gpt-cached" },
|
|
availableModels: [],
|
|
availableSkills: [{ name: "writer", enabled: true }],
|
|
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();
|
|
connectionMock.state.client = client;
|
|
const view = await chatView();
|
|
|
|
await view.setState({ threadId: "thread-1", threadTitle: "Restored thread" }, {} as never);
|
|
await view.onOpen();
|
|
|
|
expect(client.resumeThread).not.toHaveBeenCalled();
|
|
await view.surface.focusThread("thread-1");
|
|
|
|
expect(client.resumeThread).toHaveBeenCalledWith("thread-1", "/vault");
|
|
expect(client.threadTurnsList).toHaveBeenCalledWith("thread-1", null, 20);
|
|
});
|
|
|
|
it("resumes a restored thread before sending the first message", async () => {
|
|
const client = connectedClient();
|
|
connectionMock.state.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.resumeThread).toHaveBeenCalledWith("thread-1", "/vault");
|
|
expect(client.startTurn).toHaveBeenCalledWith({
|
|
threadId: "thread-1",
|
|
cwd: "/vault",
|
|
input: [{ type: "text", text: "hello" }],
|
|
clientUserMessageId: expect.stringMatching(/^local-user-\d+-[A-Za-z0-9_-]+-[a-z0-9]+$/),
|
|
});
|
|
});
|
|
expect(view.getState()).toEqual({ version: 1, threadId: "thread-1", threadTitle: "Restored thread" });
|
|
});
|
|
|
|
it("does not revive a completed turn when the startTurn response arrives late", async () => {
|
|
const startTurn = deferred<{ turn: { id: string } }>();
|
|
const client = connectedClient({
|
|
startTurn: vi.fn(() => startTurn.promise),
|
|
});
|
|
connectionMock.state.client = client;
|
|
const view = await chatView();
|
|
|
|
await view.onOpen();
|
|
await view.surface.openThread("thread-1");
|
|
view.surface.setComposerText("hello");
|
|
await submitComposerByEnter(view);
|
|
await waitForAsyncWork(() => {
|
|
expect(client.startTurn).toHaveBeenCalled();
|
|
});
|
|
|
|
connectionMock.state.onNotification?.(turnStartedNotification("thread-1", "turn-1"));
|
|
connectionMock.state.onNotification?.(turnCompletedNotification("thread-1", "turn-1"));
|
|
expect(view.surface.openPanelSnapshot()).toMatchObject({ turnLifecycle: { kind: "idle" } });
|
|
|
|
startTurn.resolve({ turn: { id: "turn-1" } });
|
|
await flushAsyncTicks();
|
|
|
|
expect(view.surface.openPanelSnapshot()).toMatchObject({ turnLifecycle: { kind: "idle" } });
|
|
});
|
|
|
|
it("requests a workspace layout save after resuming a thread", async () => {
|
|
const requestSaveLayout = vi.fn();
|
|
const client = connectedClient();
|
|
connectionMock.state.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("resets to an unstarted empty chat without starting a thread", async () => {
|
|
const requestSaveLayout = vi.fn();
|
|
const client = connectedClient();
|
|
connectionMock.state.client = client;
|
|
const view = await chatView({ requestSaveLayout });
|
|
|
|
await view.surface.openThread("thread-1");
|
|
await view.surface.startNewThread();
|
|
|
|
expect(client.startThread).not.toHaveBeenCalled();
|
|
expect(view.getState()).toEqual({ version: 1 });
|
|
expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: null, turnLifecycle: { kind: "idle" }, hasComposerDraft: false });
|
|
expect(requestSaveLayout).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("focuses the composer after panel thread actions", async () => {
|
|
const client = connectedClient();
|
|
connectionMock.state.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("does not start a thread when /clear is given arguments", async () => {
|
|
const client = connectedClient({
|
|
startThread: vi.fn().mockResolvedValue(startedThread("thread-new")),
|
|
});
|
|
connectionMock.state.client = client;
|
|
const view = await chatView();
|
|
|
|
await view.onOpen();
|
|
await view.onOpen();
|
|
await view.surface.connect();
|
|
view.surface.setComposerText("/clear hello");
|
|
await submitComposerByEnter(view);
|
|
|
|
expect(client.startThread).not.toHaveBeenCalled();
|
|
expect(client.startTurn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("focuses an open panel instead of resuming a duplicate slash resume thread", async () => {
|
|
const focusThreadInOpenView = vi.fn().mockResolvedValue(true);
|
|
const host = chatHost({
|
|
focusThreadInOpenView,
|
|
});
|
|
connectionMock.state.client = connectedClient({
|
|
listThreads: vi.fn().mockResolvedValue({ data: [threadFixture("thread-1")] }),
|
|
});
|
|
const view = await chatView({ host });
|
|
|
|
await view.onOpen();
|
|
await view.surface.connect();
|
|
view.surface.setComposerText("/resume thread-1");
|
|
await submitComposerByEnter(view);
|
|
|
|
expect(focusThreadInOpenView).toHaveBeenCalledWith("thread-1");
|
|
expect(connectionMock.state.client["resumeThread"]).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("resumes slash resume threads in the current panel when they are not already open", async () => {
|
|
const focusThreadInOpenView = vi.fn().mockResolvedValue(false);
|
|
const client = connectedClient({
|
|
listThreads: vi.fn().mockResolvedValue({ data: [threadFixture("thread-1")] }),
|
|
threadTurnsList: vi.fn().mockResolvedValue({ data: [turnWithUserMessage("restored prompt")], nextCursor: null }),
|
|
});
|
|
connectionMock.state.client = client;
|
|
const view = await chatView({
|
|
host: chatHost({
|
|
focusThreadInOpenView,
|
|
}),
|
|
});
|
|
|
|
await view.onOpen();
|
|
await view.surface.connect();
|
|
view.surface.setComposerText("/resume thread-1");
|
|
await submitComposerByEnter(view);
|
|
|
|
expect(focusThreadInOpenView).toHaveBeenCalledWith("thread-1");
|
|
expect(client.resumeThread).toHaveBeenCalledWith("thread-1", "/vault");
|
|
});
|
|
|
|
it("keeps resumed messages pinned to bottom after slash resume in the same empty panel", async () => {
|
|
const client = connectedClient({
|
|
listThreads: vi.fn().mockResolvedValue({ data: [threadFixture("thread-1")] }),
|
|
threadTurnsList: vi.fn().mockResolvedValue({ data: [turnWithUserMessage("restored prompt")], nextCursor: null }),
|
|
});
|
|
connectionMock.state.client = client;
|
|
const view = await chatView();
|
|
|
|
await view.onOpen();
|
|
await view.surface.connect();
|
|
const messages = view.containerEl.querySelector<HTMLElement>(".codex-panel__messages");
|
|
expect(messages).not.toBeNull();
|
|
if (!messages) return;
|
|
const restoreMessagesLayout = mockMessagesLayout({ scrollHeight: ESTIMATED_MESSAGE_BLOCK_HEIGHT, clientHeight: 100 });
|
|
messages.scrollTop = 0;
|
|
|
|
view.surface.setComposerText("/resume thread-1");
|
|
await submitComposerByEnter(view);
|
|
await waitForMessagesFrame(messages);
|
|
await waitForMessagesFrame(messages);
|
|
|
|
const renderedMessages = view.containerEl.querySelector<HTMLElement>(".codex-panel__messages");
|
|
expect(renderedMessages?.scrollTop).toBe(0);
|
|
restoreMessagesLayout();
|
|
});
|
|
|
|
it("routes slash archive through shared panel notifications", async () => {
|
|
const recordThreadArchived = vi.fn();
|
|
const host = chatHost({
|
|
recordThreadArchived,
|
|
});
|
|
const client = connectedClient({
|
|
listThreads: vi.fn().mockResolvedValue({ data: [threadFixture("thread-1")] }),
|
|
});
|
|
connectionMock.state.client = client;
|
|
const view = await chatView({ host });
|
|
|
|
await view.onOpen();
|
|
await view.surface.connect();
|
|
view.surface.setComposerText("/archive thread-1");
|
|
await submitComposerByEnter(view);
|
|
|
|
expect(client.archiveThread).toHaveBeenCalledWith("thread-1");
|
|
expect(recordThreadArchived).toHaveBeenCalledWith("thread-1");
|
|
});
|
|
|
|
it("replaces the current panel with the forked thread after fork and archive", async () => {
|
|
const recordThreadArchived = vi.fn();
|
|
const host = chatHost({ recordThreadArchived });
|
|
const client = connectedClient({
|
|
listThreads: vi.fn().mockResolvedValue({ data: [threadFixture("source")] }),
|
|
resumeThread: vi.fn((threadId: string) => Promise.resolve(resumedThread(threadId))),
|
|
threadTurnsList: vi.fn().mockResolvedValue({ data: [completedTurn("turn-1")], nextCursor: null }),
|
|
forkThread: vi.fn().mockResolvedValue({ thread: threadFixture("forked") }),
|
|
});
|
|
connectionMock.state.client = client;
|
|
const view = await chatView({ host });
|
|
|
|
await view.onOpen();
|
|
await view.surface.connect();
|
|
await view.surface.openThread("source");
|
|
await waitForAsyncWork(() => {
|
|
expect(view.containerEl.textContent).toContain("done");
|
|
});
|
|
requiredButton(view.containerEl, ".codex-panel__fork-message").click();
|
|
await waitForAsyncWork(() => {
|
|
expect(view.containerEl.querySelector(".codex-panel__fork-and-archive-message")).not.toBeNull();
|
|
});
|
|
requiredButton(view.containerEl, ".codex-panel__fork-and-archive-message").click();
|
|
|
|
await waitForAsyncWork(() => {
|
|
expect(client.forkThread).toHaveBeenCalledWith("source", "/vault");
|
|
expect(client.archiveThread).toHaveBeenCalledWith("source");
|
|
expect(client.resumeThread).toHaveBeenLastCalledWith("forked", "/vault");
|
|
expect(view.getState()).toEqual({ version: 1, threadId: "forked", threadTitle: "Restored thread" });
|
|
expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: "forked" });
|
|
expect(recordThreadArchived).toHaveBeenCalledWith("source");
|
|
});
|
|
});
|
|
|
|
it("clears the active thread when another view archives it", async () => {
|
|
const requestSaveLayout = vi.fn();
|
|
const client = connectedClient();
|
|
connectionMock.state.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 title as a composer name before an explicit rename notification", 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 to work on this task...");
|
|
|
|
host.threadCatalog.replaceActiveThreadsSnapshot([panelThread({ id: "thread-1", name: "Explicit name" })]);
|
|
view.surface.applyThreadRenamed("thread-1", "Explicit name");
|
|
|
|
await waitForAsyncWork(() => {
|
|
expect(composerPlaceholder(view)).toBe("Ask Codex to work on “Explicit name”...");
|
|
});
|
|
});
|
|
|
|
it("uses the active explicit thread name in the composer placeholder", async () => {
|
|
const client = connectedClient();
|
|
connectionMock.state.client = client;
|
|
const view = await chatView();
|
|
|
|
await view.onOpen();
|
|
await view.surface.openThread("thread-1");
|
|
|
|
expect(composerPlaceholder(view)).toBe("Ask Codex to work on “Restored thread”...");
|
|
});
|
|
|
|
it("does not use preview-only thread text in the composer placeholder", async () => {
|
|
const client = connectedClient({
|
|
resumeThread: vi.fn().mockResolvedValue({
|
|
...resumedThread("thread-1"),
|
|
thread: { ...resumedThread("thread-1").thread, name: null, preview: "Restored preview" },
|
|
}),
|
|
});
|
|
connectionMock.state.client = client;
|
|
const view = await chatView();
|
|
|
|
await view.onOpen();
|
|
await view.surface.openThread("thread-1");
|
|
|
|
expect(composerPlaceholder(view)).toBe("Ask Codex to work on this task...");
|
|
});
|
|
|
|
it("updates the composer placeholder from shared rename notifications", async () => {
|
|
const client = connectedClient();
|
|
connectionMock.state.client = client;
|
|
const host = chatHost();
|
|
const view = await chatView({ host });
|
|
|
|
await view.onOpen();
|
|
await view.surface.openThread("thread-1");
|
|
host.threadCatalog.replaceActiveThreadsSnapshot([panelThread({ id: "thread-1", name: "Renamed thread" })]);
|
|
view.surface.applyThreadRenamed("thread-1", "Renamed thread");
|
|
|
|
await waitForAsyncWork(() => {
|
|
expect(composerPlaceholder(view)).toBe("Ask Codex to work on “Renamed thread”...");
|
|
});
|
|
|
|
host.threadCatalog.replaceActiveThreadsSnapshot([panelThread({ id: "thread-1", name: null })]);
|
|
view.surface.applyThreadRenamed("thread-1", null);
|
|
|
|
await waitForAsyncWork(() => {
|
|
expect(composerPlaceholder(view)).toBe("Ask Codex to work on this task...");
|
|
});
|
|
});
|
|
|
|
it("keeps composer draft and selection while updating the placeholder", async () => {
|
|
const client = connectedClient();
|
|
connectionMock.state.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.threadCatalog.replaceActiveThreadsSnapshot([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 to work on “Renamed thread”...");
|
|
});
|
|
});
|
|
|
|
it("scrolls resumed messages to the bottom after history hydrates", async () => {
|
|
const client = connectedClient({
|
|
threadTurnsList: vi.fn().mockResolvedValue({ data: [turnWithUserMessage("restored prompt")], nextCursor: null }),
|
|
});
|
|
connectionMock.state.client = client;
|
|
const view = await chatView();
|
|
|
|
await view.onOpen();
|
|
const messages = view.containerEl.querySelector<HTMLElement>(".codex-panel__messages");
|
|
expect(messages).not.toBeNull();
|
|
if (!messages) return;
|
|
const restoreMessagesLayout = mockMessagesLayout({ scrollHeight: ESTIMATED_MESSAGE_BLOCK_HEIGHT, clientHeight: 100 });
|
|
messages.scrollTop = 0;
|
|
|
|
await view.surface.openThread("thread-1");
|
|
await waitForMessagesFrame(messages);
|
|
await waitForMessagesFrame(messages);
|
|
|
|
const renderedMessages = view.containerEl.querySelector<HTMLElement>(".codex-panel__messages");
|
|
expect(renderedMessages?.scrollTop).toBe(0);
|
|
restoreMessagesLayout();
|
|
});
|
|
|
|
it("leaves focused thread scroll position to the message stream", async () => {
|
|
const client = connectedClient({
|
|
threadTurnsList: vi.fn().mockResolvedValue({ data: [turnWithUserMessage("restored prompt")], nextCursor: null }),
|
|
});
|
|
connectionMock.state.client = client;
|
|
const view = await chatView();
|
|
const restoreMessagesLayout = mockMessagesLayout({ scrollHeight: 1_000, clientHeight: 100 });
|
|
|
|
await view.onOpen();
|
|
await view.surface.openThread("thread-1");
|
|
const messages = view.containerEl.querySelector<HTMLElement>(".codex-panel__messages");
|
|
expect(messages).not.toBeNull();
|
|
if (!messages) return;
|
|
messages.scrollTop = 0;
|
|
|
|
await view.surface.focusThread("thread-1");
|
|
|
|
expect(messages.scrollTop).toBe(0);
|
|
restoreMessagesLayout();
|
|
});
|
|
|
|
it("leaves scroll position to the message stream when its leaf is focused", async () => {
|
|
const activeLeafChangeListeners: ((leaf: unknown) => void)[] = [];
|
|
const client = connectedClient({
|
|
threadTurnsList: vi.fn().mockResolvedValue({ data: [turnWithUserMessage("restored prompt")], nextCursor: null }),
|
|
});
|
|
connectionMock.state.client = client;
|
|
const view = await chatView({ activeLeafChangeListeners });
|
|
const restoreMessagesLayout = mockMessagesLayout({ scrollHeight: 1_000, clientHeight: 100 });
|
|
|
|
await view.onOpen();
|
|
await view.surface.openThread("thread-1");
|
|
const messages = view.containerEl.querySelector<HTMLElement>(".codex-panel__messages");
|
|
expect(messages).not.toBeNull();
|
|
if (!messages) return;
|
|
messages.scrollTop = 0;
|
|
|
|
activeLeafChangeListeners.forEach((listener) => {
|
|
listener((view as unknown as { leaf: unknown }).leaf);
|
|
});
|
|
|
|
expect(messages.scrollTop).toBe(0);
|
|
restoreMessagesLayout();
|
|
});
|
|
|
|
it("renders resumed thread metadata before history hydration completes", async () => {
|
|
const history = deferred<{ data: unknown[]; nextCursor: null }>();
|
|
const client = connectedClient({
|
|
threadTurnsList: vi.fn(() => history.promise),
|
|
});
|
|
connectionMock.state.client = client;
|
|
const view = await chatView();
|
|
|
|
const opening = view.surface.openThread("thread-1");
|
|
await waitForAsyncWork(() => {
|
|
expect(client.threadTurnsList).toHaveBeenCalledWith("thread-1", null, 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({
|
|
resumeThread: vi.fn().mockResolvedValue({
|
|
...resumedThread("thread-1"),
|
|
initialTurnsPage: {
|
|
data: [completedTurn("turn-1")],
|
|
nextCursor: "older-cursor",
|
|
backwardsCursor: null,
|
|
},
|
|
}),
|
|
threadTurnsList: vi.fn().mockResolvedValue({ data: [turnWithUserMessage("fallback prompt")], nextCursor: null }),
|
|
});
|
|
connectionMock.state.client = client;
|
|
const view = await chatView();
|
|
|
|
await view.onOpen();
|
|
await view.surface.openThread("thread-1");
|
|
|
|
expect(client.resumeThread).toHaveBeenCalledWith("thread-1", "/vault");
|
|
expect(client.threadTurnsList).not.toHaveBeenCalled();
|
|
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({
|
|
resumeThread: vi.fn((threadId: string) => (threadId === "thread-1" ? firstResume.promise : secondResume.promise)),
|
|
});
|
|
connectionMock.state.client = client;
|
|
const view = await chatView();
|
|
|
|
const firstOpen = view.surface.openThread("thread-1");
|
|
await waitForAsyncWork(() => {
|
|
expect(client.resumeThread).toHaveBeenCalledWith("thread-1", "/vault");
|
|
});
|
|
const secondOpen = view.surface.openThread("thread-2");
|
|
await waitForAsyncWork(() => {
|
|
expect(client.resumeThread).toHaveBeenCalledWith("thread-2", "/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" });
|
|
expect(client.threadTurnsList).toHaveBeenCalledTimes(1);
|
|
expect(client.threadTurnsList).toHaveBeenCalledWith("thread-2", null, 20);
|
|
});
|
|
|
|
it("invalidates stale history hydration when a second resume starts", async () => {
|
|
const firstHistory = deferred<{ data: unknown[]; nextCursor: null }>();
|
|
const client = connectedClient({
|
|
resumeThread: vi.fn((threadId: string) => Promise.resolve(resumedThread(threadId))),
|
|
threadTurnsList: vi.fn((threadId: string) =>
|
|
threadId === "thread-1" ? firstHistory.promise : Promise.resolve({ data: [], nextCursor: null }),
|
|
),
|
|
});
|
|
connectionMock.state.client = client;
|
|
const view = await chatView();
|
|
|
|
const firstOpen = view.surface.openThread("thread-1");
|
|
await waitForAsyncWork(() => {
|
|
expect(client.threadTurnsList).toHaveBeenCalledWith("thread-1", null, 20);
|
|
});
|
|
const secondOpen = view.surface.openThread("thread-2");
|
|
await waitForAsyncWork(() => {
|
|
expect(client.threadTurnsList).toHaveBeenCalledWith("thread-2", null, 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");
|
|
});
|
|
|
|
it("scrolls restored messages to the bottom when the panel is focused", async () => {
|
|
const activeLeafChangeListeners: ((leaf: unknown) => void)[] = [];
|
|
const view = await chatView({ activeLeafChangeListeners });
|
|
|
|
await view.onOpen();
|
|
const messages = view.containerEl.querySelector<HTMLElement>(".codex-panel__messages");
|
|
expect(messages).not.toBeNull();
|
|
if (!messages) return;
|
|
Object.defineProperty(messages, "scrollHeight", { value: ESTIMATED_MESSAGE_BLOCK_HEIGHT, configurable: true });
|
|
Object.defineProperty(messages, "clientHeight", { value: 100, configurable: true });
|
|
messages.scrollTop = 0;
|
|
|
|
activeLeafChangeListeners.forEach((listener) => {
|
|
listener(view.leaf);
|
|
});
|
|
await new Promise<void>((resolve) => {
|
|
messages.win.requestAnimationFrame(() => {
|
|
resolve();
|
|
});
|
|
});
|
|
|
|
const renderedMessages = view.containerEl.querySelector<HTMLElement>(".codex-panel__messages");
|
|
expect(renderedMessages?.scrollTop).toBe(0);
|
|
});
|
|
});
|
|
|
|
function connectedClient(overrides: Partial<ReturnType<typeof baseClient>> = {}): ReturnType<typeof baseClient> {
|
|
return {
|
|
...baseClient(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function baseClient() {
|
|
return {
|
|
readEffectiveConfig: vi.fn().mockResolvedValue({}),
|
|
listModels: vi.fn().mockResolvedValue({ data: [] }),
|
|
listSkills: vi.fn().mockResolvedValue({ data: [] }),
|
|
readAccountRateLimits: vi.fn().mockResolvedValue({ rateLimits: null }),
|
|
listThreads: vi.fn().mockResolvedValue({ data: [] }),
|
|
startThread: vi.fn().mockResolvedValue(startedThread("thread-new")),
|
|
resumeThread: vi.fn().mockResolvedValue(resumedThread("thread-1")),
|
|
threadTurnsList: vi.fn().mockResolvedValue({ data: [], nextCursor: null }),
|
|
startTurn: vi.fn().mockResolvedValue({ turn: { id: "turn-1" } }),
|
|
forkThread: vi.fn().mockResolvedValue({ thread: threadFixture("thread-forked") }),
|
|
rollbackThread: vi.fn().mockResolvedValue({ thread: threadFixture("thread-forked") }),
|
|
setThreadName: vi.fn().mockResolvedValue({}),
|
|
getThreadGoal: vi.fn().mockResolvedValue({ goal: null }),
|
|
setThreadGoal: vi.fn().mockResolvedValue({ goal: goalFixture("thread-1") }),
|
|
injectThreadItems: vi.fn().mockResolvedValue({}),
|
|
readThread: vi.fn().mockResolvedValue({ thread: threadFixture("thread-1") }),
|
|
archiveThread: vi.fn().mockResolvedValue({}),
|
|
};
|
|
}
|
|
|
|
function goalFixture(threadId: string) {
|
|
return {
|
|
threadId,
|
|
objective: "Finish",
|
|
status: "active",
|
|
tokenBudget: null,
|
|
tokensUsed: 0,
|
|
timeUsedSeconds: 0,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
};
|
|
}
|
|
|
|
function startedThread(threadId: string) {
|
|
return {
|
|
thread: {
|
|
id: threadId,
|
|
name: null,
|
|
preview: "",
|
|
cwd: "/vault",
|
|
cliVersion: "0.0.0",
|
|
},
|
|
cwd: "/vault",
|
|
model: null,
|
|
reasoningEffort: null,
|
|
serviceTier: null,
|
|
approvalsReviewer: null,
|
|
};
|
|
}
|
|
|
|
function resumedThread(threadId: string) {
|
|
return {
|
|
thread: {
|
|
id: threadId,
|
|
name: "Restored thread",
|
|
preview: "Restored thread",
|
|
cwd: "/vault",
|
|
cliVersion: "0.0.0",
|
|
},
|
|
cwd: "/vault",
|
|
model: null,
|
|
reasoningEffort: null,
|
|
serviceTier: null,
|
|
approvalsReviewer: null,
|
|
};
|
|
}
|
|
|
|
function threadFromRecord(record: ThreadRecord): Thread {
|
|
return {
|
|
id: record.id,
|
|
preview: record.preview,
|
|
name: record.name,
|
|
archived: false,
|
|
createdAt: record.createdAt,
|
|
updatedAt: record.updatedAt,
|
|
};
|
|
}
|
|
|
|
function threadFixture(threadId: string): ThreadRecord {
|
|
return {
|
|
id: threadId,
|
|
sessionId: "session",
|
|
forkedFromId: null,
|
|
parentThreadId: null,
|
|
preview: "Restored thread",
|
|
ephemeral: false,
|
|
modelProvider: "openai",
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
status: { type: "idle" },
|
|
path: null,
|
|
cwd: "/vault",
|
|
cliVersion: "0.0.0",
|
|
source: "unknown",
|
|
threadSource: null,
|
|
agentNickname: null,
|
|
agentRole: null,
|
|
gitInfo: null,
|
|
name: null,
|
|
turns: [],
|
|
};
|
|
}
|
|
|
|
function panelThread(overrides: Partial<Thread> = {}): Thread {
|
|
return {
|
|
id: "thread-1",
|
|
preview: "",
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
name: null,
|
|
archived: false,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function turnWithUserMessage(text: string) {
|
|
return {
|
|
id: "turn-1",
|
|
startedAt: 1,
|
|
completedAt: 2,
|
|
items: [{ type: "userMessage", id: "user-1", clientId: null, content: [{ type: "text", text, text_elements: [] }] }],
|
|
};
|
|
}
|
|
|
|
function completedTurn(turnId: string) {
|
|
return {
|
|
id: turnId,
|
|
status: "completed",
|
|
error: null,
|
|
startedAt: 1,
|
|
completedAt: 2,
|
|
durationMs: 1,
|
|
itemsView: "full",
|
|
items: [
|
|
{ type: "userMessage", id: "user-1", clientId: null, content: [{ type: "text", text: "hello", text_elements: [] }] },
|
|
{ type: "agentMessage", id: "agent-1", text: "done", phase: "final_answer", memoryCitation: null },
|
|
],
|
|
};
|
|
}
|
|
|
|
function turnStartedNotification(threadId: string, turnId: string): Extract<ServerNotification, { method: "turn/started" }> {
|
|
return {
|
|
method: "turn/started",
|
|
params: {
|
|
threadId,
|
|
turn: {
|
|
id: turnId,
|
|
status: "inProgress",
|
|
error: null,
|
|
startedAt: 1,
|
|
completedAt: null,
|
|
durationMs: null,
|
|
itemsView: "full",
|
|
items: [],
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function turnCompletedNotification(threadId: string, turnId: string): Extract<ServerNotification, { method: "turn/completed" }> {
|
|
return {
|
|
method: "turn/completed",
|
|
params: {
|
|
threadId,
|
|
turn: {
|
|
id: turnId,
|
|
status: "completed",
|
|
error: null,
|
|
startedAt: 1,
|
|
completedAt: 2,
|
|
durationMs: 1,
|
|
itemsView: "full",
|
|
items: [
|
|
{ type: "userMessage", id: "user-1", clientId: null, content: [{ type: "text", text: "hello", text_elements: [] }] },
|
|
{ type: "agentMessage", id: "agent-1", text: "done", phase: "final_answer", memoryCitation: null },
|
|
],
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function composerElement(view: { containerEl: HTMLElement }): HTMLTextAreaElement {
|
|
const composer = view.containerEl.querySelector<HTMLTextAreaElement>(".codex-panel__composer-input");
|
|
if (!composer) throw new Error("Expected composer input");
|
|
return composer;
|
|
}
|
|
|
|
function composerPlaceholder(view: { containerEl: HTMLElement }): string | null {
|
|
return composerElement(view).getAttribute("placeholder");
|
|
}
|
|
|
|
function waitForMessagesFrame(messages: HTMLElement): Promise<void> {
|
|
return new Promise((resolve) => {
|
|
messages.win.requestAnimationFrame(() => {
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
function mockMessagesLayout(metrics: { scrollHeight: number; clientHeight: number }): () => void {
|
|
const scrollHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollHeight");
|
|
const clientHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientHeight");
|
|
const offsetHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetHeight");
|
|
Object.defineProperty(HTMLElement.prototype, "scrollHeight", {
|
|
configurable: true,
|
|
get() {
|
|
return this instanceof HTMLElement && this.classList.contains("codex-panel__messages") ? metrics.scrollHeight : 0;
|
|
},
|
|
});
|
|
Object.defineProperty(HTMLElement.prototype, "clientHeight", {
|
|
configurable: true,
|
|
get() {
|
|
return this instanceof HTMLElement && this.classList.contains("codex-panel__messages") ? metrics.clientHeight : 0;
|
|
},
|
|
});
|
|
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
|
|
configurable: true,
|
|
get() {
|
|
return this instanceof HTMLElement && this.classList.contains("codex-panel__messages") ? metrics.clientHeight : 0;
|
|
},
|
|
});
|
|
return () => {
|
|
restorePrototypeProperty(HTMLElement.prototype, "scrollHeight", scrollHeightDescriptor);
|
|
restorePrototypeProperty(HTMLElement.prototype, "clientHeight", clientHeightDescriptor);
|
|
restorePrototypeProperty(HTMLElement.prototype, "offsetHeight", offsetHeightDescriptor);
|
|
};
|
|
}
|
|
|
|
function mockMessageViewportOffsetMetrics(metrics: { clientHeight: number; clientWidth: number }): () => void {
|
|
const offsetHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetHeight");
|
|
const offsetWidthDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetWidth");
|
|
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
|
|
configurable: true,
|
|
get() {
|
|
return this instanceof HTMLElement && this.classList.contains("codex-panel__messages") ? metrics.clientHeight : 0;
|
|
},
|
|
});
|
|
Object.defineProperty(HTMLElement.prototype, "offsetWidth", {
|
|
configurable: true,
|
|
get() {
|
|
return this instanceof HTMLElement && this.classList.contains("codex-panel__messages") ? metrics.clientWidth : 0;
|
|
},
|
|
});
|
|
return () => {
|
|
restorePrototypeProperty(HTMLElement.prototype, "offsetHeight", offsetHeightDescriptor);
|
|
restorePrototypeProperty(HTMLElement.prototype, "offsetWidth", offsetWidthDescriptor);
|
|
};
|
|
}
|
|
|
|
function restorePrototypeProperty<T extends object>(target: T, property: keyof T, descriptor: PropertyDescriptor | undefined): void {
|
|
if (descriptor) {
|
|
Object.defineProperty(target, property, descriptor);
|
|
} else {
|
|
Reflect.deleteProperty(target, property);
|
|
}
|
|
}
|
|
|
|
function requiredTextArea(parent: ParentNode, selector: string): HTMLTextAreaElement {
|
|
const element = parent.querySelector<HTMLTextAreaElement>(selector);
|
|
if (!element) throw new Error(`Missing ${selector}`);
|
|
return element;
|
|
}
|
|
|
|
function requiredButton(parent: ParentNode, selector: string): HTMLButtonElement {
|
|
const element = parent.querySelector<HTMLButtonElement>(selector);
|
|
if (!element) throw new Error(`Missing ${selector}`);
|
|
return element;
|
|
}
|
|
|
|
async function submitComposerByEnter(view: { containerEl: HTMLElement }): Promise<void> {
|
|
await flushAsyncTicks();
|
|
const composer = requiredTextArea(view.containerEl, ".codex-panel__composer-input");
|
|
composer.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
|
await flushAsyncTicks();
|
|
}
|
|
|
|
async function flushAsyncTicks(): Promise<void> {
|
|
for (let index = 0; index < 20; index += 1) {
|
|
await Promise.resolve();
|
|
}
|
|
}
|
|
|
|
interface ChatHostFixtureOverrides {
|
|
settings?: Partial<typeof DEFAULT_SETTINGS>;
|
|
vaultPath?: string;
|
|
openThreadInNewView?: CodexChatHost["workspace"]["openThreadInNewView"];
|
|
focusThreadInOpenView?: CodexChatHost["workspace"]["focusThreadInOpenView"];
|
|
openTurnDiff?: CodexChatHost["workspace"]["openTurnDiff"];
|
|
refreshThreadsViewLiveState?: CodexChatHost["workspace"]["refreshThreadsViewLiveState"];
|
|
recordThreadArchived?: CodexChatHost["threadCatalog"]["recordThreadArchived"];
|
|
recordThreadDeleted?: CodexChatHost["threadCatalog"]["recordThreadDeleted"];
|
|
recordThreadRenamed?: CodexChatHost["threadCatalog"]["recordThreadRenamed"];
|
|
replaceActiveThreadsSnapshot?: ThreadCatalogSnapshotWriter["replaceActiveThreadsSnapshot"];
|
|
recordThreadStarted?: CodexChatHost["threadCatalog"]["recordThreadStarted"];
|
|
recordThreadForked?: CodexChatHost["threadCatalog"]["recordThreadForked"];
|
|
updateAppServerMetadata?: CodexChatHost["appServerData"]["updateAppServerMetadata"];
|
|
refreshActive?: CodexChatHost["threadCatalog"]["refreshActive"];
|
|
activeSnapshot?: CodexChatHost["threadCatalog"]["activeSnapshot"];
|
|
appServerMetadataSnapshot?: CodexChatHost["appServerData"]["appServerMetadataSnapshot"];
|
|
modelsSnapshot?: CodexChatHost["appServerData"]["modelsSnapshot"];
|
|
fetchModels?: CodexChatHost["appServerData"]["fetchModels"];
|
|
refreshModels?: CodexChatHost["appServerData"]["refreshModels"];
|
|
refreshAppServerMetadata?: CodexChatHost["appServerData"]["refreshAppServerMetadata"];
|
|
}
|
|
|
|
function chatHost(overrides: ChatHostFixtureOverrides = {}): TestCodexChatHost {
|
|
let activeThreads = overrides.activeSnapshot?.() ?? null;
|
|
let metadata = overrides.appServerMetadataSnapshot?.() ?? null;
|
|
const models = overrides.modelsSnapshot?.() ?? null;
|
|
const activeThreadResultListeners = new Set<(result: ObservedDataResult<readonly Thread[]>) => void>();
|
|
const metadataResultListeners = new Set<(result: ObservedDataResult<SharedServerMetadata>) => void>();
|
|
const modelResultListeners = new Set<(result: ObservedDataResult<readonly ModelMetadata[]>) => void>();
|
|
const settings = {
|
|
...DEFAULT_SETTINGS,
|
|
codexPath: "codex",
|
|
sendShortcut: "enter" as const,
|
|
...overrides.settings,
|
|
};
|
|
const vaultPath = overrides.vaultPath ?? "/vault";
|
|
const applyMetadataToCache = (nextMetadata: SharedServerMetadata): SharedServerMetadata => {
|
|
metadata = nextMetadata;
|
|
for (const listener of metadataResultListeners) listener(queryResult(nextMetadata));
|
|
for (const listener of modelResultListeners) listener(queryResult(nextMetadata.availableModels));
|
|
return nextMetadata;
|
|
};
|
|
const loadAppServerMetadata = async (): Promise<SharedServerMetadata | null> => {
|
|
const client = connectionMock.state.client as ReturnType<typeof baseClient> | null;
|
|
if (!client || !connectionMock.state.connected) return null;
|
|
const connectionStillCurrent = () => connectionMock.state.client === client && connectionMock.state.connected;
|
|
await client.readEffectiveConfig(vaultPath);
|
|
if (!connectionStillCurrent()) return null;
|
|
const availableModels = overrides.fetchModels
|
|
? await overrides.fetchModels()
|
|
: modelMetadataFromCatalogModels((await client.listModels(false)).data as Parameters<typeof modelMetadataFromCatalogModels>[0]);
|
|
if (!connectionStillCurrent()) return null;
|
|
const skillsResponse = await client.listSkills(vaultPath);
|
|
if (!connectionStillCurrent()) return null;
|
|
await client.readAccountRateLimits();
|
|
if (!connectionStillCurrent()) return null;
|
|
return {
|
|
runtimeConfig: emptyRuntimeConfigSnapshot(),
|
|
availableModels,
|
|
availableSkills: skillsResponse.data.flatMap(
|
|
(entry: { skills: { name: string; description?: string; path?: string; enabled?: boolean }[] }) =>
|
|
entry.skills.map((skill) => ({
|
|
name: skill.name,
|
|
description: skill.description ?? "",
|
|
path: skill.path ?? "",
|
|
enabled: skill.enabled ?? true,
|
|
})),
|
|
),
|
|
rateLimit: null,
|
|
serverDiagnostics: createServerDiagnostics(),
|
|
};
|
|
};
|
|
return {
|
|
settingsRef: {
|
|
settings,
|
|
vaultPath,
|
|
},
|
|
workspace: {
|
|
openThreadInNewView: overrides.openThreadInNewView ?? vi.fn(),
|
|
focusThreadInOpenView: overrides.focusThreadInOpenView ?? vi.fn().mockResolvedValue(false),
|
|
openTurnDiff: overrides.openTurnDiff ?? vi.fn(),
|
|
refreshThreadsViewLiveState: overrides.refreshThreadsViewLiveState ?? vi.fn(),
|
|
},
|
|
appServerData: {
|
|
updateAppServerMetadata:
|
|
overrides.updateAppServerMetadata ??
|
|
((updater) => {
|
|
const nextMetadata = updater(metadata);
|
|
if (!nextMetadata) return null;
|
|
return applyMetadataToCache(nextMetadata);
|
|
}),
|
|
appServerMetadataSnapshot: overrides.appServerMetadataSnapshot ?? vi.fn(() => metadata),
|
|
refreshAppServerMetadata:
|
|
overrides.refreshAppServerMetadata ??
|
|
vi.fn(async () => {
|
|
const nextMetadata = await loadAppServerMetadata();
|
|
return nextMetadata ? applyMetadataToCache(nextMetadata) : null;
|
|
}),
|
|
modelsSnapshot: overrides.modelsSnapshot ?? vi.fn(() => models),
|
|
fetchModels: overrides.fetchModels ?? vi.fn(async () => models ?? []),
|
|
refreshModels: overrides.refreshModels ?? vi.fn(async () => models ?? []),
|
|
observeAppServerMetadataResult: (listener, options = {}) => {
|
|
metadataResultListeners.add(listener);
|
|
if ((options.emitCurrent ?? true) && metadata) listener(queryResult(metadata));
|
|
return () => {
|
|
metadataResultListeners.delete(listener);
|
|
};
|
|
},
|
|
observeModelsResult: (listener, options = {}) => {
|
|
modelResultListeners.add(listener);
|
|
if ((options.emitCurrent ?? true) && models) listener(queryResult(models));
|
|
return () => {
|
|
modelResultListeners.delete(listener);
|
|
};
|
|
},
|
|
},
|
|
threadCatalog: {
|
|
recordThreadArchived:
|
|
overrides.recordThreadArchived ??
|
|
((threadId) => {
|
|
activeThreads = activeThreads?.filter((thread) => thread.id !== threadId) ?? null;
|
|
if (activeThreads) {
|
|
for (const listener of activeThreadResultListeners) listener(queryResult(activeThreads));
|
|
}
|
|
}),
|
|
recordThreadDeleted:
|
|
overrides.recordThreadDeleted ??
|
|
((threadId) => {
|
|
activeThreads = activeThreads?.filter((thread) => thread.id !== threadId) ?? null;
|
|
if (activeThreads) {
|
|
for (const listener of activeThreadResultListeners) listener(queryResult(activeThreads));
|
|
}
|
|
}),
|
|
recordThreadRenamed:
|
|
overrides.recordThreadRenamed ??
|
|
((threadId, name) => {
|
|
activeThreads = activeThreads?.map((thread) => (thread.id === threadId ? { ...thread, name } : thread)) ?? null;
|
|
if (activeThreads) {
|
|
for (const listener of activeThreadResultListeners) listener(queryResult(activeThreads));
|
|
}
|
|
}),
|
|
recordThreadStarted:
|
|
overrides.recordThreadStarted ??
|
|
((thread) => {
|
|
activeThreads = [thread, ...(activeThreads?.filter((item) => item.id !== thread.id) ?? [])];
|
|
for (const listener of activeThreadResultListeners) listener(queryResult(activeThreads));
|
|
}),
|
|
recordThreadForked:
|
|
overrides.recordThreadForked ??
|
|
((thread) => {
|
|
activeThreads = [thread, ...(activeThreads?.filter((item) => item.id !== thread.id) ?? [])];
|
|
for (const listener of activeThreadResultListeners) listener(queryResult(activeThreads));
|
|
}),
|
|
replaceActiveThreadsSnapshot:
|
|
overrides.replaceActiveThreadsSnapshot ??
|
|
((threads) => {
|
|
activeThreads = threads;
|
|
for (const listener of activeThreadResultListeners) listener(queryResult(threads));
|
|
}),
|
|
replaceArchivedThreadsSnapshot: vi.fn(),
|
|
refreshActive:
|
|
overrides.refreshActive ??
|
|
(vi.fn(async () => {
|
|
const client = connectionMock.state.client;
|
|
if (!client) return activeThreads ?? [];
|
|
const listThreads = client["listThreads"] as (cwd: string, options: Record<string, unknown>) => Promise<{ data: ThreadRecord[] }>;
|
|
const response = await listThreads("/vault", { archived: false, cursor: null, limit: 100 });
|
|
activeThreads = response.data.map(threadFromRecord);
|
|
for (const listener of activeThreadResultListeners) listener(queryResult(activeThreads));
|
|
return activeThreads;
|
|
}) as CodexChatHost["threadCatalog"]["refreshActive"]),
|
|
loadActive: vi.fn(async () => activeThreads ?? []),
|
|
activeSnapshot: overrides.activeSnapshot ?? vi.fn(() => activeThreads),
|
|
observeActive: (listener, options = {}) => {
|
|
activeThreadResultListeners.add(listener);
|
|
if ((options.emitCurrent ?? true) && activeThreads) listener(queryResult(activeThreads));
|
|
return () => {
|
|
activeThreadResultListeners.delete(listener);
|
|
};
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function queryResult<T>(data: T | null): ObservedDataResult<T> {
|
|
return {
|
|
data,
|
|
error: null,
|
|
isFetching: false,
|
|
};
|
|
}
|
|
|
|
async function chatView(
|
|
options: { activeLeafChangeListeners?: ((leaf: unknown) => void)[]; host?: CodexChatHost; requestSaveLayout?: () => void } = {},
|
|
) {
|
|
const host = options.host ?? chatHost();
|
|
const { CodexChatView } = await import("../../../src/features/chat/host/view");
|
|
const containerEl = document.createElement("div");
|
|
document.body.appendChild(containerEl);
|
|
containerEl.createDiv();
|
|
containerEl.createDiv();
|
|
return new CodexChatView(
|
|
{
|
|
app: {
|
|
workspace: {
|
|
getActiveFile: vi.fn(() => null),
|
|
getActiveViewOfType: vi.fn(() => null),
|
|
getLastOpenFiles: vi.fn(() => []),
|
|
on: vi.fn((eventName: string, callback: (leaf: unknown) => void) => {
|
|
if (eventName === "active-leaf-change") options.activeLeafChangeListeners?.push(callback);
|
|
return {};
|
|
}),
|
|
openLinkText: vi.fn(),
|
|
requestSaveLayout: options.requestSaveLayout ?? vi.fn(),
|
|
},
|
|
vault: {
|
|
on: vi.fn(() => ({})),
|
|
offref: vi.fn(),
|
|
getFiles: vi.fn(() => []),
|
|
getMarkdownFiles: vi.fn(() => []),
|
|
getAbstractFileByPath: vi.fn(() => null),
|
|
},
|
|
metadataCache: {
|
|
on: vi.fn(() => ({})),
|
|
offref: vi.fn(),
|
|
getFirstLinkpathDest: vi.fn(() => null),
|
|
fileToLinktext: vi.fn(() => ""),
|
|
getFileCache: vi.fn(() => null),
|
|
},
|
|
},
|
|
containerEl,
|
|
} as never,
|
|
host,
|
|
);
|
|
}
|