From ea0414e88cffa6b522cc13c0613b3c8085983a53 Mon Sep 17 00:00:00 2001 From: yu <2799341137@qq.com> Date: Sat, 1 Aug 2026 22:11:05 +0800 Subject: [PATCH] fix(agent): unify live and historical conversation state --- AGENTS.md | 1 + CHANGELOG.md | 3 + canvas-agent/package.json | 2 +- canvas-agent/src/agent/codex-client.test.ts | 408 ++++++++++ canvas-agent/src/agent/codex-client.ts | 350 ++++++-- canvas-agent/src/agent/codex-event-history.ts | 170 ++++ canvas-agent/src/agent/codex-history.test.ts | 426 ++++++++++ canvas-agent/src/agent/codex-history.ts | 499 ++++++++++-- canvas-agent/src/agent/codex.ts | 119 +-- canvas-agent/src/canvas/session.test.ts | 206 ++++- canvas-agent/src/canvas/session.ts | 157 +++- canvas-agent/src/server/http.ts | 143 ++-- docs/content/docs/progress/pending-test.mdx | 8 +- web/src/components/agent/agent-api.ts | 8 +- .../components/agent/agent-chat-message.tsx | 130 ++- web/src/components/agent/agent-chat.tsx | 92 ++- .../agent/agent-event-formatters.ts | 201 +++-- .../components/agent/agent-history-view.tsx | 12 +- web/src/components/agent/agent-panel.tsx | 2 +- .../components/agent/local-agent-panel.tsx | 752 +++++++++++++----- web/src/pages/canvas/project.tsx | 6 - web/src/services/agent-chat-storage.ts | 112 ++- web/src/stores/use-agent-store.ts | 11 +- web/src/styles/globals.css | 4 + 24 files changed, 3246 insertions(+), 576 deletions(-) create mode 100644 canvas-agent/src/agent/codex-client.test.ts create mode 100644 canvas-agent/src/agent/codex-event-history.ts create mode 100644 canvas-agent/src/agent/codex-history.test.ts diff --git a/AGENTS.md b/AGENTS.md index 5a83aa7..f9958a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,3 +82,4 @@ - 当前画布项目和“我的素材”主要保存在浏览器本地,不要在文档中误写成已支持云同步。 - 当前 AI API Key 存在浏览器本地,并由前端直接请求 OpenAI 兼容接口;涉及安全说明时要写清楚。 - Docker 静态资源路径目前仍是待办项,文档中不要过度承诺生产部署已经完全验证。 +- Agent 对话消息必须同时按 `threadId`、`turnId` 和 `itemId` 归属;实时事件只用于补充未物化的 turn,历史快照成为权威后不得重复合并同一条消息。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bc1896..0f43787 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ + [优化] Agent 排查日志改为筛选栏固定的结构化滚动列表,支持筛选、展开详情并折叠连续重复事件。 + [优化] Agent 对话与排查日志统一使用居中的回到底部入口,向上浏览时暂停跟随并可快速返回最新内容。 ++ [优化] Agent 对话将同一轮连续命令合并为按数量折叠的命令组,默认隐藏冗长命令预览。 ++ [修复] Agent 对话代码块关闭行号后多行内容被拼接为一行的问题。 ++ [修复] 统一 Agent 实时事件与线程历史的消息归属,避免多窗口或刷新后出现过程重复、命令记录丢失和画布操作串页。 ## v0.12.1 - 2026-07-31 diff --git a/canvas-agent/package.json b/canvas-agent/package.json index f736ccc..823c6b3 100644 --- a/canvas-agent/package.json +++ b/canvas-agent/package.json @@ -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" diff --git a/canvas-agent/src/agent/codex-client.test.ts b/canvas-agent/src/agent/codex-client.test.ts new file mode 100644 index 0000000..aa8144c --- /dev/null +++ b/canvas-agent/src/agent/codex-client.test.ts @@ -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; + plansByTurn: Map; + lastUsage: unknown; + answerServerRequest(message: Record): void; + failAll(message: string): void; + handle(message: Record): void; + handleNotification(method: string, params: Record): void; +}; + +const emptyEventHistory = { record: () => Promise.resolve(), recordTurn: () => Promise.resolve() }; + +test("审批只在 app-server 确认 resolved 后清除", () => { + const writes: Array> = []; + 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> = []; + 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> = []; + 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> = []; + 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> = []; + 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> = []; + 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 })?.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> = []; + 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((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> = []; + 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> = []; + const events: Array<{ type: string; payload: unknown }> = []; + let release!: () => void; + const persisted = new Promise((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 : {}; + return { threadId: value.thread_id, turnId: value.turn_id }; +} + +function eventType(payload: unknown) { + return payload && typeof payload === "object" ? (payload as Record).type : undefined; +} diff --git a/canvas-agent/src/agent/codex-client.ts b/canvas-agent/src/agent/codex-client.ts index 3153b8d..23c29a1 100644 --- a/canvas-agent/src/agent/codex-client.ts +++ b/canvas-agent/src/agent/codex-client.ts @@ -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 }; +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(); private textByItem = new Map(); + private reasoningTextByItem = new Map>(); private lastUsage: unknown = null; private pending = new Map(); - private activeTurns = new Map(); + private activeTurns = new Map(); private completedTurns = new Map(); private pendingDeltas = new Map(); + private startedItems = new Map(); + private itemSequences = new Map(); + private nextItemSequences = new Map(); private plansByTurn = new Map(); - private approvalRequests = new Map(); + private approvalRequests = new Map(); + private finalizingTurns = new Map>(); + 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) {} /** 启动并初始化 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 | 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) { + 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(); + 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(); + 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, 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; diff --git a/canvas-agent/src/agent/codex-event-history.ts b/canvas-agent/src/agent/codex-event-history.ts new file mode 100644 index 0000000..a2650c4 --- /dev/null +++ b/canvas-agent/src/agent/codex-event-history.ts @@ -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 = 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 => { + 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(task: () => Promise) { + 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; + 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 | undefined, next: Record) { + 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) { + return truncateValue(value) as Record; +} + +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; +} diff --git a/canvas-agent/src/agent/codex-history.test.ts b/canvas-agent/src/agent/codex-history.test.ts new file mode 100644 index 0000000..4584463 --- /dev/null +++ b/canvas-agent/src/agent/codex-history.test.ts @@ -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(); + 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"); +}); diff --git a/canvas-agent/src/agent/codex-history.ts b/canvas-agent/src/agent/codex-history.ts index a59bb3e..b6ade7b 100644 --- a/canvas-agent/src/agent/codex-history.ts +++ b/canvas-agent/src/agent/codex-history.ts @@ -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 }; +export type CodexSupplementalHistoryItem = { threadId: string; turnId: string; itemId: string; sequence?: number; item: Record }; +export type CodexSupplementalHistoryTurn = { threadId: string; turnId: string; turn: Record }; +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(); + 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) => 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) { + 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(); + 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(); + 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(); + 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(); + 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), id: itemId }; + }); +} + +function historyItemType(item: unknown) { + const type = String(field(normalizeSupplementalItem(item && typeof item === "object" && !Array.isArray(item) ? item as Record : {}), "type") || ""); + return type; +} + +function hasReplacementCharacter(item: unknown) { + const value = item && typeof item === "object" && !Array.isArray(item) ? item as Record : {}; + 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 : {}; + 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 : {}); + 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(); + 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) { + if (!standard || typeof standard !== "object" || Array.isArray(standard)) return supplemental; + const merged = { ...supplemental }; + Object.entries(standard as Record).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) { + const type = String(field(item, "type") || ""); + const normalizedType = supplementalItemTypes[type] || type; + return normalizedType === type ? item : { ...item, type: normalizedType }; +} + +const supplementalItemTypes: Record = { + 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>((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}` : "工具操作"; } diff --git a/canvas-agent/src/agent/codex.ts b/canvas-agent/src/agent/codex.ts index 5a56d6a..475ac64 100644 --- a/canvas-agent/src/agent/codex.ts +++ b/canvas-agent/src/agent/codex.ts @@ -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 = Promise.resolve(); let codexApp: CodexAppClient | null = null; let codexAppStart: Promise | null = null; -let codexThreadId = ""; -const unmaterializedThreadIds = new Set(); +/** 仅表示最近主动加载/选择的线程;运行中的 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; diff --git a/canvas-agent/src/canvas/session.test.ts b/canvas-agent/src/canvas/session.test.ts index ff0792c..ea5fcb3 100644 --- a/canvas-agent/src/canvas/session.test.ts +++ b/canvas-agent/src/canvas/session.test.ts @@ -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>; + 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>; + 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>; + + 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>).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>; + 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] : []; + }); } /** 触发连接关闭事件。 */ diff --git a/canvas-agent/src/canvas/session.ts b/canvas-agent/src/canvas/session.ts index 6f400e0..1993994 100644 --- a/canvas-agent/src/canvas/session.ts +++ b/canvas-agent/src/canvas/session.ts @@ -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 }; export type CodexState = { busy: boolean; threadId: string; turnId: string }; +export const AGENT_PROTOCOL_VERSION = 3; const SITE_TOOLS = new Set([ "site_navigate", @@ -30,8 +32,12 @@ export class CanvasSession { private clients = new Map(); private clientFocusOrder = new Map(); private pending = new Map(); + private pendingApprovals = new Map>(); private canvasStates = new Map(); private turnAttachments = new Map(); + private codexReplayEvents = new Map(); + private codexReplayActiveItems = new Set(); + 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) { + 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) { 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), 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 = {}) { - this.emitAll(type, { ...payload, threadId }); + const data: Record = { ...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) { + 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) { + 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 : {}; +} + /** 向 SSE 连接写入一个事件。 */ function sendEvent(res: ServerResponse, type: string, payload: unknown) { res.write(`event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`); diff --git a/canvas-agent/src/server/http.ts b/canvas-agent/src/server/http.ts index 5db6379..e51e223 100644 --- a/canvas-agent/src/server/http.ts +++ b/canvas-agent/src/server/http.ts @@ -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 : { 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 : { 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 = {}) => { 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).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).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 : { 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 : { 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) { + 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: "无效的审批决定" }); diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index d19e539..0588e9e 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -22,9 +22,10 @@ description: 当前版本已实现但仍需人工验证的变更项 - Agent 排查日志顺序与跟随:日志应按时间从旧到新排列,进入日志或切换筛选时定位到最新事件;停留底部时自动跟随新日志,展开最后一条后应继续停留在完整详情底部,向上浏览后暂停跟随并显示与对话区视觉高度一致的居中圆形向下箭头,新日志数量只在悬浮提示中显示;列表底部不应出现额外留白,点击按钮后回到底部并恢复跟随。 - Agent 对话统计:用户消息应右对齐并使用透明无气泡的简洁排版,用户和 Codex 两侧均不显示人物头像,消息下方均不显示时间或 Token 信息;输入框上方应居中展示最新一次模型调用的输入、缓存、输出 Token 用量,不显示会话累计值,数值更新时应从旧值平滑滚动到新值而非突然跳变,新建、切换或删除当前会话后应清空旧统计。 - Agent 回复实时显示:在右侧 Agent 发送消息后,用户消息下方应立即出现“正在思考...”,任务运行期间不应闪退;工具完成后应显示 Codex 正在继续处理及已等待时长,等待超过 30 秒时提示可继续等待或停止本轮;Codex 的回复应在当前对话中持续显示,实时事件缺失时也应在任务完成后自动同步完整内容,无需切换到历史或日志再返回对话。模型繁忙等任务失败时应立即结束等待状态,在对话中显示中文错误原因和重试建议,诊断日志不应再把失败轮次记为“处理完成”,刷新历史后错误仍应保留。新建尚未发送首条消息的空会话不应反复出现历史读取失败。 +- Agent 对话实时与历史一致性:运行包含思考、命令及画布工具的较长任务,在执行中刷新发起页面或打开第二个页面时,已出现的用户消息、思考过程、工具卡和回复前缀均应保留并继续更新;同一思考或工具只显示一张卡片,任务结束及再次刷新后内容、顺序和状态应一致。切换页面焦点不能改变本轮画布工具目标,发起页面断开后不得把图片或写操作转交其他画布,使用相同页面身份重连后才能继续处理。 - Agent 流式交互性能:发送长回复时文字应连续平滑出现,输入框、滚动和画布操作不应随回复变长而明显卡顿;长历史会话中只有当前流式消息持续更新,屏幕外消息不应造成明显布局压力;任务完成后应通过 SSE 自动同步完整历史,不再持续请求 `/health` 轮询状态。 -- Agent 过程时间线:新建 Agent 对话后 Codex 应生成可读思考摘要,并在运行时依次显示中文的思考摘要、执行计划、命令执行、网页搜索、文件修改和画布工具活动;思考摘要的图标、标题和箭头应稳定保持在同一行,默认收起且无边框,点击箭头后才展开 Codex 实际返回的具体摘要,其中 Markdown 强调、列表和代码应正确渲染而非显示原始符号,完成事件不应再用「已完成分析」覆盖已经收到的摘要;命令执行也应使用无边框紧凑行,显示「已执行 1 条命令」和单行命令预览,点击后再展开工作目录、耗时、退出状态和运行输出;其他工具调用继续使用紧凑卡片排版,状态图标、标题和状态文字保持在同一行;结构化任务进度不应混在对话时间线中,而应独立放在对话区下方、Token 统计上方,支持展开和折叠,并逐项实时更新「待处理」「进行中」「已完成」状态;新任务生成时默认展开,任务结束后保留最新结果,同一计划更新不应生成重复内容;文件详情展示文件路径与新增/修改/删除动作,工具详情不应出现请求 ID、英文工具名或原始 JSON;一轮对话结束自动同步历史以及刷新页面后从历史中 resume 对话时,思考摘要、命令和工具过程记录仍应完整保留并保持相同展示。 -- Agent 权限控制:输入框可选择「请求批准」「自动审查」「完全访问」;请求批准模式下,Codex 编辑工作区外文件、执行受限命令或访问网络时应在对话中显示审批卡片,支持拒绝、允许一次和本会话允许,并能依次处理多个并发请求;自动审查模式应仅将需要用户决定的风险操作送入审批;完全访问必须先显示风险确认,启用后可访问网络和本机文件且不再请求审批;选择应在刷新后保留。 +- Agent 过程时间线:新建 Agent 对话后 Codex 应生成可读思考摘要,并在运行时依次显示中文的思考摘要、执行计划、命令执行、网页搜索、文件修改和画布工具活动;思考摘要的图标、标题和箭头应稳定保持在同一行,默认收起且无边框,点击箭头后才展开 Codex 实际返回的具体摘要,其中 Markdown 强调、列表和代码应正确渲染而非显示原始符号,多行代码块和文本流程图应保留原始换行并允许横向滚动,完成事件不应再用「已完成分析」覆盖已经收到的摘要;同一轮连续命令应合并为显示数量的无边框折叠行,折叠时不显示命令预览,单条展开后直接显示详情,多条展开后可逐条查看工作目录、耗时、退出状态和运行输出;其他工具调用继续使用紧凑卡片排版,状态图标、标题和状态文字保持在同一行;结构化任务进度不应混在对话时间线中,而应独立放在对话区下方、Token 统计上方,支持展开和折叠,并逐项实时更新「待处理」「进行中」「已完成」状态;新任务生成时默认展开,任务结束后保留最新结果,同一计划更新不应生成重复内容;文件详情展示文件路径与新增/修改/删除动作,工具详情不应出现请求 ID、英文工具名或原始 JSON;一轮对话结束自动同步历史以及刷新页面后从历史中 resume 对话时,思考摘要、命令和工具过程记录仍应完整保留并保持相同展示。 +- Agent 权限控制:输入框可选择「请求批准」「自动审查」「完全访问」;请求批准模式下,Codex 编辑工作区外文件、执行受限命令或访问网络时应在对话中显示审批卡片,支持拒绝、允许一次和本会话允许,并能依次处理多个并发请求;提交决定后卡片应保持禁用等待,只有 Codex 确认处理后才移除;等待审批时刷新页面,未处理的审批卡应自动恢复且不能让任务永久卡住;自动审查模式应仅将需要用户决定的风险操作送入审批;完全访问必须先显示风险确认,启用后可访问网络和本机文件且不再请求审批;选择应在刷新后保留。 - Agent 历史记录:点击记录卡片应直接进入对应对话,不再显示「进入」按钮;可勾选单条或全选多条记录并批量删除,删除当前对话后聊天内容应清空。 - Agent 默认新对话:每次进入任一画布并连接 Agent 后,对话区应保持空白且不自动恢复上一次会话;第一次发送消息时才创建新线程,不应产生未发送消息的空历史记录;需要继续旧对话时可在「历史」中主动选择恢复。 - Agent 当前画布优先:在已打开某个画布时要求 Agent 创建、修改、整理或生成内容,Agent 应直接读取并操作当前画布,不应先调用 `canvas_list_projects` 或使用 `site_navigate` 重复进入画布;只有明确要求查看或切换其他画布时才允许查询画布列表并导航。 @@ -48,9 +49,10 @@ description: 当前版本已实现但仍需人工验证的变更项 - 提示词来源界面:来源应以卡片列表展示,启用开关位于左侧,数量、同步状态和上次成功时间作为次级信息显示,查看、拉取及自定义来源编辑/删除操作使用带文字按钮;底部定时拉取区域应保持独立边框布局。 - 画布提示词库:不应再显示「我的提示词」分组;不展开任何公共来源直接搜索其中的提示词,匹配项应自动显示;点击「插入画布」后应创建正文正确且标题保持为提示词标题的文本节点。 - 全站 Agent:新增 `generation_get_status` 工具,画布生成节点可按 `nodeIds` 查询,生图和视频工作台提交后会返回 `taskId` 并可查询排队、运行、成功或失败状态;需验证查询只由当前活动标签页返回。 -- 本地 Agent 多标签页隔离:同时打开两个不同画布并连接同一个 Agent,分别聚焦标签页后通过 MCP 读取和修改画布,操作应只落在当前聚焦页面;网页面板发起的整个 Codex turn 应固定操作发起页面,即使中途聚焦另一标签页也不能切换目标;关闭当前页面后应回退到最近聚焦且仍连接的页面,其他页面回传同一请求结果应被拒绝。 +- 本地 Agent 多标签页隔离:同时打开两个不同画布并连接同一个 Agent,分别聚焦标签页后通过 MCP 读取和修改画布,操作应只落在当前聚焦页面;网页面板发起的整个 Codex turn 应固定操作发起页面,即使中途聚焦另一标签页也不能切换目标;非运行状态关闭当前页面后应回退到最近聚焦且仍连接的页面,运行中关闭发起页面则不得把操作转交其他画布,只有同一页面身份重连后才能继续;其他页面回传同一请求结果应被拒绝。 - 本地 Agent 多标签页会话同步:所有标签页共享同一个站点级 Codex 活跃线程;任一页面发送消息、新建、恢复或删除会话后,其他页面应同步活跃线程和聊天记录;Agent 输出仅显示在事件所属线程,运行中不能新建、恢复、删除或再次发送任务。 - 本地 Agent 运行状态同步:在一个标签页运行较长 Codex 任务,等待某张工具卡显示「工具完成」后再打开或刷新第二个标签页;第二个标签页应立即显示 Codex 正在运行并禁用发送,整轮结束后两个标签页同时恢复;工具卡只显示「工具完成」,整轮结束由「本轮完成」表示。 - 本地 Agent 图片附件落画布:在右侧 Agent 上传参考图并要求基于商品信息创建生图流程,附件应创建为保持原比例的真实图片节点,分析提示词应创建为文本节点,二者都应连接到生成配置节点;刷新页面后参考图仍可显示并参与生成。任务中途切换到其他标签页时,附件只能写入发起任务的标签页;若发起标签页关闭,附件读取应失败且不能落入其他画布。 - Agent 对话滚动:从历史或日志切回对话、恢复其他会话时应自动定位到最新消息;手动向上浏览后应显示与日志完全相同尺寸、位置和样式的居中圆形向下箭头,新消息不强制打断阅读,对话底部不应出现额外留白,点击按钮后平滑回到底部并继续跟随新消息。 - Agent 消息区分:用户消息应在右侧使用透明无气泡排版,AI 回复应在左侧使用无头像的开放式 Markdown 排版;长文本、多张附件、错误消息及浅色/深色主题下均应清晰且不溢出。 +- Agent 命令记录:命令执行中应显示运行状态;完成后,同一轮相邻命令应合并为「已执行 N 条命令」折叠行且默认不显示原始命令,单条展开后直接显示完整详情,多条展开后可分别查看命令、输出和退出状态;对话结束或刷新后分组与记录不应消失。 diff --git a/web/src/components/agent/agent-api.ts b/web/src/components/agent/agent-api.ts index a0503f6..63a8874 100644 --- a/web/src/components/agent/agent-api.ts +++ b/web/src/components/agent/agent-api.ts @@ -1,6 +1,6 @@ import type { CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops"; -type AgentConfigResponse = { ok?: boolean; url?: string; token?: string; hasToken?: boolean }; +type AgentConfigResponse = { ok?: boolean; protocolVersion?: number; url?: string; token?: string; hasToken?: boolean }; export async function postState(endpoint: string, token: string, clientId: string, snapshot: CanvasAgentSnapshot | null) { try { @@ -19,13 +19,17 @@ export async function activateAgentClient(endpoint: string, token: string, clien } export async function postToolResult(endpoint: string, token: string, clientId: string, body: { requestId: string; result?: unknown; error?: string }) { - await fetch(`${endpoint}/canvas/result?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(clientId)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); + await fetchAgentJson(endpoint, token, `/canvas/result?clientId=${encodeURIComponent(clientId)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); } export async function postCodexApproval(endpoint: string, token: string, requestId: string, decision: "accept" | "acceptForSession" | "decline") { await fetchAgentJson(endpoint, token, "/agent/codex/approval", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ requestId, decision }) }); } +export async function acknowledgeCodexHistory(endpoint: string, token: string, threadId: string, turnIds: string[]) { + await fetchAgentJson(endpoint, token, "/agent/codex/history/ack", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ threadId, turnIds }) }); +} + export async function revealAgentLocalFile(endpoint: string, token: string, path: string) { await fetchAgentJson(endpoint, token, "/agent/local-file/reveal", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ path }) }); } diff --git a/web/src/components/agent/agent-chat-message.tsx b/web/src/components/agent/agent-chat-message.tsx index cc995d4..4ee43c1 100644 --- a/web/src/components/agent/agent-chat-message.tsx +++ b/web/src/components/agent/agent-chat-message.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, type ReactNode } from "react"; +import { useEffect, useId, useState, type ReactNode } from "react"; import { App, Button, Image, Modal } from "antd"; import { Brain, CheckCircle2, ChevronDown, ChevronRight, Circle, CircleAlert, Copy, ExternalLink, FilePenLine, FileText, FolderOpen, ListChecks, LoaderCircle, Search, ShieldAlert, TerminalSquare, Wrench, XCircle } from "lucide-react"; import { Streamdown, type LinkSafetyModalProps } from "streamdown"; @@ -182,9 +182,9 @@ export function AgentApprovalCard({ approval, theme, onDecision }: { approval: A
- - - + + +
); @@ -195,26 +195,32 @@ export function AgentToolCard({ title, text, detail, theme }: { title: string; t if (plan) return ; const kind = String(objectField(detail, "kind") || ""); if (kind === "reasoning") return ; - if (kind === "command") return ; + if (kind === "command") return ; const state = toolCardState(title, text, detail); const view = userDetail(detail); const showText = title !== "读取画布" || text !== "已读取当前画布内容"; - return ( -
- { if (!view) event.preventDefault(); }}> -
- {toolIcon(kind, state.icon)} - {title} - {state.label} - {view ? : null} + const className = "group min-w-0 rounded-xl border px-3 py-2.5 text-left"; + const style = { borderColor: theme.node.stroke, background: "transparent", color: theme.node.text }; + const content = ( + <> +
+ {toolIcon(kind, state.icon)} + {title} + {state.label} + {view ? : null} +
+ {showText ? ( +
+ {text}
- {showText ? ( -
- {text} -
- ) : null} -
- {view ?
: null} + ) : null} + + ); + if (!view) return
{content}
; + return ( +
+ {content} +
); } @@ -238,27 +244,81 @@ function AgentReasoningSummary({ text, detail, theme }: { text: string; detail?: ); } -function AgentCommandSummary({ text, detail, theme }: { text: string; detail?: unknown; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) { - const view = userDetail(detail); - const status = String(objectField(detail, "status") || ""); - const running = ["inProgress", "in_progress", "running", "started", "pending"].includes(status); - const failed = ["failed", "error"].includes(status); - const color = failed ? "#dc2626" : running ? "#d97706" : theme.node.muted; +type AgentCommandItem = Pick; + +export function AgentCommandGroup({ items, theme }: { items: AgentCommandItem[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) { + const states = items.map((item) => commandViewState(item.detail)); + const running = states.some((state) => state.running); + const failed = states.filter((state) => state.failed).length; + const expandable = items.some((item) => Boolean(item.text.trim() || userDetail(item.detail))); + const color = running ? "#d97706" : failed ? "#dc2626" : theme.node.muted; + const label = running + ? items.length > 1 ? `正在执行 ${items.length} 条命令` : "正在执行命令" + : `已执行 ${items.length} 条命令${failed ? ` · ${failed} 条失败` : ""}`; + const header = ( +
+ {running ? : } + {label} + {expandable ? : null} +
+ ); + if (!expandable) return
{header}
; return (
- { if (!view) event.preventDefault(); }}> -
- {running ? : } - {failed ? "命令执行失败" : running ? "正在执行命令" : "已执行 1 条命令"} - {view ? : null} -
-
{text}
-
- {view ?
: null} + {header} + {items.length === 1 + ? + :
{items.map((item, index) => )}
+ }
); } +function AgentSingleCommand({ item, theme }: { item: AgentCommandItem; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) { + const view = userDetail(item.detail); + return ( +
+ {item.text ?
{item.text}
: null} + {view ? : null} +
+ ); +} + +function AgentCommandEntry({ item, index, theme }: { item: AgentCommandItem; index: number; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) { + const [open, setOpen] = useState(false); + const detailId = useId(); + const view = userDetail(item.detail); + const state = commandViewState(item.detail); + const status = state.failed ? "执行失败" : state.running ? "执行中" : "已完成"; + const color = state.failed ? "#dc2626" : state.running ? "#d97706" : "#16a34a"; + const content = ( + <> + {index + 1} + {item.text || "命令"} + + {state.running ? : state.failed ? : } + + {view ? : null} + + ); + return ( +
+ {view + ? + :
{content}
} + {view && open ?
: null} +
+ ); +} + +function commandViewState(detail: unknown) { + const status = String(objectField(detail, "status") || "").toLowerCase(); + return { + running: ["inprogress", "in_progress", "running", "started", "pending"].includes(status), + failed: ["failed", "error"].includes(status), + }; +} + function AgentPlanCard({ title, plan, theme }: { title: string; plan: PlanDetail; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) { const [open, setOpen] = useState(true); const completed = plan.tasks.filter((item) => item.status === "completed").length; diff --git a/web/src/components/agent/agent-chat.tsx b/web/src/components/agent/agent-chat.tsx index 2164ba8..4a761c1 100644 --- a/web/src/components/agent/agent-chat.tsx +++ b/web/src/components/agent/agent-chat.tsx @@ -1,10 +1,10 @@ -import { memo, useCallback, useEffect, useRef, useState } from "react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { motion, useSpring, useTransform } from "motion/react"; import { canvasThemes } from "@/lib/canvas-theme"; import { summarizeCanvasAgentOps } from "@/lib/canvas/canvas-agent-ops"; import { useAgentStore, type AgentChatItem, type AgentPendingApproval, type AgentPendingToolCall, type AgentTokenUsage } from "@/stores/use-agent-store"; -import { AgentApprovalCard, AgentChatMessage, AgentPendingToolCard, AgentToolCard, AgentWorkingMessage } from "./agent-chat-message"; +import { AgentApprovalCard, AgentChatMessage, AgentCommandGroup, AgentPendingToolCard, AgentToolCard, AgentWorkingMessage } from "./agent-chat-message"; import { agentMessageToChatMessage, currentPlanMessage, isPlanMessage, latestPlanMessage, toolCallDetail, toolName, workingActivity } from "./agent-event-formatters"; import { AgentScrollToBottom } from "./agent-scroll-to-bottom"; @@ -31,7 +31,9 @@ export function AgentChatTimeline({ onApprovalDecision: (approval: AgentPendingApproval, decision: "accept" | "acceptForSession" | "decline") => void; }) { const messages = useAgentStore((state) => state.messages); + const timeline = useMemo(() => groupTimelineMessages(messages), [messages]); const listRef = useRef(null); + const contentRef = useRef(null); const followMessagesRef = useRef(true); const [showScrollToBottom, setShowScrollToBottom] = useState(false); const streaming = messages.some((message) => message.streamId); @@ -54,23 +56,39 @@ export function AgentChatTimeline({ const frame = requestAnimationFrame(() => (followMessagesRef.current ? scrollToBottom("auto") : updateScrollState())); return () => cancelAnimationFrame(frame); }, [messages, pendingApprovals, pendingTool, scrollToBottom, updateScrollState, waiting]); + useEffect(() => { + const content = contentRef.current; + if (!content) return; + let frame = 0; + const observer = new ResizeObserver(() => { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => (followMessagesRef.current ? scrollToBottom("auto") : updateScrollState())); + }); + observer.observe(content); + return () => { + observer.disconnect(); + cancelAnimationFrame(frame); + }; + }, [scrollToBottom, updateScrollState]); return (
-
- {messages.map((item) => ( - isPlanMessage(item) ? null : - ))} - {pendingTool ? ( - - ) : null} - {pendingApprovals.map((approval) => onApprovalDecision(approval, decision)} />)} - {(sending || waiting) && !streaming && !pendingTool && !pendingApprovals.length ? : null} +
+
+ {timeline.map((entry) => entry.type === "commands" + ? + : )} + {pendingTool ? ( + + ) : null} + {pendingApprovals.map((approval) => onApprovalDecision(approval, decision)} />)} + {(sending || waiting) && !streaming && !pendingTool && !pendingApprovals.length ? : null} +
{showScrollToBottom ? ( scrollToBottom()} /> @@ -97,6 +115,46 @@ const AgentChatMessageRow = memo(function AgentChatMessageRow({ item, theme }: { ); }); +const AgentCommandGroupRow = memo(function AgentCommandGroupRow({ items, theme }: { items: AgentChatItem[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) { + return ( +
item.streamId) ? undefined : historyMessageStyle}> + +
+ ); +}); + +type AgentTimelineEntry = { type: "message"; item: AgentChatItem } | { type: "commands"; id: string; items: AgentChatItem[] }; + +function groupTimelineMessages(messages: AgentChatItem[]) { + const timeline: AgentTimelineEntry[] = []; + let commands: AgentChatItem[] = []; + let commandScope = ""; + const flushCommands = () => { + if (!commands.length) return; + timeline.push({ type: "commands", id: `commands:${commands[0].id}`, items: commands }); + commands = []; + commandScope = ""; + }; + messages.forEach((item) => { + if (isPlanMessage(item)) return; + if (isCommandMessage(item)) { + const scope = item.threadId && item.turnId ? `${item.threadId}\0${item.turnId}` : item.id; + if (commands.length && scope !== commandScope) flushCommands(); + commands.push(item); + commandScope = scope; + return; + } + flushCommands(); + timeline.push({ type: "message", item }); + }); + flushCommands(); + return timeline; +} + +function isCommandMessage(item: AgentChatItem) { + return item.role === "tool" && item.detail && typeof item.detail === "object" && (item.detail as { kind?: unknown }).kind === "command"; +} + export function AgentUsageBar({ usage, theme }: { usage: AgentTokenUsage; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) { return (
diff --git a/web/src/components/agent/agent-event-formatters.ts b/web/src/components/agent/agent-event-formatters.ts index ad40a91..ee4223d 100644 --- a/web/src/components/agent/agent-event-formatters.ts +++ b/web/src/components/agent/agent-event-formatters.ts @@ -3,13 +3,17 @@ import { summarizeCanvasAgentOps, type CanvasAgentOp } from "@/lib/canvas/canvas import { randomId } from "@/lib/utils"; import { useAgentStore, type AgentAttachment, type AgentChatItem, type AgentEventLog, type AgentTokenUsage } from "@/stores/use-agent-store"; import type { AgentChatAttachment } from "./agent-chat-message"; +export const REASONING_PLACEHOLDER = "正在分析任务…"; export type AgentEventPayload = { agent?: string; type?: string; threadId?: string; thread_id?: string; + turnId?: string; turn_id?: string; + sourceClientId?: string; + replayed?: boolean; item?: AgentEventItem; error?: { message?: string }; message?: string; @@ -60,7 +64,6 @@ export function agentAttachmentToChatAttachment(item: AgentAttachment): AgentCha export function formatAgentEvent(event: AgentEventPayload): Omit | null { const item = event.item; - if (event.type === "item.completed" && item?.type === "error") return { role: "error", title: "错误", text: normalizeText(item.message), detail: item }; if (event.type === "item.completed" && item?.type === "agent_message") return { role: "assistant", title: "Codex", text: stringText(item.text) }; return null; } @@ -70,45 +73,50 @@ export function formatAgentActivity(event: AgentEventPayload): Omit (row ? [row] : [])); - return { kind: "command", status, rows, output: item.error?.message || stringText(item.aggregatedOutput) }; + const commandStatus = typeof item.exitCode === "number" && item.exitCode !== 0 ? "failed" : status; + return { kind: "command", status: commandStatus, rows, output: item.error?.message || stringText(item.aggregatedOutput) }; } function activityFiles(value: unknown) { @@ -184,7 +193,7 @@ function webSearchSummary(item: AgentEventItem) { const action = item.action; const type = stringText(objectField(action, "type")); if (type === "openPage") return `打开网页:${stringText(objectField(action, "url"))}`; - if (type === "findInPage") return `在网页中查找“${stringText(objectField(action, "pattern")) || "相关内容"}”`; + if (type === "findInPage") return `在网页中查找“${stringText(objectField(action, "pattern")) || "内容"}”`; return `搜索:${stringText(item.query) || stringText(objectField(action, "query")) || "相关资料"}`; } @@ -196,6 +205,7 @@ function webSearchDetailRows(item: AgentEventItem) { 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(objectField(value, "text")); } @@ -227,6 +237,19 @@ export function isCurrentThreadEvent(event: { threadId?: string; thread_id?: str return Boolean(threadId) && threadId === useAgentStore.getState().activeThreadId; } +export function registerLiveAgentTurn( + event: { replayed?: boolean; threadId?: string; thread_id?: string; turnId?: string; turn_id?: string }, + authoritativeTurns: ReadonlySet, + liveTurns: Set, +) { + const threadId = event.threadId || event.thread_id || ""; + const turnId = event.turnId || event.turn_id || ""; + const key = threadId && turnId ? `${threadId}\0${turnId}` : ""; + if (event.replayed && key && authoritativeTurns.has(key)) return false; + if (key) liveTurns.add(key); + return true; +} + export function formatLogText(logs: AgentEventLog[], context: AgentLogContext) { const head = [ "Infinite Canvas Agent 诊断", @@ -325,26 +348,23 @@ export function toolName(name: string) { return name ? `调用工具:${name}` : "工具操作"; } -export function siteToolSummary(name: string, result: unknown) { +function siteToolSummary(name: string, result: unknown, input: unknown) { const data = result && typeof result === "object" ? (result as Record) : {}; + if (name === "site_navigate") return `已打开${routeName(stringText(objectField(input, "path")) || "/")}`; if (name === "canvas_list_projects") return `共 ${numberField(data, "total")} 个画布`; if (name === "prompts_search") return `找到 ${numberField(data, "total")} 条提示词`; if (name === "assets_list") return `共 ${numberField(data, "total")} 个资产`; - if (name === "assets_add") return "已加入我的资产"; + if (name === "assets_add") return "已加入我的素材"; if (name === "generation_get_status") { const summary = data.summary && typeof data.summary === "object" ? (data.summary as Record) : {}; return `共 ${numberField(data, "total")} 个任务,排队 ${numberField(summary, "queued")},运行中 ${numberField(summary, "running")},成功 ${numberField(summary, "succeeded")},失败 ${numberField(summary, "failed")}`; } if (name === "workbench_image_generate" || name === "workbench_video_generate") return typeof data.note === "string" ? data.note : "已在工作台执行"; if (name === "workbench_image_get_config" || name === "workbench_video_get_config") return "已读取工作台配置"; - return "已完成"; + return ""; } -export function isReadTool(name: string) { - return name === "canvas_get_state" || name === "canvas_get_selection" || name === "canvas_export_snapshot"; -} - -function isMcpToolItem(item?: AgentEventItem) { +function isMcpToolItem(item?: AgentEventItem): item is AgentEventItem & { type: "mcp_tool_call" } { return item?.type === "mcp_tool_call"; } @@ -358,23 +378,26 @@ export function toolCallDetail(name: string, input: unknown, status: string, err } function toolInputRows(name: string, input: unknown) { + input = parseToolArguments(input); if (name === "site_navigate") return [detailRow("目标页面", routeName(stringText(objectField(input, "path")) || "/"))].flatMap((row) => (row ? [row] : [])); if (name === "prompts_search") return [detailRow("搜索内容", objectField(input, "query"))].flatMap((row) => (row ? [row] : [])); if (name === "canvas_create_text_node") return [detailRow("文本内容", objectField(input, "text"))].flatMap((row) => (row ? [row] : [])); if (name === "canvas_apply_ops") return [detailRow("操作内容", summarizeCanvasAgentOps((objectField(input, "ops") as CanvasAgentOp[] | undefined) || []))].flatMap((row) => (row ? [row] : [])); - if (name === "canvas_create_attachment_nodes") return [detailRow("图片数量", Array.isArray(objectField(input, "nodes")) ? (objectField(input, "nodes") as unknown[]).length : 0)].flatMap((row) => (row ? [row] : [])); + if (name === "canvas_create_attachment_nodes") return [detailRow("图片数量", Array.isArray(objectField(input, "attachmentIds")) ? (objectField(input, "attachmentIds") as unknown[]).length : 0)].flatMap((row) => (row ? [row] : [])); return []; } export function toolSummary(item?: AgentEventItem) { const result = parseToolResult(item?.result); + const name = String(item?.tool || ""); + if (name === "site_navigate" || isSiteTool(name)) return siteToolSummary(name, result, parseToolArguments(item?.arguments)); const nodeField = objectField(result, "nodes"); const connectionField = objectField(result, "connections"); const nodes = Array.isArray(nodeField) ? nodeField : []; const connections = Array.isArray(connectionField) ? connectionField : []; - if (item?.tool === "canvas_get_state" && (Array.isArray(nodeField) || Array.isArray(connectionField))) return canvasContentSummary(nodes, connections.length); - if (Array.isArray(nodeField) || Array.isArray(connectionField)) return `读取到 ${nodes.length} 个节点,${connections.length} 条连线`; - return "工具调用完成"; + if (name === "canvas_get_state") return Array.isArray(nodeField) || Array.isArray(connectionField) ? canvasContentSummary(nodes, connections.length) : "已读取当前画布内容"; + if (name === "canvas_get_selection") return "已读取当前选中内容"; + return ""; } function canvasContentSummary(nodes: unknown[], connections: number) { @@ -479,17 +502,6 @@ function numberField(value: unknown, key: string) { return typeof field === "number" ? field : 0; } -export function mergeAgentText(prev: string, next: string) { - if (!next || prev === next || prev.endsWith(next)) return prev; - if (next.startsWith(prev)) return next; - for (let size = Math.min(prev.length, next.length); size > 0; size--) { - if (prev.endsWith(next.slice(0, size))) return `${prev}${next.slice(size)}`; - } - const half = Math.floor(prev.length / 2); - if (prev.length > 12 && next.length > 12 && prev.slice(half) === next.slice(0, prev.length - half)) return prev; - return `${prev}${next}`; -} - export function promptWithAttachments(text: string, attachments: AgentAttachment[]) { return text || (attachments.length ? "请处理上传的图片附件。" : ""); } @@ -506,15 +518,63 @@ export function isCanvasWriteTool(name: string) { return name === "canvas_apply_ops" || name === "canvas_create_attachment_nodes"; } +function parseToolArguments(value: unknown) { + if (typeof value !== "string") return value; + try { + return JSON.parse(value) as unknown; + } catch { + return {}; + } +} + +export function agentMessageId(threadId: string, turnId: string, itemId: string) { + return `${threadId}:${turnId}:${itemId}`; +} + +export function scopeChatItem(item: AgentChatItem, threadId: string, turnId: string) { + const scopeTurnId = turnId || "pending"; + const prefix = `${threadId || "local"}:${scopeTurnId}:`; + const sourceItemId = item.itemId || (item.id.startsWith(prefix) ? item.id.slice(prefix.length) : item.id); + const itemId = item.role === "user" ? "synthetic:user" : sourceItemId; + return { ...item, id: agentMessageId(threadId || "local", scopeTurnId, itemId), itemId, threadId, turnId }; +} + +export function bindPendingTurnMessages(messages: AgentChatItem[], threadId: string, turnId: string) { + const index = messages.findLastIndex((item) => item.role === "user" && item.threadId === threadId && !item.turnId); + if (index < 0) return messages; + return messages.map((item, itemIndex) => itemIndex === index ? scopeChatItem(item, threadId, turnId) : item); +} + +export function upsertAgentMessage(messages: AgentChatItem[], item: AgentChatItem) { + const index = messages.findIndex((current) => current.id === item.id); + if (index < 0) return [...messages, item]; + const current = messages[index]; + const next = { ...current, ...item, attachments: item.attachments || current.attachments, historyText: item.historyText || current.historyText }; + return messages.map((message, itemIndex) => itemIndex === index ? next : message); +} + +export function mergeAgentMessages(snapshot: AgentChatItem[], current: AgentChatItem[], threadId: string, liveTurnKeys: ReadonlySet) { + let messages = [...snapshot]; + current.filter((item) => item.threadId === threadId).forEach((item) => { + const live = Boolean(item.turnId && liveTurnKeys.has(`${threadId}\0${item.turnId}`)); + const index = messages.findIndex((message) => message.id === item.id); + if (index < 0) { + if (!item.turnId || live) messages = upsertAgentMessage(messages, item); + return; + } + const history = messages[index]; + const next = live + ? { ...history, ...item, attachments: item.attachments || history.attachments, historyText: item.historyText || history.historyText } + : { ...history, attachments: item.attachments || history.attachments, historyText: item.historyText || history.historyText }; + messages = messages.map((message, itemIndex) => itemIndex === index ? next : message); + }); + return messages; +} + export function normalizeHistoryMessages(messages: AgentChatItem[]) { return messages - .map((item, index) => ({ - ...item, - id: item.id || `history-${index}`, - text: normalizeText(item.text), - streamId: undefined, - })) - .filter((item) => item.text); + .filter((item) => (normalizeText(item.text) || item.role === "tool") && item.itemId && item.threadId && item.turnId) + .map(({ streamId: _streamId, ...item }) => scopeChatItem({ ...item, text: normalizeText(item.text) } as AgentChatItem, item.threadId!, item.turnId!)); } export function mergeHistoryAttachments(messages: AgentChatItem[], currentMessages: AgentChatItem[]) { @@ -523,22 +583,35 @@ export function mergeHistoryAttachments(messages: AgentChatItem[], currentMessag .reverse() .map((item) => { if (item.role !== "user") return item; - const index = currentUsers.findIndex((current) => current.text === item.text || current.historyText === item.text); + let index = item.clientMessageId + ? currentUsers.findIndex((current) => current.clientMessageId === item.clientMessageId && current.threadId === item.threadId) + : -1; + if (index < 0 && item.turnId) index = currentUsers.findIndex((current) => current.turnId === item.turnId && current.threadId === item.threadId); + if (index < 0) { + const candidates = currentUsers + .map((current, candidateIndex) => ({ current, candidateIndex })) + .filter(({ current }) => current.threadId === item.threadId && (current.text === item.text || current.historyText === item.text)); + if (new Set(candidates.map(({ current }) => current.clientMessageId || current.id)).size === 1) index = candidates[0]?.candidateIndex ?? -1; + } if (index < 0) return item; const current = currentUsers.splice(index, 1)[0]; - return { ...item, id: current.id, text: current.text, historyText: current.historyText, attachments: current.attachments }; + return { ...item, text: current.text, historyText: current.historyText, attachments: current.attachments }; }) .reverse(); } -export function mergeHistoryMessages(historyMessages: AgentChatItem[], currentMessages: AgentChatItem[]) { - if (!currentMessages.length) return historyMessages; - const remaining = [...historyMessages]; - const messages = currentMessages.map((current) => { - const index = remaining.findIndex((history) => history.id === current.id || ((current.role === "user" || current.role === "assistant") && history.role === current.role && history.text === current.text)); - if (index < 0) return current; - const history = remaining.splice(index, 1)[0]; - return { ...current, ...history, id: current.id, attachments: current.attachments || history.attachments }; - }); - return [...messages, ...remaining]; +export function reasoningActivityText(items: Record, fallback = "") { + const summaries = Object.values(items).map((item) => item.trim()).filter(isReasoningSummary); + return summaries.join("\n\n") || fallback || REASONING_PLACEHOLDER; +} + +export function isReasoningSummary(value = "") { + const text = value.trim(); + return Boolean(text && text !== REASONING_PLACEHOLDER && text !== "已完成分析"); +} + +export function mergeStreamText(prefix: string, incoming: string) { + if (!prefix || incoming.startsWith(prefix)) return incoming || prefix; + if (!incoming || prefix.startsWith(incoming)) return prefix; + return incoming.length >= prefix.length ? incoming : prefix; } diff --git a/web/src/components/agent/agent-history-view.tsx b/web/src/components/agent/agent-history-view.tsx index a195188..0918a2a 100644 --- a/web/src/components/agent/agent-history-view.tsx +++ b/web/src/components/agent/agent-history-view.tsx @@ -33,6 +33,7 @@ export function AgentHistoryView({ const [selectedIds, setSelectedIds] = useState(() => new Set()); const selectedThreads = threads.filter((thread) => selectedIds.has(thread.id)); const allSelected = Boolean(threads.length) && selectedThreads.length === threads.length; + const canResume = connected && !loading && !busy; const toggleThread = (threadId: string) => { setSelectedIds((current) => { const next = new Set(current); @@ -76,12 +77,15 @@ export function AgentHistoryView({
onResumeThread(thread.id)} + onClick={() => { + if (canResume) onResumeThread(thread.id); + }} onKeyDown={(event) => { - if (event.key !== "Enter" && event.key !== " ") return; + if (!canResume || (event.key !== "Enter" && event.key !== " ")) return; event.preventDefault(); onResumeThread(thread.id); }} diff --git a/web/src/components/agent/agent-panel.tsx b/web/src/components/agent/agent-panel.tsx index 5b675f6..19a0896 100644 --- a/web/src/components/agent/agent-panel.tsx +++ b/web/src/components/agent/agent-panel.tsx @@ -44,7 +44,7 @@ export function AgentPanel() { initial={{ width: 0, opacity: 0 }} animate={{ width: panelOpen ? width + 1 : 0, opacity: panelOpen ? 1 : 0 }} transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: [0.22, 1, 0.36, 1] }} - style={{ overflow: "clip", pointerEvents: panelClosing ? "none" : undefined }} + style={{ overflow: "clip", pointerEvents: panelOpen && !panelClosing ? undefined : "none" }} > (["minimal", "low", "medium", "high", "xhigh", "max", "ultra"]); const AGENT_REASONING_LABELS: Record = { minimal: "最低", low: "轻度", medium: "中", high: "高", xhigh: "极高", max: "最高", ultra: "Ultra" }; type AgentWorkspace = { workspacePath: string; activeThreadId?: string }; type AgentThreadsResponse = { ok?: boolean; workspace?: AgentWorkspace; data?: AgentThreadSummary[] }; -type AgentThreadResponse = { ok?: boolean; workspace?: AgentWorkspace; thread?: AgentThreadSummary; messages?: AgentChatItem[] }; +type AgentThreadResponse = { ok?: boolean; workspace?: AgentWorkspace; thread?: AgentThreadSummary; messages?: AgentChatItem[]; settledTurnIds?: string[]; historyReady?: boolean }; +type AgentWorkspaceResponse = { ok?: boolean; workspace?: AgentWorkspace }; +type AgentTurnResponse = { ok?: boolean; threadId?: string }; type AgentModelsResponse = { ok?: boolean; data?: AgentModel[] }; type AgentCodexState = { busy?: boolean; threadId?: string; turnId?: string }; -type AgentHelloEvent = { ok?: boolean; clientId?: string; codex?: AgentCodexState }; -type AgentWorkspaceEvent = { activeThreadId?: string; threadId?: string; emptyThread?: boolean; draftThread?: boolean }; -type AgentChatEvent = { threadId?: string; sourceClientId?: string; message?: AgentChatItem }; +type AgentHelloEvent = { ok?: boolean; protocolVersion?: number; clientId?: string; workspace?: { activeThreadId?: string }; codex?: AgentCodexState; pendingApprovals?: AgentPendingApproval[] }; +type AgentWorkspaceEvent = { activeThreadId?: string; threadId?: string; sourceClientId?: string; emptyThread?: boolean; draftThread?: boolean }; +type AgentChatEvent = { threadId?: string; turnId?: string; sourceClientId?: string; replayed?: boolean; message?: AgentChatItem }; +type AgentClientGlobal = typeof globalThis & { __infiniteCanvasAgentClientIdPromise?: Promise }; + +function authoritativeHistoryTurnKeys(threadId: string, settledTurnIds: string[]) { + return new Set(settledTurnIds.map((turnId) => `${threadId}\0${turnId}`)); +} export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?: boolean; headless?: boolean; autoConnect?: boolean }) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; @@ -124,36 +136,125 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded? const connectedRef = useRef(false); const errorLoggedRef = useRef(false); const attachmentUrlsRef = useRef(new Set()); - const clientIdRef = useRef(randomId()); + const clientIdRef = useRef(""); + const [clientReady, setClientReady] = useState(false); const loadThreadsSequenceRef = useRef(0); - const resetThreadRef = useRef | null>(null); + const threadMessagesRef = useRef(new Map()); + const authoritativeHistoryTurnsRef = useRef(new Set()); + const liveTurnKeysRef = useRef(new Set()); + const threadOperationRef = useRef(0); + const threadOperationSequenceRef = useRef(0); const endpoint = useMemo(() => url.trim().replace(/\/$/, ""), [url]); const urlAgentAutoConnect = searchParams.has("agentUrl") && searchParams.has("agentToken"); - const loadThreads = useCallback(async (skipHistory = false) => { + useEffect(() => { + let disposed = false; + void acquireAgentClientId().then((clientId) => { + if (!disposed) { + clientIdRef.current = clientId; + setClientReady(true); + } + }); + return () => { disposed = true; }; + }, []); + const loadThreadSnapshot = useCallback(async (threadId: string, sequence: number, response?: AgentThreadResponse, expectedTurnId = "") => { + const storedMessagesPromise = readAgentUserMessages(threadId).catch(() => []); + let thread = response; + let lastError: unknown; + for (const delayMs of HISTORY_RETRY_DELAYS_MS) { + if (delayMs) await delay(delayMs); + if (sequence !== loadThreadsSequenceRef.current || useAgentStore.getState().activeThreadId !== threadId) return false; + try { + thread ||= await fetchAgentJson(endpoint, token, `/agent/codex/threads/${encodeURIComponent(threadId)}`); + lastError = undefined; + } catch (error) { + lastError = error; + thread = undefined; + continue; + } + const history = normalizeHistoryMessages(thread.messages || []); + const storedMessages = await storedMessagesPromise; + const latest = useAgentStore.getState(); + if (sequence !== loadThreadsSequenceRef.current || latest.activeThreadId !== threadId) return false; + const historyTurns = authoritativeHistoryTurnKeys(threadId, thread.settledTurnIds || []); + const hasExpectedTurn = !expectedTurnId || historyTurns.has(`${threadId}\0${expectedTurnId}`); + historyTurns.forEach((key) => liveTurnKeysRef.current.delete(key)); + if (latest.activeTurnId) liveTurnKeysRef.current.add(`${threadId}\0${latest.activeTurnId}`); + authoritativeHistoryTurnsRef.current = historyTurns; + const attachmentSources = [...latest.messages, ...storedMessages.map((item) => ({ ...item, threadId }))]; + const snapshot = mergeHistoryAttachments(history, attachmentSources); + const messages = mergeAgentMessages(snapshot, latest.messages, threadId, liveTurnKeysRef.current); + threadMessagesRef.current.set(threadId, messages); + setAgentState({ messages, connectError: "" }); + const coveredTurnIds = [...historyTurns].map((key) => key.slice(threadId.length + 1)); + if (coveredTurnIds.length) void acknowledgeCodexHistory(endpoint, token, threadId, coveredTurnIds).catch(() => undefined); + if (hasExpectedTurn && (thread.historyReady !== false || Boolean(expectedTurnId))) return true; + thread = undefined; + } + if (lastError) throw lastError; + return false; + }, [endpoint, setAgentState, token]); + const applyWorkspaceChange = useCallback((data: AgentWorkspaceEvent) => { + const nextThreadId = data.activeThreadId ?? data.threadId ?? ""; + const current = useAgentStore.getState(); + const threadChanged = current.activeThreadId !== nextThreadId; + const emptyThread = Boolean(data.emptyThread || data.draftThread); + const pendingMessage = [...current.messages].reverse().find((item) => item.role === "user" && !item.turnId); + const keepPendingMessage = Boolean( + data.emptyThread + && pendingMessage + && (current.sending || current.waiting) + && (!data.sourceClientId || data.sourceClientId === clientIdRef.current), + ); + if (threadChanged && current.activeThreadId) { + const messages = keepPendingMessage ? current.messages.filter((item) => item.id !== pendingMessage!.id) : current.messages; + threadMessagesRef.current.set(current.activeThreadId, messages); + } + if (emptyThread && nextThreadId) threadMessagesRef.current.delete(nextThreadId); + if (threadChanged || emptyThread) { + loadThreadsSequenceRef.current += 1; + authoritativeHistoryTurnsRef.current.clear(); + liveTurnKeysRef.current.clear(); + } + const messages = keepPendingMessage + ? [scopeChatItem(pendingMessage!, nextThreadId, "")] + : emptyThread ? [] + : threadChanged ? threadMessagesRef.current.get(nextThreadId) || [] + : current.messages; + pendingToolRef.current = null; + setAgentState({ + activeThreadId: nextThreadId, + activeTurnId: threadChanged || emptyThread ? "" : current.activeTurnId, + messages, + tokenUsage: threadChanged || emptyThread ? null : current.tokenUsage, + pendingTool: null, + pendingApprovals: threadChanged || emptyThread ? [] : current.pendingApprovals, + }); + return loadThreadsSequenceRef.current; + }, [setAgentState]); + const loadThreads = useCallback(async (skipHistory = false, expectedTurnId = "") => { if (!connectedRef.current && !useAgentStore.getState().connected) return; - const sequence = ++loadThreadsSequenceRef.current; + let sequence = ++loadThreadsSequenceRef.current; setAgentState({ loadingThreads: true }); try { - const currentThreadId = useAgentStore.getState().activeThreadId; - const currentThreadRequest = currentThreadId && !skipHistory ? fetchAgentJson(endpoint, token, `/agent/codex/threads/${encodeURIComponent(currentThreadId)}`).catch(() => null) : null; - const storedMessagesRequest = currentThreadId && !skipHistory ? readAgentUserMessages(currentThreadId) : null; const data = await fetchAgentJson(endpoint, token, `/agent/codex/threads`); - let nextMessages: AgentChatItem[] = []; - if (currentThreadId && !skipHistory) { - let thread = await currentThreadRequest; - thread ||= await fetchAgentJson(endpoint, token, `/agent/codex/threads/${encodeURIComponent(currentThreadId)}`); - const storedMessages = (await storedMessagesRequest) || []; - const currentMessages = useAgentStore.getState().messages; - nextMessages = mergeHistoryMessages(mergeHistoryAttachments(normalizeHistoryMessages(thread.messages || []), [...storedMessages, ...currentMessages]), currentMessages); - } if (sequence !== loadThreadsSequenceRef.current) return; - setAgentState({ threads: data.data || [], workspacePath: data.workspace?.workspacePath || "", ...(skipHistory ? {} : { messages: nextMessages }) }); + const current = useAgentStore.getState(); + const currentThreadId = data.workspace?.activeThreadId ?? current.activeThreadId; + if (currentThreadId !== current.activeThreadId) sequence = applyWorkspaceChange({ activeThreadId: currentThreadId }); + if (sequence !== loadThreadsSequenceRef.current || useAgentStore.getState().activeThreadId !== currentThreadId) return; + setAgentState({ threads: data.data || [], workspacePath: data.workspace?.workspacePath || "" }); + if (currentThreadId && !skipHistory) { + await loadThreadSnapshot(currentThreadId, sequence, undefined, expectedTurnId); + } else { + authoritativeHistoryTurnsRef.current.clear(); + liveTurnKeysRef.current.clear(); + } } catch (error) { addEventLog("读取历史失败", error); } finally { - if (sequence === loadThreadsSequenceRef.current) setAgentState({ loadingThreads: false }); + if (sequence === loadThreadsSequenceRef.current && !threadOperationRef.current) setAgentState({ loadingThreads: false }); } - }, [endpoint, setAgentState, token]); + }, [applyWorkspaceChange, endpoint, loadThreadSnapshot, setAgentState, token]); // canvasContext 命令式订阅:保持 ref 最新,并在快照变化时防抖上报,全程不触发面板重渲染。 useEffect(() => { let timer: ReturnType | null = null; @@ -178,20 +279,61 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded? useEffect(() => () => attachmentUrlsRef.current.forEach((url) => URL.revokeObjectURL(url)), []); useEffect(() => { - if (!enabled || !token.trim()) return; + if (!clientReady || !enabled || !token.trim()) return; localStorage.setItem("canvas-agent-url", endpoint); localStorage.setItem("canvas-agent-token", token); const clientId = clientIdRef.current; + let disposed = false; + let protocolRejected = false; let eventQueue = Promise.resolve(); + const isCurrentConnection = () => !disposed && clientIdRef.current === clientId; const enqueueEvent = (task: () => void | Promise) => { - eventQueue = eventQueue.then(task).catch((error) => addEventLog("同步会话失败", error)); + eventQueue = eventQueue.then(async () => { + if (isCurrentConnection()) await task(); + }).catch((error) => { + if (isCurrentConnection()) addEventLog("同步会话失败", error); + }); }; const source = new EventSource(`${endpoint}/events?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(clientId)}`); source.addEventListener("hello", (event) => { - const busy = Boolean(parseEventData(event)?.codex?.busy); + if (!isCurrentConnection()) return; + const hello = parseEventData(event); + if (hello?.protocolVersion !== AGENT_PROTOCOL_VERSION) { + const text = "本地 Agent 版本过旧,请重启 Canvas Agent 后重新连接"; + protocolRejected = true; + source.close(); + connectedRef.current = false; + setAgentState({ enabled: false, connected: false, waiting: false, sending: false, activity: "需要重启 Agent", connectError: text, silentConnect: false, pendingTool: null, pendingApprovals: [] }); + addEventLog("Agent 版本不匹配", text, hello); + if (!headless) message.error(text); + return; + } + const codex = hello?.codex; + const busy = Boolean(codex?.busy); + const nextThreadId = hello?.workspace?.activeThreadId ?? useAgentStore.getState().activeThreadId; + applyWorkspaceChange({ activeThreadId: nextThreadId }); + const current = useAgentStore.getState(); + const nextTurnId = codex?.threadId === nextThreadId ? codex.turnId ?? "" : ""; + if (nextTurnId) liveTurnKeysRef.current.add(`${nextThreadId}\0${nextTurnId}`); + const activeTurnId = busy ? nextTurnId : ""; + const pendingApprovals = busy ? (hello?.pendingApprovals || []).filter((item) => !item.threadId || item.threadId === nextThreadId) : []; + const messages = activeTurnId + ? bindPendingTurnMessages(current.messages.filter((item) => !isConnectionErrorMessage(item)), nextThreadId, activeTurnId) + : current.messages.filter((item) => !isConnectionErrorMessage(item)); errorLoggedRef.current = false; connectedRef.current = true; - setAgentState({ connected: true, activity: busy ? "Codex 正在运行" : "已连接", waiting: busy, sending: false, connectError: "", silentConnect: false, messages: useAgentStore.getState().messages.filter((item) => !isConnectionErrorMessage(item)) }); + setAgentState({ + connected: true, + activity: pendingApprovals.length ? "等待权限确认" : busy ? "Codex 正在运行" : "已连接", + waiting: busy, + sending: false, + connectError: "", + silentConnect: false, + activeThreadId: nextThreadId, + activeTurnId, + messages, + pendingApprovals, + }); if (!headless) message.success("本地 Agent 已连接"); void postState(endpoint, token, clientId, canvasContextRef.current?.snapshot || null); if (document.visibilityState === "visible" && document.hasFocus()) void activateAgentClient(endpoint, token, clientId); @@ -201,28 +343,53 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded? if (!data) return; enqueueEvent(async () => { const busy = Boolean(data.busy); - setAgentState({ activity: busy ? "Codex 正在运行" : "完成", waiting: busy, ...(busy ? {} : { sending: false }) }); - if (!busy) await loadThreads(); + const current = useAgentStore.getState(); + const appliesToCurrentThread = !data.threadId || data.threadId === current.activeThreadId; + if (!appliesToCurrentThread) return; + const turnId = data.turnId || current.activeTurnId; + if (turnId) liveTurnKeysRef.current.add(`${current.activeThreadId}\0${turnId}`); + const activeTurnId = busy ? turnId : ""; + const messages = activeTurnId ? bindPendingTurnMessages(current.messages, current.activeThreadId, activeTurnId) : current.messages; + setAgentState({ + activity: busy ? "Codex 正在运行" : current.activity === "处理失败" ? "处理失败" : "完成", + waiting: busy, + sending: false, + activeTurnId, + messages, + }); + if (!busy && current.waiting) void loadThreads(false, turnId); }); }); source.addEventListener("tool_call", (event) => { + if (!isCurrentConnection()) return; const data = parseEventData(event); if (data) void handleToolCall(endpoint, token, data); }); source.addEventListener("codex_approval", (event) => { + if (!isCurrentConnection()) return; const data = parseEventData(event); if (!data || !isCurrentThreadEvent(data)) return; setAgentState({ pendingApprovals: [...useAgentStore.getState().pendingApprovals.filter((item) => item.requestId !== data.requestId), data], activity: "等待权限确认" }); addEventLog("等待权限确认", data.reason || data.method, data); }); source.addEventListener("codex_approval_resolved", (event) => { - const data = parseEventData<{ requestId?: string }>(event); - if (data?.requestId) setAgentState({ pendingApprovals: useAgentStore.getState().pendingApprovals.filter((item) => item.requestId !== data.requestId) }); + if (!isCurrentConnection()) return; + const data = parseEventData<{ requestId?: string; decision?: "accept" | "acceptForSession" | "decline" | "cancel" }>(event); + if (!data?.requestId) return; + const current = useAgentStore.getState(); + const approval = current.pendingApprovals.find((item) => item.requestId === data.requestId); + const pendingApprovals = current.pendingApprovals.filter((item) => item.requestId !== data.requestId); + setAgentState({ pendingApprovals, activity: approvalActivity(pendingApprovals, current.waiting, current.activity) }); + const decision = data.decision || approval?.deciding; + if (approval && decision) addEventLog(decision === "accept" || decision === "acceptForSession" ? "已批准权限" : "已取消权限", approval.reason || approval.method, approval); }); source.addEventListener("agent_event", (event) => { const data = parseEventData(event); if (data) enqueueEvent(() => { - if (isCurrentThreadEvent(data)) handleAgentEvent(data); + if (!isCurrentThreadEvent(data)) return; + const shouldProcess = registerLiveAgentTurn(data, authoritativeHistoryTurnsRef.current, liveTurnKeysRef.current); + if (data.type !== "usage.updated" && !shouldProcess) return; + return handleAgentEvent(data); }); }); source.addEventListener("workspace_changed", (event) => { @@ -231,10 +398,14 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded? enqueueEvent(async () => { const nextThreadId = data.activeThreadId ?? data.threadId ?? ""; const current = useAgentStore.getState(); - const keepPendingMessage = Boolean(data.emptyThread && current.sending && current.messages.some((message) => message.role === "user")); - pendingToolRef.current = null; - setAgentState({ activeThreadId: nextThreadId, ...(keepPendingMessage ? {} : { messages: [] }), tokenUsage: null, pendingTool: null, pendingApprovals: [] }); - if (!data.draftThread) await loadThreads(Boolean(data.emptyThread)); + const pendingMessage = [...current.messages].reverse().find((item) => item.role === "user" && !item.turnId); + const keepPendingMessage = Boolean(data.emptyThread && pendingMessage && (current.sending || current.waiting) && (!data.sourceClientId || data.sourceClientId === clientIdRef.current)); + const pendingThreadId = pendingMessage?.threadId || current.activeThreadId; + if (keepPendingMessage && nextThreadId) { + await moveAgentUserMessage(pendingThreadId, nextThreadId, pendingMessage!.clientMessageId || pendingMessage!.itemId || pendingMessage!.id).catch(() => undefined); + } + applyWorkspaceChange(data); + if (!data.draftThread) void loadThreads(Boolean(data.emptyThread)); }); }); source.addEventListener("chat_message", (event) => { @@ -242,10 +413,35 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded? if (!data?.message) return; enqueueEvent(() => { if (!isCurrentThreadEvent(data)) return; - addMessage(data.message!); + if (!registerLiveAgentTurn(data, authoritativeHistoryTurnsRef.current, liveTurnKeysRef.current)) return; + const current = useAgentStore.getState(); + const threadId = data.threadId || data.message!.threadId || current.activeThreadId; + const turnId = data.turnId ?? data.message!.turnId ?? ""; + const clientMessageId = data.message!.clientMessageId || data.message!.itemId || data.message!.id; + if (current.activeThreadId !== threadId) return; + const pending = data.message!.role === "user" + ? [...current.messages].reverse().find((item) => item.role === "user" && item.threadId === threadId && !item.turnId && (!clientMessageId || item.clientMessageId === clientMessageId)) + : undefined; + const next = scopeChatItem({ + ...data.message!, + ...(pending ? { clientMessageId: pending.clientMessageId, text: pending.text, historyText: pending.historyText, attachments: pending.attachments } : {}), + }, threadId, turnId); + const currentMessages = pending && turnId ? current.messages.filter((item) => item.id !== pending.id) : current.messages; + const messages = upsertAgentMessage(currentMessages, next); + setAgentState({ messages }); + if (next.role === "user" && clientMessageId) void bindPendingAgentUserMessage(threadId, clientMessageId, turnId).catch(() => undefined); + if (next.role === "user" && !next.attachments?.length) { + void readAgentUserMessages(threadId).then((storedMessages) => { + const stored = storedMessages.find((item) => item.id === clientMessageId || Boolean(turnId && item.turnId === turnId)); + const latest = useAgentStore.getState(); + if (!stored || latest.activeThreadId !== threadId || !latest.messages.some((item) => item.id === next.id)) return; + setAgentState({ messages: upsertAgentMessage(latest.messages, { ...next, text: stored.text, historyText: stored.historyText, attachments: stored.attachments }) }); + }).catch(() => undefined); + } }); }); source.addEventListener("agent_log", (event) => { + if (!isCurrentConnection()) return; const text = parseEventData<{ text?: unknown }>(event)?.text; addEventLog("日志", text, text); }); @@ -254,10 +450,12 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded? if (!data) return; enqueueEvent(() => { if (!isCurrentThreadEvent(data)) return; - showAgentError(data.message, data.turn_id); + if (!registerLiveAgentTurn(data, authoritativeHistoryTurnsRef.current, liveTurnKeysRef.current)) return; + showAgentError(data.message, data, !data.replayed); }); }); source.onerror = () => { + if (disposed || protocolRejected) return; const wasConnected = connectedRef.current; const silent = useAgentStore.getState().silentConnect && !wasConnected; const text = wasConnected ? "本地 Agent 连接失败或已断开" : "连接失败,请检查地址和 token"; @@ -267,18 +465,29 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded? } errorLoggedRef.current = true; connectedRef.current = false; - clearAgentSession({ activity: wasConnected ? "连接断开" : "连接失败", connected: false, connectError: silent ? "" : text, silentConnect: false }); + pendingToolRef.current = null; + setAgentState({ + activity: wasConnected ? "连接断开" : "连接失败", + connected: false, + waiting: false, + sending: false, + connectError: silent ? "" : text, + silentConnect: false, + pendingTool: null, + pendingApprovals: [], + }); if (!wasConnected) { source.close(); setAgentState({ enabled: false }); } }; return () => { + disposed = true; source.close(); connectedRef.current = false; loadThreadsSequenceRef.current += 1; }; - }, [enabled, endpoint, loadThreads, message, setAgentState, token]); + }, [applyWorkspaceChange, clientReady, enabled, endpoint, loadThreads, message, setAgentState, token]); useEffect(() => { if (connected) void loadThreads(); @@ -325,29 +534,26 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded? const text = prompt.trim(); const files = attachments; const requestPrompt = promptWithAttachments(text, files); - if (!connected || !requestPrompt || sending || waiting) return; + const currentState = useAgentStore.getState(); + if (!currentState.connected || !requestPrompt || currentState.sending || currentState.waiting || currentState.loadingThreads) return; if (attachmentPayloadBytes(files) > MAX_ATTACHMENT_PAYLOAD_BYTES) { addMessage({ role: "error", title: "图片过大", text: "图片附件超过 30MB,请删减后再发送。" }); return; } const messageId = createId(); const userText = text || `发送了 ${files.length} 张图片`; - setAgentState({ prompt: "", attachments: [], activity: "发送中", sending: true }); - addMessage({ id: messageId, role: "user", text: userText, historyText: requestPrompt, attachments: files }); - let threadId = useAgentStore.getState().activeThreadId; + loadThreadsSequenceRef.current += 1; + const currentBeforeSend = useAgentStore.getState(); + const requestThreadId = currentBeforeSend.activeThreadId; + setAgentState({ prompt: "", attachments: [], activity: "发送中", sending: true, loadingThreads: false, activeTurnId: "", messages: currentBeforeSend.messages }); + addMessage({ id: messageId, itemId: "synthetic:user", clientMessageId: messageId, threadId: requestThreadId, turnId: "", role: "user", text: userText, historyText: requestPrompt, attachments: files }); + let threadId = requestThreadId; try { - await resetThreadRef.current; - if (!threadId) { - const created = await fetchAgentJson(endpoint, token, "/agent/codex/threads/new", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ permissionMode }) }); - threadId = created.thread?.id || created.workspace?.activeThreadId || ""; - if (!threadId) throw new Error("新建对话失败"); - setAgentState({ activeThreadId: threadId, tokenUsage: null }); - } - if (files.length) void saveAgentUserMessage(threadId, { id: messageId, role: "user", text: userText, historyText: requestPrompt, attachments: files }).catch(() => undefined); + if (files.length) await savePendingAgentUserMessage({ id: messageId, role: "user", text: userText, historyText: requestPrompt, attachments: files }); const modelName = models.find((item) => item.model === model)?.displayName || model || "默认模型"; const effortName = reasoningEffort ? AGENT_REASONING_LABELS[reasoningEffort] : "默认强度"; addEventLog("发送任务", `${modelName} · ${effortName}${files.length ? ` · 附件 ${files.length}` : ""} · ${compactText(text) || "仅附件"}`); - const data = await fetchAgentJson<{ threadId?: string }>(endpoint, token, "/agent/codex/turn", { + const accepted = await fetchAgentJson(endpoint, token, "/agent/codex/turn", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ @@ -362,24 +568,40 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded? attachments: files.map(({ id, name, type, size, width, height, dataUrl }) => ({ id, name, type, size, width, height, dataUrl })), }), }); - if (data.threadId) setAgentState({ activeThreadId: data.threadId }); + threadId = accepted.threadId || threadId; + if (!threadId) throw new Error("启动对话失败"); + if (files.length) { + const latestMessage = useAgentStore.getState().messages.find((item) => item.clientMessageId === messageId); + const acceptedThreadId = latestMessage?.threadId || threadId; + await bindPendingAgentUserMessage(acceptedThreadId, messageId, latestMessage?.turnId || "").catch((error) => addEventLog("保存附件历史失败", error)); + } files.forEach((item) => { URL.revokeObjectURL(item.url); attachmentUrlsRef.current.delete(item.url); }); - setAgentState({ sending: false, waiting: true, activity: "Codex 正在运行" }); } catch (error) { + if (files.length) await deletePendingAgentUserMessage(messageId).catch(() => undefined); const text = error instanceof Error ? error.message : "发送失败"; const busy = text.includes("Codex 正在运行"); const state = useAgentStore.getState(); - setAgentState({ - activity: busy ? "Codex 正在运行" : "发送失败", - ...(state.prompt || state.attachments.length ? {} : { prompt, attachments: files }), + const removeFailedPending = (messages: AgentChatItem[]) => messages.filter((item) => item.clientMessageId !== messageId || Boolean(item.turnId)); + threadMessagesRef.current.forEach((messages, cachedThreadId) => { + const next = removeFailedPending(messages); + if (next.length !== messages.length) threadMessagesRef.current.set(cachedThreadId, next); }); - addMessage({ role: "error", title: busy ? "任务仍在运行" : "发送失败", text }); + const ownsCurrentThread = state.activeThreadId === (threadId || requestThreadId); + if (ownsCurrentThread) { + setAgentState({ + activity: busy ? "Codex 正在运行" : "发送失败", + sending: false, + messages: removeFailedPending(state.messages), + ...(state.prompt || state.attachments.length ? {} : { prompt, attachments: files }), + }); + addMessage({ threadId: state.activeThreadId, turnId: "", role: "error", title: busy ? "任务仍在运行" : "发送失败", text }); + } else { + setAgentState({ sending: false, messages: removeFailedPending(state.messages) }); + } addEventLog("发送失败", error); - } finally { - setAgentState({ sending: false }); } }; @@ -453,10 +675,8 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded? const result = await runSiteTool(payload.name, payload.input || {}, navigate, { canvasSnapshot: canvasContextRef.current?.snapshot || null }); await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, result }); addEventLog(`${toolName(payload.name)}完成`, result, result); - addMessage({ role: "tool", title: toolName(payload.name), text: siteToolSummary(payload.name, result), detail: toolCallDetail(payload.name, payload.input, "completed") }); } catch (error) { const message = error instanceof Error ? error.message : "工具执行失败"; - addMessage({ role: "tool", title: toolName(payload.name), text: message, detail: toolCallDetail(payload.name, payload.input, "failed", message) }); await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, error: message }); } return; @@ -488,15 +708,8 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded? } await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, result }); addEventLog(`${toolName(payload.name)}完成`, result, result); - addMessage({ - role: "tool", - title: toolName(payload.name), - text: appliedOps.length ? summarizeCanvasAgentOps(appliedOps) || "已完成画布操作" : payload.name === "site_navigate" ? `已打开${routeName(input.path || "/")}` : "已完成", - detail: toolCallDetail(payload.name, input, "completed"), - }); } catch (error) { const message = error instanceof Error ? error.message : "画布操作失败"; - addMessage({ role: "tool", title: toolName(payload.name), text: message, detail: toolCallDetail(payload.name, payload.input, "failed", message) }); await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, error: message }); } }; @@ -504,7 +717,6 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded? const rejectPendingTool = async () => { if (!pendingTool) return; await postToolResult(endpoint, token, clientIdRef.current, { requestId: pendingTool.requestId, error: "用户取消了画布工具调用" }); - addMessage({ role: "tool", title: toolName(pendingTool.name), text: "用户已取消本次操作", detail: toolCallDetail(pendingTool.name, pendingTool.input, "declined") }); pendingToolRef.current = null; setAgentState({ pendingTool: null }); }; @@ -518,11 +730,23 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded? }; const decideApproval = async (approval: AgentPendingApproval, decision: "accept" | "acceptForSession" | "decline") => { - setAgentState({ pendingApprovals: useAgentStore.getState().pendingApprovals.filter((item) => item.requestId !== approval.requestId), activity: decision === "decline" ? "已拒绝权限请求" : "Codex 正在运行" }); + const current = useAgentStore.getState(); + const pending = current.pendingApprovals.find((item) => item.requestId === approval.requestId); + if (!pending || pending.deciding) return; + setAgentState({ pendingApprovals: current.pendingApprovals.map((item) => item.requestId === approval.requestId ? { ...item, deciding: decision } : item), activity: "正在提交权限决定" }); try { await postCodexApproval(endpoint, token, approval.requestId, decision); - addEventLog(decision === "decline" ? "已拒绝权限" : "已批准权限", approval.reason || approval.method, approval); + const latest = useAgentStore.getState(); + if (latest.pendingApprovals.some((item) => item.requestId === approval.requestId)) setAgentState({ activity: "等待 Codex 确认权限" }); } catch (error) { + const latest = useAgentStore.getState(); + const expired = error instanceof Error && error.message.includes("审批请求已失效"); + const resolved = expired || !latest.pendingApprovals.some((item) => item.requestId === approval.requestId); + const pendingApprovals = resolved + ? latest.pendingApprovals.filter((item) => item.requestId !== approval.requestId) + : latest.pendingApprovals.map((item) => item.requestId === approval.requestId ? { ...item, deciding: undefined } : item); + setAgentState({ pendingApprovals, activity: approvalActivity(pendingApprovals, latest.waiting, latest.activity) }); + if (resolved) return; addEventLog("权限审批失败", error); message.error(error instanceof Error ? error.message : "权限审批失败"); } @@ -590,18 +814,23 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded? }, [confirmTools, setAgentState, urlAgentAutoConnect]); useEffect(() => { - if (!autoConnect || autoConnectRef.current || enabled || connected) return; + if ((!autoConnect && !urlAgentAutoConnect) || autoConnectRef.current || enabled || connected) return; autoConnectRef.current = true; void toggleAgentConnection({ silent: true }); - }, [autoConnect, connected, enabled]); + }, [autoConnect, connected, enabled, urlAgentAutoConnect]); function clearAgentSession(patch: Parameters[0] = {}) { loadThreadsSequenceRef.current += 1; + threadMessagesRef.current.clear(); + authoritativeHistoryTurnsRef.current.clear(); + liveTurnKeysRef.current.clear(); + threadOperationRef.current = 0; setAgentState({ messages: [], tokenUsage: null, threads: [], activeThreadId: "", + activeTurnId: "", workspacePath: "", loadingThreads: false, waiting: false, @@ -613,60 +842,78 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded? pendingToolRef.current = null; } - const startNewThread = () => { - if (!connected || sending || waiting) return; - setAgentState({ activeThreadId: "", messages: [], tokenUsage: null, activeTab: "chat", activity: "新对话", pendingTool: null, pendingApprovals: [] }); - pendingToolRef.current = null; - const request = fetchAgentJson(endpoint, token, "/agent/codex/threads/reset", { method: "POST" }).catch((error) => { + const beginThreadOperation = () => { + const operation = ++threadOperationSequenceRef.current; + threadOperationRef.current = operation; + setAgentState({ loadingThreads: true }); + return operation; + }; + + const finishThreadOperation = (operation: number) => { + if (threadOperationRef.current !== operation) return; + threadOperationRef.current = 0; + setAgentState({ loadingThreads: false }); + }; + + const startNewThread = async () => { + const current = useAgentStore.getState(); + if (!current.connected || current.sending || current.waiting || current.loadingThreads) return; + const operation = beginThreadOperation(); + setAgentState({ activeTab: "chat", activity: "正在新建对话" }); + try { + const result = await fetchAgentJson(endpoint, token, "/agent/codex/threads/reset", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ clientId: clientIdRef.current }) }); + if (threadOperationRef.current !== operation) return; + const latest = useAgentStore.getState(); + if (latest.activeThreadId || latest.messages.length) applyWorkspaceChange({ activeThreadId: result.workspace?.activeThreadId || "", emptyThread: true, draftThread: true, sourceClientId: clientIdRef.current }); + setAgentState({ activeTab: "chat", activity: "新对话" }); + } catch (error) { addEventLog("新建对话失败", error); message.error(error instanceof Error ? error.message : "新建对话失败"); - throw error; - }); - resetThreadRef.current = request; - void request.finally(() => { - if (resetThreadRef.current === request) resetThreadRef.current = null; - }).catch(() => undefined); + await loadThreads(); + } finally { + finishThreadOperation(operation); + } }; const resumeThread = async (threadId: string) => { - if (!connected || !threadId || sending || waiting) return; - setAgentState({ loadingThreads: true }); + const current = useAgentStore.getState(); + if (!current.connected || !threadId || current.sending || current.waiting || current.loadingThreads) return; + const operation = beginThreadOperation(); try { - const current = useAgentStore.getState(); - const [data, storedMessages] = await Promise.all([ - fetchAgentJson(endpoint, token, `/agent/codex/threads/${encodeURIComponent(threadId)}/resume`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ permissionMode }) }), - readAgentUserMessages(threadId), - ]); - const localMessages = current.activeThreadId === threadId ? current.messages : []; - setAgentState({ activeThreadId: data.thread?.id || threadId, messages: mergeHistoryAttachments(normalizeHistoryMessages(data.messages || []), [...storedMessages, ...localMessages]), tokenUsage: null, activeTab: "chat", activity: "已恢复会话" }); + await fetchAgentJson(endpoint, token, `/agent/codex/threads/${encodeURIComponent(threadId)}/resume`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ permissionMode, clientId: clientIdRef.current }) }); + await loadThreads(); + if (useAgentStore.getState().activeThreadId === threadId) setAgentState({ activeTab: "chat", activity: "已恢复会话" }); } catch (error) { addEventLog("恢复对话失败", error); message.error(error instanceof Error ? error.message : "恢复对话失败"); + await loadThreads(); } finally { - setAgentState({ loadingThreads: false }); + finishThreadOperation(operation); } }; const deleteThreads = async (threadIds: string[]) => { - if (!connected || !threadIds.length || sending || waiting) return; - setAgentState({ loadingThreads: true }); + if (!connected || !threadIds.length || sending || waiting || loadingThreads) return; + const operation = beginThreadOperation(); + const deletedThreadIds: string[] = []; try { - await Promise.all(threadIds.map((threadId) => fetchAgentJson(endpoint, token, `/agent/codex/threads/${encodeURIComponent(threadId)}/delete`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({}) }))); - void deleteAgentThreadMessages(threadIds).catch(() => undefined); - const current = useAgentStore.getState(); - const deleted = new Set(threadIds); - setAgentState({ - threads: current.threads.filter((thread) => !deleted.has(thread.id)), - activeThreadId: deleted.has(current.activeThreadId) ? "" : current.activeThreadId, - messages: deleted.has(current.activeThreadId) ? [] : current.messages, - tokenUsage: deleted.has(current.activeThreadId) ? null : current.tokenUsage, - }); - message.success(`已删除 ${threadIds.length} 条记录`); + for (const threadId of new Set(threadIds)) { + await fetchAgentJson(endpoint, token, `/agent/codex/threads/${encodeURIComponent(threadId)}/delete`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ clientId: clientIdRef.current }) }); + threadMessagesRef.current.delete(threadId); + deletedThreadIds.push(threadId); + } + void deleteAgentThreadMessages(deletedThreadIds).catch(() => undefined); + await loadThreads(); + message.success(`已删除 ${deletedThreadIds.length} 条记录`); } catch (error) { + if (deletedThreadIds.length) { + void deleteAgentThreadMessages(deletedThreadIds).catch(() => undefined); + } + await loadThreads(); addEventLog("删除对话失败", error); message.error(error instanceof Error ? error.message : "删除对话失败"); } finally { - setAgentState({ loadingThreads: false }); + finishThreadOperation(operation); } }; @@ -684,24 +931,10 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded? const addMessage = (item: Omit & { id?: string }) => { const text = normalizeText(item.text); if (!text && !item.attachments?.length) return; - const next = { ...item, id: item.id || `${Date.now()}-${Math.random()}`, text } as AgentChatItem; - const currentMessages = useAgentStore.getState().messages; - if (currentMessages.some((message) => message.id === next.id)) return; - if (next.streamId) { - const index = currentMessages.findIndex((message) => message.streamId === next.streamId); - if (index >= 0) { - setAgentState({ messages: currentMessages.map((message, i) => (i === index ? { ...message, ...next, id: message.id, text: next.text || message.text } : message)) }); - return; - } - } - const last = currentMessages.at(-1); - if (last?.role === "assistant" && next.role === "assistant" && last.title === next.title) { - const merged = mergeAgentText(last.text, next.text); - if (merged === last.text) return; - setAgentState({ messages: [...useAgentStore.getState().messages.slice(0, -1), { ...last, text: merged, meta: next.meta || last.meta }] }); - return; - } - pushMessage(next); + const current = useAgentStore.getState(); + const itemId = item.itemId || item.id || createId(); + const next = scopeChatItem({ ...item, id: item.id || itemId, itemId, text } as AgentChatItem, item.threadId ?? current.activeThreadId, item.turnId ?? current.activeTurnId); + setAgentState({ messages: upsertAgentMessage(current.messages, next) }); }; const addEventLog = (title: string, text: unknown, raw?: unknown) => { @@ -712,91 +945,153 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded? }; const upsertActivityMessage = (item: AgentChatItem) => { - const currentMessages = useAgentStore.getState().messages; - const index = currentMessages.findIndex((message) => message.id === item.id); - if (index < 0) { - pushMessage(item); - return; - } - setAgentState({ - messages: currentMessages.map((message, i) => { - if (i !== index) return message; - const preserveReasoning = item.title === "思考摘要" && item.text === "已完成分析" && Boolean(message.text.trim()) && message.text !== activityPlaceholder("reasoning"); - return { ...message, ...item, ...(preserveReasoning ? { text: message.text } : {}) }; - }), - }); + setAgentState({ messages: upsertAgentMessage(useAgentStore.getState().messages, item) }); }; - const appendActivityDelta = (item: AgentEventItem) => { - if (!item.id) return; - const delta = stringText(item.delta); - if (!delta) return; + const appendActivityDelta = (event: AgentEventPayload) => { + const item = event.item; + if (!item?.id) return; + const text = stringText(item.text) || stringText(item.delta); + const isDelta = Boolean(stringText(item.delta)); + if (!text) return; + if (item.type === "reasoning") { + const scoped = scopeEventChatItem(event, activityDeltaFallback(item, text), "synthetic:reasoning"); + const current = useAgentStore.getState().messages.find((message) => message.id === scoped.id); + const activityItems = { ...(current?.activityItems || {}) }; + const previous = activityItems[item.id] || ""; + activityItems[item.id] = isDelta ? `${previous === activityPlaceholder("reasoning") ? "" : previous}${text}` : text; + upsertActivityMessage({ ...scoped, title: "思考摘要", text: reasoningActivityText(activityItems), activityItems, detail: activityDetail(current?.detail || scoped.detail, "reasoning", "inProgress") }); + return; + } + const scoped = scopeEventChatItem(event, activityDeltaFallback(item, text), item.id); const currentMessages = useAgentStore.getState().messages; - const index = currentMessages.findIndex((message) => message.id === item.id); + const index = currentMessages.findIndex((message) => message.id === scoped.id); if (index < 0) { - if (!delta.trim()) return; - upsertActivityMessage(activityDeltaFallback(item, delta)); + if (!text.trim()) return; + upsertActivityMessage(scoped); return; } const current = currentMessages[index]; if (item.type === "command_execution") { const detail = activityDetail(current.detail, "command", "inProgress"); - detail.output = `${detail.output || ""}${delta}`; - setAgentState({ messages: currentMessages.map((message, i) => (i === index ? { ...message, detail } : message)) }); + detail.output = isDelta ? `${stringText(detail.output)}${text}` : text; + setAgentState({ messages: currentMessages.map((message, itemIndex) => itemIndex === index ? { ...message, detail } : message) }); return; } const placeholder = activityPlaceholder(item.type); - if (!delta.trim() && current.text === placeholder) return; - const text = current.text === placeholder ? delta : `${current.text}${delta}`; - setAgentState({ messages: currentMessages.map((message, i) => (i === index ? { ...message, text, detail: { ...activityDetail(message.detail, activityKind(item.type), "inProgress") } } : message)) }); + if (!text.trim() && current.text === placeholder) return; + const nextText = isDelta ? `${current.text === placeholder ? "" : current.text}${text}` : mergeStreamText(current.text, text); + setAgentState({ messages: currentMessages.map((message, itemIndex) => itemIndex === index ? { ...message, text: nextText, detail: { ...activityDetail(message.detail, activityKind(item.type), "inProgress") } } : message) }); }; - const finishPlanActivity = (turnId: string, status?: string) => { - const id = `plan-${turnId}`; + const upsertEventActivity = (event: AgentEventPayload, item: Omit) => { + const itemId = event.item?.id; + if (!itemId) return; + if (event.item?.type === "reasoning") { + const scoped = scopeEventChatItem(event, { ...item, id: "synthetic:reasoning" }, "synthetic:reasoning"); + const current = useAgentStore.getState().messages.find((message) => message.id === scoped.id); + const activityItems = { ...(current?.activityItems || {}) }; + const previous = activityItems[itemId] || ""; + const incoming = normalizeText(item.text); + activityItems[itemId] = incoming === "已完成分析" && previous && previous !== activityPlaceholder("reasoning") ? previous : incoming; + upsertActivityMessage({ ...scoped, title: "思考摘要", text: reasoningActivityText(activityItems, incoming), activityItems }); + return; + } + upsertActivityMessage(scopeEventChatItem(event, { ...item, id: itemId }, itemId)); + }; + + const finishEmptyReasoningActivity = (event: AgentEventPayload) => { + const itemId = event.item?.id; + if (!itemId) return; + const scopedId = scopeEventChatItem(event, { id: "synthetic:reasoning", role: "tool", text: "" }, "synthetic:reasoning").id; + const currentMessages = useAgentStore.getState().messages; + const index = currentMessages.findIndex((message) => message.id === scopedId); + if (index < 0) return; + const current = currentMessages[index]; + const activityItems = { ...(current.activityItems || {}) }; + delete activityItems[itemId]; + if (!Object.values(activityItems).some(isReasoningSummary)) { + setAgentState({ messages: currentMessages.filter((_, itemIndex) => itemIndex !== index) }); + return; + } + setAgentState({ messages: currentMessages.map((message, itemIndex) => itemIndex === index ? { ...message, text: reasoningActivityText(activityItems), activityItems, detail: activityDetail(message.detail, "reasoning", "completed") } : message) }); + }; + + const finishPlanActivity = (event: AgentEventPayload) => { + const id = scopeEventChatItem(event, { id: "synthetic:plan", role: "tool", text: "" }, "synthetic:plan").id; const currentMessages = useAgentStore.getState().messages; const index = currentMessages.findIndex((message) => message.id === id); if (index < 0) return; const current = currentMessages[index]; - const detail = activityDetail(current.detail, "todo", turnPlanStatus(current.detail, status)); - setAgentState({ messages: currentMessages.map((message, i) => (i === index ? { ...message, detail } : message)) }); + const detail = activityDetail(current.detail, "todo", turnPlanStatus(current.detail, event.status)); + setAgentState({ messages: currentMessages.map((message, itemIndex) => itemIndex === index ? { ...message, detail } : message) }); }; - const showAgentError = (value: unknown, turnId?: string) => { + const showAgentError = (value: unknown, event?: AgentEventPayload, log = true) => { const error = agentErrorView(value); - const item = { id: turnId ? `error-${turnId}` : createId(), role: "error" as const, title: error.title, text: error.text }; + const item = event + ? scopeEventChatItem(event, { id: "synthetic:error", role: "error", title: error.title, text: error.text }, "synthetic:error") + : scopeChatItem({ id: createId(), role: "error", title: error.title, text: error.text }, useAgentStore.getState().activeThreadId, useAgentStore.getState().activeTurnId); + const state = useAgentStore.getState(); + const current = state.messages.find((message) => message.id === item.id); + if (current && !normalizeText(value)) return; upsertActivityMessage(item); - setAgentState({ activity: "处理失败", waiting: false, sending: false }); - addEventLog("处理失败", error.text, value); + setAgentState({ activity: "处理失败", pendingApprovals: [] }); + if (log) addEventLog("处理失败", error.text, value); }; const handleAgentEvent = async (event: AgentEventPayload) => { if (event.type === "usage.updated") setAgentState({ tokenUsage: eventUsage(event) }); - const log = formatAgentEventLog(event); + const log = event.replayed ? null : formatAgentEventLog(event); + const activity = formatAgentActivity(event); if (log) addEventLog(log.title, log.text); - if (event.type === "thread.started" && event.thread_id) setAgentState({ activeThreadId: event.thread_id }); + if (event.type === "turn.started" && (event.turnId || event.turn_id)) { + const scope = eventScope(event); + const current = useAgentStore.getState(); + if (!scope.threadId || !scope.turnId) return; + liveTurnKeysRef.current.add(`${scope.threadId}\0${scope.turnId}`); + setAgentState({ activeTurnId: scope.turnId, messages: bindPendingTurnMessages(current.messages, scope.threadId, scope.turnId) }); + } if (event.type === "item.updated" && event.item?.type === "agent_message" && event.item.id) { - appendStreamDelta(event.item.id, stringText(event.item.delta)); + const delta = stringText(event.item.delta); + appendStreamText(event, delta || stringText(event.item.text), Boolean(delta)); return; } if (event.type === "item.updated" && event.item) { - appendActivityDelta(event.item); + appendActivityDelta(event); return; } if (event.type === "plan.updated" && event.turn_id) { const plan = formatAgentPlan(event); - if (plan) upsertActivityMessage({ ...plan, id: `plan-${event.turn_id}` }); + if (plan) upsertActivityMessage(scopeEventChatItem(event, { ...plan, id: "synthetic:plan" }, "synthetic:plan")); + return; + } + if (event.type === "item.completed" && event.item?.type === "error") { + showAgentError(event.item.message, event, !event.replayed); return; } if (event.type === "item.completed" && event.item?.type === "agent_message" && event.item.id) { + const scoped = scopeEventChatItem(event, { id: event.item.id, role: "assistant", title: "Codex", text: stringText(event.item.text) }, event.item.id); const currentMessages = useAgentStore.getState().messages; - const index = currentMessages.findIndex((message) => message.streamId === event.item?.id); + const index = currentMessages.findIndex((message) => message.id === scoped.id); if (index >= 0) { const text = stringText(event.item.text); - setAgentState({ messages: currentMessages.map((message, i) => (i === index ? { ...message, text: text || message.text, streamId: undefined } : message)) }); + setAgentState({ messages: currentMessages.map((message, itemIndex) => itemIndex === index ? { ...message, text: text || message.text, streamId: undefined } : message) }); return; } + addMessage(scoped); + return; } - if (event.type === "item.completed" && event.item?.type === "image_generation" && event.item.id) { + if (event.type === "item.completed" && event.item?.type === "reasoning" && !activity) { + finishEmptyReasoningActivity(event); + return; + } + if (event.type === "item.completed" && !activity && event.item?.id && event.item.type === "plan") { + const id = scopeEventChatItem(event, { id: event.item.id, role: "tool", text: "" }, event.item.id).id; + setAgentState({ messages: useAgentStore.getState().messages.filter((item) => item.id !== id) }); + return; + } + if (!event.replayed && event.type === "item.completed" && event.item?.type === "image_generation" && event.item.id && event.sourceClientId === clientIdRef.current) { const generated = await importGeneratedImages(endpoint, token, event.item); if (generated.length) { const context = canvasContextRef.current; @@ -817,33 +1112,42 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded? const result = context.applyOps(ops); void postState(endpoint, token, clientIdRef.current, result); } - addMessage({ id: `generated-${event.item.id}`, role: "assistant", text: context ? "已将生成图片添加到当前画布。" : "图片已生成。", attachments: generated.map((image) => image.attachment) }); - return; + addEventLog("导入生成图片", context ? "已添加到发起任务的画布" : "图片已生成"); } } - const activity = formatAgentActivity(event); if (activity && event.item?.id) { - upsertActivityMessage({ ...activity, id: event.item.id }); + upsertEventActivity(event, activity); return; } if (event.type === "turn.completed") { - if (event.turn_id) finishPlanActivity(event.turn_id, event.status); - setAgentState({ messages: useAgentStore.getState().messages.map((message) => (message.streamId ? { ...message, streamId: undefined } : message)) }); - if (event.status === "failed") showAgentError(event.error?.message, event.turn_id); + const scope = eventScope(event); + if (scope.turnId) { + finishPlanActivity(event); + liveTurnKeysRef.current.add(`${scope.threadId}\0${scope.turnId}`); + } + const current = useAgentStore.getState(); + setAgentState({ + activeTurnId: current.activeTurnId === scope.turnId ? "" : current.activeTurnId, + messages: current.messages.map((message) => message.threadId === scope.threadId && message.turnId === scope.turnId && message.streamId ? { ...message, streamId: undefined } : message), + }); + if (event.status === "failed") showAgentError(event.error?.message, event, !event.replayed); } const item = formatAgentEvent(event); - if (item) addMessage(item); + if (item) addMessage(scopeEventChatItem(event, { ...item, id: event.item?.id || createId() }, event.item?.id || createId())); }; - const appendStreamDelta = (streamId: string, delta: string) => { - if (!delta) return; + const appendStreamText = (event: AgentEventPayload, text: string, isDelta = false) => { + if (!text) return; + const itemId = event.item?.id; + if (!itemId) return; + const scoped = scopeEventChatItem(event, { id: itemId, role: "assistant", title: "Codex", text, streamId: itemId }, itemId); const currentMessages = useAgentStore.getState().messages; - const index = currentMessages.findIndex((message) => message.streamId === streamId); + const index = currentMessages.findIndex((message) => message.id === scoped.id); if (index < 0) { - pushMessage({ id: `stream-${streamId}`, role: "assistant", title: "Codex", text: delta, streamId }); + pushMessage(scoped); return; } - setAgentState({ messages: currentMessages.map((message, i) => (i === index ? { ...message, text: `${message.text}${delta}` } : message)) }); + setAgentState({ messages: currentMessages.map((message, itemIndex) => itemIndex === index ? { ...message, text: isDelta ? `${message.text}${text}` : mergeStreamText(message.text, text) } : message) }); }; const content = ( @@ -970,6 +1274,70 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded? return embedded ? content : null; } +function acquireAgentClientId() { + const scope = globalThis as AgentClientGlobal; + scope.__infiniteCanvasAgentClientIdPromise ||= (async () => { + const storedClientId = readAgentClientId(); + let clientId = storedClientId || randomId(); + if (!navigator.locks) { + if (!storedClientId) saveAgentClientId(clientId); + return clientId; + } + while (true) { + const acquired = await new Promise((resolve, reject) => { + void navigator.locks.request(`infinite-canvas-agent:${clientId}`, { ifAvailable: true }, async (lock) => { + if (!lock) return resolve(false); + resolve(true); + await new Promise(() => undefined); + }).catch(reject); + }); + if (acquired) { + saveAgentClientId(clientId); + return clientId; + } + clientId = randomId(); + } + })().catch(() => { + const clientId = randomId(); + saveAgentClientId(clientId); + return clientId; + }); + return scope.__infiniteCanvasAgentClientIdPromise; +} + +function readAgentClientId() { + try { + return sessionStorage.getItem("canvas-agent-client-id") || ""; + } catch { + return ""; + } +} + +function saveAgentClientId(clientId: string) { + try { + sessionStorage.setItem("canvas-agent-client-id", clientId); + } catch { + // 内存身份仍可保证当前页面会话内的请求归属一致。 + } +} + +function eventScope(event: AgentEventPayload) { + return { + threadId: event.threadId || event.thread_id || "", + turnId: event.turnId || event.turn_id || "", + }; +} + +function scopeEventChatItem(event: AgentEventPayload, item: AgentChatItem, itemId: string) { + const scope = eventScope(event); + return scopeChatItem({ ...item, itemId }, scope.threadId, scope.turnId); +} + +function approvalActivity(pendingApprovals: AgentPendingApproval[], waiting: boolean, fallback: string) { + if (pendingApprovals.length) return "等待权限确认"; + return waiting ? "Codex 正在运行" : fallback; +} + async function attachmentNodeOps(endpoint: string, token: string, clientId: string, value: unknown): Promise { const nodes = Array.isArray(value) ? value : []; if (!nodes.length) throw new Error("没有可添加的图片附件"); @@ -1044,3 +1412,7 @@ function readDataUrl(file: Blob) { reader.readAsDataURL(file); }); } + +function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index d28d18a..6506029 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -157,7 +157,6 @@ function InfiniteCanvasPage() { const agentPanelOpen = useAgentStore((state) => state.panelOpen); const toggleAgentPanel = useAgentStore((state) => state.togglePanel); const openAgentPanel = useAgentStore((state) => state.openPanel); - const setAgentState = useAgentStore((state) => state.setAgentState); const containerRef = useRef(null); const imageInputRef = useRef(null); const uploadTargetRef = useRef<{ nodeId?: string; position?: Position } | null>(null); @@ -353,11 +352,6 @@ function InfiniteCanvasPage() { void restore(); }, [hydrated, navigate, openProject, projectId]); - useEffect(() => { - if (!projectLoaded) return; - setAgentState({ activeThreadId: "", messages: [], tokenUsage: null, pendingTool: null }); - }, [projectId, projectLoaded, setAgentState]); - useEffect(() => { if (!projectLoaded || !["new", "recent", "choose"].includes(searchParams.get("mode") || "")) return; if (!searchParams.has("agentUrl")) openAgentPanel(); diff --git a/web/src/services/agent-chat-storage.ts b/web/src/services/agent-chat-storage.ts index 357c24b..827b8bb 100644 --- a/web/src/services/agent-chat-storage.ts +++ b/web/src/services/agent-chat-storage.ts @@ -3,33 +3,127 @@ import localforage from "localforage"; import { upscaleDataUrl } from "@/lib/canvas/canvas-image-data"; import type { AgentAttachment, AgentChatItem } from "@/stores/use-agent-store"; -export type StoredAgentUserMessage = Pick & { role: "user"; historyText: string }; +export type StoredAgentUserMessage = Pick & { role: "user"; historyText: string; threadId?: string; turnId?: string }; const store = localforage.createInstance({ name: "infinite-canvas", storeName: "agent_chat_messages" }); +const mutations = new Map>(); const indexKey = (threadId: string) => `thread:${threadId}`; const messageKey = (threadId: string, messageId: string) => `message:${threadId}:${messageId}`; +const pendingKey = (messageId: string) => `pending:${messageId}`; +const threadMutationKey = (threadId: string) => `thread:${threadId}`; +const pendingMutationKey = (messageId: string) => `pending:${messageId}`; export async function saveAgentUserMessage(threadId: string, message: StoredAgentUserMessage) { if (!message.attachments?.length) return; - const attachments = await Promise.all((message.attachments || []).map(createThumbnail)); - await store.setItem(messageKey(threadId, message.id), { ...message, attachments }); - const ids = (await store.getItem(indexKey(threadId))) || []; - if (!ids.includes(message.id)) await store.setItem(indexKey(threadId), [...ids, message.id]); + if (!threadId) return savePendingAgentUserMessage(message); + await saveThreadAgentUserMessage(threadId, message); +} + +/** Persist attachments before a turn is accepted. The record is moved to a thread after the server assigns one. */ +export async function savePendingAgentUserMessage(message: StoredAgentUserMessage) { + if (!message.id || !message.attachments?.length) return; + await mutateScopes([pendingMutationKey(message.id)], async () => { + const attachments = await Promise.all(message.attachments!.map(createThumbnail)); + await store.setItem(pendingKey(message.id), { ...message, threadId: undefined, turnId: undefined, attachments }); + }); +} + +export async function deletePendingAgentUserMessage(messageId: string) { + if (!messageId) return; + await mutateScopes([pendingMutationKey(messageId)], () => store.removeItem(pendingKey(messageId))); } export async function readAgentUserMessages(threadId: string) { + await mutations.get(threadMutationKey(threadId))?.catch(() => undefined); const ids = (await store.getItem(indexKey(threadId))) || []; return (await Promise.all(ids.map((id) => store.getItem(messageKey(threadId, id))))).filter((item): item is StoredAgentUserMessage => Boolean(item)); } +/** Bind a pending message to the server thread, preserving an already-known turn id. */ +export async function bindPendingAgentUserMessage(threadId: string, messageId: string, turnId = "") { + if (!threadId || !messageId) return; + await mutateScopes([pendingMutationKey(messageId), threadMutationKey(threadId)], async () => { + const pending = await store.getItem(pendingKey(messageId)); + const key = messageKey(threadId, messageId); + const existing = await store.getItem(key); + if (!pending && !existing) return; + const message = mergeStoredMessage(existing, pending, threadId, turnId); + await putThreadMessage(threadId, key, message); + if (pending) await store.removeItem(pendingKey(messageId)); + }); +} + +export async function bindAgentUserMessageTurn(threadId: string, messageId: string, turnId: string) { + await bindPendingAgentUserMessage(threadId, messageId, turnId); +} + +export async function moveAgentUserMessage(fromThreadId: string, toThreadId: string, messageId: string) { + if (!toThreadId || !messageId || fromThreadId === toThreadId) return bindPendingAgentUserMessage(toThreadId, messageId); + const scopes = [pendingMutationKey(messageId), threadMutationKey(toThreadId), ...(fromThreadId ? [threadMutationKey(fromThreadId)] : [])]; + await mutateScopes(scopes, async () => { + const pending = await store.getItem(pendingKey(messageId)); + const fromKey = fromThreadId ? messageKey(fromThreadId, messageId) : ""; + const from = fromKey ? await store.getItem(fromKey) : null; + const toKey = messageKey(toThreadId, messageId); + const existing = await store.getItem(toKey); + const source = pending || from; + if (!source && !existing) return; + await putThreadMessage(toThreadId, toKey, mergeStoredMessage(existing, source, toThreadId)); + if (pending) await store.removeItem(pendingKey(messageId)); + if (from && fromThreadId) await removeThreadMessage(fromThreadId, fromKey, messageId); + }); +} + export async function deleteAgentThreadMessages(threadIds: string[]) { - await Promise.all( - threadIds.map(async (threadId) => { + await mutateScopes(threadIds.map(threadMutationKey), async () => { + await Promise.all(threadIds.map(async (threadId) => { const ids = (await store.getItem(indexKey(threadId))) || []; await Promise.all(ids.map((id) => store.removeItem(messageKey(threadId, id)))); await store.removeItem(indexKey(threadId)); - }), - ); + })); + }); +} + +async function saveThreadAgentUserMessage(threadId: string, message: StoredAgentUserMessage) { + await mutateScopes([threadMutationKey(threadId)], async () => { + const attachments = await Promise.all(message.attachments!.map(createThumbnail)); + await putThreadMessage(threadId, messageKey(threadId, message.id), { ...message, threadId, attachments }); + }); +} + +async function putThreadMessage(threadId: string, key: string, message: StoredAgentUserMessage) { + await store.setItem(key, { ...message, threadId }); + const ids = (await store.getItem(indexKey(threadId))) || []; + if (!ids.includes(message.id)) await store.setItem(indexKey(threadId), [...ids, message.id]); +} + +function mergeStoredMessage(existing: StoredAgentUserMessage | null, source: StoredAgentUserMessage | null | undefined, threadId: string, turnId = "") { + const message = { ...(source || {}), ...(existing || {}) } as StoredAgentUserMessage; + if (!message.attachments?.length && source?.attachments?.length) message.attachments = source.attachments; + if (!message.text && source?.text) message.text = source.text; + if (!message.historyText && source?.historyText) message.historyText = source.historyText; + return { ...message, threadId, ...(turnId ? { turnId } : message.turnId ? { turnId: message.turnId } : {}) }; +} + +async function removeThreadMessage(threadId: string, key: string, messageId: string) { + await store.removeItem(key); + const ids = (await store.getItem(indexKey(threadId))) || []; + const remaining = ids.filter((id) => id !== messageId); + if (remaining.length) await store.setItem(indexKey(threadId), remaining); + else await store.removeItem(indexKey(threadId)); +} + +async function mutateScopes(scopes: string[], mutation: () => Promise) { + const ids = [...new Set(scopes.filter(Boolean))].sort(); + const operation = Promise.all(ids.map((id) => mutations.get(id)?.catch(() => undefined))).then(mutation); + ids.forEach((id) => mutations.set(id, operation)); + try { + await operation; + } finally { + ids.forEach((id) => { + if (mutations.get(id) === operation) mutations.delete(id); + }); + } } async function createThumbnail(attachment: AgentAttachment): Promise { diff --git a/web/src/stores/use-agent-store.ts b/web/src/stores/use-agent-store.ts index b8e12fd..54390ec 100644 --- a/web/src/stores/use-agent-store.ts +++ b/web/src/stores/use-agent-store.ts @@ -4,7 +4,7 @@ import type { CanvasAgentOp, CanvasAgentSnapshot } from "@/lib/canvas/canvas-age export type AgentChatRole = "user" | "assistant" | "system" | "tool" | "error"; export type AgentAttachment = { id: string; name: string; type: string; size: number; width: number; height: number; url: string; dataUrl: string }; -export type AgentChatItem = { id: string; role: AgentChatRole; title?: string; text: string; historyText?: string; meta?: string; detail?: unknown; attachments?: AgentAttachment[]; streamId?: string }; +export type AgentChatItem = { id: string; itemId?: string; clientMessageId?: string; threadId?: string; turnId?: string; role: AgentChatRole; title?: string; text: string; historyText?: string; meta?: string; detail?: unknown; attachments?: AgentAttachment[]; streamId?: string; activityItems?: Record }; export type AgentEventLog = { id: string; time: string; title: string; text: string; raw?: unknown }; export type AgentPendingToolCall = { requestId: string; name: string; input?: { ops?: CanvasAgentOp[]; path?: string } & Record }; export type AgentPermissionMode = "request" | "automatic" | "full"; @@ -17,7 +17,8 @@ export type AgentModel = { supportedReasoningEfforts: Array<{ reasoningEffort: AgentReasoningEffort; description?: string }>; isDefault?: boolean; }; -export type AgentPendingApproval = { requestId: string; method: string; threadId?: string; turnId?: string; itemId?: string; reason?: string; command?: unknown; cwd?: string; grantRoot?: string; networkApprovalContext?: unknown; permissions?: unknown }; +export type AgentApprovalDecision = "accept" | "acceptForSession" | "decline"; +export type AgentPendingApproval = { requestId: string; method: string; threadId?: string; turnId?: string; itemId?: string; reason?: string; command?: unknown; cwd?: string; grantRoot?: string; networkApprovalContext?: unknown; permissions?: unknown; deciding?: AgentApprovalDecision }; export type AgentCanvasContext = { snapshot: CanvasAgentSnapshot; applyOps: (ops?: CanvasAgentOp[]) => CanvasAgentSnapshot; undoOps: () => CanvasAgentSnapshot | null; canUndo: boolean }; export type AgentThreadSummary = { id: string; preview: string; name?: string | null; cwd?: string; status?: string; source?: unknown; createdAt?: number; updatedAt?: number }; export type AgentTokenUsage = { input: number; cached: number; output: number }; @@ -47,6 +48,7 @@ type AgentStore = { eventLogs: AgentEventLog[]; threads: AgentThreadSummary[]; activeThreadId: string; + activeTurnId: string; workspacePath: string; loadingThreads: boolean; activeTab: AgentPanelTab; @@ -93,6 +95,7 @@ export const useAgentStore = create((set, get) => ({ eventLogs: [], threads: [], activeThreadId: "", + activeTurnId: "", workspacePath: "", loadingThreads: false, activeTab: "setup", @@ -111,7 +114,7 @@ export const useAgentStore = create((set, get) => ({ if (!get().panelMounted || get().panelClosing) return; set({ panelOpen: false, panelClosing: true }); setTimeout(() => { - if (get().panelClosing) set({ panelMounted: false, panelClosing: false }); + if (get().panelClosing) set({ panelClosing: false }); }, CANVAS_AGENT_PANEL_MOTION_MS); }, togglePanel: () => (get().panelOpen ? get().closePanel() : get().openPanel()), @@ -139,7 +142,7 @@ export const useAgentStore = create((set, get) => ({ connectTimer = null; set({ enabled: false, connected: false, silentConnect: false, activity: "离线", ...patch }); }, - addMessage: (item) => set((state) => ({ messages: [...state.messages.slice(-120), item] })), + addMessage: (item) => set((state) => ({ messages: [...state.messages, item] })), addEventLog: (item) => set((state) => ({ eventLogs: [...state.eventLogs.slice(-160), item] })), clearEventLogs: () => set({ eventLogs: [] }), })); diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index e414bac..4070d92 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -523,6 +523,10 @@ line-height: 1.15rem; } +.agent-streamdown [data-streamdown="code-block-body"] code > span { + display: block; +} + .agent-streamdown [data-streamdown="code-block-actions"] { gap: 0; margin: 0;