murashit_codex-panel/tests/features/chat/app-server/transports.test.ts

359 lines
14 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import type { AppServerClient } from "../../../../src/app-server/connection/client";
import type { ThreadRecord } from "../../../../src/app-server/protocol/thread";
import type { TurnItem, TurnRecord } from "../../../../src/app-server/protocol/turn";
import type { CodexInput } from "../../../../src/domain/chat/input";
import { createChatThreadGoalReadTransport, createChatThreadGoalTransport } from "../../../../src/features/chat/app-server/goals/transport";
import { createThreadReferenceResolver } from "../../../../src/features/chat/app-server/references/thread-reference-resolver";
import { createChatRuntimeSettingsTransport } from "../../../../src/features/chat/app-server/runtime/thread-settings-transport";
import {
createChatThreadHistoryTransport,
createChatThreadResumeTransport,
} from "../../../../src/features/chat/app-server/threads/loading-transport";
import { createChatThreadMutationTransport } from "../../../../src/features/chat/app-server/threads/transport";
import { createChatTurnTransport } from "../../../../src/features/chat/app-server/turns/transport";
import { deferred } from "../../../support/async";
const textInput = (text: string): CodexInput => [{ type: "text", text }];
describe("chat app-server transports", () => {
it("starts turns with the session vault path and returns chat-owned turn ids", async () => {
const startTurn = vi.fn().mockResolvedValue({ turn: { id: "turn-1" } });
const client = { startTurn } as unknown as AppServerClient;
const transport = createChatTurnTransport({
vaultPath: "/vault",
currentClient: () => client,
connectedClient: vi.fn().mockResolvedValue(client),
});
await expect(
transport.startTurn({
threadId: "thread",
input: textInput("hello"),
clientUserMessageId: "local-user",
}),
).resolves.toEqual({ turnId: "turn-1" });
expect(startTurn).toHaveBeenCalledWith({
threadId: "thread",
cwd: "/vault",
input: textInput("hello"),
clientUserMessageId: "local-user",
});
});
it("drops stale turn transport responses after the current client changes", async () => {
const start = deferred<{ turn: { id: string } }>();
const firstClient = { startTurn: vi.fn().mockReturnValue(start.promise) } as unknown as AppServerClient;
const secondClient = {} as unknown as AppServerClient;
let currentClient = firstClient;
const transport = createChatTurnTransport({
vaultPath: "/vault",
currentClient: () => currentClient,
connectedClient: vi.fn().mockResolvedValue(firstClient),
});
const starting = transport.startTurn({ threadId: "thread", input: textInput("hello"), clientUserMessageId: "local-user" });
currentClient = secondClient;
start.resolve({ turn: { id: "turn-1" } });
await expect(starting).resolves.toBeNull();
});
it("compacts threads through a connected app-server client", async () => {
const compactThread = vi.fn().mockResolvedValue({});
const client = { compactThread } as unknown as AppServerClient;
const transport = createChatThreadMutationTransport({
vaultPath: "/vault",
currentClient: () => client,
connectedClient: vi.fn().mockResolvedValue(client),
});
await expect(transport.compactThread("thread")).resolves.toBe(true);
expect(compactThread).toHaveBeenCalledWith("thread");
});
it("forks threads with the session vault path and returns panel threads", async () => {
const forkThread = vi.fn().mockResolvedValue({ thread: threadRecord("forked") });
const client = { forkThread } as unknown as AppServerClient;
const transport = createChatThreadMutationTransport({
vaultPath: "/vault",
currentClient: () => client,
connectedClient: vi.fn().mockResolvedValue(client),
});
const thread = await transport.forkThread("source");
expect(forkThread).toHaveBeenCalledWith("source", "/vault");
expect(thread).toMatchObject({ id: "forked", preview: "Preview", archived: false });
});
it("drops stale fork transport responses after the current client changes", async () => {
const fork = deferred<{ thread: ThreadRecord }>();
const firstClient = { forkThread: vi.fn().mockReturnValue(fork.promise) } as unknown as AppServerClient;
const secondClient = {} as unknown as AppServerClient;
let currentClient = firstClient;
const transport = createChatThreadMutationTransport({
vaultPath: "/vault",
currentClient: () => currentClient,
connectedClient: vi.fn().mockResolvedValue(firstClient),
});
const forking = transport.forkThread("source");
currentClient = secondClient;
fork.resolve({ thread: threadRecord("forked") });
await expect(forking).resolves.toBeNull();
});
it("projects rollback turns into message stream items", async () => {
const rollbackThread = vi.fn().mockResolvedValue({ thread: threadRecord("thread", [turn([userMessage("u1", "prompt")])]) });
const client = { rollbackThread } as unknown as AppServerClient;
const transport = createChatThreadMutationTransport({
vaultPath: "/vault",
currentClient: () => client,
connectedClient: vi.fn().mockResolvedValue(client),
});
const snapshot = await transport.rollbackThread("thread");
expect(rollbackThread).toHaveBeenCalledWith("thread");
expect(snapshot?.thread.id).toBe("thread");
expect(snapshot?.cwd).toBe("/vault");
expect(snapshot?.items).toEqual([expect.objectContaining({ kind: "message", role: "user", text: "prompt" })]);
});
it("reads thread history pages as message stream items", async () => {
const threadTurnsList = vi.fn().mockResolvedValue({
data: [turn([userMessage("u1", "prompt"), agentMessage("a1", "answer")])],
nextCursor: "older",
});
const client = { threadTurnsList } as unknown as AppServerClient;
const transport = createChatThreadHistoryTransport({
currentClient: () => client,
});
const page = await transport.readHistoryPage("thread", "cursor", 20);
expect(threadTurnsList).toHaveBeenCalledWith("thread", "cursor", 20);
expect(page?.nextCursor).toBe("older");
expect(page?.hadTurns).toBe(true);
expect(page?.items).toEqual([
expect.objectContaining({ kind: "message", role: "user", text: "prompt" }),
expect.objectContaining({ kind: "message", role: "assistant", text: "answer" }),
]);
});
it("drops stale history transport responses after the current client changes", async () => {
const history = deferred<{ data: TurnRecord[]; nextCursor: string | null }>();
const firstClient = { threadTurnsList: vi.fn().mockReturnValue(history.promise) } as unknown as AppServerClient;
const secondClient = {} as unknown as AppServerClient;
let currentClient = firstClient;
const transport = createChatThreadHistoryTransport({
currentClient: () => currentClient,
});
const loading = transport.readHistoryPage("thread", "cursor", 20);
currentClient = secondClient;
history.resolve({ data: [turn([userMessage("u1", "prompt")])], nextCursor: "older" });
await expect(loading).resolves.toBeNull();
});
it("resumes threads with the session vault path and projects initial history", async () => {
const resumeThread = vi.fn().mockResolvedValue({
thread: { ...threadRecord("thread"), path: "/tmp/rollout.jsonl" },
cwd: "/vault",
model: "gpt-test",
serviceTier: null,
approvalsReviewer: "user",
reasoningEffort: null,
initialTurnsPage: {
data: [turn([userMessage("u1", "prompt")])],
nextCursor: "older",
},
});
const client = { resumeThread } as unknown as AppServerClient;
const transport = createChatThreadResumeTransport({
vaultPath: "/vault",
currentClient: () => client,
connectedClient: vi.fn().mockResolvedValue(client),
});
const snapshot = await transport.resumeThread("thread");
expect(resumeThread).toHaveBeenCalledWith("thread", "/vault");
expect(snapshot?.activation.thread.id).toBe("thread");
expect(snapshot?.activation.cwd).toBe("/vault");
expect(snapshot?.rolloutPath).toBe("/tmp/rollout.jsonl");
expect(snapshot?.initialHistoryPage).toMatchObject({
nextCursor: "older",
hadTurns: true,
items: [expect.objectContaining({ kind: "message", role: "user", text: "prompt" })],
});
});
it("drops stale resume transport responses after the current client changes", async () => {
const resume = deferred<AppServerThreadResumeResponse>();
const firstClient = { resumeThread: vi.fn().mockReturnValue(resume.promise) } as unknown as AppServerClient;
const secondClient = {} as unknown as AppServerClient;
let currentClient = firstClient;
const transport = createChatThreadResumeTransport({
vaultPath: "/vault",
currentClient: () => currentClient,
connectedClient: vi.fn().mockResolvedValue(firstClient),
});
const resuming = transport.resumeThread("thread");
currentClient = secondClient;
resume.resolve(threadResumeResponse("thread"));
await expect(resuming).resolves.toBeNull();
});
it("returns no resume snapshot when no connected client is available", async () => {
const transport = createChatThreadResumeTransport({
vaultPath: "/vault",
currentClient: () => null,
connectedClient: vi.fn().mockResolvedValue(null),
});
await expect(transport.resumeThread("thread")).resolves.toBeNull();
});
it("distinguishes absent goals from unavailable goal clients", async () => {
const client = { getThreadGoal: vi.fn().mockResolvedValue({ goal: null }) } as unknown as AppServerClient;
const transport = createChatThreadGoalTransport({
currentClient: () => client,
connectedClient: vi.fn().mockResolvedValue(client),
});
const unavailable = createChatThreadGoalTransport({
currentClient: () => null,
connectedClient: vi.fn().mockResolvedValue(null),
});
const readOnlyUnavailable = createChatThreadGoalReadTransport({
currentClient: () => null,
});
await expect(transport.readThreadGoal("thread")).resolves.toBeNull();
await expect(unavailable.readThreadGoal("thread")).resolves.toBeUndefined();
await expect(readOnlyUnavailable.readThreadGoal("thread")).resolves.toBeUndefined();
});
it("drops stale runtime settings updates after the current client changes", async () => {
const update = deferred<void>();
const firstClient = { updateThreadSettings: vi.fn().mockReturnValue(update.promise) } as unknown as AppServerClient;
const secondClient = {} as unknown as AppServerClient;
let currentClient = firstClient;
const transport = createChatRuntimeSettingsTransport({
currentClient: () => currentClient,
});
const updating = transport.updateThreadSettings("thread", { model: "gpt-5.5" });
currentClient = secondClient;
update.resolve(undefined);
await expect(updating).resolves.toBe(false);
expect(firstClient.updateThreadSettings).toHaveBeenCalledWith("thread", { model: "gpt-5.5" });
});
it("resolves referenced thread input at the app-server boundary", async () => {
const threadTurnsList = vi.fn().mockResolvedValue({
data: [turn([userMessage("u1", "元の依頼"), agentMessage("a1", "回答")])],
nextCursor: null,
});
const client = { threadTurnsList } as unknown as AppServerClient;
const setStatus = vi.fn();
const resolver = createThreadReferenceResolver({
currentClient: () => client,
codexInput: (text) => textInput(text),
addSystemMessage: vi.fn(),
setStatus,
});
const result = await resolver.referThread(
{ id: "019abcde-0000-7000-8000-000000000001", preview: "", name: "Other", createdAt: 1, updatedAt: 1, archived: false },
"summarize",
);
expect(threadTurnsList).toHaveBeenCalledWith("019abcde-0000-7000-8000-000000000001", null, 20);
expect(result?.input[0]).toMatchObject({
type: "text",
text: expect.stringContaining("Reference thread history:"),
});
expect(result?.referencedThread).toMatchObject({ title: "Other", includedTurns: 1, turnLimit: 20 });
expect(setStatus).toHaveBeenCalledWith("Referencing 019abcde (1/20 turns).");
});
});
type AppServerThreadResumeResponse = Awaited<ReturnType<AppServerClient["resumeThread"]>>;
function threadRecord(id: string, turns: readonly TurnRecord[] = [], overrides: Partial<ThreadRecord> = {}): ThreadRecord {
return {
id,
sessionId: id,
forkedFromId: null,
parentThreadId: null,
preview: "Preview",
ephemeral: false,
modelProvider: "openai",
createdAt: 1,
updatedAt: 1,
status: { type: "idle" },
path: null,
cwd: "/vault",
cliVersion: "codex-cli 0.0.0",
source: "unknown",
threadSource: null,
agentNickname: null,
agentRole: null,
gitInfo: null,
name: null,
turns,
...overrides,
};
}
function threadResumeResponse(threadId: string, overrides: Partial<AppServerThreadResumeResponse> = {}): AppServerThreadResumeResponse {
return {
thread: threadRecord(threadId) as AppServerThreadResumeResponse["thread"],
cwd: "/vault",
model: "gpt-test",
modelProvider: "openai",
serviceTier: null,
runtimeWorkspaceRoots: [],
instructionSources: [],
approvalPolicy: "never",
approvalsReviewer: "user",
sandbox: { type: "readOnly", networkAccess: false },
activePermissionProfile: null,
reasoningEffort: null,
multiAgentMode: "none",
initialTurnsPage: null,
...overrides,
};
}
function turn(items: TurnRecord["items"], overrides: Partial<TurnRecord> = {}): TurnRecord {
return {
id: "turn",
items,
itemsView: "full",
status: "completed",
error: null,
startedAt: null,
completedAt: null,
durationMs: null,
...overrides,
};
}
function userMessage(id: string, text: string): TurnItem {
return { type: "userMessage", id, clientId: null, content: [{ type: "text", text, text_elements: [] }] };
}
function agentMessage(id: string, text: string): TurnItem {
return { type: "agentMessage", id, text, phase: "final_answer", memoryCitation: null };
}