fix(agent): unify live and historical conversation state

This commit is contained in:
yu
2026-08-01 22:11:05 +08:00
parent ee5804e586
commit ea0414e88c
24 changed files with 3246 additions and 576 deletions
+1 -1
View File
@@ -15,7 +15,7 @@
"scripts": {
"dev": "tsx src/index.ts",
"debug": "tsx src/index.ts --debug",
"test": "tsx --test src/canvas/session.test.ts",
"test": "tsx --test src/canvas/session.test.ts src/agent/codex-client.test.ts src/agent/codex-history.test.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"prepack": "npm run build"
+408
View File
@@ -0,0 +1,408 @@
import assert from "node:assert/strict";
import test from "node:test";
import { CodexAppClient } from "./codex-client.js";
type TestClient = {
currentThreadId: string;
currentTurnId: string;
completedTurns: Map<string, Error | null>;
plansByTurn: Map<string, unknown>;
lastUsage: unknown;
answerServerRequest(message: Record<string, unknown>): void;
failAll(message: string): void;
handle(message: Record<string, unknown>): void;
handleNotification(method: string, params: Record<string, unknown>): void;
};
const emptyEventHistory = { record: () => Promise.resolve(), recordTurn: () => Promise.resolve() };
test("审批只在 app-server 确认 resolved 后清除", () => {
const writes: Array<Record<string, unknown>> = [];
const events: Array<{ type: string; payload: unknown }> = [];
const child = { stdin: { write: (line: string) => (writes.push(JSON.parse(line)), true) } };
const client = Reflect.construct(CodexAppClient, [child, (type: string, payload: unknown) => events.push({ type, payload }), emptyEventHistory]) as CodexAppClient;
const testClient = client as unknown as TestClient;
testClient.answerServerRequest({ id: 17, method: "item/commandExecution/requestApproval", params: { threadId: "thread-1", turnId: "turn-1" } });
assert.equal(events.filter((item) => item.type === "codex_approval").length, 1);
assert.equal(client.resolveApproval("17", "accept"), true);
assert.equal(writes.length, 1);
assert.equal(events.some((item) => item.type === "codex_approval_resolved"), false);
assert.equal(client.resolveApproval("17", "accept"), true);
assert.equal(writes.length, 1);
testClient.handleNotification("serverRequest/resolved", { requestId: "17" });
const resolved = events.find((item) => item.type === "codex_approval_resolved");
assert.deepEqual(resolved?.payload, { threadId: "thread-1", turnId: "turn-1", requestId: "17", decision: "accept" });
assert.equal(client.resolveApproval("17", "accept"), false);
});
test("中断请求只作用于当前运行线程", async () => {
const writes: Array<Record<string, unknown>> = [];
const child = { stdin: { write: (line: string) => (writes.push(JSON.parse(line)), true) } };
const client = Reflect.construct(CodexAppClient, [child, () => undefined, emptyEventHistory]) as CodexAppClient;
const testClient = client as unknown as TestClient;
testClient.currentThreadId = "thread-1";
testClient.currentTurnId = "turn-1";
assert.equal(await client.interruptCurrentTurn("thread-2"), false);
assert.equal(writes.length, 0);
const interrupt = client.interruptCurrentTurn("thread-1");
const request = writes.find((item) => item.method === "turn/interrupt");
assert.ok(request);
testClient.handle({ id: request.id, result: {} });
assert.equal(await interrupt, true);
});
test("turn/started 早于 turn/start 响应时保持完整事件归属", async () => {
const writes: Array<Record<string, unknown>> = [];
const events: Array<{ type: string; payload: unknown }> = [];
const child = { stdin: { write: (line: string) => (writes.push(JSON.parse(line)), true) } };
const client = Reflect.construct(CodexAppClient, [child, (type: string, payload: unknown) => events.push({ type, payload }), emptyEventHistory]) as CodexAppClient;
const testClient = client as unknown as TestClient;
const turnIds: string[] = [];
const running = client.startTurn("thread-1", "测试", [], "request", undefined, undefined, (turnId) => turnIds.push(turnId));
const request = writes.find((item) => item.method === "turn/start");
assert.ok(request);
testClient.handleNotification("turn/started", { turn: { id: "turn-1", status: "inProgress" } });
assert.deepEqual(turnIds, ["turn-1"]);
testClient.handleNotification("item/started", { item: { id: "reasoning-1", type: "reasoning" } });
testClient.handleNotification("turn/completed", { turn: { id: "turn-1", status: "completed" } });
testClient.handle({ id: request.id, result: { turn: { id: "turn-1" } } });
await running;
assert.deepEqual(turnIds, ["turn-1"]);
const scopedEvents = events.filter((item) => item.type === "agent_event");
assert.deepEqual(scopedEvents.map((item) => eventScope(item.payload)), [
{ threadId: "thread-1", turnId: "turn-1" },
{ threadId: "thread-1", turnId: "turn-1" },
{ threadId: "thread-1", turnId: "turn-1" },
]);
assert.deepEqual(eventScope(events.find((item) => item.type === "agent_done")?.payload), { threadId: "thread-1", turnId: "turn-1" });
});
test("turn/start 响应早于通知时 onTurn 仍只调用一次", async () => {
const writes: Array<Record<string, unknown>> = [];
const events: Array<{ type: string; payload: unknown }> = [];
const child = { stdin: { write: (line: string) => (writes.push(JSON.parse(line)), true) } };
const client = Reflect.construct(CodexAppClient, [child, (type: string, payload: unknown) => events.push({ type, payload }), emptyEventHistory]) as CodexAppClient;
const testClient = client as unknown as TestClient;
const turnIds: string[] = [];
const running = client.startTurn("thread-1", "测试", [], "request", undefined, undefined, (turnId) => turnIds.push(turnId));
const request = writes.find((item) => item.method === "turn/start");
assert.ok(request);
testClient.handle({ id: request.id, result: { turn: { id: "turn-1" } } });
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(turnIds, ["turn-1"]);
testClient.handleNotification("turn/started", { threadId: "thread-1", turn: { id: "turn-1", status: "inProgress" } });
testClient.handleNotification("turn/completed", { threadId: "thread-1", turn: { id: "turn-1", status: "completed" } });
await running;
assert.deepEqual(turnIds, ["turn-1"]);
assert.equal(events.filter((item) => item.type === "agent_event" && eventType(item.payload) === "turn.started").length, 1);
});
test("turn/started 通知缺失时使用 turn/start 响应回调", async () => {
const writes: Array<Record<string, unknown>> = [];
const child = { stdin: { write: (line: string) => (writes.push(JSON.parse(line)), true) } };
const client = Reflect.construct(CodexAppClient, [child, () => undefined, emptyEventHistory]) as CodexAppClient;
const testClient = client as unknown as TestClient;
const turnIds: string[] = [];
const running = client.startTurn("thread-1", "测试", [], "request", undefined, undefined, (turnId) => turnIds.push(turnId));
const request = writes.find((item) => item.method === "turn/start");
assert.ok(request);
testClient.handle({ id: request.id, result: { turn: { id: "turn-1" } } });
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(turnIds, ["turn-1"]);
testClient.handleNotification("turn/completed", { threadId: "thread-1", turn: { id: "turn-1", status: "completed" } });
await running;
assert.deepEqual(turnIds, ["turn-1"]);
});
test("onTurn 按 threadId 和 turnId 去重", async () => {
const writes: Array<Record<string, unknown>> = [];
const child = { stdin: { write: (line: string) => (writes.push(JSON.parse(line)), true) } };
const client = Reflect.construct(CodexAppClient, [child, () => undefined, emptyEventHistory]) as CodexAppClient;
const testClient = client as unknown as TestClient;
const turnIds: string[] = [];
const first = client.startTurn("thread-1", "测试", [], "request", undefined, undefined, (turnId) => turnIds.push(`thread-1:${turnId}`));
const firstRequest = writes.find((item) => item.method === "turn/start");
assert.ok(firstRequest);
testClient.handle({ id: firstRequest.id, result: { turn: { id: "turn-1" } } });
await new Promise((resolve) => setImmediate(resolve));
testClient.handleNotification("turn/started", { threadId: "thread-1", turn: { id: "turn-1", status: "inProgress" } });
testClient.handleNotification("turn/completed", { threadId: "thread-1", turn: { id: "turn-1", status: "completed" } });
await first;
const second = client.startTurn("thread-2", "测试", [], "request", undefined, undefined, (turnId) => turnIds.push(`thread-2:${turnId}`));
const secondRequest = writes.filter((item) => item.method === "turn/start").at(-1);
assert.ok(secondRequest);
testClient.handle({ id: secondRequest.id, result: { turn: { id: "turn-1" } } });
await new Promise((resolve) => setImmediate(resolve));
testClient.handleNotification("turn/started", { threadId: "thread-2", turn: { id: "turn-1", status: "inProgress" } });
testClient.handleNotification("turn/completed", { threadId: "thread-2", turn: { id: "turn-1", status: "completed" } });
await second;
assert.deepEqual(turnIds, ["thread-1:turn-1", "thread-2:turn-1"]);
});
test("稀疏的命令完成通知会保留开始通知中的命令内容", () => {
const events: Array<{ type: string; payload: unknown }> = [];
const persisted: unknown[] = [];
const child = { stdin: { write: () => true } };
const history = { record: (entry: unknown) => (persisted.push(entry), Promise.resolve()) };
const client = Reflect.construct(CodexAppClient, [child, (type: string, payload: unknown) => events.push({ type, payload }), history]) as CodexAppClient;
const testClient = client as unknown as TestClient;
testClient.handleNotification("item/started", { threadId: "thread-1", turnId: "turn-1", item: { id: "command-1", type: "commandExecution", command: "Get-Location", cwd: "D:\\infinite-canvas" } });
testClient.handleNotification("item/commandExecution/outputDelta", { threadId: "thread-1", turnId: "turn-1", itemId: "command-1", delta: "D:\\infinite-canvas" });
testClient.handleNotification("item/completed", { threadId: "thread-1", turnId: "turn-1", item: { id: "command-1", type: "commandExecution", status: "completed", exitCode: 0 } });
const completed = events.find((event) => event.type === "agent_event" && eventType(event.payload) === "item.completed");
const item = (completed?.payload as { item?: Record<string, unknown> })?.item;
assert.equal(item?.command, "Get-Location");
assert.equal(item?.cwd, "D:\\infinite-canvas");
assert.equal(item?.status, "completed");
assert.equal(item?.aggregatedOutput, "D:\\infinite-canvas");
assert.deepEqual(persisted, [{
threadId: "thread-1",
turnId: "turn-1",
itemId: "command-1",
sequence: 1,
item,
}]);
});
test("稀疏的 plan 完成通知会保留流式正文", () => {
const events: Array<{ type: string; payload: unknown }> = [];
const persisted: unknown[] = [];
const child = { stdin: { write: () => true } };
const history = { record: (entry: unknown) => (persisted.push(entry), Promise.resolve()), recordTurn: () => Promise.resolve() };
const client = Reflect.construct(CodexAppClient, [child, (type: string, payload: unknown) => events.push({ type, payload }), history]) as CodexAppClient;
const testClient = client as unknown as TestClient;
testClient.handleNotification("item/plan/delta", { threadId: "thread-1", turnId: "turn-1", itemId: "plan-1", delta: "第一步\n第二步" });
testClient.handleNotification("item/completed", { threadId: "thread-1", turnId: "turn-1", item: { id: "plan-1", type: "plan", status: "completed" } });
const completed = events.find((event) => event.type === "agent_event" && eventType(event.payload) === "item.completed");
assert.equal((completed?.payload as { item?: { text?: string } })?.item?.text, "第一步\n第二步");
assert.equal(((persisted[0] as { item?: { text?: string } })?.item?.text), "第一步\n第二步");
});
test("reasoning 完成通知缺少 summary 时保留流式摘要", () => {
const events: Array<{ type: string; payload: unknown }> = [];
const persisted: unknown[] = [];
const child = { stdin: { write: () => true } };
const history = { record: (entry: unknown) => (persisted.push(entry), Promise.resolve()), recordTurn: () => Promise.resolve() };
const client = Reflect.construct(CodexAppClient, [child, (type: string, payload: unknown) => events.push({ type, payload }), history]) as CodexAppClient;
const testClient = client as unknown as TestClient;
testClient.handleNotification("item/reasoning/summaryTextDelta", { threadId: "thread-1", turnId: "turn-1", itemId: "reasoning-1", summaryIndex: 0, delta: "分析结果" });
testClient.handleNotification("item/completed", { threadId: "thread-1", turnId: "turn-1", item: { id: "reasoning-1", type: "reasoning", status: "completed" } });
const completed = events.find((event) => event.type === "agent_event" && eventType(event.payload) === "item.completed");
assert.equal((completed?.payload as { item?: { summary?: string } })?.item?.summary, "分析结果");
assert.equal(((persisted[0] as { item?: { summary?: string } })?.item?.summary), "分析结果");
});
test("流式更新只发送当前增量而不重复传输累计正文", async () => {
const events: Array<{ type: string; payload: unknown }> = [];
const child = { stdin: { write: () => true } };
const client = Reflect.construct(CodexAppClient, [child, (type: string, payload: unknown) => events.push({ type, payload }), emptyEventHistory]) as CodexAppClient;
const testClient = client as unknown as TestClient;
testClient.handleNotification("item/agentMessage/delta", { threadId: "thread-1", turnId: "turn-1", itemId: "assistant-1", delta: "第一段" });
await new Promise((resolve) => setTimeout(resolve, 50));
testClient.handleNotification("item/agentMessage/delta", { threadId: "thread-1", turnId: "turn-1", itemId: "assistant-1", delta: "第二段" });
await new Promise((resolve) => setTimeout(resolve, 50));
const updates = events.filter((event) => event.type === "agent_event" && eventType(event.payload) === "item.updated");
assert.deepEqual(updates.map((event) => (event.payload as { item: { delta?: string; text?: string } }).item), [
{ id: "assistant-1", type: "agent_message", delta: "第一段" },
{ id: "assistant-1", type: "agent_message", delta: "第二段" },
]);
});
test("新版协作工具完成通知会归一化并写入补充历史", () => {
const events: Array<{ type: string; payload: unknown }> = [];
const persisted: unknown[] = [];
const child = { stdin: { write: () => true } };
const history = { record: (entry: unknown) => (persisted.push(entry), Promise.resolve()), recordTurn: () => Promise.resolve() };
const client = Reflect.construct(CodexAppClient, [child, (type: string, payload: unknown) => events.push({ type, payload }), history]) as CodexAppClient;
const testClient = client as unknown as TestClient;
testClient.handleNotification("item/completed", { threadId: "thread-1", turnId: "turn-1", item: { id: "collab-1", type: "collabAgentToolCall", status: "completed" } });
const completed = events.find((event) => event.type === "agent_event" && eventType(event.payload) === "item.completed");
assert.equal((completed?.payload as { item?: { type?: string } })?.item?.type, "collab_tool_call");
assert.equal(((persisted[0] as { item?: { type?: string } })?.item?.type), "collab_tool_call");
});
test("并行条目按开始顺序保存而不是完成顺序", () => {
const persisted: unknown[] = [];
const child = { stdin: { write: () => true } };
const history = { record: (entry: unknown) => (persisted.push(entry), Promise.resolve()) };
const client = Reflect.construct(CodexAppClient, [child, () => undefined, history]) as CodexAppClient;
const testClient = client as unknown as TestClient;
testClient.handleNotification("item/started", { threadId: "thread-1", turnId: "turn-1", item: { id: "first", type: "commandExecution", command: "first" } });
testClient.handleNotification("item/started", { threadId: "thread-1", turnId: "turn-1", item: { id: "second", type: "commandExecution", command: "second" } });
testClient.handleNotification("item/completed", { threadId: "thread-1", turnId: "turn-1", item: { id: "second", type: "commandExecution", status: "completed" } });
testClient.handleNotification("item/completed", { threadId: "thread-1", turnId: "turn-1", item: { id: "first", type: "commandExecution", status: "completed" } });
assert.deepEqual(persisted.map((entry) => ({ itemId: (entry as { itemId: string }).itemId, sequence: (entry as { sequence: number }).sequence })), [
{ itemId: "second", sequence: 2 },
{ itemId: "first", sequence: 1 },
]);
});
test("turn 完成通知会保存本轮输入与终态 turn", async () => {
const persistedTurns: unknown[] = [];
const writes: Array<Record<string, unknown>> = [];
const child = { stdin: { write: (line: string) => (writes.push(JSON.parse(line)), true) } };
const history = { record: () => Promise.resolve(), recordTurn: (entry: unknown) => (persistedTurns.push(entry), Promise.resolve()) };
const client = Reflect.construct(CodexAppClient, [child, () => undefined, history]) as CodexAppClient;
const testClient = client as unknown as TestClient;
const running = client.startTurn("thread-1", "执行 Get-Location", [], "request");
const request = writes.find((item) => item.method === "turn/start");
assert.ok(request);
testClient.handle({ id: request.id, result: { turn: { id: "turn-1" } } });
await new Promise((resolve) => setImmediate(resolve));
testClient.handleNotification("turn/completed", { threadId: "thread-1", turn: { id: "turn-1", status: "completed", durationMs: 120 } });
await running;
assert.deepEqual(persistedTurns, [{ threadId: "thread-1", turnId: "turn-1", turn: { id: "turn-1", status: "completed", durationMs: 120, input: "执行 Get-Location" } }]);
});
test("turn 完成状态会等待补充历史落盘后再广播", async () => {
const events: Array<{ type: string; payload: unknown }> = [];
let release!: () => void;
const persisted = new Promise<void>((resolve) => { release = resolve; });
const child = { stdin: { write: () => true } };
const history = { record: () => Promise.resolve(), recordTurn: () => persisted };
const client = Reflect.construct(CodexAppClient, [child, (type: string, payload: unknown) => events.push({ type, payload }), history]) as CodexAppClient;
const testClient = client as unknown as TestClient;
testClient.handleNotification("turn/completed", { threadId: "thread-1", turn: { id: "turn-1", status: "completed" } });
assert.equal(events.some((event) => event.type === "agent_event" && eventType(event.payload) === "turn.completed"), false);
assert.equal(events.some((event) => event.type === "agent_done"), false);
release();
await new Promise((resolve) => setImmediate(resolve));
assert.equal(events.some((event) => event.type === "agent_event" && eventType(event.payload) === "turn.completed"), true);
assert.equal(events.some((event) => event.type === "agent_done"), true);
});
test("app-server 失效时清除不能跨进程复用的 turn 状态", () => {
const child = { stdin: { write: () => true } };
const client = Reflect.construct(CodexAppClient, [child, () => undefined, emptyEventHistory]) as CodexAppClient;
const testClient = client as unknown as TestClient;
testClient.completedTurns.set("thread-1\0turn-1", null);
testClient.plansByTurn.set("thread-1\0turn-1", { threadId: "thread-1" });
testClient.lastUsage = { inputTokens: 1 };
testClient.failAll("app-server stopped");
assert.equal(testClient.completedTurns.size, 0);
assert.equal(testClient.plansByTurn.size, 0);
assert.equal(testClient.lastUsage, null);
});
test("app-server 在 turn 完成通知前退出时保存失败终态", async () => {
const events: Array<{ type: string; payload: unknown }> = [];
const persistedTurns: unknown[] = [];
const child = { stdin: { write: () => true } };
const history = { record: () => Promise.resolve(), recordTurn: (entry: unknown) => (persistedTurns.push(entry), Promise.resolve()) };
const client = Reflect.construct(CodexAppClient, [child, (type: string, payload: unknown) => events.push({ type, payload }), history]) as CodexAppClient;
const testClient = client as unknown as TestClient;
const running = client.startTurn("thread-1", "执行失败任务", [], "request");
testClient.handle({ id: 1, result: { turn: { id: "turn-1" } } });
await new Promise((resolve) => setImmediate(resolve));
testClient.failAll("Codex app-server exited: 1");
await assert.rejects(running, /Codex app-server exited/);
assert.deepEqual(persistedTurns, [{
threadId: "thread-1",
turnId: "turn-1",
turn: { id: "turn-1", status: "failed", error: { message: "Codex app-server exited: 1" }, input: "执行失败任务" },
}]);
const completed = events.find((event) => event.type === "agent_event" && eventType(event.payload) === "turn.completed");
assert.equal((completed?.payload as { status?: string })?.status, "failed");
});
test("turn/started 已到达但 turn/start 尚未响应时退出仍保存失败终态", async () => {
const writes: Array<Record<string, unknown>> = [];
const events: Array<{ type: string; payload: unknown }> = [];
const persistedTurns: unknown[] = [];
const child = { stdin: { write: (line: string) => (writes.push(JSON.parse(line)), true) } };
const history = { record: () => Promise.resolve(), recordTurn: (entry: unknown) => (persistedTurns.push(entry), Promise.resolve()) };
const client = Reflect.construct(CodexAppClient, [child, (type: string, payload: unknown) => events.push({ type, payload }), history]) as CodexAppClient;
const testClient = client as unknown as TestClient;
const running = client.startTurn("thread-1", "响应前退出", [], "request");
assert.ok(writes.some((item) => item.method === "turn/start"));
testClient.handleNotification("turn/started", { threadId: "thread-1", turn: { id: "turn-1", status: "inProgress" } });
testClient.failAll("Codex app-server exited: 1");
await assert.rejects(running, /Codex app-server exited/);
assert.deepEqual(persistedTurns, [{
threadId: "thread-1",
turnId: "turn-1",
turn: { id: "turn-1", status: "failed", error: { message: "Codex app-server exited: 1" }, input: "响应前退出" },
}]);
assert.equal(events.some((event) => event.type === "agent_event" && eventType(event.payload) === "turn.completed"), true);
assert.equal(events.some((event) => event.type === "agent_done"), true);
});
test("app-server 退出时等待失败历史落盘后再结束 turn", async () => {
const writes: Array<Record<string, unknown>> = [];
const events: Array<{ type: string; payload: unknown }> = [];
let release!: () => void;
const persisted = new Promise<void>((resolve) => { release = resolve; });
const child = { stdin: { write: (line: string) => (writes.push(JSON.parse(line)), true) } };
const history = { record: () => Promise.resolve(), recordTurn: () => persisted };
const client = Reflect.construct(CodexAppClient, [child, (type: string, payload: unknown) => events.push({ type, payload }), history]) as CodexAppClient;
const testClient = client as unknown as TestClient;
const running = client.startTurn("thread-1", "等待落盘", [], "request");
const request = writes.find((item) => item.method === "turn/start");
assert.ok(request);
testClient.handle({ id: request.id, result: { turn: { id: "turn-1" } } });
await new Promise((resolve) => setImmediate(resolve));
let settled = false;
const outcome = running.then(() => { settled = true; }, () => { settled = true; });
testClient.failAll("Codex app-server exited: 1");
await new Promise((resolve) => setImmediate(resolve));
assert.equal(settled, false);
assert.equal(events.some((event) => event.type === "agent_event" && eventType(event.payload) === "turn.completed"), false);
assert.equal(events.some((event) => event.type === "agent_done"), false);
release();
await outcome;
assert.equal(settled, true);
assert.equal(events.some((event) => event.type === "agent_event" && eventType(event.payload) === "turn.completed"), true);
assert.equal(events.some((event) => event.type === "agent_done"), true);
});
function eventScope(payload: unknown) {
const value = payload && typeof payload === "object" ? payload as Record<string, unknown> : {};
return { threadId: value.thread_id, turnId: value.turn_id };
}
function eventType(payload: unknown) {
return payload && typeof payload === "object" ? (payload as Record<string, unknown>).type : undefined;
}
+284 -66
View File
@@ -7,17 +7,27 @@ import stripAnsi from "strip-ansi";
import { VERSION } from "../config.js";
import { logger } from "../utils/logger.js";
import { field, type JsonRecord } from "../utils/value.js";
import { codexEventHistory, type CodexEventHistory } from "./codex-event-history.js";
import type { CodexNotificationParams, CodexPlanUpdate, CodexReasoningEffort, CodexRequestMethod, CodexRequestParams, CodexRequestResult, CodexTurnInput } from "./codex-protocol.js";
import type { AgentEmit, AgentPermissionMode } from "./types.js";
type AgentEvent = JsonRecord & { type: string; usage?: unknown };
type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void };
type ItemDeltaParams = { threadId: string; turnId: string; itemId: string; delta: string };
type ActiveTurn = PendingRequest & { threadId: string; turnId: string; prompt: string };
type ItemDeltaParams = { threadId: string; turnId: string; itemId: string; delta: string; summaryIndex?: number };
type PendingDelta = { delta: string; itemType: string; params: ItemDeltaParams; timer: ReturnType<typeof setTimeout> };
type ApprovalRequest = { id: number; method: string; params: JsonRecord; decision?: string };
type PendingTurnStart = { threadId: string; prompt: string; turnId?: string; onTurn?: (turnId: string) => void };
const canvasAgentMcp = canvasAgentMcpCommand();
const require = createRequire(import.meta.url);
const STREAM_UPDATE_INTERVAL_MS = 40;
const supplementalItemTypes = new Set(["agent_message", "reasoning", "plan", "mcp_tool_call", "command_execution", "file_change", "dynamic_tool_call", "collab_tool_call", "web_search", "image_view", "image_generation", "context_compaction"]);
/** 表示错误已经通过 app-server 终态或进程事件通知过网页。 */
export class CodexReportedError extends Error {
override name = "CodexReportedError";
}
/** 封装 Codex app-server 的 JSON-RPC 通信与事件转换。 */
export class CodexAppClient {
@@ -25,23 +35,37 @@ export class CodexAppClient {
private buffer = "";
private currentThreadId = "";
private currentTurnId = "";
private pendingTurnStart?: PendingTurnStart;
private startedTurnKeys = new Set<string>();
private textByItem = new Map<string, string>();
private reasoningTextByItem = new Map<string, Map<number, string>>();
private lastUsage: unknown = null;
private pending = new Map<number, PendingRequest>();
private activeTurns = new Map<string, PendingRequest>();
private activeTurns = new Map<string, ActiveTurn>();
private completedTurns = new Map<string, Error | null>();
private pendingDeltas = new Map<string, PendingDelta>();
private startedItems = new Map<string, JsonRecord>();
private itemSequences = new Map<string, number>();
private nextItemSequences = new Map<string, number>();
private plansByTurn = new Map<string, CodexPlanUpdate>();
private approvalRequests = new Map<string, { id: number; method: string; params: JsonRecord }>();
private approvalRequests = new Map<string, ApprovalRequest>();
private finalizingTurns = new Map<string, Promise<void>>();
private failing = false;
/** 保存 app-server 子进程和事件出口。 */
private constructor(private child: ChildProcess, private emit: AgentEmit) {}
private constructor(private child: ChildProcess, private emit: AgentEmit, private eventHistory: Pick<CodexEventHistory, "record" | "recordTurn"> = codexEventHistory) {}
/** 启动并初始化 Codex app-server。 */
static async start(emit: AgentEmit, onExit: () => void) {
logger.info("Starting Codex app-server", { executable: process.execPath, codex: codexBin() });
const child = spawn(process.execPath, [codexBin(), "app-server", "--stdio"], { stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
const client = new CodexAppClient(child, emit);
let stopped = false;
const stop = () => {
if (stopped) return;
stopped = true;
onExit();
};
child.stdout?.on("data", (chunk) => client.read(chunk.toString()));
child.stderr?.on("data", (chunk) => {
const text = stripAnsi(chunk.toString()).replace(/^\d{4}-\d{2}-\d{2}T[\d:.]+Z\s+/, "");
@@ -51,11 +75,13 @@ export class CodexAppClient {
child.on("error", (error) => {
logger.error("Codex app-server process error", error);
emit("agent_error", { message: error.message });
client.failAll(error.message, true);
stop();
});
child.on("exit", (code) => {
logger.warn("Codex app-server exited", { code });
client.failAll(`Codex app-server exited: ${code ?? 0}`);
onExit();
stop();
emit("agent_log", { text: `Codex app-server exited: ${code ?? 0}` });
});
await client.request("initialize", { clientInfo: { name: "canvas-agent", title: "Infinite Canvas Agent", version: VERSION }, capabilities: { experimentalApi: true, requestAttestation: false } });
@@ -104,35 +130,49 @@ export class CodexAppClient {
/** 清理已归档线程的任务计划缓存。 */
clearPlanUpdates(threadId: string) {
this.plansByTurn.forEach((item, turnId) => {
if (item.threadId === threadId) this.plansByTurn.delete(turnId);
this.plansByTurn.forEach((item, key) => {
if (item.threadId === threadId) this.plansByTurn.delete(key);
});
}
/** 启动一个 Codex turn 并等待完成通知。 */
async startTurn(threadId: string, prompt: string, images: string[], permissionMode: AgentPermissionMode, model?: string, effort?: CodexReasoningEffort, onTurn?: (turnId: string) => void) {
this.currentThreadId = threadId;
const { turn } = await this.request("turn/start", { threadId, input: codexInput(prompt, images), ...turnSettings(permissionMode), ...(model ? { model } : {}), ...(effort ? { effort } : {}) });
const turnId = turn.id;
if (!turnId) throw new Error("Codex app-server 没有返回 turn id");
this.currentTurnId = turnId;
onTurn?.(turnId);
const completed = this.completedTurns.get(turnId);
if (this.completedTurns.has(turnId)) {
this.completedTurns.delete(turnId);
this.currentThreadId = "";
this.currentTurnId = "";
if (completed) throw completed;
return;
this.currentTurnId = "";
this.lastUsage = null;
const pendingStart: PendingTurnStart = { threadId, prompt, onTurn };
this.pendingTurnStart = pendingStart;
try {
const { turn } = await this.request("turn/start", { threadId, input: codexInput(prompt, images), ...turnSettings(permissionMode), ...(model ? { model } : {}), ...(effort ? { effort } : {}) });
const turnId = turn.id;
if (!turnId) throw new Error("Codex app-server 没有返回 turn id");
pendingStart.turnId = turnId;
this.currentTurnId = turnId;
this.notifyTurnStarted(threadId, turnId, pendingStart);
const turnKey = turnCacheKey(threadId, turnId);
const completed = this.completedTurns.get(turnKey);
if (this.completedTurns.has(turnKey)) {
this.completedTurns.delete(turnKey);
this.currentThreadId = "";
this.currentTurnId = "";
if (completed) throw completed;
return;
}
await new Promise((resolve, reject) => this.activeTurns.set(turnKey, { resolve, reject, threadId, turnId, prompt }));
} catch (error) {
if (!this.currentTurnId) this.currentThreadId = "";
throw error;
} finally {
if (this.pendingTurnStart === pendingStart) this.pendingTurnStart = undefined;
if (pendingStart.turnId) this.startedTurnKeys.delete(turnCacheKey(threadId, pendingStart.turnId));
}
await new Promise((resolve, reject) => this.activeTurns.set(turnId, { resolve, reject }));
}
/** 中断当前正在运行的 Codex turn。 */
async interruptCurrentTurn() {
/** 中断当前正在运行且属于指定线程的 Codex turn。 */
async interruptCurrentTurn(requestedThreadId?: string) {
const threadId = this.currentThreadId;
const turnId = this.currentTurnId;
if (!threadId || !turnId) return false;
if (!threadId || !turnId || (requestedThreadId && requestedThreadId !== threadId)) return false;
try {
logger.warn("Interrupting active Codex turn", { threadId, turnId });
await this.request("turn/interrupt", { threadId, turnId });
@@ -147,10 +187,12 @@ export class CodexAppClient {
resolveApproval(requestId: string, decision: string) {
const request = this.approvalRequests.get(requestId);
if (!request) return false;
this.approvalRequests.delete(requestId);
if (request.decision) return true;
request.decision = decision;
const permissions = field(request.params, "permissions") || field(request.params, "requestedPermissions");
const accepted = decision === "accept" || decision === "acceptForSession";
const result = request.method === "item/permissions/requestApproval"
? { permissions: decision === "decline" ? {} : permissions || {}, scope: decision === "acceptForSession" ? "session" : "turn" }
? { permissions: accepted ? permissions || {} : {}, scope: decision === "acceptForSession" ? "session" : "turn" }
: { decision };
this.write({ id: request.id, result });
return true;
@@ -209,14 +251,26 @@ export class CodexAppClient {
private handleNotification(method: string, params: JsonRecord) {
if (method === "serverRequest/resolved") {
const requestId = String(field(params, "requestId") || "");
if (requestId) this.approvalRequests.delete(requestId);
this.emit("codex_approval_resolved", { requestId, ...params });
const request = requestId ? this.approvalRequests.get(requestId) : undefined;
if (request) {
this.approvalRequests.delete(requestId);
this.emit("codex_approval_resolved", { ...request.params, ...params, requestId, decision: request.decision });
}
return;
}
if (!field(params, "threadId") && this.currentThreadId && (method === "turn/started" || method === "turn/completed" || method === "turn/plan/updated")) params = { ...params, threadId: this.currentThreadId };
const turnEvent = method.startsWith("turn/") || method.startsWith("item/") || method === "thread/tokenUsage/updated" || method === "error";
if (turnEvent) {
const threadId = String(field(params, "threadId") || this.currentThreadId);
const turnId = String(field(params, "turnId") || field(field(params, "turn"), "id") || this.currentTurnId);
if (method === "turn/started") {
this.currentThreadId = threadId;
this.currentTurnId = turnId;
this.notifyTurnStarted(threadId, turnId);
}
params = { ...params, ...(threadId ? { threadId } : {}), ...(turnId ? { turnId } : {}) };
}
if (method === "item/agentMessage/delta") {
const value = params as unknown as CodexNotificationParams<"item/agentMessage/delta">;
this.textByItem.set(value.itemId, `${this.textByItem.get(value.itemId) || ""}${value.delta}`);
return this.emitDelta("agent_message", value);
}
if (method === "item/plan/delta") return this.emitDelta("plan", params as unknown as CodexNotificationParams<"item/plan/delta">);
@@ -225,7 +279,7 @@ export class CodexAppClient {
if (method === "turn/plan/updated") {
const value = params as unknown as CodexNotificationParams<"turn/plan/updated">;
const update: CodexPlanUpdate = { ...value, threadId: value.threadId || "" };
if (update.threadId && update.turnId) this.plansByTurn.set(update.turnId, update);
if (update.threadId && update.turnId) this.plansByTurn.set(turnCacheKey(update.threadId, update.turnId), update);
params = update as unknown as JsonRecord;
}
if (method === "thread/tokenUsage/updated") {
@@ -235,66 +289,162 @@ export class CodexAppClient {
}
const event = normalizeCodexNotification(method, params);
if (!event) return;
const eventScope = codexEventScope(params);
if (event.type === "item.started" || event.type === "item.completed") {
const item = field(event, "item") as JsonRecord | undefined;
const id = String(field(item, "id") || "");
const threadId = String(field(event, "thread_id") || this.currentThreadId);
const turnId = String(field(event, "turn_id") || this.currentTurnId);
const key = itemCacheKey({ threadId, turnId, itemId: id });
if (id && threadId && turnId && event.type === "item.started") {
this.assignItemSequence(threadId, turnId, id);
this.startedItems.set(key, item || {});
}
if (id && event.type === "item.completed") {
const started = this.startedItems.get(key);
if (started) event.item = mergeDefinedRecord(started, item || {});
this.startedItems.delete(key);
}
}
if (event.type === "item.completed") {
const item = field(event, "item") as JsonRecord | undefined;
const id = String(field(item, "id") || "");
this.flushDelta(id);
const streamedText = this.textByItem.get(id);
if (item?.type === "agent_message" && streamedText && !item.text) item.text = streamedText;
if (id) this.textByItem.delete(id);
const key = itemCacheKey({ threadId: String(field(event, "thread_id") || this.currentThreadId), turnId: String(field(event, "turn_id") || this.currentTurnId), itemId: id });
this.flushDelta(key);
const streamedText = this.textByItem.get(key);
if ((item?.type === "agent_message" || item?.type === "plan") && streamedText && !String(item.text || "").trim()) item.text = streamedText;
if (item?.type === "reasoning" && streamedText && !readableCodexText(field(item, "summary"))) item.summary = streamedText;
if (item?.type === "command_execution" && streamedText && !String(item.aggregatedOutput || "").trim()) item.aggregatedOutput = streamedText;
const threadId = String(field(event, "thread_id") || this.currentThreadId);
const turnId = String(field(event, "turn_id") || this.currentTurnId);
if (id && threadId && turnId && item && supplementalItemTypes.has(String(item.type || ""))) {
const sequence = this.assignItemSequence(threadId, turnId, id);
void this.eventHistory.record({ threadId, turnId, itemId: id, sequence, item }).catch((error) => logger.warn("Failed to persist Codex event history", { threadId, turnId, itemId: id, error }));
}
if (id) {
this.textByItem.delete(key);
this.reasoningTextByItem.delete(key);
}
}
let turnPersistence: Promise<void> | undefined;
if (event.type === "turn.completed") {
const turn = field(params, "turn");
const turnId = String(field(turn, "id") || field(params, "turnId") || "");
const plan = this.plansByTurn.get(turnId);
if (plan) this.plansByTurn.set(turnId, { ...plan, turnStatus: String(field(turn, "status") || "completed") });
const threadId = String(field(params, "threadId") || field(event, "thread_id") || "");
const planKey = turnCacheKey(threadId, turnId);
const plan = this.plansByTurn.get(planKey);
if (plan) this.plansByTurn.set(planKey, { ...plan, turnStatus: String(field(turn, "status") || "completed") });
if (threadId && turnId) {
const input = this.pendingTurnStart?.threadId === threadId ? this.pendingTurnStart.prompt : "";
const turnRecord = { ...(turn && typeof turn === "object" && !Array.isArray(turn) ? turn as JsonRecord : { id: turnId, status: field(turn, "status") || "completed" }), ...(input ? { input } : {}) };
turnPersistence = this.eventHistory.recordTurn({ threadId, turnId, turn: turnRecord }).catch((error) => logger.warn("Failed to persist Codex turn history", { threadId, turnId, error }));
this.finalizingTurns.set(planKey, turnPersistence);
}
this.finishTurnDeltas(threadId, turnId);
}
if (event.type === "turn.completed") event.usage = this.lastUsage;
this.emit("agent_event", { agent: "codex", ...event });
if (event.type === "turn.completed") {
const turn = (params as unknown as CodexNotificationParams<"turn/completed">).turn;
const turnId = turn.id;
const pending = this.activeTurns.get(turnId);
const error = turn.error;
if (pending) {
this.activeTurns.delete(turnId);
error ? pending.reject(new Error(error.message || "Codex turn failed")) : pending.resolve(event);
} else if (turnId) {
this.completedTurns.set(turnId, error ? new Error(error.message || "Codex turn failed") : null);
}
if (turnId === this.currentTurnId) {
this.currentThreadId = "";
this.currentTurnId = "";
}
this.emit("agent_done", { agent: "codex", usage: event.usage, ...codexEventScope(params) });
event.usage = this.lastUsage;
const complete = () => this.completeTurn(event, params, eventScope);
if (turnPersistence) void turnPersistence.then(complete);
else complete();
return;
}
this.emit("agent_event", { agent: "codex", ...event });
}
/** 补充历史落盘后再广播 turn 终态,确保界面完成状态可跨 Agent 重启恢复。 */
private completeTurn(event: AgentEvent, params: JsonRecord, eventScope: ReturnType<typeof codexEventScope>) {
const turn = (params as unknown as CodexNotificationParams<"turn/completed">).turn;
const turnId = turn.id;
const turnKey = turnCacheKey(String(field(params, "threadId") || field(event, "thread_id") || ""), turnId);
this.finalizingTurns.delete(turnKey);
this.emit("agent_event", { agent: "codex", ...event });
const pending = this.activeTurns.get(turnKey);
const error = turn.error;
const failure = error ? new CodexReportedError(error.message || "Codex turn failed") : null;
if (pending) {
this.activeTurns.delete(turnKey);
failure ? pending.reject(failure) : pending.resolve(event);
} else if (turnId) {
this.completedTurns.set(turnKey, failure);
}
if (turnId === this.currentTurnId) {
this.currentThreadId = "";
this.currentTurnId = "";
}
this.emit("agent_done", { agent: "codex", usage: event.usage, ...eventScope });
}
/** 合并并广播 Agent 文本或执行输出增量。 */
private emitDelta(itemType: string, params: ItemDeltaParams) {
const id = params.itemId;
const pending = this.pendingDeltas.get(id);
params = { ...params, threadId: params.threadId || this.currentThreadId, turnId: params.turnId || this.currentTurnId };
const key = itemCacheKey(params);
this.textByItem.set(key, itemType === "reasoning" ? this.appendReasoningText(key, params) : `${this.textByItem.get(key) || ""}${params.delta}`);
const pending = this.pendingDeltas.get(key);
if (pending) {
pending.delta += params.delta;
pending.itemType = itemType;
pending.params = params;
return;
}
this.pendingDeltas.set(id, {
this.pendingDeltas.set(key, {
delta: params.delta,
itemType,
params,
timer: setTimeout(() => this.flushDelta(id), STREAM_UPDATE_INTERVAL_MS),
timer: setTimeout(() => this.flushDelta(key), STREAM_UPDATE_INTERVAL_MS),
});
}
/** 合并短时间内的文本增量,减少 SSE 传输和前端渲染次数。 */
private flushDelta(id: string) {
const pending = this.pendingDeltas.get(id);
private flushDelta(key: string) {
const pending = this.pendingDeltas.get(key);
if (!pending) return;
clearTimeout(pending.timer);
this.pendingDeltas.delete(id);
if (pending.delta) this.emit("agent_event", { agent: "codex", type: "item.updated", item: { id, type: pending.itemType, delta: pending.delta }, ...codexEventScope(pending.params as unknown as JsonRecord) });
this.pendingDeltas.delete(key);
if (pending.delta) this.emit("agent_event", { agent: "codex", type: "item.updated", item: { id: pending.params.itemId, type: pending.itemType, delta: pending.delta }, ...codexEventScope(pending.params as unknown as JsonRecord) });
}
/** 按 Codex summaryIndex 保存 reasoning 分段,顺序与线程历史一致。 */
private appendReasoningText(key: string, params: ItemDeltaParams) {
const segments = this.reasoningTextByItem.get(key) || new Map<number, string>();
this.reasoningTextByItem.set(key, segments);
return appendReasoningDelta(segments, params.summaryIndex, params.delta);
}
/** turn 结束时发送最后一批增量并清理未收到 item.completed 的缓存。 */
private finishTurnDeltas(threadId: string, turnId: string) {
const prefix = `${turnCacheKey(threadId, turnId)}\0`;
[...this.pendingDeltas.keys()].filter((key) => key.startsWith(prefix)).forEach((key) => this.flushDelta(key));
[...this.textByItem.keys()].filter((key) => key.startsWith(prefix)).forEach((key) => this.textByItem.delete(key));
[...this.reasoningTextByItem.keys()].filter((key) => key.startsWith(prefix)).forEach((key) => this.reasoningTextByItem.delete(key));
[...this.startedItems.keys()].filter((key) => key.startsWith(prefix)).forEach((key) => this.startedItems.delete(key));
[...this.itemSequences.keys()].filter((key) => key.startsWith(prefix)).forEach((key) => this.itemSequences.delete(key));
this.nextItemSequences.delete(turnCacheKey(threadId, turnId));
}
/** 为一个 turn 内的 item 固定开始顺序,完成通知只更新内容。 */
private assignItemSequence(threadId: string, turnId: string, itemId: string) {
const key = itemCacheKey({ threadId, turnId, itemId });
const existing = this.itemSequences.get(key);
if (existing !== undefined) return existing;
const turnKey = turnCacheKey(threadId, turnId);
const sequence = (this.nextItemSequences.get(turnKey) || 0) + 1;
this.nextItemSequences.set(turnKey, sequence);
this.itemSequences.set(key, sequence);
return sequence;
}
/** 在通知或 turn/start 响应到达时回调一次 turn 启动状态。 */
private notifyTurnStarted(threadId: string, turnId: string, fallback?: PendingTurnStart) {
if (!threadId || !turnId) return;
const pending = this.pendingTurnStart;
const registration = pending?.threadId === threadId && (!pending.turnId || pending.turnId === turnId) ? pending : fallback;
if (registration && !registration.turnId) registration.turnId = turnId;
const key = turnCacheKey(threadId, turnId);
if (this.startedTurnKeys.has(key)) return;
if (!registration) return;
this.startedTurnKeys.add(key);
registration.onTurn?.(turnId);
}
/** 自动回复 app-server 发起的授权或交互请求。 */
@@ -325,19 +475,79 @@ export class CodexAppClient {
}
/** 拒绝进程退出时仍未完成的请求与 turn。 */
private failAll(message: string) {
[...this.pending.values(), ...this.activeTurns.values()].forEach((item) => item.reject(new Error(message)));
private failAll(message: string, reported = false) {
if (this.failing) return;
this.failing = true;
this.approvalRequests.forEach((request, requestId) => this.emit("codex_approval_resolved", { ...request.params, requestId, decision: request.decision || "cancel" }));
const failedTurns = new Map<string, { threadId: string; turnId: string; prompt: string }>();
this.activeTurns.forEach(({ threadId, turnId, prompt }, key) => {
if (!this.finalizingTurns.has(key)) failedTurns.set(key, { threadId, turnId, prompt });
});
const pendingStart = this.pendingTurnStart;
if (pendingStart?.turnId) {
const key = turnCacheKey(pendingStart.threadId, pendingStart.turnId);
if (!this.finalizingTurns.has(key) && !failedTurns.has(key)) failedTurns.set(key, { threadId: pendingStart.threadId, turnId: pendingStart.turnId, prompt: pendingStart.prompt });
}
const finalizing = [...this.finalizingTurns.values()];
const persistence = [...failedTurns.values()].map(({ threadId, turnId, prompt }) => {
const turn = { id: turnId, status: "failed", error: { message }, ...(prompt ? { input: prompt } : {}) };
return this.eventHistory.recordTurn({ threadId, turnId, turn }).catch((historyError) => logger.warn("Failed to persist Codex turn failure", { threadId, turnId, error: historyError }));
});
const error = reported || failedTurns.size || finalizing.length ? new CodexReportedError(message) : new Error(message);
this.pendingDeltas.forEach((item) => clearTimeout(item.timer));
this.pending.clear();
this.activeTurns.clear();
this.pendingDeltas.clear();
this.textByItem.clear();
this.reasoningTextByItem.clear();
this.startedItems.clear();
this.itemSequences.clear();
this.nextItemSequences.clear();
this.plansByTurn.clear();
this.completedTurns.clear();
this.approvalRequests.clear();
this.pendingTurnStart = undefined;
this.startedTurnKeys.clear();
this.lastUsage = null;
this.currentThreadId = "";
this.currentTurnId = "";
void Promise.all([...finalizing, ...persistence]).then(() => {
failedTurns.forEach(({ threadId, turnId, prompt }) => {
const turn = { id: turnId, status: "failed", error: { message }, ...(prompt ? { input: prompt } : {}) };
this.emit("agent_event", { agent: "codex", type: "turn.completed", status: "failed", error: { message }, thread_id: threadId, turn_id: turnId, turn });
this.emit("agent_done", { agent: "codex", status: "failed", error: { message }, thread_id: threadId, turn_id: turnId });
});
this.pending.forEach((item) => item.reject(error));
this.activeTurns.forEach((item) => item.reject(error));
this.pending.clear();
this.activeTurns.clear();
this.finalizingTurns.clear();
});
}
}
/** 将 Codex 的字符串、摘要数组或文本对象转换为展示文本。 */
function readableCodexText(value: unknown): string {
if (typeof value === "string") return value.trim();
if (Array.isArray(value)) return value.map(readableCodexText).filter(Boolean).join("\n");
if (!value || typeof value !== "object") return "";
return readableCodexText(field(value, "text"));
}
/** 合并单个 reasoning 分段增量并按 summaryIndex 输出。 */
export function appendReasoningDelta(segments: Map<number, string>, summaryIndex: number | undefined, delta: string) {
const index = Number.isInteger(summaryIndex) ? Number(summaryIndex) : 0;
segments.set(index, `${segments.get(index) || ""}${delta}`);
return [...segments.entries()].sort(([left], [right]) => left - right).map(([, text]) => text.trim()).filter(Boolean).join("\n");
}
/** 生成仅供进程内缓存使用的完整 turn 与 item 作用域键。 */
function itemCacheKey(scope: { threadId: string; turnId: string; itemId: string }) {
return `${turnCacheKey(scope.threadId, scope.turnId)}\0${scope.itemId}`;
}
function turnCacheKey(threadId: string, turnId: string) {
return `${threadId}\0${turnId}`;
}
/** 生成 Codex 调用 Canvas Agent MCP 的启动命令。 */
function canvasAgentMcpCommand() {
const current = process.argv.find((arg) => /index\.(t|j)s$/.test(arg)) || "";
@@ -395,7 +605,7 @@ function normalizeItem(item: unknown) {
if (value.type === "commandExecution") value.type = "command_execution";
if (value.type === "fileChange") value.type = "file_change";
if (value.type === "dynamicToolCall") value.type = "dynamic_tool_call";
if (value.type === "collabToolCall") value.type = "collab_tool_call";
if (value.type === "collabToolCall" || value.type === "collabAgentToolCall") value.type = "collab_tool_call";
if (value.type === "webSearch") value.type = "web_search";
if (value.type === "imageView") value.type = "image_view";
if (value.type === "imageGeneration") value.type = "image_generation";
@@ -405,6 +615,14 @@ function normalizeItem(item: unknown) {
return value;
}
function mergeDefinedRecord(started: JsonRecord, completed: JsonRecord) {
const merged = { ...started };
Object.entries(completed).forEach(([key, value]) => {
if (value !== undefined) merged[key] = value;
});
return merged;
}
/** 将 Codex token usage 转换为前端字段。 */
function normalizeUsage(params: CodexNotificationParams<"thread/tokenUsage/updated">) {
const last = params.tokenUsage.last;
@@ -0,0 +1,170 @@
import fs from "node:fs/promises";
import path from "node:path";
import { CONFIG_DIR } from "../config.js";
import type { CodexSupplementalHistory, CodexSupplementalHistoryItem, CodexSupplementalHistoryTurn } from "./codex-history.js";
type CodexEventHistoryData = { version: 1; items: CodexSupplementalHistoryItem[]; turns: CodexSupplementalHistoryTurn[] };
const MAX_ITEMS = 5000;
const MAX_STRING_LENGTH = 100_000;
export const CODEX_EVENT_HISTORY_FILE = path.join(CONFIG_DIR, "codex-event-history.json");
/** 保存 Codex 持久线程投影可能省略的实时完成事件。 */
export class CodexEventHistory {
private data?: CodexEventHistoryData;
private queue: Promise<void> = Promise.resolve();
constructor(private file = CODEX_EVENT_HISTORY_FILE) {}
/** 按 threadId、turnId 和 itemId 新增或更新一条补充事件。 */
record(entry: CodexSupplementalHistoryItem) {
return this.run(async () => {
const data = await this.load();
const index = data.items.findIndex((item) => sameItem(item, entry));
const previous = index >= 0 ? data.items[index] : undefined;
const nextEntry = normalizeEntry({
...entry,
...(entry.sequence === undefined && previous?.sequence !== undefined ? { sequence: previous.sequence } : {}),
item: mergeRecord(previous?.item, entry.item),
});
const items = [...data.items];
if (index >= 0) items[index] = nextEntry;
else items.push(nextEntry);
const nextData = { ...data, items: items.slice(-MAX_ITEMS) };
await this.save(nextData);
this.data = nextData;
});
}
/** 保存 turn 终态,使标准线程历史尚未物化时仍可恢复完整轮次。 */
recordTurn(entry: CodexSupplementalHistoryTurn) {
return this.run(async () => {
const data = await this.load();
const index = data.turns.findIndex((turn) => sameTurn(turn, entry));
const previous = index >= 0 ? data.turns[index] : undefined;
const nextEntry = normalizeTurn({ ...entry, turn: mergeRecord(previous?.turn, entry.turn) });
const turns = [...data.turns];
if (index >= 0) turns[index] = nextEntry;
else turns.push(nextEntry);
const nextData = { ...data, turns: turns.slice(-MAX_ITEMS) };
await this.save(nextData);
this.data = nextData;
});
}
/** 按 item 开始顺序返回指定线程的补充事件。 */
readThread(threadId: string) {
return this.run(async (): Promise<CodexSupplementalHistory> => {
const data = await this.load();
return {
items: data.items.filter((item) => item.threadId === threadId).sort(compareEntries).map(cloneEntry),
turns: data.turns.filter((turn) => turn.threadId === threadId).map(cloneTurn),
};
});
}
/** 归档线程后删除其补充事件。 */
removeThread(threadId: string) {
return this.run(async () => {
const data = await this.load();
const items = data.items.filter((item) => item.threadId !== threadId);
const turns = data.turns.filter((turn) => turn.threadId !== threadId);
if (items.length === data.items.length && turns.length === data.turns.length) return;
const nextData = { version: 1 as const, items, turns };
await this.save(nextData);
this.data = nextData;
});
}
private run<T>(task: () => Promise<T>) {
const result = this.queue.then(task, task);
this.queue = result.then(() => undefined, () => undefined);
return result;
}
private async load() {
if (this.data) return this.data;
try {
const value = JSON.parse(await fs.readFile(this.file, "utf8")) as Partial<CodexEventHistoryData>;
this.data = value.version === 1 && Array.isArray(value.items) && Array.isArray(value.turns) ? { version: 1, items: value.items.map(normalizeEntry), turns: value.turns.map(normalizeTurn) } : emptyHistory();
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT" && !(error instanceof SyntaxError)) throw error;
this.data = emptyHistory();
}
return this.data;
}
private async save(data: CodexEventHistoryData) {
await fs.mkdir(path.dirname(this.file), { recursive: true });
const temporaryFile = `${this.file}.${process.pid}.${Date.now()}.tmp`;
try {
await fs.writeFile(temporaryFile, JSON.stringify(data, null, 2));
await fs.rename(temporaryFile, this.file);
} finally {
await fs.unlink(temporaryFile).catch(() => undefined);
}
}
}
export const codexEventHistory = new CodexEventHistory();
function emptyHistory(): CodexEventHistoryData {
return { version: 1, items: [], turns: [] };
}
function sameItem(left: CodexSupplementalHistoryItem, right: CodexSupplementalHistoryItem) {
return left.threadId === right.threadId && left.turnId === right.turnId && left.itemId === right.itemId;
}
function sameTurn(left: CodexSupplementalHistoryTurn, right: CodexSupplementalHistoryTurn) {
return left.threadId === right.threadId && left.turnId === right.turnId;
}
function cloneEntry(entry: CodexSupplementalHistoryItem) {
return structuredClone(entry);
}
function cloneTurn(entry: CodexSupplementalHistoryTurn) {
return structuredClone(entry);
}
function compareEntries(left: CodexSupplementalHistoryItem, right: CodexSupplementalHistoryItem) {
if (left.sequence !== undefined && right.sequence !== undefined && left.sequence !== right.sequence) return left.sequence - right.sequence;
if (left.sequence !== undefined) return -1;
if (right.sequence !== undefined) return 1;
return 0;
}
function normalizeEntry(entry: CodexSupplementalHistoryItem): CodexSupplementalHistoryItem {
return {
...entry,
...(entry.sequence === undefined ? {} : { sequence: entry.sequence }),
item: truncateRecord(entry.item),
};
}
function normalizeTurn(entry: CodexSupplementalHistoryTurn): CodexSupplementalHistoryTurn {
return { ...entry, turn: truncateRecord({ ...entry.turn, id: entry.turnId }) };
}
function mergeRecord(previous: Record<string, unknown> | undefined, next: Record<string, unknown>) {
if (!previous) return next;
const merged = { ...previous };
Object.entries(next).forEach(([key, value]) => {
if (value !== undefined) merged[key] = value;
});
return merged;
}
function truncateRecord(value: Record<string, unknown>) {
return truncateValue(value) as Record<string, unknown>;
}
function truncateValue(value: unknown): unknown {
if (typeof value === "string") return value.length > MAX_STRING_LENGTH ? `${value.slice(0, MAX_STRING_LENGTH)}\n[输出已截断]` : value;
if (Array.isArray(value)) return value.map(truncateValue);
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, truncateValue(item)]));
return value;
}
@@ -0,0 +1,426 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { appendReasoningDelta } from "./codex-client.js";
import { CodexEventHistory } from "./codex-event-history.js";
import { settledTurnIds, summarizeCodexThread, threadMessages } from "./codex-history.js";
test("线程摘要提取结构化状态类型", () => {
assert.equal(summarizeCodexThread({ id: "thread-1", status: { type: "notLoaded" } }).status, "notLoaded");
assert.equal(summarizeCodexThread({ id: "thread-1", status: "idle" }).status, "idle");
});
test("reasoning 增量按 summaryIndex 聚合", () => {
const segments = new Map<number, string>();
appendReasoningDelta(segments, 1, "第二");
appendReasoningDelta(segments, 0, "第一");
assert.equal(appendReasoningDelta(segments, 1, "段"), "第一\n第二段");
});
test("只有终态 turn 会交给历史快照作为权威内容", () => {
assert.deepEqual(settledTurnIds({ turns: [
{ id: "completed", status: "completed" },
{ id: "failed", status: "failed" },
{ id: "interrupted", status: "interrupted" },
{ id: "running", status: "inProgress" },
] }), ["completed", "failed", "interrupted"]);
});
test("标准历史的运行中状态不会覆盖本地已经完成的 turn", () => {
const supplemental = {
items: [{ threadId: "thread-1", turnId: "turn-1", itemId: "assistant-1", sequence: 1, item: { id: "assistant-1", type: "agent_message", text: "完整回答" } }],
turns: [{ threadId: "thread-1", turnId: "turn-1", turn: { id: "turn-1", status: "completed", input: "问题" } }],
};
const thread = { id: "thread-1", turns: [{ id: "turn-1", status: "inProgress", items: [] }] };
assert.deepEqual(settledTurnIds(thread, supplemental), ["turn-1"]);
assert.deepEqual(threadMessages(thread, [], supplemental).map((item) => item.text), ["问题", "完整回答"]);
});
test("标准历史进入终态后保持权威状态", () => {
const supplemental = {
items: [],
turns: [{ threadId: "thread-1", turnId: "turn-1", turn: { id: "turn-1", status: "completed", input: "问题" } }],
};
const thread = { id: "thread-1", turns: [{ id: "turn-1", status: "failed", error: { message: "标准错误" }, items: [] }] };
const messages = threadMessages(thread, [], supplemental);
assert.deepEqual(settledTurnIds(thread, supplemental), ["turn-1"]);
assert.equal(messages.at(-1)?.role, "error");
assert.equal(messages.at(-1)?.text, "标准错误");
});
test("线程历史将多个 reasoning 条目投影为一张稳定卡片", () => {
const messages = threadMessages({
id: "thread-1",
turns: [{
id: "turn-1",
status: "completed",
items: [
{ id: "user-1", type: "userMessage", content: [{ type: "text", text: "问题" }] },
{ id: "reasoning-1", type: "reasoning", summary: ["第一段", "第二段"] },
{ id: "reasoning-2", type: "reasoning", summary: [{ text: "第三段" }] },
{ id: "assistant-1", type: "agentMessage", text: "回答" },
],
}],
});
const reasoning = messages.filter((item) => item.itemId === "synthetic:reasoning");
assert.equal(reasoning.length, 1);
assert.equal(reasoning[0].id, "thread-1:turn-1:synthetic:reasoning");
assert.equal(reasoning[0].text, "第一段\n第二段\n\n第三段");
assert.deepEqual(reasoning[0].activityItems, { "reasoning-1": "第一段\n第二段", "reasoning-2": "第三段" });
});
test("空 reasoning 不进入完成后的线程历史", () => {
const messages = threadMessages({ id: "thread-1", turns: [{ id: "turn-1", status: "completed", items: [{ id: "reasoning-1", type: "reasoning", summary: [] }] }] });
assert.equal(messages.some((item) => item.itemId === "synthetic:reasoning"), false);
});
test("用户消息使用与实时消息一致的 turn 级稳定 ID", () => {
const messages = threadMessages({ id: "thread-1", turns: [{ id: "turn-1", status: "completed", items: [{ id: "codex-user-1", type: "userMessage", content: [{ type: "text", text: "问题" }] }] }] });
assert.equal(messages[0].id, "thread-1:turn-1:synthetic:user");
assert.equal(messages[0].itemId, "synthetic:user");
});
test("Codex 历史省略命令时使用补充事件恢复完整命令卡片", () => {
const messages = threadMessages({
id: "thread-1",
turns: [{
id: "turn-1",
status: "completed",
items: [
{ id: "user-1", type: "userMessage", content: [{ type: "text", text: "执行命令" }] },
{ id: "assistant-1", type: "agentMessage", text: "完成" },
],
}],
}, [], { items: [{
threadId: "thread-1",
turnId: "turn-1",
itemId: "command-1",
item: { id: "command-1", type: "command_execution", command: "Get-Location", status: "completed", exitCode: 0, aggregatedOutput: "D:\\infinite-canvas" },
}], turns: [] });
const command = messages.find((item) => item.itemId === "command-1");
assert.equal(command?.text, "Get-Location");
assert.deepEqual(command?.detail, {
kind: "command",
status: "completed",
rows: [{ label: "退出状态", value: "0" }],
output: "D:\\infinite-canvas",
});
});
test("Codex 标准历史与补充事件重复时以标准历史为准", () => {
const messages = threadMessages({
id: "thread-1",
turns: [{ id: "turn-1", status: "completed", items: [{ id: "command-1", type: "commandExecution", command: "标准命令", status: "completed" }] }],
}, [], { items: [{
threadId: "thread-1",
turnId: "turn-1",
itemId: "command-1",
item: { id: "command-1", type: "command_execution", command: "补充命令", status: "completed" },
}], turns: [] });
assert.equal(messages.filter((item) => item.itemId === "command-1").length, 1);
assert.equal(messages.find((item) => item.itemId === "command-1")?.text, "标准命令");
});
test("标准历史重写 item id 时仍与同一条实时事件合并", () => {
const messages = threadMessages({
id: "thread-1",
turns: [{ id: "turn-1", status: "completed", items: [
{ id: "item-13", type: "userMessage", content: [{ type: "text", text: "执行命令" }] },
{ id: "item-14", type: "agentMessage", text: "我将仅查看当前路径。" },
{ id: "item-15", type: "agentMessage", text: "完成。" },
] }],
}, [], { items: [
{ threadId: "thread-1", turnId: "turn-1", itemId: "msg-commentary", sequence: 2, item: { id: "msg-commentary", type: "agent_message", text: "我将仅查看当前路径。" } },
{ threadId: "thread-1", turnId: "turn-1", itemId: "command-1", sequence: 3, item: { id: "command-1", type: "command_execution", command: "Get-Location", status: "completed" } },
{ threadId: "thread-1", turnId: "turn-1", itemId: "msg-final", sequence: 4, item: { id: "msg-final", type: "agent_message", text: "完成。" } },
], turns: [] });
assert.deepEqual(messages.map((item) => item.itemId), ["synthetic:user", "msg-commentary", "command-1", "msg-final"]);
});
test("标准历史正文损坏且重写 item id 时不会重复显示同一条回复", () => {
const messages = threadMessages({
id: "thread-1",
turns: [{ id: "turn-1", status: "completed", items: [{ id: "item-38", type: "agentMessage", text: "连接" }] }],
}, [], { items: [{
threadId: "thread-1",
turnId: "turn-1",
itemId: "msg-007367",
sequence: 1,
item: { id: "msg-007367", type: "agent_message", text: "连接服务" },
}], turns: [] });
const replies = messages.filter((item) => item.role === "assistant");
assert.equal(replies.length, 1);
assert.equal(replies[0].itemId, "msg-007367");
assert.equal(replies[0].text, "连接服务");
});
test("标准历史同时保留损坏临时条目和稳定条目时移除损坏副本", () => {
const cleanText = "目前能排除“网页没开”和“Canvas Agent 没连”:前端和 Agent 均正常。";
const messages = threadMessages({
id: "thread-1",
turns: [{ id: "turn-1", status: "completed", items: [
{ id: "user-1", type: "userMessage", content: [{ type: "text", text: "检查连接" }] },
{ id: "item-38", type: "agentMessage", text: "目前能排除“网页没开”和“Canvas Agent 没连:前端和 Agent 均正常。" },
{ id: "command-1", type: "commandExecution", command: "Get-NetTCPConnection", status: "completed" },
{ id: "msg-stable", type: "agentMessage", text: cleanText },
] }],
}, [], { items: [
{ threadId: "thread-1", turnId: "turn-1", itemId: "command-1", sequence: 1, item: { id: "command-1", type: "command_execution", command: "Get-NetTCPConnection", status: "completed" } },
{ threadId: "thread-1", turnId: "turn-1", itemId: "msg-stable", sequence: 2, item: { id: "msg-stable", type: "agent_message", text: cleanText } },
], turns: [] });
const replies = messages.filter((item) => item.role === "assistant");
assert.equal(replies.length, 1);
assert.equal(replies[0].itemId, "msg-stable");
assert.equal(replies[0].text, cleanText);
});
test("标准历史条目稀疏时按字段补全补充事件", () => {
const messages = threadMessages({
id: "thread-1",
turns: [{ id: "turn-1", status: "completed", items: [{ id: "command-1", type: "commandExecution", command: "标准命令", status: "completed" }] }],
}, [], { items: [{
threadId: "thread-1",
turnId: "turn-1",
itemId: "command-1",
sequence: 1,
item: { id: "command-1", type: "command_execution", command: "补充命令", cwd: "D:\\infinite-canvas", aggregatedOutput: "输出", exitCode: 0 },
}], turns: [] });
const command = messages.find((item) => item.itemId === "command-1");
assert.equal(command?.text, "标准命令");
assert.deepEqual(command?.detail, {
kind: "command",
status: "completed",
rows: [{ label: "工作目录", value: "D:\\infinite-canvas" }, { label: "退出状态", value: "0" }],
output: "输出",
});
});
test("标准历史的 falsy 字段仍优先于补充事件", () => {
const messages = threadMessages({
id: "thread-1",
turns: [{ id: "turn-1", status: "completed", items: [{ id: "command-1", type: "commandExecution", command: "", cwd: null, status: "completed", exitCode: 0, success: false, aggregatedOutput: "" }] }],
}, [], { items: [{
threadId: "thread-1",
turnId: "turn-1",
itemId: "command-1",
sequence: 1,
item: { id: "command-1", type: "command_execution", command: "补充命令", cwd: "补充目录", exitCode: 9, success: true, aggregatedOutput: "补充输出" },
}], turns: [] });
const command = messages.find((item) => item.itemId === "command-1");
assert.equal(command?.text, "命令执行失败");
assert.deepEqual(command?.detail, { kind: "command", status: "failed", rows: [{ label: "退出状态", value: "0" }], output: "" });
});
test("补充事件按 item 开始顺序插入标准历史锚点之间", () => {
const messages = threadMessages({
id: "thread-1",
turns: [{ id: "turn-1", status: "completed", items: [
{ id: "user-1", type: "userMessage", content: [{ type: "text", text: "执行" }] },
{ id: "second", type: "commandExecution", command: "second", status: "completed" },
{ id: "assistant", type: "agentMessage", text: "完成" },
] }],
}, [], { items: [
{ threadId: "thread-1", turnId: "turn-1", itemId: "first", sequence: 1, item: { id: "first", type: "command_execution", command: "first", status: "completed" } },
{ threadId: "thread-1", turnId: "turn-1", itemId: "second", sequence: 2, item: { id: "second", type: "command_execution", command: "补充 second", status: "completed" } },
{ threadId: "thread-1", turnId: "turn-1", itemId: "third", sequence: 3, item: { id: "third", type: "command_execution", command: "third", status: "completed" } },
{ threadId: "thread-1", turnId: "turn-1", itemId: "assistant", sequence: 4, item: { id: "assistant", type: "agent_message", text: "补充回答" } },
], turns: [] });
assert.deepEqual(messages.map((item) => item.itemId), ["synthetic:user", "first", "second", "third", "assistant"]);
assert.equal(messages.find((item) => item.itemId === "second")?.text, "second");
assert.equal(messages.find((item) => item.itemId === "assistant")?.text, "完成");
});
test("补充事件写入本地 JSON 后可在 Agent 重启后恢复", async (context) => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "canvas-agent-history-"));
context.after(() => fs.rm(directory, { recursive: true, force: true }));
const file = path.join(directory, "codex-event-history.json");
const entry = {
threadId: "thread-1",
turnId: "turn-1",
itemId: "command-1",
item: { id: "command-1", type: "command_execution", command: "Get-Location", status: "completed" },
};
await new CodexEventHistory(file).record(entry);
assert.deepEqual(await new CodexEventHistory(file).readThread("thread-1"), { items: [entry], turns: [] });
});
test("补充历史 JSON 损坏后会从空历史恢复并允许重新写入", async (context) => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "canvas-agent-history-"));
context.after(() => fs.rm(directory, { recursive: true, force: true }));
const file = path.join(directory, "codex-event-history.json");
await fs.writeFile(file, "{\"version\":1,\"items\":[");
const history = new CodexEventHistory(file);
assert.deepEqual(await history.readThread("thread-1"), { items: [], turns: [] });
await history.record({ threadId: "thread-1", turnId: "turn-1", itemId: "item-1", item: { id: "item-1", type: "agent_message", text: "恢复成功" } });
assert.deepEqual(await new CodexEventHistory(file).readThread("thread-1"), {
items: [{ threadId: "thread-1", turnId: "turn-1", itemId: "item-1", item: { id: "item-1", type: "agent_message", text: "恢复成功" } }],
turns: [],
});
});
test("标准历史尚未物化 turn 时从本地终态事件恢复完整对话", async (context) => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "canvas-agent-history-"));
context.after(() => fs.rm(directory, { recursive: true, force: true }));
const file = path.join(directory, "codex-event-history.json");
const history = new CodexEventHistory(file);
await history.record({ threadId: "thread-1", turnId: "turn-1", itemId: "command-1", sequence: 1, item: { id: "command-1", type: "command_execution", command: "Get-Location", status: "completed", exitCode: 0, aggregatedOutput: "D:\\infinite-canvas" } });
await history.record({ threadId: "thread-1", turnId: "turn-1", itemId: "assistant-1", sequence: 2, item: { id: "assistant-1", type: "agent_message", text: "完成" } });
await history.recordTurn({ threadId: "thread-1", turnId: "turn-1", turn: { id: "turn-1", status: "completed", input: "执行 Get-Location" } });
const supplemental = await new CodexEventHistory(file).readThread("thread-1");
const thread = { id: "thread-1", turns: [] };
const messages = threadMessages(thread, [], supplemental);
assert.deepEqual(messages.map((message) => message.itemId), ["synthetic:user", "command-1", "assistant-1"]);
assert.equal(messages[0].text, "执行 Get-Location");
assert.equal(messages[1].text, "Get-Location");
assert.equal(messages[2].text, "完成");
assert.deepEqual(settledTurnIds(thread, supplemental), ["turn-1"]);
});
test("归档线程只清除该线程的补充事件", async (context) => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "canvas-agent-history-"));
context.after(() => fs.rm(directory, { recursive: true, force: true }));
const history = new CodexEventHistory(path.join(directory, "codex-event-history.json"));
const entry = (threadId: string) => ({ threadId, turnId: "turn-1", itemId: "command-1", item: { id: "command-1", type: "command_execution" } });
await history.record(entry("thread-1"));
await history.record(entry("thread-2"));
await history.removeThread("thread-1");
assert.deepEqual(await history.readThread("thread-1"), { items: [], turns: [] });
assert.deepEqual(await history.readThread("thread-2"), { items: [entry("thread-2")], turns: [] });
});
test("补充事件更新时保留已有字段并限制单项输出大小", async (context) => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "canvas-agent-history-"));
context.after(() => fs.rm(directory, { recursive: true, force: true }));
const history = new CodexEventHistory(path.join(directory, "codex-event-history.json"));
await history.record({ threadId: "thread-1", turnId: "turn-1", itemId: "command-1", sequence: 1, item: { id: "command-1", type: "command_execution", command: "Get-Location", cwd: "D:\\infinite-canvas" } });
await history.record({ threadId: "thread-1", turnId: "turn-1", itemId: "command-1", item: { id: "command-1", status: "completed", aggregatedOutput: "x".repeat(100_001) } });
const [entry] = (await history.readThread("thread-1")).items;
assert.equal(entry.sequence, 1);
assert.equal(entry.item.command, "Get-Location");
assert.equal(entry.item.cwd, "D:\\infinite-canvas");
assert.equal(String(entry.item.aggregatedOutput).endsWith("[输出已截断]"), true);
});
test("补充事件落盘失败时不污染内存读取", async (context) => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "canvas-agent-history-"));
context.after(() => fs.rm(directory, { recursive: true, force: true }));
const file = path.join(directory, "history-target");
const history = new CodexEventHistory(file);
assert.deepEqual(await history.readThread("thread-1"), { items: [], turns: [] });
await fs.mkdir(file);
await assert.rejects(() => history.record({ threadId: "thread-1", turnId: "turn-1", itemId: "command-1", item: { id: "command-1", type: "command_execution" } }));
assert.deepEqual(await history.readThread("thread-1"), { items: [], turns: [] });
});
test("turn 错误优先于条目错误且只生成一张错误卡片", () => {
const messages = threadMessages({
id: "thread-1",
turns: [{ id: "turn-1", status: "failed", error: { message: "turn error" }, items: [{ id: "error-1", type: "error", message: "item error" }] }],
});
const errors = messages.filter((item) => item.role === "error");
assert.equal(errors.length, 1);
assert.equal(errors[0].id, "thread-1:turn-1:synthetic:error");
assert.equal(errors[0].text, "turn error");
});
test("失败条目的状态与错误详情在历史中保持完整", () => {
const messages = threadMessages({
id: "thread-1",
turns: [{
id: "turn-1",
status: "completed",
items: [
{ id: "command-1", type: "commandExecution", command: "exit 1", status: "completed", exitCode: 1, aggregatedOutput: "failed" },
{ id: "image-1", type: "imageGeneration", status: "completed", success: false },
{ id: "context-1", type: "contextCompaction", status: "failed", error: { message: "compact failed" } },
{ id: "collab-1", type: "collabToolCall", status: "completed", success: false },
{ id: "reasoning-1", type: "reasoning", status: "failed", summary: ["分析失败"] },
],
}],
});
const byId = new Map(messages.map((item) => [item.itemId, item]));
assert.equal((byId.get("command-1")?.detail as { status?: string }).status, "failed");
assert.equal((byId.get("image-1")?.detail as { status?: string }).status, "failed");
assert.equal(byId.get("context-1")?.text, "compact failed");
assert.equal((byId.get("context-1")?.detail as { output?: string }).output, "compact failed");
assert.equal((byId.get("collab-1")?.detail as { status?: string }).status, "failed");
assert.equal((byId.get("synthetic:reasoning")?.detail as { status?: string }).status, "failed");
});
test("新版协作工具条目在标准历史和补充历史中保持同一张卡片", () => {
const messages = threadMessages({
id: "thread-1",
turns: [{ id: "turn-1", status: "completed", items: [{ id: "collab-1", type: "collabAgentToolCall", status: "completed" }] }],
}, [], { items: [{
threadId: "thread-1",
turnId: "turn-1",
itemId: "collab-1",
sequence: 1,
item: { id: "collab-1", type: "collab_tool_call", status: "completed" },
}], turns: [] });
assert.equal(messages.filter((item) => item.itemId === "collab-1").length, 1);
assert.equal(messages[0].title, "协作处理");
});
test("尚未完成的 turn 只由实时事件展示,不进入历史快照", () => {
const messages = threadMessages({
id: "thread-1",
turns: [{ id: "turn-1", status: "inProgress", items: [{ id: "reasoning-1", type: "reasoning", status: "pending", summary: ["正在分析"] }] }],
});
assert.deepEqual(messages, []);
});
test("缺少稳定 item id 的历史条目不会生成无法对齐的临时消息", () => {
const messages = threadMessages({ id: "thread-1", turns: [{ id: "turn-1", status: "completed", items: [{ type: "agentMessage", text: "回答" }] }] });
assert.equal(messages.length, 0);
});
test("通用工具只由标题和结构化状态表达完成结果", () => {
const messages = threadMessages({
id: "thread-1",
turns: [{ id: "turn-1", status: "completed", items: [{ id: "tool-1", type: "mcpToolCall", tool: "list_mcp_resources", status: "completed" }] }],
});
assert.equal(messages.length, 1);
assert.equal(messages[0].title, "调用工具:list_mcp_resources");
assert.equal(messages[0].text, "");
assert.equal((messages[0].detail as { status?: string }).status, "completed");
});
test("命令完成条目缺少 command 时仍保留历史卡片", () => {
const messages = threadMessages({
id: "thread-1",
turns: [{
id: "turn-1",
status: "completed",
items: [{ id: "command-1", type: "commandExecution", status: "completed", exitCode: 0, aggregatedOutput: "done" }],
}],
});
assert.equal(messages.length, 1);
assert.equal(messages[0].itemId, "command-1");
assert.equal(messages[0].title, "执行命令");
assert.equal(messages[0].text, "命令已完成");
assert.equal((messages[0].detail as { status?: string }).status, "completed");
});
+445 -54
View File
@@ -1,17 +1,23 @@
import { field } from "../utils/value.js";
import type { CodexPlanUpdate } from "./codex-protocol.js";
type AgentHistoryMessage = { id: string; role: "user" | "assistant" | "tool" | "error"; title?: string; text: string; detail?: unknown; streamId?: string };
type AgentHistoryMessage = { id: string; itemId: string; threadId: string; turnId: string; role: "user" | "assistant" | "tool" | "error"; title?: string; text: string; detail?: unknown; activityItems?: Record<string, string> };
export type CodexSupplementalHistoryItem = { threadId: string; turnId: string; itemId: string; sequence?: number; item: Record<string, unknown> };
export type CodexSupplementalHistoryTurn = { threadId: string; turnId: string; turn: Record<string, unknown> };
export type CodexSupplementalHistory = { items: CodexSupplementalHistoryItem[]; turns: CodexSupplementalHistoryTurn[] };
const emptySupplementalHistory: CodexSupplementalHistory = { items: [], turns: [] };
/** 将 Codex 线程转换为列表展示所需的摘要。 */
export function summarizeCodexThread(thread: unknown) {
const status = field(thread, "status");
return {
id: String(field(thread, "id") || ""),
sessionId: String(field(thread, "sessionId") || ""),
preview: displayUserText(String(field(thread, "preview") || "")),
name: stringOrNull(field(thread, "name")),
cwd: String(field(thread, "cwd") || ""),
status: String(field(thread, "status") || ""),
status: typeof status === "string" ? status : String(field(status, "type") || ""),
source: field(thread, "source"),
threadSource: field(thread, "threadSource"),
createdAt: Number(field(thread, "createdAt") || 0),
@@ -19,22 +25,62 @@ export function summarizeCodexThread(thread: unknown) {
};
}
/** 返回已经终结、可由持久化历史作为权威内容的 turn。 */
export function settledTurnIds(thread: unknown, supplementalHistory: CodexSupplementalHistory = emptySupplementalHistory) {
return mergeHistoryTurns(thread, supplementalHistory).flatMap((turn) => {
const id = String(field(turn, "id") || "");
return id && isSettledTurn(turn) ? [id] : [];
});
}
/** 将 Codex turn items 转换为网页聊天历史。 */
export function threadMessages(thread: unknown, planUpdates: CodexPlanUpdate[] = []): AgentHistoryMessage[] {
const turns = arrayValue(field(thread, "turns"));
export function threadMessages(thread: unknown, planUpdates: CodexPlanUpdate[] = [], supplementalHistory: CodexSupplementalHistory = emptySupplementalHistory): AgentHistoryMessage[] {
const threadId = String(field(thread, "id") || "");
if (!threadId) return [];
const turns = mergeHistoryTurns(thread, supplementalHistory);
const plansByTurn = new Map(planUpdates.map((item) => [item.turnId, item]));
const supplementalByTurn = new Map<string, CodexSupplementalHistoryItem[]>();
supplementalHistory.items.filter((item) => item.threadId === threadId).forEach((item) => supplementalByTurn.set(item.turnId, [...(supplementalByTurn.get(item.turnId) || []), item]));
const messages: AgentHistoryMessage[] = [];
turns.forEach((turn, turnIndex) => {
const turnId = String(field(turn, "id") || turnIndex);
turns.forEach((turn) => {
const turnId = String(field(turn, "id") || "");
if (!turnId || !isSettledTurn(turn)) return;
const turnError = String(field(field(turn, "error"), "message") || "").trim();
const planMessage = structuredPlanMessage(plansByTurn.get(turnId) || { threadId: "", turnId, explanation: stringOrNull(field(turn, "explanation")), plan: arrayValue(field(turn, "plan")) as CodexPlanUpdate["plan"], turnStatus: String(field(turn, "status") || "") });
const items = mergeHistoryItems(arrayValue(field(turn, "items")), supplementalByTurn.get(turnId) || []);
const push = (message: Omit<AgentHistoryMessage, "itemId" | "threadId" | "turnId">) => messages.push({ ...message, id: historyMessageId(threadId, turnId, message.id), itemId: message.id, threadId, turnId });
const planMessage = structuredPlanMessage(plansByTurn.get(turnId) || { threadId, turnId, explanation: stringOrNull(field(turn, "explanation")), plan: arrayValue(field(turn, "plan")) as CodexPlanUpdate["plan"], turnStatus: String(field(turn, "status") || "") }, threadId);
const reasoningItems = items.flatMap((item) => {
if (field(item, "type") !== "reasoning") return [];
const id = String(field(item, "id") || "");
const text = readableText(field(item, "summary"));
return id && text ? [{ id, text, status: itemStatus(item, itemErrorMessage(item)) }] : [];
});
const reasoningText = reasoningItems.map((item) => item.text).join("\n\n");
const reasoningStatus = aggregateItemStatus(reasoningItems.map((item) => item.status));
const itemErrors = items.flatMap((item) => field(item, "type") === "error" ? [readableText(field(item, "message"))] : []).filter(Boolean);
const itemError = itemErrors[itemErrors.length - 1] || "";
let userAdded = false;
let planAdded = false;
arrayValue(field(turn, "items")).forEach((item, itemIndex) => {
let reasoningAdded = false;
const fallbackUserText = displayUserText(String(field(turn, "input") || ""));
if (fallbackUserText && !items.some((item) => field(item, "type") === "userMessage")) {
push({ id: "synthetic:user", role: "user", text: fallbackUserText });
userAdded = true;
if (planMessage) {
messages.push(planMessage);
planAdded = true;
}
}
items.forEach((item) => {
const type = String(field(item, "type") || "");
const id = String(field(item, "id") || `${turnIndex}-${itemIndex}`);
const id = String(field(item, "id") || "");
if (!id) return;
if (type === "userMessage") {
const text = displayUserText(userInputText(field(item, "content")));
if (text) messages.push({ id, role: "user", text });
if (text && !userAdded) {
push({ id: "synthetic:user", role: "user", text });
userAdded = true;
}
if (planMessage && !planAdded) {
messages.push(planMessage);
planAdded = true;
@@ -42,55 +88,337 @@ export function threadMessages(thread: unknown, planUpdates: CodexPlanUpdate[] =
}
if (type === "agentMessage") {
const text = String(field(item, "text") || "").trim();
if (text) messages.push({ id, role: "assistant", title: "Codex", text });
if (text) push({ id, role: "assistant", title: "Codex", text });
}
if (type === "mcpToolCall") {
const tool = String(field(item, "tool") || "工具调用");
const error = String(field(field(item, "error"), "message") || "");
const input = toolArguments(field(item, "arguments"));
messages.push({ id, role: "tool", title: toolName(tool), text: error || toolHistorySummary(tool, item, input), detail: toolHistoryDetail(tool, item, input, error) });
push({ id, role: "tool", title: toolName(tool), text: error || toolHistorySummary(tool, item, input), detail: toolHistoryDetail(tool, item, input, error) });
}
if (type === "commandExecution") {
const command = String(field(item, "command") || "").trim();
if (command) messages.push({ id, role: "tool", title: "执行命令", text: command, detail: commandDetail(item) });
const detail = commandDetail(item);
push({ id, role: "tool", title: "执行命令", text: command || (detail.status === "failed" ? "命令执行失败" : "命令已完成"), detail });
}
if (type === "fileChange") {
const changes = arrayValue(field(item, "changes"));
messages.push({ id, role: "tool", title: "修改文件", text: fileChangeSummary(changes), detail: { kind: "file", status: field(item, "status"), files: changes.map((change) => ({ path: String(field(change, "path") || "未知文件"), action: changeKind(field(change, "kind")) })) } });
const error = itemErrorMessage(item);
push({ id, role: "tool", title: "修改文件", text: error || fileChangeSummary(changes), detail: { kind: "file", status: itemStatus(item, error), files: changes.map((change) => ({ path: String(field(change, "path") || "未知文件"), action: changeKind(field(change, "kind")) })), ...(error ? { output: error } : {}) } });
}
if (type === "reasoning") {
const text = readableText(field(item, "summary"));
if (text) messages.push({ id, role: "tool", title: "思考摘要", text, detail: { kind: "reasoning", status: "completed" } });
if (type === "reasoning" && reasoningText && !reasoningAdded) {
const sourceItemIds = reasoningItems.map((item) => item.id);
push({ id: "synthetic:reasoning", role: "tool", title: "思考摘要", text: reasoningText, activityItems: Object.fromEntries(reasoningItems.map((item) => [item.id, item.text])), detail: { kind: "reasoning", status: reasoningStatus, sourceItemIds } });
reasoningAdded = true;
}
if (type === "plan") {
const text = String(field(item, "text") || "").trim();
if (text) messages.push({ id, role: "tool", title: "执行计划", text, detail: { kind: "plan", status: "completed" } });
const error = itemErrorMessage(item);
if (text || error) push({ id, role: "tool", title: "执行计划", text: error || text, detail: { kind: "plan", status: itemStatus(item, error), ...(error ? { output: error } : {}) } });
}
if (type === "webSearch") {
const error = itemErrorMessage(item);
push({ id, role: "tool", title: "搜索资料", text: error || webSearchSummary(item), detail: { kind: "search", status: itemStatus(item, error), rows: webSearchRows(item), ...(error ? { output: error } : {}) } });
}
if (type === "imageView") {
const error = itemErrorMessage(item);
push({ id, role: "tool", title: "查看图片", text: error || String(field(item, "path") || "已查看图片"), detail: { kind: "image", status: itemStatus(item, error), ...(error ? { output: error } : {}) } });
}
if (type === "imageGeneration") {
const error = String(field(field(item, "error"), "message") || "");
const normalizedStatus = itemStatus(item, error);
push({ id, role: "tool", title: "内置生图", text: error || (normalizedStatus === "failed" ? "图片生成失败" : "图片生成完成"), detail: { kind: "image", status: normalizedStatus, savedPath: field(item, "savedPath"), ...(error ? { output: error } : {}) } });
}
if (type === "contextCompaction") {
const error = itemErrorMessage(item);
push({ id, role: "tool", title: "整理上下文", text: error || "已整理当前对话,继续处理任务", detail: { kind: "context", status: itemStatus(item, error), ...(error ? { output: error } : {}) } });
}
if (type === "webSearch") messages.push({ id, role: "tool", title: "搜索资料", text: webSearchSummary(item), detail: { kind: "search", status: "completed", rows: webSearchRows(item) } });
if (type === "imageView") messages.push({ id, role: "tool", title: "查看图片", text: String(field(item, "path") || "已查看图片"), detail: { kind: "image", status: "completed" } });
if (type === "imageGeneration") messages.push({ id, role: "tool", title: "内置生图", text: String(field(item, "savedPath") || "图片生成完成"), detail: { kind: "image", status: field(item, "status"), savedPath: field(item, "savedPath") } });
if (type === "contextCompaction") messages.push({ id, role: "tool", title: "整理上下文", text: "已整理当前对话,继续处理任务", detail: { kind: "context", status: "completed" } });
if (type === "dynamicToolCall") {
const tool = String(field(item, "tool") || "");
const title = toolName(tool);
const error = String(field(field(item, "error"), "message") || "");
const status = String(field(item, "status") || "");
const failed = Boolean(error) || field(item, "success") === false || status === "failed" || status === "error";
messages.push({ id, role: "tool", title, text: error || readableText(field(item, "contentItems")) || `${title}${failed ? "失败" : "完成"}`, detail: { kind: "tool", status: failed ? "failed" : status } });
const error = itemErrorMessage(item);
push({ id, role: "tool", title, text: error || readableText(field(item, "contentItems")), detail: toolHistoryDetail(tool, item, toolArguments(field(item, "arguments")), error) });
}
if (type === "collabToolCall" || type === "collabAgentToolCall") {
const error = String(field(field(item, "error"), "message") || "");
const normalizedStatus = itemStatus(item, error);
push({ id, role: "tool", title: "协作处理", text: error || (normalizedStatus === "failed" ? "协作任务失败" : "已完成协作任务"), detail: { kind: "tool", status: normalizedStatus, ...(error ? { output: error } : {}) } });
}
if (type === "collabToolCall") messages.push({ id, role: "tool", title: "协作处理", text: "已完成协作任务", detail: { kind: "tool", status: field(item, "status") } });
});
if (planMessage && !planAdded) messages.push(planMessage);
if (turnError) {
const error = userFacingCodexError(turnError);
messages.push({ id: `error-${turnId}`, role: "error", title: error.title, text: error.text });
if (itemError || turnError) {
const error = userFacingCodexError(turnError || itemError);
push({ id: "synthetic:error", role: "error", title: error.title, text: error.text });
}
});
return messages.filter((item) => item.text).slice(-120);
return latestCompleteTurns(messages.filter((item) => item.text || item.role === "tool"), 120);
}
/** 合并标准 turn 与本地终态事件,标准历史已有字段保持优先。 */
function mergeHistoryTurns(thread: unknown, supplementalHistory: CodexSupplementalHistory) {
const threadId = String(field(thread, "id") || "");
const standardTurns = arrayValue(field(thread, "turns"));
const supplementalTurns = supplementalHistory.turns.filter((entry) => entry.threadId === threadId && entry.turnId);
const supplementalById = new Map(supplementalTurns.map((entry) => [entry.turnId, entry.turn]));
const standardIds = new Set(standardTurns.map((turn) => String(field(turn, "id") || "")).filter(Boolean));
return [
...standardTurns.map((turn) => {
const turnId = String(field(turn, "id") || "");
const supplemental = supplementalById.get(turnId);
return supplemental ? mergeHistoryTurn(turn, supplemental) : turn;
}),
...supplementalTurns.filter((entry) => !standardIds.has(entry.turnId)).map((entry) => entry.turn),
];
}
/** 终态不可被标准历史中尚未物化完成的临时状态降级。 */
function mergeHistoryTurn(standard: unknown, supplemental: Record<string, unknown>) {
const merged = mergeHistoryItem(standard, supplemental);
const standardStatus = String(field(standard, "status") || "");
const supplementalStatus = String(field(supplemental, "status") || "");
if (!isSettledStatus(standardStatus) && isSettledStatus(supplementalStatus)) merged.status = supplementalStatus;
return merged;
}
/** 以 Codex 标准历史为准,只补入同一 turn 中被持久线程投影省略的实时条目。 */
function mergeHistoryItems(items: unknown[], supplementalItems: CodexSupplementalHistoryItem[]) {
const supplemental = supplementalItems
.filter((entry) => entry.itemId)
.map((entry, index) => ({ ...entry, item: normalizeSupplementalItem(entry.item), fallbackIndex: index }))
.sort(compareSupplementalEntries);
items = removeCorruptedHistoryDuplicates(items, supplemental);
items = reconcileHistoryItemIds(items, supplemental);
const standardIds = new Set(items.map((item) => String(field(item, "id") || "")).filter(Boolean));
const supplementalById = new Map(supplemental.map((entry) => [entry.itemId, entry]));
const mergedStandard = items.map((item) => {
const id = String(field(item, "id") || "");
const supplementalEntry = id ? supplementalById.get(id) : undefined;
return supplementalEntry ? mergeHistoryItem(item, supplementalEntry.item) : item;
});
const sequenced = supplemental.filter((entry) => entry.sequence !== undefined);
let merged = sequenced.length ? mergeSequencedItems(mergedStandard, sequenced) : mergedStandard;
supplemental.forEach((entry, index) => {
if (standardIds.has(entry.itemId) || entry.sequence !== undefined) return;
const nextStandardId = supplemental.slice(index + 1).find((candidate) => standardIds.has(candidate.itemId))?.itemId;
const insertAt = nextStandardId ? merged.findIndex((candidate) => String(field(candidate, "id") || "") === nextStandardId) : -1;
if (insertAt >= 0) merged.splice(insertAt, 0, entry.item);
else merged.push(entry.item);
});
return merged;
}
/** thread/read 会重写部分 item id;按类型、内容和出现顺序恢复实时事件的稳定身份。 */
function reconcileHistoryItemIds(items: unknown[], supplementalItems: CodexSupplementalHistoryItem[]) {
const supplementalById = new Map(supplementalItems.map((entry) => [entry.itemId, entry]));
const reservedIds = new Set(items.map((item) => String(field(item, "id") || "")).filter((id) => supplementalById.has(id)));
const candidates = new Map<string, CodexSupplementalHistoryItem[]>();
supplementalItems.forEach((entry) => {
if (reservedIds.has(entry.itemId)) return;
const identity = historyItemIdentity(entry.item);
if (identity) candidates.set(identity, [...(candidates.get(identity) || []), entry]);
});
const replacements = new Map<number, string>();
const usedSupplementalIds = new Set(reservedIds);
items.forEach((item, index) => {
const id = String(field(item, "id") || "");
if (!id || supplementalById.has(id)) return;
const identity = historyItemIdentity(item);
const candidate = identity ? candidates.get(identity)?.shift() : undefined;
if (!candidate || usedSupplementalIds.has(candidate.itemId)) return;
replacements.set(index, candidate.itemId);
usedSupplementalIds.add(candidate.itemId);
});
const remainingStandard = new Map<string, number[]>();
items.forEach((item, index) => {
const id = String(field(item, "id") || "");
if (!id || supplementalById.has(id) || replacements.has(index)) return;
const type = historyItemType(item);
if (type) remainingStandard.set(type, [...(remainingStandard.get(type) || []), index]);
});
const remainingSupplemental = new Map<string, CodexSupplementalHistoryItem[]>();
supplementalItems.forEach((entry) => {
if (usedSupplementalIds.has(entry.itemId)) return;
const type = historyItemType(entry.item);
if (type) remainingSupplemental.set(type, [...(remainingSupplemental.get(type) || []), entry]);
});
remainingStandard.forEach((standardIndexes, type) => {
const supplemental = remainingSupplemental.get(type) || [];
if (standardIndexes.length === supplemental.length) {
standardIndexes.forEach((index, position) => {
const entry = supplemental[position];
replacements.set(index, entry.itemId);
usedSupplementalIds.add(entry.itemId);
});
return;
}
if (supplemental.length !== 1) return;
const corrupted = standardIndexes.filter((index) => hasReplacementCharacter(items[index]));
if (corrupted.length === 1) {
replacements.set(corrupted[0], supplemental[0].itemId);
usedSupplementalIds.add(supplemental[0].itemId);
}
});
return items.map((item, index) => {
const itemId = replacements.get(index);
if (!itemId || !item || typeof item !== "object" || Array.isArray(item)) return item;
return { ...(item as Record<string, unknown>), id: itemId };
});
}
function historyItemType(item: unknown) {
const type = String(field(normalizeSupplementalItem(item && typeof item === "object" && !Array.isArray(item) ? item as Record<string, unknown> : {}), "type") || "");
return type;
}
function hasReplacementCharacter(item: unknown) {
const value = item && typeof item === "object" && !Array.isArray(item) ? item as Record<string, unknown> : {};
return [value.text, value.summary, value.message, value.aggregatedOutput].some((fieldValue) => readableText(fieldValue).includes("\uFFFD"));
}
/** thread/read 偶尔同时返回损坏临时条目和已物化条目,只保留补充历史已确认的稳定副本。 */
function removeCorruptedHistoryDuplicates(items: unknown[], supplementalItems: CodexSupplementalHistoryItem[]) {
const standardIds = new Set(items.map((item) => String(field(item, "id") || "")).filter(Boolean));
const stableItems = supplementalItems.filter((entry) => standardIds.has(entry.itemId) && !hasReplacementCharacter(entry.item));
return items.filter((item) => {
if (!hasReplacementCharacter(item)) return true;
const identity = comparableHistoryText(item);
if (identity.length < 8) return true;
return !stableItems.some((entry) => historyItemType(entry.item) === historyItemType(item) && comparableHistoryText(entry.item) === identity);
});
}
function comparableHistoryText(item: unknown) {
const value = item && typeof item === "object" && !Array.isArray(item) ? item as Record<string, unknown> : {};
const type = historyItemType(value);
const text = type === "agentMessage" ? value.text : type === "reasoning" ? value.summary : type === "plan" ? value.text : "";
return readableText(text).normalize("NFKC").toLocaleLowerCase().replace(/[\uFFFD\p{P}\p{S}\p{Z}]+/gu, "");
}
/** 只使用消息本身的稳定语义字段,不使用状态、结果或临时投影 id。 */
function historyItemIdentity(item: unknown) {
const normalized = normalizeSupplementalItem(item && typeof item === "object" && !Array.isArray(item) ? item as Record<string, unknown> : {});
const type = String(field(normalized, "type") || "");
let value: unknown;
if (type === "agentMessage") value = String(field(normalized, "text") || "").trim();
else if (type === "reasoning") value = readableText(field(normalized, "summary"));
else if (type === "plan") value = String(field(normalized, "text") || "").trim();
else if (type === "mcpToolCall" || type === "dynamicToolCall") value = [field(normalized, "tool"), toolArguments(field(normalized, "arguments"))];
else if (type === "commandExecution") value = String(field(normalized, "command") || "").trim();
else if (type === "fileChange") value = arrayValue(field(normalized, "changes")).map((change) => [field(change, "path"), field(change, "kind")]);
else if (type === "collabToolCall" || type === "collabAgentToolCall") value = [field(normalized, "tool"), field(normalized, "prompt"), field(normalized, "receiverThreadId"), field(normalized, "receiverAgentId")];
else if (type === "webSearch") value = [field(normalized, "query"), field(field(normalized, "action"), "type"), field(field(normalized, "action"), "query"), field(field(normalized, "action"), "url"), field(field(normalized, "action"), "pattern")];
else if (type === "imageView") value = field(normalized, "path");
else if (type === "imageGeneration") value = field(normalized, "prompt") || field(normalized, "savedPath");
else if (type === "contextCompaction") value = true;
else return "";
return `${type}\0${JSON.stringify(stableHistoryValue(value))}`;
}
function stableHistoryValue(value: unknown): unknown {
if (Array.isArray(value)) return value.map(stableHistoryValue);
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => [key, stableHistoryValue(item)]));
return value;
}
function mergeSequencedItems(standardItems: unknown[], supplementalItems: CodexSupplementalHistoryItem[]) {
const byId = new Map(supplementalItems.map((entry) => [entry.itemId, entry]));
const emitted = new Set<string>();
const merged: unknown[] = [];
standardItems.forEach((item) => {
const id = String(field(item, "id") || "");
const sequence = id ? byId.get(id)?.sequence : undefined;
if (sequence !== undefined) {
supplementalItems.forEach((entry) => {
if (entry.sequence !== undefined && entry.sequence < sequence && !emitted.has(entry.itemId)) {
merged.push(findStandardItem(standardItems, entry.itemId) || entry.item);
emitted.add(entry.itemId);
}
});
if (emitted.has(id)) return;
if (id) emitted.add(id);
merged.push(item);
return;
}
merged.push(item);
});
supplementalItems.forEach((entry) => {
if (!emitted.has(entry.itemId)) merged.push(entry.item);
});
return merged;
}
function findStandardItem(items: unknown[], itemId: string) {
return items.find((item) => String(field(item, "id") || "") === itemId);
}
function compareSupplementalEntries(left: CodexSupplementalHistoryItem & { fallbackIndex: number }, right: CodexSupplementalHistoryItem & { fallbackIndex: number }) {
if (left.sequence !== undefined && right.sequence !== undefined && left.sequence !== right.sequence) return left.sequence - right.sequence;
if (left.sequence !== undefined) return -1;
if (right.sequence !== undefined) return 1;
return left.fallbackIndex - right.fallbackIndex;
}
/** 标准历史字段优先,补充事件只填充标准历史没有返回的字段。 */
function mergeHistoryItem(standard: unknown, supplemental: Record<string, unknown>) {
if (!standard || typeof standard !== "object" || Array.isArray(standard)) return supplemental;
const merged = { ...supplemental };
Object.entries(standard as Record<string, unknown>).forEach(([key, value]) => {
const supplementalValue = merged[key];
if (typeof value === "string" && typeof supplementalValue === "string" && value.includes("\uFFFD") && !supplementalValue.includes("\uFFFD")) return;
if (value !== undefined) merged[key] = value;
});
return merged;
}
/** 将实时事件的 snake_case 类型还原为 thread/read 使用的 camelCase 类型。 */
function normalizeSupplementalItem(item: Record<string, unknown>) {
const type = String(field(item, "type") || "");
const normalizedType = supplementalItemTypes[type] || type;
return normalizedType === type ? item : { ...item, type: normalizedType };
}
const supplementalItemTypes: Record<string, string> = {
agent_message: "agentMessage",
mcp_tool_call: "mcpToolCall",
command_execution: "commandExecution",
file_change: "fileChange",
dynamic_tool_call: "dynamicToolCall",
collab_tool_call: "collabToolCall",
web_search: "webSearch",
image_view: "imageView",
image_generation: "imageGeneration",
context_compaction: "contextCompaction",
};
/** 只按完整 turn 截取最近历史,避免列表从某轮中间开始。 */
function latestCompleteTurns(messages: AgentHistoryMessage[], limit: number) {
const turns: AgentHistoryMessage[][] = [];
messages.forEach((message) => {
const current = turns.at(-1);
if (!current || current[0].turnId !== message.turnId) turns.push([message]);
else current.push(message);
});
const selected: AgentHistoryMessage[][] = [];
let count = 0;
for (let index = turns.length - 1; index >= 0; index -= 1) {
const turn = turns[index];
if (selected.length && count + turn.length > limit) break;
selected.unshift(turn);
count += turn.length;
}
return selected.flat();
}
function isSettledTurn(turn: unknown) {
return isSettledStatus(String(field(turn, "status") || ""));
}
function isSettledStatus(status: string) {
return ["completed", "failed", "interrupted", "cancelled", "canceled"].includes(status);
}
/** 将结构化任务计划转换为聊天进度卡片。 */
function structuredPlanMessage(update: CodexPlanUpdate): AgentHistoryMessage | null {
function structuredPlanMessage(update: CodexPlanUpdate, threadId: string): AgentHistoryMessage | null {
const tasks = arrayValue(update.plan).flatMap((item) => {
const step = String(field(item, "step") || "").trim();
return step ? [{ step, status: String(field(item, "status") || "pending") }] : [];
@@ -98,7 +426,10 @@ function structuredPlanMessage(update: CodexPlanUpdate): AgentHistoryMessage | n
if (!tasks.length) return null;
const completed = tasks.filter((item) => item.status === "completed").length;
return {
id: `plan-${update.turnId}`,
id: historyMessageId(threadId, update.turnId, "synthetic:plan"),
itemId: "synthetic:plan",
threadId,
turnId: update.turnId,
role: "tool",
title: "任务进度",
text: `已完成 ${completed}/${tasks.length}`,
@@ -106,6 +437,11 @@ function structuredPlanMessage(update: CodexPlanUpdate): AgentHistoryMessage | n
};
}
/** 生成跨线程和 turn 唯一的聊天消息 ID。 */
function historyMessageId(threadId: string, turnId: string, itemId: string) {
return `${threadId}:${turnId}:${itemId}`;
}
/** 根据步骤和 turn 状态生成任务卡片状态。 */
function planStatus(tasks: Array<{ status: string }>, turnStatus?: string) {
if (turnStatus === "failed") return "failed";
@@ -155,34 +491,60 @@ function stringOrNull(value: unknown) {
/** 生成命令执行的用户可读详情。 */
function commandDetail(item: unknown) {
const error = String(field(field(item, "error"), "message") || "");
const status = String(field(item, "status") || "completed");
const exitCode = field(item, "exitCode");
const failed = Boolean(error) || field(item, "success") === false || status === "failed" || status === "error" || (typeof exitCode === "number" && exitCode !== 0);
const rows = [
textRow("工作目录", field(item, "cwd")),
textRow("退出状态", field(item, "exitCode")),
textRow("退出状态", exitCode),
durationRow(field(item, "durationMs")),
].filter(Boolean);
return { kind: "command", status: field(item, "status"), rows, output: String(field(item, "aggregatedOutput") || "").trim() };
return { kind: "command", status: failed ? "failed" : status, rows, output: error || String(field(item, "aggregatedOutput") || "").trim() };
}
/** 生成 MCP 工具的用户可读详情。 */
function toolHistoryDetail(tool: string, item: unknown, input: unknown, error: string) {
return { kind: "tool", status: error ? "failed" : field(item, "status"), rows: toolInputRows(tool, input), ...(error ? { output: error } : {}) };
return { kind: "tool", status: itemStatus(item, error), rows: toolInputRows(tool, input), ...(error ? { output: error } : {}) };
}
function itemErrorMessage(item: unknown) {
return String(field(field(item, "error"), "message") || "");
}
function itemStatus(item: unknown, error = "") {
const status = String(field(item, "status") || "completed");
return error || field(item, "success") === false || status === "failed" || status === "error" ? "failed" : status;
}
function aggregateItemStatus(statuses: string[]) {
if (statuses.includes("failed")) return "failed";
if (statuses.includes("interrupted")) return "interrupted";
if (statuses.some((status) => ["inProgress", "in_progress", "running", "started", "pending"].includes(status))) return "inProgress";
return "completed";
}
/** 生成 MCP 工具在对话中的结果摘要。 */
function toolHistorySummary(tool: string, item: unknown, input: unknown) {
const result = parseToolResult(field(item, "result"));
if (tool === "site_navigate") return `已打开${routeName(String(field(input, "path") || "/"))}`;
if (tool === "canvas_list_projects") return "已读取画布列表";
if (tool === "canvas_list_projects") return `${numberValue(field(result, "total"))} 个画布`;
if (tool === "canvas_get_state") {
const result = parseToolResult(field(item, "result"));
const nodes = arrayValue(field(result, "nodes"));
const connections = arrayValue(field(result, "connections"));
return nodes.length || connections.length || result ? canvasContentSummary(nodes, connections.length) : "已读取当前画布内容";
return Array.isArray(field(result, "nodes")) || Array.isArray(field(result, "connections")) ? canvasContentSummary(nodes, connections.length) : "已读取当前画布内容";
}
if (tool === "canvas_get_selection") return "已读取当前选中内容";
if (tool === "prompts_search") return `已搜索提示词“${String(field(input, "query") || "") || "全部"}`;
if (tool === "assets_list") return "已读取我的素材";
if (tool === "generation_get_status") return "已检查生成任务状态";
return `${toolName(tool)}已完成`;
if (tool === "prompts_search") return `找到 ${numberValue(field(result, "total"))} 条提示词`;
if (tool === "assets_list") return `${numberValue(field(result, "total"))} 个资产`;
if (tool === "assets_add") return "已加入我的素材";
if (tool === "generation_get_status") {
const summary = field(result, "summary");
return `${numberValue(field(result, "total"))} 个任务,排队 ${numberValue(field(summary, "queued"))},运行中 ${numberValue(field(summary, "running"))},成功 ${numberValue(field(summary, "succeeded"))},失败 ${numberValue(field(summary, "failed"))}`;
}
if (tool === "workbench_image_generate" || tool === "workbench_video_generate") return String(field(result, "note") || "已在工作台执行");
if (tool === "workbench_image_get_config" || tool === "workbench_video_get_config") return "已读取工作台配置";
return "";
}
/** 按节点类型生成人类可读的画布内容概览。 */
@@ -226,16 +588,38 @@ function toolInputRows(tool: string, input: unknown) {
if (tool === "site_navigate") return [textRow("目标页面", routeName(String(field(input, "path") || "/")))].filter(Boolean);
if (tool === "prompts_search") return [textRow("搜索内容", field(input, "query"))].filter(Boolean);
if (tool === "canvas_create_text_node") return [textRow("文本内容", field(input, "text"))].filter(Boolean);
if (tool === "canvas_apply_ops") return [textRow("操作数量", arrayValue(field(input, "ops")).length)].filter(Boolean);
if (tool === "canvas_apply_ops") return [textRow("操作内容", summarizeCanvasOps(arrayValue(field(input, "ops"))))].filter(Boolean);
if (tool === "canvas_create_attachment_nodes") return [textRow("图片数量", arrayValue(field(input, "attachmentIds")).length)].filter(Boolean);
return [];
}
function summarizeCanvasOps(ops: unknown[]) {
const counts = ops.reduce<Record<string, number>>((result, op) => {
const type = String(field(op, "type") || "");
if (type) result[type] = (result[type] || 0) + 1;
return result;
}, {});
return Object.entries(counts).map(([type, count]) => `${canvasOpLabel(type)} ${count}`).join("");
}
function canvasOpLabel(type: string) {
if (type === "add_node") return "新增节点";
if (type === "update_node") return "更新节点";
if (type === "delete_node") return "删除节点";
if (type === "delete_connections") return "删除连线";
if (type === "connect_nodes") return "连接";
if (type === "set_viewport") return "调整视图";
if (type === "select_nodes") return "选择节点";
if (type === "run_generation") return "触发生成";
return type;
}
/** 生成人类可读的文件变更摘要。 */
function fileChangeSummary(changes: unknown[]) {
if (!changes.length) return "已完成文件修改";
const names = changes.slice(0, 3).map((change) => String(field(change, "path") || "未知文件"));
if (changes.length === 1) return `${changeKind(field(changes[0], "kind"))}${names[0]}`;
return `涉及 ${changes.length} 个文件:${names.join("、")}${changes.length > names.length ? " 等" : ""}`;
if (changes.length === 1) return `${changeKind(field(changes[0], "kind"))} ${names[0]}`;
return `已修改 ${changes.length} 个文件:${names.join("、")}${changes.length > names.length ? " 等" : ""}`;
}
/** 生成网页搜索摘要。 */
@@ -257,6 +641,7 @@ function webSearchRows(item: unknown) {
function readableText(value: unknown): string {
if (typeof value === "string") return value.trim();
if (Array.isArray(value)) return value.map(readableText).filter(Boolean).join("\n");
if (!value || typeof value !== "object") return "";
return readableText(field(value, "text"));
}
@@ -281,6 +666,12 @@ function durationRow(value: unknown) {
return duration > 0 ? { label: "耗时", value: `${(duration / 1000).toFixed(1)}` } : null;
}
/** 将未知数值转换为有限数字。 */
function numberValue(value: unknown) {
const number = Number(value || 0);
return Number.isFinite(number) ? number : 0;
}
/** 将文件变更类型转换为中文。 */
function changeKind(value: unknown) {
if (value === "add") return "新增";
@@ -309,7 +700,7 @@ function toolName(name: string) {
if (name === "apply_patch" || name.endsWith("__apply_patch")) return "修改文件";
if (name === "web__run" || name.endsWith("__web__run")) return "搜索资料";
if (name === "site_navigate") return "打开页面";
if (name === "canvas_list_projects") return "查看画布列表";
if (name === "canvas_list_projects") return "画布列表";
if (name === "canvas_apply_ops") return "画布操作";
if (name === "canvas_get_state") return "读取画布";
if (name === "canvas_get_selection") return "读取选区";
@@ -334,13 +725,13 @@ function toolName(name: string) {
if (name === "canvas_select_nodes") return "选择节点";
if (name === "canvas_set_viewport") return "调整视口";
if (name === "canvas_run_generation") return "触发生成";
if (name === "workbench_image_get_config") return "读取生图置";
if (name === "workbench_image_generate") return "生图工作台生成";
if (name === "workbench_video_get_config") return "读取视频置";
if (name === "workbench_video_generate") return "视频作台生成";
if (name === "workbench_image_get_config") return "生图置";
if (name === "workbench_image_generate") return "生图工作台生成";
if (name === "workbench_video_get_config") return "视频置";
if (name === "workbench_video_generate") return "视频作台生成";
if (name === "prompts_search") return "搜索提示词";
if (name === "assets_list") return "查看我的素材";
if (name === "assets_add") return "添加到我的素材";
if (name === "generation_get_status") return "查看生成状态";
if (name === "assets_list") return "资产列表";
if (name === "assets_add") return "添加资产";
if (name === "generation_get_status") return "生成任务状态";
return name ? `调用工具:${name}` : "工具操作";
}
+66 -53
View File
@@ -4,8 +4,9 @@ import path from "node:path";
import { logger } from "../utils/logger.js";
import { errorMessage, field } from "../utils/value.js";
import { CodexAppClient } from "./codex-client.js";
import { summarizeCodexThread, threadMessages } from "./codex-history.js";
import { CodexAppClient, CodexReportedError } from "./codex-client.js";
import { codexEventHistory } from "./codex-event-history.js";
import { settledTurnIds, summarizeCodexThread, threadMessages } from "./codex-history.js";
import type { CodexReasoningEffort } from "./codex-protocol.js";
import type { AgentAttachment, AgentEmit, AgentPermissionMode } from "./types.js";
@@ -14,22 +15,22 @@ type CodexRunOptions = { threadId?: string; cwd?: string; permissionMode?: Agent
let codexQueue: Promise<unknown> = Promise.resolve();
let codexApp: CodexAppClient | null = null;
let codexAppStart: Promise<CodexAppClient> | null = null;
let codexThreadId = "";
const unmaterializedThreadIds = new Set<string>();
/** 仅表示最近主动加载/选择的线程;运行中的 turn 身份由 CodexAppClient 自己维护。 */
let loadedThreadId = "";
export { summarizeCodexThread } from "./codex-history.js";
/** 将 Codex turn 加入串行队列并等待执行完成。 */
export async function runCodexTurn(prompt: string, emit: AgentEmit, attachments: AgentAttachment[] = [], options: CodexRunOptions = {}) {
export async function runCodexTurn(prompt: string, lifecycleEmit: AgentEmit, attachments: AgentAttachment[] = [], options: CodexRunOptions = {}) {
if (!prompt.trim()) return;
codexQueue = codexQueue.catch(() => undefined).then(() => runCodexTurnNow(prompt, emit, attachments, options));
codexQueue = codexQueue.catch(() => undefined).then(() => runCodexTurnNow(prompt, lifecycleEmit, attachments, options));
await codexQueue;
}
/** 中断当前线程正在执行的 Codex turn。 */
export async function interruptCodexTurn(threadId?: string) {
if (!codexApp || (threadId && threadId !== codexThreadId)) return false;
return await codexApp.interruptCurrentTurn();
if (!codexApp) return false;
return await codexApp.interruptCurrentTurn(threadId);
}
/** 回复当前 app-server 的待处理权限请求。 */
@@ -41,20 +42,17 @@ export async function resolveCodexApproval(requestId: string, decision: string)
export async function startCodexThread(emit: AgentEmit, cwd?: string, permissionMode: AgentPermissionMode = "request") {
const app = await getCodexApp(emit);
const thread = await app.startThread(cwd, permissionMode);
codexThreadId = String(field(thread, "id") || "");
if (codexThreadId) unmaterializedThreadIds.add(codexThreadId);
loadedThreadId = String(field(thread, "id") || "");
return thread;
}
/** 恢复指定 Codex 线程并返回聊天历史。 */
export async function resumeCodexThread(emit: AgentEmit, threadId: string, cwd?: string, permissionMode: AgentPermissionMode = "request") {
const app = await getCodexApp(emit);
await loadCodexThread(emit, threadId, cwd, false);
const thread = await app.resumeThread(threadId, cwd, permissionMode);
assertThreadWorkspace(thread, cwd);
codexThreadId = String(field(thread, "id") || threadId);
const historyThread = await loadCodexThread(emit, codexThreadId, cwd, true);
return { thread, messages: threadMessages(historyThread, app.planUpdates(threadId)) };
const thread = await resumeLoadedThread(app, threadId, cwd, permissionMode, true);
const history = await loadCodexHistory(emit, threadId, cwd);
const supplementalItems = await codexEventHistory.readThread(threadId);
return { thread, messages: threadMessages(history.thread, app.planUpdates(threadId), supplementalItems), settledTurnIds: settledTurnIds(history.thread, supplementalItems), historyReady: history.historyReady };
}
/** 查询当前工作空间中的 Codex 线程。 */
@@ -80,29 +78,24 @@ export async function listCodexModels(emit: AgentEmit) {
/** 读取指定 Codex 线程及其聊天历史。 */
export async function readCodexThread(emit: AgentEmit, threadId: string, cwd?: string) {
const app = await getCodexApp(emit);
let thread: unknown;
try {
thread = await loadCodexThread(emit, threadId, cwd, !unmaterializedThreadIds.has(threadId));
} catch (error) {
if (!/not materialized yet.*includeTurns/i.test(errorMessage(error))) throw error;
unmaterializedThreadIds.add(threadId);
thread = await loadCodexThread(emit, threadId, cwd, false);
}
return { thread: summarizeCodexThread(thread), messages: threadMessages(thread, app.planUpdates(threadId)) };
}
/** 确认指定 Codex 线程属于当前工作空间。 */
export async function verifyCodexThreadWorkspace(emit: AgentEmit, threadId: string, cwd: string) {
await loadCodexThread(emit, threadId, cwd, false);
const history = await loadCodexHistory(emit, threadId, cwd);
const supplementalItems = await codexEventHistory.readThread(threadId);
return { thread: summarizeCodexThread(history.thread), messages: threadMessages(history.thread, app.planUpdates(threadId), supplementalItems), settledTurnIds: settledTurnIds(history.thread, supplementalItems), historyReady: history.historyReady };
}
/** 归档指定 Codex 线程。 */
export async function archiveCodexThread(emit: AgentEmit, threadId: string, cwd?: string) {
const app = await getCodexApp(emit);
await loadCodexThread(emit, threadId, cwd, false);
try {
await loadCodexThread(emit, threadId, cwd, false);
} catch (error) {
if (!isRecoverableThreadError(error)) throw error;
await resumeLoadedThread(app, threadId, cwd, "request", false);
}
await app.archiveThread(threadId);
app.clearPlanUpdates(threadId);
unmaterializedThreadIds.delete(threadId);
await codexEventHistory.removeThread(threadId);
if (loadedThreadId === threadId) loadedThreadId = "";
}
/** 判断线程异常是否允许自动新建线程后重试。 */
@@ -111,29 +104,27 @@ export function isRecoverableThreadError(error: unknown) {
}
/** 执行一次 Codex turn,并负责附件临时文件和线程恢复。 */
async function runCodexTurnNow(prompt: string, emit: AgentEmit, attachments: AgentAttachment[], options: CodexRunOptions) {
async function runCodexTurnNow(prompt: string, lifecycleEmit: AgentEmit, attachments: AgentAttachment[], options: CodexRunOptions) {
let files: string[] = [];
try {
options.onStart?.();
files = await writeAttachmentFiles(attachments);
const app = await getCodexApp(options.appEmit || emit);
let threadId = await ensureCodexThread(app, options, emit);
const app = await getCodexApp(options.appEmit || lifecycleEmit);
let threadId = await ensureCodexThread(app, options, lifecycleEmit);
options.onThread?.(threadId);
unmaterializedThreadIds.delete(threadId);
try {
await app.startTurn(threadId, prompt, files, options.permissionMode || "request", options.model, options.effort, options.onTurn);
} catch (error) {
if (!isRecoverableThreadError(error)) throw error;
emit("agent_log", { text: `Codex thread unavailable, starting a new thread: ${errorMessage(error)}` });
codexThreadId = "";
threadId = await ensureCodexThread(app, { cwd: options.cwd }, emit);
lifecycleEmit("agent_log", { text: `Codex thread unavailable, starting a new thread: ${errorMessage(error)}` });
loadedThreadId = "";
threadId = await ensureCodexThread(app, { cwd: options.cwd }, lifecycleEmit);
options.onThread?.(threadId);
unmaterializedThreadIds.delete(threadId);
await app.startTurn(threadId, prompt, files, options.permissionMode || "request", options.model, options.effort, options.onTurn);
}
} catch (error) {
logger.error("Codex turn failed", error);
emit("agent_error", { message: errorMessage(error) });
if (!(error instanceof CodexReportedError)) lifecycleEmit("agent_error", { message: errorMessage(error) });
} finally {
options.onFinish?.();
await Promise.all(files.map((file) => fs.unlink(file).catch(() => undefined)));
@@ -143,25 +134,21 @@ async function runCodexTurnNow(prompt: string, emit: AgentEmit, attachments: Age
/** 恢复请求线程或创建新的 Codex 线程。 */
async function ensureCodexThread(app: CodexAppClient, options: CodexRunOptions, emit: AgentEmit) {
if (options.threadId) {
if (options.threadId === codexThreadId) return codexThreadId;
if (options.threadId === loadedThreadId) return loadedThreadId;
try {
const result = await app.readThread(options.threadId, false);
assertThreadWorkspace(field(result, "thread") || {}, options.cwd);
const thread = await app.resumeThread(options.threadId, options.cwd, options.permissionMode || "request");
assertThreadWorkspace(thread, options.cwd);
codexThreadId = String(field(thread, "id") || options.threadId);
return codexThreadId;
await resumeLoadedThread(app, options.threadId, options.cwd, options.permissionMode || "request", true);
return loadedThreadId;
} catch (error) {
if (!isRecoverableThreadError(error)) throw error;
emit("agent_log", { text: `Codex thread unavailable, starting a new thread: ${errorMessage(error)}` });
loadedThreadId = "";
}
}
if (!codexThreadId) {
if (!loadedThreadId) {
const thread = await app.startThread(options.cwd, options.permissionMode || "request");
codexThreadId = String(field(thread, "id") || "");
if (codexThreadId) unmaterializedThreadIds.add(codexThreadId);
loadedThreadId = String(field(thread, "id") || "");
}
return codexThreadId;
return loadedThreadId;
}
/** 从 app-server 读取线程并校验工作空间。 */
@@ -173,12 +160,38 @@ async function loadCodexThread(emit: AgentEmit, threadId: string, cwd: string |
return thread;
}
/** 读取线程历史,并显式标记 Codex 是否已经物化 turns。 */
async function loadCodexHistory(emit: AgentEmit, threadId: string, cwd?: string) {
try {
return { thread: await loadCodexThread(emit, threadId, cwd, true), historyReady: true };
} catch (error) {
if (/not materialized yet.*includeTurns/i.test(errorMessage(error))) return { thread: await loadCodexThread(emit, threadId, cwd, false), historyReady: false };
if (!isRecoverableThreadError(error)) throw error;
const app = await getCodexApp(emit);
const thread = await resumeLoadedThread(app, threadId, cwd, "request", false);
try {
return { thread: await loadCodexThread(emit, threadId, cwd, true), historyReady: true };
} catch (historyError) {
if (/not materialized yet.*includeTurns/i.test(errorMessage(historyError))) return { thread, historyReady: false };
throw historyError;
}
}
}
/** 恢复线程并统一校验工作空间与进程内活动线程。 */
async function resumeLoadedThread(app: CodexAppClient, threadId: string, cwd?: string, permissionMode: AgentPermissionMode = "request", updateLoaded = true) {
const thread = await app.resumeThread(threadId, cwd, permissionMode);
assertThreadWorkspace(thread, cwd);
if (updateLoaded) loadedThreadId = String(field(thread, "id") || threadId);
return thread;
}
/** 获取已启动的 Codex app-server 客户端。 */
async function getCodexApp(emit: AgentEmit) {
if (codexApp) return codexApp;
codexAppStart ||= CodexAppClient.start(emit, () => {
codexApp = null;
codexThreadId = "";
loadedThreadId = "";
});
try {
codexApp = await codexAppStart;
+194 -12
View File
@@ -197,15 +197,37 @@ test("shared thread events are broadcast with the active thread id", (t) => {
test("new clients receive the current Codex state and later updates", (t) => {
const session = new CanvasSession();
session.setCodexState({ busy: true, threadId: "thread-2", turnId: "turn-1" });
const client = connect(session, "first");
session.trackCodexEvent("codex_approval", { requestId: "approval-1", threadId: "thread-2" });
const client = connect(session, "first", "thread-2");
t.after(() => client.close());
assert.deepEqual(field(client.event("hello"), "codex"), { busy: true, threadId: "thread-2", turnId: "turn-1" });
const hello = client.event("hello");
assert.equal(field(hello, "protocolVersion"), 3);
assert.deepEqual(field(hello, "workspace"), { activeThreadId: "thread-2" });
assert.deepEqual(field(hello, "codex"), { busy: true, threadId: "thread-2", turnId: "turn-1" });
assert.deepEqual(field(hello, "pendingApprovals"), [{ requestId: "approval-1", threadId: "thread-2" }]);
session.trackCodexEvent("codex_approval_resolved", { requestId: "approval-1" });
assert.deepEqual(session.codexPendingApprovals, []);
session.trackCodexEvent("codex_approval", { requestId: "approval-2", threadId: "thread-2" });
session.setCodexState({ busy: false });
assert.deepEqual(session.codexPendingApprovals, [{ requestId: "approval-2", threadId: "thread-2" }]);
session.trackCodexEvent("agent_error", { message: "app-server exited" });
assert.deepEqual(session.codexPendingApprovals, []);
assert.deepEqual(client.event("codex_state"), { busy: false, threadId: "thread-2", turnId: "turn-1" });
});
test("Codex 写操作在多窗口之间互斥且不能与运行 turn 并发", () => {
const session = new CanvasSession();
assert.equal(session.beginCodexMutation(), true);
assert.equal(session.beginCodexMutation(), false);
session.endCodexMutation();
assert.equal(session.beginCodexMutation(), true);
session.endCodexMutation();
session.setCodexState({ busy: true, threadId: "thread-1", turnId: "turn-1" });
assert.equal(session.beginCodexMutation(), false);
});
test("a bound client remains the tool target while focus changes", async (t) => {
const session = new CanvasSession();
const first = connect(session, "first");
@@ -230,7 +252,7 @@ test("a bound client remains the tool target while focus changes", async (t) =>
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-second");
});
test("closing the bound client falls back to the active client", async (t) => {
test("a disconnected bound client never falls back and can resume with the same client id", async (t) => {
const session = new CanvasSession();
const first = connect(session, "first");
const second = connect(session, "second");
@@ -244,17 +266,170 @@ test("closing the bound client falls back to the active client", async (t) => {
session.activateClient("second");
first.close();
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-second");
const result = session.callTool("canvas_create_text_node", { text: "fallback" });
const call = second.event("tool_call");
session.resolveResult("second", { requestId: String(field(call, "requestId")), result: { ok: true } });
await assert.rejects(session.callTool("canvas_get_state", {}), /当前没有已连接画布/);
assert.equal(second.event("tool_call"), undefined);
const reconnected = connect(session, "first");
t.after(() => reconnected.close());
session.updateState(snapshot("canvas-first-reconnected"), "first");
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-first-reconnected");
const result = session.callTool("canvas_create_text_node", { text: "reconnected" });
const call = reconnected.event("tool_call");
assert.equal(second.event("tool_call"), undefined);
session.resolveResult("first", { requestId: String(field(call, "requestId")), result: { ok: true } });
assert.deepEqual(await result, { ok: true });
});
test("新连接会回放当前运行 turn 的最新事件快照", (t) => {
const session = new CanvasSession();
session.setCodexState({ busy: true, threadId: "thread-1", turnId: "turn-1" });
session.emitThread("chat_message", "thread-1", { turnId: "turn-1", message: { id: "thread-1:turn-1:synthetic:user", itemId: "synthetic:user", clientMessageId: "local-message-1", role: "user", text: "问题" } });
session.emitThread("agent_event", "thread-1", { turnId: "turn-1", type: "item.updated", item: { id: "reasoning-1", type: "reasoning", text: "分析中" } });
const client = connect(session, "first", "thread-1");
t.after(() => client.close());
assert.deepEqual(client.events("chat_message"), [{ threadId: "thread-1", turnId: "turn-1", message: { id: "thread-1:turn-1:synthetic:user", itemId: "synthetic:user", clientMessageId: "local-message-1", role: "user", text: "问题" }, replayed: true }]);
assert.deepEqual(client.events("agent_event"), [{ threadId: "thread-1", turnId: "turn-1", type: "item.updated", item: { id: "reasoning-1", type: "reasoning", text: "分析中" }, replayed: true }]);
});
test("同一 item 的多次增量只回放最新内容", (t) => {
const session = new CanvasSession();
session.setCodexState({ busy: true, threadId: "thread-1", turnId: "turn-1" });
session.emitThread("agent_event", "thread-1", { turnId: "turn-1", type: "item.updated", item: { id: "assistant-1", type: "agent_message", text: "第一段" } });
session.emitThread("agent_event", "thread-1", { turnId: "turn-1", type: "item.updated", item: { id: "assistant-1", type: "agent_message", text: "第一段和第二段" } });
const client = connect(session, "first", "thread-1");
t.after(() => client.close());
const events = client.events("agent_event") as Array<Record<string, unknown>>;
assert.equal(events.length, 1);
assert.equal(field(field(events[0], "item"), "text"), "第一段和第二段");
});
test("增量事件重放时转换为完整文本快照", (t) => {
const session = new CanvasSession();
session.setCodexState({ busy: true, threadId: "thread-1", turnId: "turn-1" });
session.emitThread("agent_event", "thread-1", { turnId: "turn-1", type: "item.updated", item: { id: "assistant-1", type: "agent_message", delta: "第一段" } });
session.emitThread("agent_event", "thread-1", { turnId: "turn-1", type: "item.updated", item: { id: "assistant-1", type: "agent_message", delta: "第二段" } });
const client = connect(session, "first", "thread-1");
t.after(() => client.close());
const [event] = client.events("agent_event") as Array<Record<string, unknown>>;
assert.deepEqual(field(event, "item"), { id: "assistant-1", type: "agent_message", text: "第一段第二段" });
});
test("并行 item 更新后重放仍保留开始顺序和命令字段", (t) => {
const session = new CanvasSession();
session.setCodexState({ busy: true, threadId: "thread-1", turnId: "turn-1" });
session.emitThread("agent_event", "thread-1", { turnId: "turn-1", type: "item.started", item: { id: "first", type: "command_execution", command: "first", cwd: "D:\\infinite-canvas" } });
session.emitThread("agent_event", "thread-1", { turnId: "turn-1", type: "item.started", item: { id: "second", type: "command_execution", command: "second" } });
session.emitThread("agent_event", "thread-1", { turnId: "turn-1", type: "item.updated", item: { id: "first", type: "command_execution", delta: "output" } });
const client = connect(session, "first", "thread-1");
t.after(() => client.close());
const events = client.events("agent_event") as Array<Record<string, unknown>>;
assert.deepEqual(events.map((event) => field(field(event, "item"), "id")), ["first", "second"]);
assert.deepEqual(field(events[0], "item"), { id: "first", type: "command_execution", command: "first", cwd: "D:\\infinite-canvas", text: "output" });
});
test("长 turn 不会淘汰仍在更新的活动条目快照", (t) => {
const session = new CanvasSession();
session.setCodexState({ busy: true, threadId: "thread-1", turnId: "turn-1" });
session.emitThread("agent_event", "thread-1", { turnId: "turn-1", type: "item.started", item: { id: "active-item", type: "command_execution", command: "long-running" } });
for (let index = 0; index < 260; index += 1) {
session.emitThread("agent_event", "thread-1", { turnId: "turn-1", type: "item.completed", item: { id: `completed-${index}`, type: "command_execution", command: `command-${index}` } });
}
const client = connect(session, "first", "thread-1");
t.after(() => client.close());
assert.equal((client.events("agent_event") as Array<Record<string, unknown>>).some((event) => field(field(event, "item"), "id") === "active-item"), true);
});
test("内置生图事件会回放展示但标记为不可重复执行", (t) => {
const session = new CanvasSession();
session.setCodexState({ busy: true, threadId: "thread-1", turnId: "turn-1" });
session.emitThread("agent_event", "thread-1", { turnId: "turn-1", type: "item.completed", item: { id: "image-1", type: "image_generation", savedPath: "D:/image.png" } });
const client = connect(session, "first", "thread-1");
t.after(() => client.close());
assert.deepEqual(client.events("agent_event"), [{
threadId: "thread-1",
turnId: "turn-1",
type: "item.completed",
item: { id: "image-1", type: "image_generation", savedPath: "D:/image.png" },
replayed: true,
}]);
});
test("turn 结束后保留实时快照,直到网页确认权威历史", (t) => {
const session = new CanvasSession();
session.setCodexState({ busy: true, threadId: "thread-1", turnId: "turn-1" });
session.emitThread("agent_event", "thread-1", { turnId: "turn-1", type: "item.updated", item: { id: "assistant-1", type: "agent_message", text: "回答" } });
session.setCodexState({ busy: false });
const client = connect(session, "first", "thread-1");
t.after(() => client.close());
assert.equal(client.events("agent_event").length, 1);
session.acknowledgeCodexHistory("thread-1", ["turn-1"]);
const next = connect(session, "second", "thread-1");
t.after(() => next.close());
assert.deepEqual(next.events("agent_event"), []);
});
test("开始下一 turn 时只回放当前 turn 的事件", (t) => {
const session = new CanvasSession();
session.setCodexState({ busy: true, threadId: "thread-1", turnId: "turn-1" });
session.emitThread("agent_event", "thread-1", { turnId: "turn-1", type: "item.updated", item: { id: "assistant", type: "agent_message", text: "第一轮" } });
session.setCodexState({ busy: false, turnId: "turn-1" });
session.setCodexState({ busy: true, turnId: "turn-2" });
session.emitThread("agent_event", "thread-1", { turnId: "turn-2", type: "item.updated", item: { id: "assistant", type: "agent_message", text: "第二轮" } });
const client = connect(session, "first", "thread-1");
t.after(() => client.close());
const events = client.events("agent_event") as Array<Record<string, unknown>>;
assert.equal(events.length, 1);
assert.deepEqual(events.map((event) => field(field(event, "item"), "text")), ["第二轮"]);
});
test("同一用户消息从 pending 绑定 turn 后只回放最终版本", (t) => {
const session = new CanvasSession();
session.setCodexState({ busy: true, threadId: "thread-1", turnId: "" });
session.emitThread("chat_message", "thread-1", { message: { id: "pending", itemId: "synthetic:user", clientMessageId: "message-1", role: "user", text: "问题" } });
session.setCodexState({ turnId: "turn-1" });
session.emitThread("chat_message", "thread-1", { turnId: "turn-1", message: { id: "final", itemId: "synthetic:user", clientMessageId: "message-1", role: "user", text: "问题" } });
const client = connect(session, "first", "thread-1");
t.after(() => client.close());
assert.deepEqual(client.events("chat_message"), [{
threadId: "thread-1",
turnId: "turn-1",
message: { id: "final", itemId: "synthetic:user", clientMessageId: "message-1", role: "user", text: "问题" },
replayed: true,
}]);
});
test("切换活动线程会清除上一线程的实时快照", (t) => {
const session = new CanvasSession();
session.setCodexState({ busy: true, threadId: "thread-1", turnId: "turn-1" });
session.emitThread("agent_event", "thread-1", { turnId: "turn-1", type: "item.updated", item: { id: "assistant-1", type: "agent_message", text: "回答" } });
session.setCodexState({ busy: false, threadId: "thread-1", turnId: "turn-1" });
session.setCodexState({ threadId: "thread-2", turnId: "" });
session.setCodexState({ threadId: "thread-1", turnId: "" });
const client = connect(session, "first", "thread-1");
t.after(() => client.close());
assert.deepEqual(client.events("agent_event"), []);
});
/** 创建用于测试的画布 SSE 连接。 */
function connect(session: CanvasSession, clientId: string) {
function connect(session: CanvasSession, clientId: string, activeThreadId = "") {
const response = new FakeSseResponse();
session.openEvents(new URL(`http://127.0.0.1/events?clientId=${clientId}`), response as unknown as ServerResponse);
session.openEvents(new URL(`http://127.0.0.1/events?clientId=${clientId}`), response as unknown as ServerResponse, activeThreadId);
return response;
}
@@ -285,9 +460,16 @@ class FakeSseResponse extends EventEmitter {
/** 读取指定类型的首个 SSE 事件数据。 */
event(type: string) {
const chunk = this.chunks.find((item) => item.startsWith(`event: ${type}\n`));
const data = chunk?.split("\n").find((line) => line.startsWith("data: "))?.slice(6);
return data ? (JSON.parse(data) as unknown) : undefined;
return this.events(type)[0];
}
/** 读取指定类型的全部 SSE 事件数据。 */
events(type: string) {
return this.chunks.flatMap((chunk) => {
if (!chunk.startsWith(`event: ${type}\n`)) return [];
const data = chunk.split("\n").find((line) => line.startsWith("data: "))?.slice(6);
return data ? [JSON.parse(data) as unknown] : [];
});
}
/** 触发连接关闭事件。 */
+150 -7
View File
@@ -10,7 +10,9 @@ import type { CanvasSnapshot } from "./types.js";
type PendingRequest = { clientId: string; resolve: (value: unknown) => void; reject: (error: Error) => void };
type TurnAttachment = { clientId: string; id: string; name: string; type: string; size: number; width: number; height: number; dataUrl: string };
type ReplayEvent = { type: string; payload: Record<string, unknown> };
export type CodexState = { busy: boolean; threadId: string; turnId: string };
export const AGENT_PROTOCOL_VERSION = 3;
const SITE_TOOLS = new Set<ToolName>([
"site_navigate",
@@ -30,8 +32,12 @@ export class CanvasSession {
private clients = new Map<string, ServerResponse>();
private clientFocusOrder = new Map<string, number>();
private pending = new Map<string, PendingRequest>();
private pendingApprovals = new Map<string, Record<string, unknown>>();
private canvasStates = new Map<string, CanvasSnapshot>();
private turnAttachments = new Map<string, TurnAttachment>();
private codexReplayEvents = new Map<string, ReplayEvent>();
private codexReplayActiveItems = new Set<string>();
private codexMutationBusy = false;
private activeClientId = "";
private boundClientId = "";
private focusSequence = 0;
@@ -39,7 +45,7 @@ export class CanvasSession {
/** 获取当前目标网页的画布状态。 */
private get canvasState() {
return this.canvasStates.get(this.targetClientId) || null;
return this.clients.has(this.targetClientId) ? this.canvasStates.get(this.targetClientId) || null : null;
}
/** 获取当前 turn 绑定或最近激活的网页客户端。 */
@@ -49,7 +55,7 @@ export class CanvasSession {
/** 返回 Canvas Agent 当前连接状态。 */
health() {
return { ok: true, hasCanvas: Boolean(this.canvasState), clients: this.clients.size, codexBusy: this.codexState.busy };
return { ok: true, protocolVersion: AGENT_PROTOCOL_VERSION, hasCanvas: Boolean(this.canvasState), clients: this.clients.size, codexBusy: this.codexState.busy };
}
/** 返回 Codex 是否正在执行任务。 */
@@ -57,17 +63,84 @@ export class CanvasSession {
return this.codexState.busy;
}
get codexThreadId() {
return this.codexState.threadId;
}
/** 判断网页客户端是否仍连接到当前 Agent。 */
hasClient(clientId: string) {
return this.clients.has(clientId);
}
/** 原子取得 Codex 写操作权限,避免多个网页并发切换或修改会话。 */
beginCodexMutation() {
if (this.codexState.busy || this.codexMutationBusy) return false;
this.codexMutationBusy = true;
return true;
}
/** 释放 Codex 写操作权限。 */
endCodexMutation() {
this.codexMutationBusy = false;
}
/** 返回当前 Codex turn 的线程、turn 和发起网页。 */
get codexEventScope() {
return {
threadId: this.codexState.threadId,
turnId: this.codexState.busy ? this.codexState.turnId : "",
sourceClientId: this.codexState.busy ? this.boundClientId : "",
};
}
/** 返回刷新后仍需展示的 Codex 权限请求。 */
get codexPendingApprovals() {
return [...this.pendingApprovals.values()];
}
/** 跟踪需要跨页面重连恢复的 Codex 权限请求。 */
trackCodexEvent(type: string, payload: Record<string, unknown>) {
const requestId = String(payload.requestId || "");
if (type === "codex_approval" && requestId) this.pendingApprovals.set(requestId, payload);
if (type === "codex_approval_resolved" && requestId) this.pendingApprovals.delete(requestId);
if (type === "agent_error") this.pendingApprovals.clear();
}
/** 更新并广播 Codex 运行状态。 */
setCodexState(patch: Partial<CodexState>) {
const next = { ...this.codexState, ...patch };
const threadChanged = next.threadId !== this.codexState.threadId;
const turnChanged = Boolean(this.codexState.turnId && next.turnId && next.turnId !== this.codexState.turnId);
const nextTurnStarted = !this.codexState.busy && next.busy;
if (threadChanged || turnChanged || nextTurnStarted) {
this.codexReplayEvents.clear();
this.codexReplayActiveItems.clear();
}
if (!next.busy) {
if (this.boundClientId && !this.clients.has(this.boundClientId)) this.boundClientId = "";
}
if (next.busy === this.codexState.busy && next.threadId === this.codexState.threadId && next.turnId === this.codexState.turnId) return;
this.codexState = next;
logger.debug("Codex state changed", this.codexState);
this.emitAll("codex_state", this.codexState);
}
/** 权威历史已覆盖指定 turn 后,清理其断线重放事件。 */
acknowledgeCodexHistory(threadId: string, turnIds: string[]) {
const acknowledged = new Set(turnIds.filter(Boolean));
if (!threadId || !acknowledged.size) return;
this.codexReplayEvents.forEach((event, key) => {
const eventThreadId = String(event.payload.threadId || event.payload.thread_id || "");
const eventTurnId = String(event.payload.turnId || event.payload.turn_id || "");
if (eventThreadId === threadId && acknowledged.has(eventTurnId)) {
this.codexReplayEvents.delete(key);
this.codexReplayActiveItems.delete(key);
}
});
}
/** 建立网页与 Canvas Agent 之间的 SSE 连接。 */
openEvents(url: URL, res: ServerResponse) {
openEvents(url: URL, res: ServerResponse, activeThreadId = "") {
const clientId = url.searchParams.get("clientId") || crypto.randomUUID();
const statusOnly = url.searchParams.get("role") === "status";
logger.info("SSE client connected", { clientId, statusOnly });
@@ -80,7 +153,8 @@ export class CanvasSession {
this.clientFocusOrder.set(clientId, ++this.focusSequence);
}
}
sendEvent(res, "hello", { ok: true, clientId, codex: this.codexState });
sendEvent(res, "hello", { ok: true, protocolVersion: AGENT_PROTOCOL_VERSION, clientId, workspace: { activeThreadId }, codex: this.codexState, pendingApprovals: this.codexPendingApprovals });
if (!statusOnly && activeThreadId && this.codexState.threadId === activeThreadId) this.codexReplayEvents.forEach((event) => sendEvent(res, event.type, event.payload));
const timer = setInterval(() => sendEvent(res, "ping", { time: Date.now() }), 15000);
res.on("close", () => {
clearInterval(timer);
@@ -89,7 +163,6 @@ export class CanvasSession {
this.clients.delete(clientId);
this.clientFocusOrder.delete(clientId);
this.canvasStates.delete(clientId);
if (this.boundClientId === clientId) this.boundClientId = "";
this.pending.forEach((item, requestId) => {
if (item.clientId !== clientId) return;
this.pending.delete(requestId);
@@ -102,7 +175,7 @@ export class CanvasSession {
/** 保存指定网页上报的最新画布快照。 */
updateState(body: unknown, clientId?: string) {
const targetClientId = clientId || this.activeClientId;
if (!targetClientId) return;
if (!targetClientId || !this.clients.has(targetClientId)) return;
const state = { ...((body && typeof body === "object" && !Array.isArray(body) ? body : {}) as Record<string, unknown>), clientId: targetClientId } as CanvasSnapshot;
this.canvasStates.set(targetClientId, state);
logger.debug("Canvas state updated", { clientId: targetClientId, nodes: state.nodes?.length || 0, connections: state.connections?.length || 0 });
@@ -182,7 +255,52 @@ export class CanvasSession {
/** 向全部网页广播带线程归属的事件。 */
emitThread(type: string, threadId: string, payload: Record<string, unknown> = {}) {
this.emitAll(type, { ...payload, threadId });
const data: Record<string, unknown> = { ...payload, threadId };
const replayKey = codexReplayKey(type, data);
const eventTurnId = String(data.turnId || data.turn_id || "");
const currentScope = threadId === this.codexState.threadId && (!this.codexState.turnId || !eventTurnId || eventTurnId === this.codexState.turnId);
if (this.codexState.busy && currentScope && replayKey) {
const item = recordValue(data.item);
const eventType = String(data.type || "");
if (type === "agent_event" && item.id && (eventType === "item.started" || eventType === "item.updated")) this.codexReplayActiveItems.add(replayKey);
if (type === "agent_event" && item.id && eventType === "item.completed") this.codexReplayActiveItems.delete(replayKey);
if (type === "agent_event" && (eventType === "turn.completed" || eventType === "error")) this.clearReplayActiveTurn(threadId, eventTurnId);
const replayData = this.replaySnapshot(replayKey, data);
this.codexReplayEvents.set(replayKey, { type, payload: { ...replayData, replayed: true } });
while (this.codexReplayEvents.size > 240) {
const evictable = [...this.codexReplayEvents.keys()].find((key) => !this.codexReplayActiveItems.has(key));
if (!evictable) break;
this.codexReplayEvents.delete(evictable);
}
}
this.emitAll(type, data);
}
/** 为断线重连保存完整的最新文本快照,实时连接仍只接收增量。 */
private replaySnapshot(replayKey: string, data: Record<string, unknown>) {
if (data.type !== "item.updated" && data.type !== "item.completed") return data;
const item = recordValue(data.item);
if (!item.id) return data;
const previous = recordValue(recordValue(this.codexReplayEvents.get(replayKey)?.payload).item);
const delta = String(item.delta || "");
if (!delta) return data;
const previousText = String(previous.text || "");
const { delta: _delta, ...snapshotItem } = item;
return { ...data, item: { ...previous, ...snapshotItem, text: `${previousText}${delta}` } };
}
private clearReplayActiveTurn(threadId: string, turnId: string) {
const prefix = `item:${turnId}:`;
this.codexReplayActiveItems.forEach((key) => {
if (key.startsWith(prefix)) this.codexReplayActiveItems.delete(key);
});
if (!turnId) return;
this.codexReplayActiveItems.forEach((key) => {
const event = this.codexReplayEvents.get(key);
const eventThreadId = String(event?.payload.threadId || event?.payload.thread_id || "");
const eventTurnId = String(event?.payload.turnId || event?.payload.turn_id || "");
if (eventThreadId === threadId && eventTurnId === turnId) this.codexReplayActiveItems.delete(key);
});
}
/** 校验工具参数并将调用分派到当前目标网页。 */
@@ -253,6 +371,31 @@ export class CanvasSession {
}
}
/** 为运行中 turn 的可重放事件生成稳定键。 */
function codexReplayKey(type: string, payload: Record<string, unknown>) {
const turnId = String(payload.turnId || payload.turn_id || "");
if (type === "chat_message") {
const message = recordValue(payload.message);
const clientMessageId = String(message.clientMessageId || "");
if (clientMessageId) return `chat:${clientMessageId}`;
const messageId = String(message.itemId || message.id || "");
return messageId ? `chat:${turnId}:${messageId}` : "";
}
if (type === "agent_error") return `error:${turnId}`;
if (type !== "agent_event") return "";
const item = recordValue(payload.item);
if (item.id) return `item:${turnId}:${String(item.id)}`;
const eventType = String(payload.type || "");
if (eventType === "plan.updated") return `plan:${turnId}`;
if (eventType === "usage.updated") return `usage:${turnId}`;
if (eventType === "turn.completed" || eventType === "error") return `${eventType}:${turnId}`;
return "";
}
function recordValue(value: unknown) {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
/** 向 SSE 连接写入一个事件。 */
function sendEvent(res: ServerResponse, type: string, payload: unknown) {
res.write(`event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`);
+95 -48
View File
@@ -4,10 +4,10 @@ import path from "node:path";
import express, { type NextFunction, type Request, type Response } from "express";
import { runClaudeTurn } from "../agent/claude.js";
import { archiveCodexThread, interruptCodexTurn, isRecoverableThreadError, listCodexModels, listCodexThreads, readCodexThread, resolveCodexApproval, resumeCodexThread, runCodexTurn, startCodexThread, summarizeCodexThread, verifyCodexThreadWorkspace } from "../agent/codex.js";
import { archiveCodexThread, interruptCodexTurn, listCodexModels, listCodexThreads, readCodexThread, resolveCodexApproval, resumeCodexThread, runCodexTurn, startCodexThread, summarizeCodexThread } from "../agent/codex.js";
import type { CodexReasoningEffort } from "../agent/codex-protocol.js";
import type { AgentAttachment, AgentPermissionMode } from "../agent/types.js";
import { CanvasSession } from "../canvas/session.js";
import { AGENT_PROTOCOL_VERSION, CanvasSession } from "../canvas/session.js";
import { DEFAULT_PORT, ensureSiteWorkspace, loadConfig, saveConfig, updateSiteWorkspace, type CanvasAgentConfig } from "../config.js";
import { logger } from "../utils/logger.js";
import { checkVersions } from "../version-check.js";
@@ -22,13 +22,24 @@ export function startHttpServer() {
const session = new CanvasSession();
/** 将 Agent 事件广播到所属线程或全部网页。 */
const emit = (type: string, payload: unknown) => {
const data = payload && typeof payload === "object" && !Array.isArray(payload) ? payload as Record<string, unknown> : { value: payload };
const threadId = String(data.threadId || data.thread_id || ensureSiteWorkspace(config).activeThreadId || "");
const scope = session.codexBusy ? session.codexEventScope : { threadId: "", turnId: "", sourceClientId: "" };
const value = payload && typeof payload === "object" && !Array.isArray(payload) ? payload as Record<string, unknown> : { value: payload };
const threadId = String(value.threadId || value.thread_id || scope.threadId || ensureSiteWorkspace(config).activeThreadId || "");
const turnId = String(value.turnId || value.turn_id || scope.turnId || "");
const sourceClientId = String(value.sourceClientId || scope.sourceClientId || "");
const data = {
...value,
...(threadId ? { threadId, thread_id: threadId } : {}),
...(turnId ? { turnId, turn_id: turnId } : {}),
...(sourceClientId ? { sourceClientId } : {}),
};
session.trackCodexEvent(type, data);
threadId ? session.emitThread(type, threadId, data) : session.emitAll(type, data);
};
/** 保存并广播当前站点工作空间的活跃线程。 */
const setActiveThread = (activeThreadId: string, payload: Record<string, unknown> = {}) => {
const workspace = updateSiteWorkspace(config, { activeThreadId: activeThreadId || undefined });
if (!session.codexBusy && session.codexThreadId !== activeThreadId) session.setCodexState({ threadId: activeThreadId, turnId: "" });
session.emitThread("workspace_changed", activeThreadId, { ...payload, activeThreadId });
return workspace;
};
@@ -52,12 +63,14 @@ export function startHttpServer() {
next();
});
app.get("/health", (_req, res) => res.json(session.health()));
app.get("/config", (_req, res) => res.json({ ok: true, url: config.url, hasToken: true }));
app.get("/config", (_req, res) => res.json({ ok: true, protocolVersion: AGENT_PROTOCOL_VERSION, url: config.url, hasToken: true }));
app.use((req, res, next) => {
if (validToken(req, requestUrl(req, config), config.token)) return next();
res.status(401).json({ ok: false, error: "invalid token" });
});
app.get("/events", (req, res) => session.openEvents(requestUrl(req, config), res));
app.get("/events", (req, res) => {
session.openEvents(requestUrl(req, config), res, ensureSiteWorkspace(config).activeThreadId || "");
});
app.post("/canvas/state", (req, res) => {
session.updateState(req.body, String(req.query.clientId || "") || undefined);
res.json({ ok: true });
@@ -103,98 +116,119 @@ export function startHttpServer() {
const result = await listCodexThreads(emit, { cwd: workspace.workspacePath, searchTerm: String(req.query.searchTerm || "") });
res.json({ ok: true, workspace, ...result });
}));
app.post("/agent/codex/threads/new", route(async (req, res) => {
if (session.codexBusy) return res.status(409).json({ ok: false, error: "Codex 正在运行,请等待当前任务完成" });
app.post("/agent/codex/threads/new", codexMutation(async (req, res) => {
const workspace = ensureSiteWorkspace(config);
const thread = await startCodexThread(emit, workspace.workspacePath, permissionMode(req.body?.permissionMode));
const activeThreadId = String((thread as Record<string, unknown>).id || "");
const nextWorkspace = setActiveThread(activeThreadId, { emptyThread: true });
const nextWorkspace = setActiveThread(activeThreadId, { emptyThread: true, sourceClientId: String(req.body?.clientId || "") });
res.json({ ok: true, workspace: nextWorkspace, thread: summarizeCodexThread(thread), messages: [] });
}));
app.post("/agent/codex/threads/reset", (req, res) => {
if (session.codexBusy) return res.status(409).json({ ok: false, error: "Codex 正在运行,请等待当前任务完成" });
res.json({ ok: true, workspace: setActiveThread("", { emptyThread: true, draftThread: true }) });
});
app.post("/agent/codex/threads/reset", codexMutation((req, res) => {
res.json({ ok: true, workspace: setActiveThread("", { emptyThread: true, draftThread: true, sourceClientId: String(req.body?.clientId || "") }) });
}));
app.get("/agent/codex/threads/:threadId", route(async (req, res) => {
const workspace = ensureSiteWorkspace(config);
const threadId = routeParam(req.params.threadId);
try {
res.json({ ok: true, workspace, ...(await readCodexThread(emit, threadId, workspace.workspacePath)) });
} catch (error) {
if (workspace.activeThreadId !== threadId || !isRecoverableThreadError(error)) throw error;
res.json({ ok: true, workspace, thread: { id: threadId, preview: "", cwd: workspace.workspacePath }, messages: [] });
}
res.json({ ok: true, workspace, ...(await readCodexThread(emit, threadId, workspace.workspacePath)) });
}));
app.post("/agent/codex/threads/:threadId/resume", route(async (req, res) => {
if (session.codexBusy) return res.status(409).json({ ok: false, error: "Codex 正在运行,请等待当前任务完成" });
app.post("/agent/codex/history/ack", (req, res) => {
const threadId = String(req.body?.threadId || "");
const turnIds = Array.isArray(req.body?.turnIds) ? req.body.turnIds.map(String) : [];
session.acknowledgeCodexHistory(threadId, turnIds);
res.json({ ok: true });
});
app.post("/agent/codex/threads/:threadId/resume", codexMutation(async (req, res) => {
const workspace = ensureSiteWorkspace(config);
const threadId = routeParam(req.params.threadId);
const result = await resumeCodexThread(emit, threadId, workspace.workspacePath, permissionMode(req.body?.permissionMode));
const nextWorkspace = setActiveThread(threadId);
const nextWorkspace = setActiveThread(threadId, { sourceClientId: String(req.body?.clientId || "") });
res.json({ ok: true, workspace: nextWorkspace, ...result });
}));
app.post("/agent/codex/threads/:threadId/delete", route(async (req, res) => {
if (session.codexBusy) return res.status(409).json({ ok: false, error: "Codex 正在运行,请等待当前任务完成" });
app.post("/agent/codex/threads/:threadId/delete", codexMutation(async (req, res) => {
const workspace = ensureSiteWorkspace(config);
const threadId = routeParam(req.params.threadId);
await archiveCodexThread(emit, threadId, workspace.workspacePath);
setActiveThread(workspace.activeThreadId === threadId ? "" : workspace.activeThreadId || "");
setActiveThread(workspace.activeThreadId === threadId ? "" : workspace.activeThreadId || "", { sourceClientId: String(req.body?.clientId || "") });
res.json({ ok: true });
}));
app.post("/agent/codex/turn", route(async (req, res) => {
if (session.codexBusy) return res.status(409).json({ ok: false, error: "Codex 正在运行,请等待当前任务完成" });
app.post("/agent/codex/turn", codexMutation(async (req, res) => {
const attachments = Array.isArray(req.body?.attachments) ? (req.body.attachments as AgentAttachment[]) : [];
const workspace = ensureSiteWorkspace(config);
const prompt = String(req.body?.prompt || "");
if (!prompt.trim()) return res.status(400).json({ ok: false, error: "请输入任务内容" });
const clientId = String(req.body?.clientId || "");
if (!clientId || !session.hasClient(clientId)) return res.status(409).json({ ok: false, error: "发起任务的网页已断开,请重新连接后再试" });
const requestedThreadId = String(req.body?.threadId || "");
const activeThreadId = workspace.activeThreadId || "";
if (requestedThreadId !== activeThreadId) return res.status(409).json({ ok: false, error: "当前会话已在其他页面切换,请同步后重试" });
const model = String(req.body?.model || "") || undefined;
const effort = reasoningEffort(req.body?.effort);
const messageId = String(req.body?.messageId || Date.now());
const messageText = String(req.body?.messageText || prompt || `发送了 ${attachments.length} 张图片`);
let threadId = activeThreadId;
logger.info("Codex turn accepted", { threadId: req.body?.threadId, model: model || "default", reasoningEffort: effort || "default", promptLength: prompt.length, attachmentCount: attachments.length });
session.setCodexState({ busy: true, threadId: String(req.body?.threadId || workspace.activeThreadId || ""), turnId: "" });
session.bindClient(clientId);
session.setCodexState({ busy: true, threadId, turnId: "" });
try {
let threadId = String(req.body?.threadId || workspace.activeThreadId || "");
let turnId = "";
if (!threadId) {
const thread = await startCodexThread(emit, workspace.workspacePath, permissionMode(req.body?.permissionMode));
threadId = String((thread as Record<string, unknown>).id || "");
setActiveThread(threadId, { emptyThread: true });
} else if (threadId !== workspace.activeThreadId) {
await verifyCodexThreadWorkspace(emit, threadId, workspace.workspacePath);
setActiveThread(threadId);
setActiveThread(threadId, { emptyThread: true, sourceClientId: clientId });
}
session.setCodexState({ busy: true, threadId, turnId: "" });
const attachmentRefs = session.setTurnAttachments(clientId, attachments);
const chatMessage = {
session.emitThread("chat_message", threadId, {
sourceClientId: clientId,
message: { id: String(req.body?.messageId || Date.now()), role: "user", text: String(req.body?.messageText || prompt || `发送了 ${attachments.length} 张图片`) },
message: { id: `${threadId}:pending:synthetic:user`, itemId: "synthetic:user", clientMessageId: messageId, threadId, turnId: "", role: "user", text: messageText },
});
let chatTurnId = "";
/** 将包装层日志和兜底错误固定广播到当前 turn。 */
const lifecycleEmit = (type: string, payload: unknown) => {
const value = payload && typeof payload === "object" && !Array.isArray(payload) ? payload as Record<string, unknown> : { value: payload };
const eventThreadId = String(value.threadId || value.thread_id || threadId);
const eventTurnId = String(value.turnId || value.turn_id || turnId);
const sourceClientId = String(value.sourceClientId || clientId);
session.emitThread(type, eventThreadId, {
...value,
threadId: eventThreadId,
thread_id: eventThreadId,
...(eventTurnId ? { turnId: eventTurnId, turn_id: eventTurnId } : {}),
...(sourceClientId ? { sourceClientId } : {}),
});
};
let chatThreadId = "";
/** 将当前 turn 事件固定广播到实际线程。 */
const turnEmit = (type: string, payload: unknown) => {
const data = payload && typeof payload === "object" && !Array.isArray(payload) ? payload as Record<string, unknown> : { value: payload };
session.emitThread(type, threadId, { ...data, ...(turnId ? { turn_id: turnId } : {}) });
};
void runCodexTurn(withAttachmentContext(prompt, attachmentRefs), turnEmit, attachments, {
void runCodexTurn(withAttachmentContext(prompt, attachmentRefs), lifecycleEmit, attachments, {
threadId,
cwd: workspace.workspacePath,
permissionMode: permissionMode(req.body?.permissionMode),
model,
effort,
appEmit: emit,
onStart: clientId ? () => session.bindClient(clientId) : undefined,
onStart: () => session.bindClient(clientId),
onThread: (actualThreadId) => {
const threadChanged = actualThreadId !== threadId;
if (actualThreadId !== threadId) {
threadId = actualThreadId;
setActiveThread(threadId, { emptyThread: true });
setActiveThread(threadId, { emptyThread: true, sourceClientId: clientId });
}
session.setCodexState({ busy: true, threadId, turnId: "" });
if (chatThreadId !== threadId) {
chatThreadId = threadId;
session.emitThread("chat_message", threadId, chatMessage);
if (threadChanged) {
session.emitThread("chat_message", threadId, {
sourceClientId: clientId,
message: { id: `${threadId}:pending:synthetic:user`, itemId: "synthetic:user", clientMessageId: messageId, threadId, turnId: "", role: "user", text: messageText },
});
}
},
onTurn: (actualTurnId) => {
turnId = actualTurnId;
if (chatTurnId !== turnId) {
chatTurnId = turnId;
session.emitThread("chat_message", threadId, {
turnId,
sourceClientId: clientId,
message: { id: `${threadId}:${turnId}:synthetic:user`, itemId: "synthetic:user", clientMessageId: messageId, threadId, turnId, role: "user", text: messageText },
});
}
logger.info("Codex turn started", { threadId, turnId, model: model || "default", reasoningEffort: effort || "default" });
session.setCodexState({ busy: true, threadId, turnId });
},
@@ -207,10 +241,23 @@ export function startHttpServer() {
});
res.json({ ok: true, threadId });
} catch (error) {
session.setCodexState({ busy: false, threadId: String(req.body?.threadId || workspace.activeThreadId || ""), turnId: "" });
session.releaseClient(clientId);
session.setCodexState({ busy: false, threadId, turnId: "" });
throw error;
}
}));
/** 将 Codex 写操作串行化,避免多窗口在异步请求期间交叉修改会话。 */
function codexMutation(handler: (req: Request, res: Response) => unknown | Promise<unknown>) {
return route(async (req, res) => {
if (!session.beginCodexMutation()) return res.status(409).json({ ok: false, error: "Codex 正在运行或正在切换会话,请稍后重试" });
try {
return await handler(req, res);
} finally {
session.endCodexMutation();
}
});
}
app.post("/agent/codex/approval", route(async (req, res) => {
const decision = String(req.body?.decision || "");
if (!["accept", "acceptForSession", "decline", "cancel"].includes(decision)) return res.status(400).json({ ok: false, error: "无效的审批决定" });