From e40f65a8a3d0ae03421fbf82fe7f55f62ea99659 Mon Sep 17 00:00:00 2001 From: tmoonlight Date: Tue, 20 Oct 2026 01:13:49 +0800 Subject: [PATCH 1/2] Pair live tool rows with their tool by call id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit During a running turn the Web transcript could show a run of rows labelled only "Tool" whose detail had a result but "请求未记录" for the request; they fixed themselves once the completed-turn self-heal reloaded history. Each tool call is mirrored on the wire by display-only tool_call / tool_result message events. They carried no call id and a content-hash message id, so the client had to guess their owner by global name and order, merged repeated calls with identical content (the same git status, the same output) into one item, and when switching into a running session appended replayed tool_result rows that history already held. - Daemon: tool_call / tool_result rows carry metadata tool_call_id, tool_index and tool_name, and their id includes the call id. - Web: live tool rows are never merged by message id; replayed rows are de-duplicated against history by call id (WS replay and REST catch-up). - Fallback: a current-turn result whose request never arrived shows its tool name, and a REST tail fetch inserts the persisted request before it. Tests: web/src/lib/sessionTranscriptToolRows.test.js, AgentLoopToolLifecycleEvents.PseudoToolRowsCarryCallIdentity. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 + docs/daemon-api.md | 10 + .../agent/tool_exec/tool_batch_scheduler.cpp | 10 +- .../agent/tool_exec/tool_result_presenter.cpp | 14 +- .../agent/tool_exec/tool_result_presenter.hpp | 7 +- .../agent/transcript/transcript_writer.cpp | 10 + .../agent_loop_tool_lifecycle_events_test.cpp | 50 +++++ web/src/lib/runTests.js | 1 + web/src/lib/sessionTranscript.js | 180 +++++++++++++++-- web/src/lib/sessionTranscriptToolRows.test.js | 185 ++++++++++++++++++ 10 files changed, 444 insertions(+), 25 deletions(-) create mode 100644 web/src/lib/sessionTranscriptToolRows.test.js diff --git a/CLAUDE.md b/CLAUDE.md index 738f0559..589f1768 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -615,6 +615,8 @@ web/ **实时状态卡住的兜底(用户 1.0.9 反馈:会话一直「正在推理」,切走再切回才看到已完成)。** 本质是页面没处理这一回合的 `busy_changed(false)` / `done`,而既有「完成回合自愈」只在**看到了** busy→false 之后才触发。三层兜底,不追究具体丢帧原因:(1) **WS 心跳**(`connection.js` + `lib/liveConnectionHealth.js`):静默 20 秒发 `ping`,ping 后 10 秒没有任何帧就摘掉旧 socket 直接重连(不等 onclose,假活连接的关闭握手要很久),页面重新可见时立即补验;`pong` 在连接层消化,**不能**派发成会话事件(否则每次心跳一次 transcript 提交)。(2) **事件游标带实例号**:`EventDispatcher::instance_id()` 构造时随机生成,`subscribe_ack` 的 payload、messages 快照与新的 `GET /api/sessions/:id/runtime` 都带 `event_seq` / `event_epoch`;daemon 重启或会话运行实体重建后 seq 从 1 重新数,前端发现实例号变了或本地 seq 比服务端还大就清零游标、广播 `session_seq_reset`,transcript 手里的 seq 确实作废时才对账 —— 否则新实例的事件全被 `isStaleSequencedEvent` 当成过期帧静默丢弃。(3) **卡住检测**(`lib/transcriptLiveWatchdog.js` 纯逻辑,`useSessionTranscript` 里只在「实时 + 在跑」时挂计时器):运行中 20 秒没收到本会话事件(页面重新可见时 4 秒)就调 runtime 探针;服务端空闲、或服务端 seq 超过页面、或实例号变化 → `resyncWithServer` 重拉尾页 + `reconcileTranscriptWithServer` + 从服务端当前 seq `replay_from_start` 重订阅,等同自动「来回切一下」;服务端仍忙且 seq 一致(长时间无输出的工具)只探不改。对账**不能**复用 `reloadHistoryTail`:它套的 `preserveLiveRuntimeOnLoad` 会在实时 seq 更大时把卡住的运行态原样保留。同一服务端状态只对账一次(`lastResyncKey`,提交成功后才记)。每次触发都经 `POST /api/diagnostics/transcript-stall`(白名单 `handlers/transcript_stall_report.cpp`)在 daemon 日志留一行 `[transcript-stall]`;`[event_dispatcher] listener failed` 现在带事件类型、seq 与异常文本。回归:前端 `liveConnectionHealth` / `connectionHeartbeat` / `transcriptLiveWatchdog` / `transcriptStallResync` 测试,C++ `transcript_stall_report_test.cpp`、`event_dispatcher_test.cpp::InstanceIdIsStableWithinAndDistinctAcrossInstances`、`web_server_smoke_test.cpp` 的 `TranscriptLiveSyncSmoke.*` 与 `TranscriptStallReportAcceptsAllowlistAndRejectsInjection`。 +**工具伪行按 call id 配对(fix: 运行中偶现一串只显示「Tool」、展开只有返回的工具行,回合结束自愈后恢复)。** 实时线上每次工具调用除 `tool_start` / `tool_end` 外还有两条只为显示、不落盘的 `message` 伪行:`tool_call`(`[Tool: 名] 参数`)与 `tool_result`(输出)。它们曾经不带 call id、id 只是 `sha1(role+content)`,于是:(1) Web 只能用全局的「按名认领 + 按序号配结果」去猜伪行属于哪个工具行;(2) 内容相同的两次调用(同一条 `git status`、同样的输出)撞 id,实时 reducer 把后一次原位并进前一次;(3) 切进运行中会话时 REST 历史 + 事件环回放,伪 `tool_call` 按内容 key 被历史的 tool_call 包装拦下,伪 `tool_result` 与历史的 role:tool 不同名拦不住,一串无名结果追加到末尾。现在 `tool_row_metadata`(`tool_result_presenter.cpp`)给两种伪行带 `tool_call_id` / `tool_index` / `tool_name`,`TranscriptWriter::dispatch_message` 把 call id 并进伪行 id;前端实时伪行不按消息 id 合并、回放按 call id 判重(`transcriptHasToolRow`)。兜底:当前回合仍有只有返回的结果(请求在重连缺口 / 订阅晚于快照时丢了)时,`useSessionTranscript` 等 600ms 拉一次 REST 尾页,`repairOrphanToolRequests` 用落盘的 `assistant.tool_calls` 在结果前补一条与历史同形的 tool_call 包装;每个 call id 只试一次,计时器不随 items 重置(流式 token 每帧都改 items)。回归:`web/src/lib/sessionTranscriptToolRows.test.js`、`agent_loop_tool_lifecycle_events_test.cpp::PseudoToolRowsCarryCallIdentity`。 + **Transcript 尾部窗口(渐进虚拟化)**:大会话切入时只渲染最近 `INITIAL_TAIL_ITEMS`(120)条投影行,更早的行折成顶部「显示更早的 N 条消息 / 显示全部」入口(`web/src/lib/transcriptWindow.js` 纯函数 + Node 测试)。窗口按**首个可见行的 user 行 id** 锚定(user 行不参与活动折叠,id 跨投影稳定;流式追加在尾部,锚不动、窗口自然增长;锚丢失 fail-open 全量)。渲染 map 走 `windowedItems`,但 `itemsRef`/scrubber 回合列表/turnFileListPlacement 仍吃全量 `renderedItems` —— scrubber 跳转与搜索深链(`searchJumpOrdinal`)目标不在 DOM 时先 `expandTranscriptWindow()` 再重试;Ctrl+F 查找是 DOM 文本搜索,`GlobalFindOverlay.openFind` 广播 `acecode:conversation-find-open` 事件让 ChatView 先全量展开。「显示更早」向上补条目需手动滚动补偿(`windowRevealScrollRef` 按 scrollHeight 差值回补,原生锚定已被 overflow-anchor:none 关掉)。窗口状态是 render-phase 调整(React 文档模式)—— 用 useEffect 会先全量渲染一帧,虚拟化就白做了;transcript 重置(条目清零)回到未初始化重定锚,因为 item id 会被重载复用。 `lib/usePreference.js` 是 UI 偏好读写的统一入口(`usePreference(key, defaults, validator?)` 返回 `[value, setValue]`,setter 接受 partial 浅合并 / 函数式更新,localStorage 写失败静默吞)。当前订阅:`ace.theme`(由 `theme.jsx::ThemeProvider`)、`acecode.singleLayoutWidths.v1`(由 `App.jsx`,`{sidebar, sidePanel}` 像素宽)、`acecode.uiPrefs.v1`(由 `App.jsx`,`{view: 'single'|'grid4'|'grid9', sidePanelCollapsed: boolean}` — view 模式与 SidePanel 折叠态跨刷新持久化)。同一 key 在整个 App 内只允许一个 hook 实例订阅,避免多 useState 之间互覆盖。 diff --git a/docs/daemon-api.md b/docs/daemon-api.md index 9dfcb87e..c5f67129 100644 --- a/docs/daemon-api.md +++ b/docs/daemon-api.md @@ -5200,6 +5200,16 @@ the messages snapshot carry the current `swarm_mode` too, plus `agent_path` `[Error]`) and `summary` otherwise, in both the session list and the chat header; they must not derive a title from message bodies. +Each tool call is also mirrored by two display-only `message` events that are +never persisted: role `tool_call` (content `[Tool: ] `) before +the result is collected, and role `tool_result` (the displayed output) after it. +Both carry `metadata.tool_call_id`, `metadata.tool_index` and +`metadata.tool_name`, matching the `tool_start` / `tool_end` payloads of the same +call, and their `id` is distinct per call even when two calls have identical +content. Clients pair these rows with the structured tool by `tool_call_id`, and +de-duplicate replayed rows against history (the persisted `assistant.tool_calls` +and role `tool` result) by the same id rather than by content. + For a successful `task_complete` call, the `tool_end` payload also includes `message_id`, the canonical id of the persisted tool-role result. Live and trajectory/replay records use the same id so clients can attach copy, fork, and diff --git a/src/engine/agent/tool_exec/tool_batch_scheduler.cpp b/src/engine/agent/tool_exec/tool_batch_scheduler.cpp index 755a4a67..46efde2c 100644 --- a/src/engine/agent/tool_exec/tool_batch_scheduler.cpp +++ b/src/engine/agent/tool_exec/tool_batch_scheduler.cpp @@ -110,7 +110,8 @@ ToolBatchOutcome ToolBatchScheduler::execute( // 的调用不再显示伪行,canonical 的 [Interrupted] 由 Phase 3 落盘。 transcript_.dispatch_message("tool_call", "[Tool: " + item.call.function_name + "] " + - item.call.function_arguments, true, nlohmann::json::object(), nlohmann::json::array()); + item.call.function_arguments, true, + tool_row_metadata(item.call, idx), nlohmann::json::array()); try { slots[idx].outcome = futures.get(item.future_index); } catch (const std::exception& e) { @@ -124,7 +125,7 @@ ToolBatchOutcome ToolBatchScheduler::execute( } batch.doom_guard.record_result(item.call, slots[idx].outcome->result); goal_.account_usage(session_manager_, 0, false); - presenter_.display(item.call, slots[idx].outcome->result); + presenter_.display(item.call, slots[idx].outcome->result, idx); } futures.join(); @@ -141,14 +142,15 @@ ToolBatchOutcome ToolBatchScheduler::execute( LOG_INFO("Tool call (write): " + tc.function_name + " id=" + tc.id); transcript_.dispatch_message("tool_call", - "[Tool: " + tc.function_name + "] " + tc.function_arguments, true, nlohmann::json::object(), nlohmann::json::array()); + "[Tool: " + tc.function_name + "] " + tc.function_arguments, true, + tool_row_metadata(tc, slot.original_index), nlohmann::json::array()); slot.outcome = lifecycle_.run(batch, tc, slot.original_index, true); batch.doom_guard.record_result(tc, slot.outcome->result); goal_.account_usage(session_manager_, 0, false); // 结果行紧跟派发。调用行在执行前已显示(权限确认弹窗需要上下文), // 写工具串行执行,顺序天然成对。 - presenter_.display(tc, slot.outcome->result); + presenter_.display(tc, slot.outcome->result, slot.original_index); if (slot.outcome->result.terminate_session_after_turn) { LOG_INFO("Stopping remaining write tools after terminal session action"); break; diff --git a/src/engine/agent/tool_exec/tool_result_presenter.cpp b/src/engine/agent/tool_exec/tool_result_presenter.cpp index e5fe0348..1c82538c 100644 --- a/src/engine/agent/tool_exec/tool_result_presenter.cpp +++ b/src/engine/agent/tool_exec/tool_result_presenter.cpp @@ -55,7 +55,16 @@ void ToolResultPresenter::materialize_attachments(ToolResult& result) { materialized.warnings.end()); } -void ToolResultPresenter::display(const ToolCall& tc, const ToolResult& result) { +nlohmann::json tool_row_metadata(const ToolCall& call, std::size_t tool_index) { + return { + {"tool_call_id", call.id}, + {"tool_index", tool_index}, + {"tool_name", call.function_name}, + }; +} + +void ToolResultPresenter::display( + const ToolCall& tc, const ToolResult& result, std::size_t tool_index) { const auto callbacks = callbacks_.snapshot(); std::string display_output = result.output; std::string ask_display = @@ -71,7 +80,8 @@ void ToolResultPresenter::display(const ToolCall& tc, const ToolResult& result) } display_output += attachment_fallback; } - transcript_.dispatch_message("tool_result", display_output, true, nlohmann::json::object(), nlohmann::json::array()); + transcript_.dispatch_message("tool_result", display_output, true, + tool_row_metadata(tc, tool_index), nlohmann::json::array()); if (callbacks.on_tool_result) { ChatMessage call_msg; call_msg.role = "tool_call"; diff --git a/src/engine/agent/tool_exec/tool_result_presenter.hpp b/src/engine/agent/tool_exec/tool_result_presenter.hpp index d7855fab..ca4f2cfb 100644 --- a/src/engine/agent/tool_exec/tool_result_presenter.hpp +++ b/src/engine/agent/tool_exec/tool_result_presenter.hpp @@ -9,6 +9,11 @@ class WorkspaceBoundary; class ToolSessionHost; class TranscriptWriter; +// 工具调用 / 结果伪行(tool_call / tool_result message 事件)的身份:与 +// tool_start / tool_end 同一个 call id 与序号。Web 据此把伪行配到结构化工具行上; +// 没有它只能按全局顺序猜,内容相同的两次调用还会被当成同一条消息合并。 +nlohmann::json tool_row_metadata(const ToolCall& call, std::size_t tool_index); + class ToolResultPresenter { public: ToolResultPresenter(WorkspaceBoundary& boundary, ToolSessionHost& host, @@ -16,7 +21,7 @@ class ToolResultPresenter { : boundary_(boundary), host_(host), transcript_(transcript), callbacks_(callbacks), session_manager_(session) {} void materialize_attachments(ToolResult& result); - void display(const ToolCall& call, const ToolResult& result); + void display(const ToolCall& call, const ToolResult& result, std::size_t tool_index); private: WorkspaceBoundary& boundary_; ToolSessionHost& host_; diff --git a/src/engine/agent/transcript/transcript_writer.cpp b/src/engine/agent/transcript/transcript_writer.cpp index 17abeb87..0676b4ed 100644 --- a/src/engine/agent/transcript/transcript_writer.cpp +++ b/src/engine/agent/transcript/transcript_writer.cpp @@ -56,6 +56,16 @@ void TranscriptWriter::dispatch_message(const std::string& role, ChatMessage tmp; tmp.role = role; tmp.content = content; + // 工具调用 / 结果伪行不落盘,id 只用于前端去重。带 tool_call_id 时把它并进 + // id 依据:同样的命令或同样的输出(git status、"OK")若共用内容哈希,前端会把 + // 后一次调用当成已有消息原位合并,工具行就少了一半、只剩无名的返回。 + if (metadata.is_object()) { + const auto call_id = metadata.find("tool_call_id"); + if (call_id != metadata.end() && call_id->is_string() && + !call_id->get_ref().empty()) { + tmp.timestamp = "tool_call_id:" + call_id->get(); + } + } if (content_parts.is_array() && !content_parts.empty()) { tmp.content_parts = content_parts; } diff --git a/tests/agent/agent_loop_tool_lifecycle_events_test.cpp b/tests/agent/agent_loop_tool_lifecycle_events_test.cpp index b0c1236d..2f37e084 100644 --- a/tests/agent/agent_loop_tool_lifecycle_events_test.cpp +++ b/tests/agent/agent_loop_tool_lifecycle_events_test.cpp @@ -605,6 +605,56 @@ TEST(AgentLoopToolLifecycleEvents, ReadOnlySameNameParallelToolsEmitKeyedStartAn fs::remove_all(cwd); } +// 回归: 工具调用 / 结果伪行(tool_call / tool_result Message 事件)必须带上与 +// tool_start 相同的 call id,且内容相同的两次调用 id 不同。修复前伪行只有内容 +// 哈希 id、没有 call id:Web 只能按全局顺序猜它属于哪个工具行,同样的命令或 +// 输出还会被当成同一条消息合并,进行中的回合就出现一串只有返回的「Tool」行。 +TEST(AgentLoopToolLifecycleEvents, PseudoToolRowsCarryCallIdentity) { + auto cwd = make_temp_dir("acecode_lifecycle_pseudo_rows"); + std::atomic reads{0}; + std::atomic writes{0}; + ToolLifecycleHarness h(cwd.string(), PermissionMode::Yolo); + h.tools().register_tool(make_probe_tool("ro_probe", true, &reads)); + h.tools().register_tool(make_fake_bash_tool(&writes)); + + ScriptedResponse tools_turn; + tools_turn.tool_calls.push_back({"call-r1", "ro_probe", "{}"}); + tools_turn.tool_calls.push_back({"call-r2", "ro_probe", "{}"}); + tools_turn.tool_calls.push_back({"call-w1", "bash", R"({"command":"git status"})"}); + tools_turn.tool_calls.push_back({"call-w2", "bash", R"({"command":"git status"})"}); + h.provider().push_response(std::move(tools_turn)); + h.provider().push_text("done"); + + ASSERT_TRUE(h.submit_and_wait()); + EXPECT_EQ(reads.load(), 2); + EXPECT_EQ(writes.load(), 2); + + const std::vector expected_ids{"call-r1", "call-r2", "call-w1", "call-w2"}; + for (const std::string role : {"tool_call", "tool_result"}) { + std::vector call_ids; + std::vector message_ids; + for (const auto& e : h.events_of(SessionEventKind::Message)) { + if (e.payload.value("role", std::string{}) != role) continue; + ASSERT_TRUE(e.payload.contains("metadata")) << role; + const auto& metadata = e.payload["metadata"]; + const std::string call_id = metadata.value("tool_call_id", std::string{}); + call_ids.push_back(call_id); + message_ids.push_back(e.payload.value("id", std::string{})); + const bool is_read = call_id.rfind("call-r", 0) == 0; + EXPECT_EQ(metadata.value("tool_name", std::string{}), is_read ? "ro_probe" : "bash"); + EXPECT_EQ(metadata.value("tool_index", -1), + static_cast(std::find(expected_ids.begin(), expected_ids.end(), call_id) - + expected_ids.begin())); + } + EXPECT_EQ(call_ids, expected_ids) << role; + ASSERT_EQ(message_ids.size(), 4u) << role; + EXPECT_NE(message_ids[0], message_ids[1]) << role << " identical read rows share an id"; + EXPECT_NE(message_ids[2], message_ids[3]) << role << " identical bash rows share an id"; + } + + fs::remove_all(cwd); +} + // 回归: 工具调用回合的 assistant 文本必须作为 Message 事件下发,给前端一个 // 权威帧整体替换流式草稿。 // diff --git a/web/src/lib/runTests.js b/web/src/lib/runTests.js index 119e054b..452ce787 100644 --- a/web/src/lib/runTests.js +++ b/web/src/lib/runTests.js @@ -139,6 +139,7 @@ import './queueCardItem.test.js'; import './sessionSubscription.test.js'; import './connectionStatusSubscriptions.test.js'; import './sessionTranscript.test.js'; +import './sessionTranscriptToolRows.test.js'; import './sessionHistoryPaging.test.js'; import './sessionOpenDiagnostics.test.js'; import { execFileSync as runHookTest } from 'node:child_process'; diff --git a/web/src/lib/sessionTranscript.js b/web/src/lib/sessionTranscript.js index 40eb0021..88138f8d 100644 --- a/web/src/lib/sessionTranscript.js +++ b/web/src/lib/sessionTranscript.js @@ -37,6 +37,36 @@ function replayMessageKey(payload) { : messageKey(payload?.role || 'system', payload?.content || ''); } +// 工具调用 / 结果伪行:daemon 的 tool_call / tool_result message 事件只为界面 +// 展示、不落盘。新 daemon 在 metadata 里带与 tool_start 相同的 tool_call_id, +// 投影据此把它们配到结构化工具行上;没有 id 时只能按全局顺序猜,一旦对不上, +// 结果就成了没有工具名、只有返回的「Tool」行。 +const PSEUDO_TOOL_ROLES = new Set(['tool_call', 'tool_result']); + +function itemToolCallId(item) { + return String(item?.toolCallId ?? item?.tool_call_id ?? item?.metadata?.tool_call_id ?? '').trim(); +} + +function pseudoToolRowCallId(payload) { + if (!PSEUDO_TOOL_ROLES.has(payload?.role)) return ''; + return String(payload?.metadata?.tool_call_id ?? payload?.tool_call_id ?? '').trim(); +} + +// 回放去重:这次调用的请求(或结果)是否已经在转录里 —— 历史的 tool_call 包装、 +// 落盘的结果条目、或已收尾的结构化工具行。内容相同的两次调用 id 不同,互不吞并。 +function transcriptHasToolRow(items, role, callId) { + return items.some((item) => { + if (item?.kind === 'tool') { + return role === 'tool_result' && item.tool?.isDone === true + && String(item.tool?.toolCallId || '') === callId; + } + if (item?.kind !== 'msg' || itemToolCallId(item) !== callId) return false; + return role === 'tool_call' + ? item.role === 'tool_call' + : item.role === 'tool_result' || item.role === 'tool'; + }); +} + // 回放(REST since=N 补拉 / WS 带游标订阅)时 token / reasoning 先攒着,直到看见 // 能决定它们归属的事件再 flush。旧写法是「遇到任何非流式事件就 flush」,但 daemon // 在最后一个 token 与 assistant message 事件之间恒有一条 usage(chat_stream 一返回 @@ -910,26 +940,30 @@ function historyItemsFromMessage(next, m, messageIndex) { for (let i = 0; i < toolCalls.length; i += 1) { const call = normalizePersistedToolCall(toolCalls[i], i); - items.push(genericHistoryMessageItem(next, m, { - messageId: persistedToolCallMessageId(m, messageIndex, call), - role: 'tool_call', - content: persistedToolCallContent(call), - contentParts: [], - metadata: { - ...(m?.metadata && typeof m.metadata === 'object' && !Array.isArray(m.metadata) ? m.metadata : {}), - tool_call_id: call.toolCallId, - tool_index: call.toolIndex, - }, - ts: transcriptTimestampMs(m) || Date.now(), - toolCallId: call.toolCallId, - toolIndex: call.toolIndex, - messageOrdinal: rawOrdinal, - })); + items.push(persistedToolCallItem(next, m, messageIndex, call, rawOrdinal)); } return items; } +function persistedToolCallItem(next, m, messageIndex, call, messageOrdinalValue = null) { + return genericHistoryMessageItem(next, m, { + messageId: persistedToolCallMessageId(m, messageIndex, call), + role: 'tool_call', + content: persistedToolCallContent(call), + contentParts: [], + metadata: { + ...(m?.metadata && typeof m.metadata === 'object' && !Array.isArray(m.metadata) ? m.metadata : {}), + tool_call_id: call.toolCallId, + tool_index: call.toolIndex, + }, + ts: transcriptTimestampMs(m) || Date.now(), + toolCallId: call.toolCallId, + toolIndex: call.toolIndex, + messageOrdinal: messageOrdinalValue, + }); +} + // 工具前言(add-tool-preamble):assistant 正文里的 标签只在实时 // 期间进 loading,落盘正文保留原文(模型会模仿自己的历史输出),所以历史加载与 // message 事件都要在这里剥掉;token 流由 daemon 剥过。 @@ -1257,7 +1291,17 @@ export function applyTranscriptReplayEvents(state, events = []) { pendingStreamEvents.push(ev); continue; } - if (ev?.type === 'message') { + if (ev?.type === 'message' && pseudoToolRowCallId(ev.payload)) { + // REST 历史已含这次调用(assistant.tool_calls 展开的包装 / 落盘结果)时, + // 回放的伪行是副本:结果伪行与历史的 role:tool 不同名,按内容 key 拦不住, + // 曾在切进运行中会话时追加出一串无名「Tool」行。按 call id 判重。 + const p = ev.payload; + if (transcriptHasToolRow(next.items, p.role, pseudoToolRowCallId(p))) { + pendingStreamEvents = []; + markEventSeqApplied(next, ev); + continue; + } + } else if (ev?.type === 'message') { const p = ev.payload || {}; const occurrenceIdentity = messageEventUsesOccurrenceIdentity(p); const incomingMessageId = p.id || ''; @@ -1578,7 +1622,14 @@ export function reduceTranscriptEvent(state, msg) { const incomingMessageId = p.id || ''; // Persisted errors carry a unique occurrence ID. Legacy errors still use // sequence identity because their content-derived IDs can repeat. - const existingIndex = incomingMessageId && !messageEventUsesOccurrenceIdentity(p) + // 实时的工具伪行每条都是一次新调用:旧 daemon 的 id 是内容哈希,同一条 + // git status 或同样的 "OK" 输出会撞 id,原位合并就把这次调用吞掉了。 + // 回放帧的去重仍按 id(见 applyTranscriptReplayEvents)。 + const livePseudoToolRow = PSEUDO_TOOL_ROLES.has(role) && msg?.replayed !== true; + // WS 带游标重连的回放帧不经 applyTranscriptReplayEvents,同样按 call id 判重。 + if (!livePseudoToolRow && pseudoToolRowCallId(p) + && transcriptHasToolRow(next.items, role, pseudoToolRowCallId(p))) break; + const existingIndex = incomingMessageId && !messageEventUsesOccurrenceIdentity(p) && !livePseudoToolRow ? next.items.findIndex((item) => item.kind === 'msg' && item.messageId === incomingMessageId) : -1; if (existingIndex >= 0) { @@ -1941,6 +1992,7 @@ export function reduceTranscriptEvent(state, msg) { export const HISTORY_PAGE_SIZE = 200; +const ORPHAN_TOOL_REPAIR_DELAY_MS = 600; // Prepending keeps every existing item ID (and therefore the DOM/window anchor). // Runtime events remain owned by the live store while disk pages arrive. @@ -1966,6 +2018,68 @@ export function mergeTranscriptHistoryPage(state, data = {}, direction = 'before return next; } +// 兜底:实时转录当前回合里只有返回、没有请求的工具行。结果伪行带着 call id, +// 但这次调用的 tool_start 与 tool_call 伪行没送到 —— 手机锁屏后重连时缺口超出 +// 事件环,或订阅晚于历史快照。请求(assistant.tool_calls)在调用执行前就已落盘, +// 用 REST 尾页给这些结果补一条与历史同形的 tool_call 包装,不必等回合结束自愈。 +function currentTurnStartIndex(items) { + for (let i = items.length - 1; i >= 0; i -= 1) { + if (items[i]?.kind === 'msg' && items[i].role === 'user') return i + 1; + } + return 0; +} + +export function orphanToolResultCallIds(items = []) { + const list = Array.isArray(items) ? items : []; + const requested = new Set(); + const results = []; + for (let i = currentTurnStartIndex(list); i < list.length; i += 1) { + const item = list[i]; + if (item?.kind === 'tool') { + const id = String(item.tool?.toolCallId || '').trim(); + if (id) requested.add(id); + continue; + } + const id = item?.kind === 'msg' ? itemToolCallId(item) : ''; + if (!id) continue; + if (item.role === 'tool_call') requested.add(id); + else if (item.role === 'tool_result' || item.role === 'tool') results.push(id); + } + return [...new Set(results.filter((id) => !requested.has(id)))]; +} + +export function repairOrphanToolRequests(state, messages = []) { + const current = state || createTranscriptState(); + const orphanIds = new Set(orphanToolResultCallIds(current.items)); + if (orphanIds.size === 0) return current; + const calls = new Map(); + (Array.isArray(messages) ? messages : []).forEach((message, messageIndex) => { + if (message?.role !== 'assistant' || !Array.isArray(message.tool_calls)) return; + message.tool_calls.forEach((raw, index) => { + const call = normalizePersistedToolCall(raw, index); + if (call.toolCallId && call.name && orphanIds.has(call.toolCallId)) { + calls.set(call.toolCallId, { message, messageIndex, call }); + } + }); + }); + if (calls.size === 0) return current; + + const next = cloneState(current); + const turnStart = currentTurnStartIndex(current.items); + const items = []; + current.items.forEach((item, index) => { + const isResult = item?.kind === 'msg' && (item.role === 'tool_result' || item.role === 'tool'); + const found = index >= turnStart && isResult ? calls.get(itemToolCallId(item)) : null; + if (found) { + items.push(persistedToolCallItem(next, found.message, found.messageIndex, found.call)); + calls.delete(found.call.toolCallId); + } + items.push(item); + }); + next.items = items; + return next; +} + export async function fetchCompletedTurnHistory(api, sid, state) { const items = Array.isArray(state?.items) ? state.items : []; let anchor = null; @@ -2040,7 +2154,14 @@ export function loadTranscriptHistory(state, data = {}, requestState = state) { pendingStreamEvents.push(ev); continue; } - if (ev?.type === 'message') { + if (ev?.type === 'message' && pseudoToolRowCallId(ev.payload)) { + const p = ev.payload; + if (transcriptHasToolRow(next.items, p.role, pseudoToolRowCallId(p))) { + pendingStreamEvents = []; + markEventSeqApplied(next, ev); + continue; + } + } else if (ev?.type === 'message') { const p = ev.payload || {}; const occurrenceIdentity = messageEventUsesOccurrenceIdentity(p); const key = replayMessageKey(p); @@ -2241,6 +2362,8 @@ export function useSessionTranscript(sessionRef, options = {}) { const historyScopeRef = useRef(null); const loadingHistoryRef = useRef(false); const pendingHistoryEventsRef = useRef([]); + const orphanRepairRef = useRef({ sid, attempted: new Set(), timer: null }); + if (orphanRepairRef.current.sid !== sid) orphanRepairRef.current = { sid, attempted: new Set(), timer: null }; sessionRefRef.current = ref; const refreshSignatureRef = useRef(''); // 卡住检测(transcriptLiveWatchdog.js)的可变现场:最近一次收到本会话事件的时间、 @@ -2584,6 +2707,27 @@ export function useSessionTranscript(sessionRef, options = {}) { // 磁盘加载;成为实时会话时由下方 WebSocket 订阅回放补齐事件。 }, [api, sid]); + // 只有返回、没有请求的工具行:稍等片刻(同批结果常连着到)后拉一次 REST 尾页 + // 补请求。每个 call id 只试一次,尾页里也没有时保留占位,回合结束自愈兜底。 + // 计时器不随 items 变化重置:流式 token 每帧都改 items,重置会让它永远等不到。 + useEffect(() => { + if (!sid || !isLive || stateSessionIdRef.current !== sid || state.loadState !== 'loaded') return; + const repair = orphanRepairRef.current; + const pending = orphanToolResultCallIds(state.items).filter((id) => !repair.attempted.has(id)); + if (pending.length === 0) return; + pending.forEach((id) => repair.attempted.add(id)); + if (repair.timer) return; + const scope = historyScopeRef.current; + repair.timer = setTimeout(() => { + repair.timer = null; + if (orphanRepairRef.current !== repair || historyScopeRef.current !== scope) return; + api.getMessages(sid, { limit: HISTORY_PAGE_SIZE }).then((data) => { + if (orphanRepairRef.current !== repair || historyScopeRef.current !== scope) return; + store.commit((current) => repairOrphanToolRequests(current, data?.messages)); + }).catch(() => {}); + }, ORPHAN_TOOL_REPAIR_DELAY_MS); + }, [api, isLive, sid, state.items, state.loadState, store]); + useEffect(() => { if (!sid || stateSessionIdRef.current !== sid || state.loadState !== 'loaded') return undefined; let secondFrame = null; diff --git a/web/src/lib/sessionTranscriptToolRows.test.js b/web/src/lib/sessionTranscriptToolRows.test.js new file mode 100644 index 00000000..c53fe01f --- /dev/null +++ b/web/src/lib/sessionTranscriptToolRows.test.js @@ -0,0 +1,185 @@ +import assert from 'node:assert/strict'; +import { + applyTranscriptReplayEvents, + createTranscriptState, + loadTranscriptHistory, + orphanToolResultCallIds, + reduceTranscriptEvent, + repairOrphanToolRequests, +} from './sessionTranscript.js'; +import { projectCollapsedTranscriptItems } from './transcriptProjection.js'; + +// 实时工具伪行(daemon 的 tool_call / tool_result message 事件)与结构化工具行 +// 的配对。回归:进行中的回合偶现一串只显示「Tool」、展开只有返回的工具行, +// 回合结束自愈后才恢复。 + +function run(name, fn) { + try { + fn(); + console.log(`[pass] ${name}`); + } catch (error) { + console.error(`[fail] ${name}`); + throw error; + } +} + +function rowMeta(id, name, index = 0) { + return { tool_call_id: id, tool_index: index, tool_name: name }; +} + +function persistedCall(id, name, args) { + return { id, type: 'function', index: 0, function: { name, arguments: args } }; +} + +function projectLive(items) { + return projectCollapsedTranscriptItems(items, { busy: true, live: true, deferTrailingToolSummary: true }); +} + +function projectedTools(items) { + return projectLive(items) + .flatMap((item) => item.collapsedItems || [item]) + .filter((item) => item.kind === 'tool'); +} + +function liveReducer() { + let state = createTranscriptState({ isLive: true }); + let seq = 0; + return { + emit(type, payload) { + state = reduceTranscriptEvent(state, { type, payload, seq: ++seq }).state; + }, + get state() { return state; }, + }; +} + +function emitBashCall(live, id, command, output, { callId = 'call-row', resultId = 'result-row' } = {}) { + live.emit('message', { + role: 'tool_call', content: `[Tool: bash] {"command":"${command}"}`, is_tool: true, + id: callId, metadata: rowMeta(id, 'bash'), + }); + live.emit('tool_start', { tool: 'bash', tool_call_id: id, args: { command } }); + live.emit('tool_end', { tool: 'bash', tool_call_id: id, success: true, output }); + live.emit('message', { + role: 'tool_result', content: output, is_tool: true, id: resultId, metadata: rowMeta(id, 'bash'), + }); +} + +run('切进运行中会话:回放的工具伪行按 call id 与 REST 历史去重,不追加无名 Tool 行', () => { + const history = loadTranscriptHistory(createTranscriptState({ isLive: true }), { + messages: [ + { id: 'u1', role: 'user', content: 'start', ts: 1 }, + { id: 'a1', role: 'assistant', content: '', tool_calls: [persistedCall('c1', 'bash', '{"command":"git checkout -b feat"}')], ts: 2 }, + { + id: 't1', role: 'tool', content: "Switched to a new branch 'feat'", tool_call_id: 'c1', ts: 3, + metadata: { tool_summary: { icon: '*', verb: 'Ran', object: 'git checkout -b feat', metrics: [] } }, + }, + ], + events: [], + busy: true, + }).state; + + let seq = 0; + const replayed = (type, payload) => ({ type, payload, seq: ++seq, replayed: true }); + const state = applyTranscriptReplayEvents(history, [ + replayed('message', { role: 'tool_call', content: '[Tool: bash] {"command":"git checkout -b feat"}', is_tool: true, id: 'r1', metadata: rowMeta('c1', 'bash') }), + replayed('tool_start', { tool: 'bash', tool_call_id: 'c1', args: { command: 'git checkout -b feat' } }), + replayed('tool_end', { tool: 'bash', tool_call_id: 'c1', success: true, output: "Switched to a new branch 'feat'" }), + replayed('message', { role: 'tool_result', content: "Switched to a new branch 'feat'", is_tool: true, id: 'r2', metadata: rowMeta('c1', 'bash') }), + // 快照之后才开始的调用:命令与输出都和上一次一样,也必须照常出现。 + replayed('message', { role: 'tool_call', content: '[Tool: bash] {"command":"git checkout -b feat"}', is_tool: true, id: 'r3', metadata: rowMeta('c2', 'bash') }), + replayed('tool_start', { tool: 'bash', tool_call_id: 'c2', args: { command: 'git checkout -b feat' } }), + replayed('tool_end', { tool: 'bash', tool_call_id: 'c2', success: true, output: "Switched to a new branch 'feat'" }), + replayed('message', { role: 'tool_result', content: "Switched to a new branch 'feat'", is_tool: true, id: 'r4', metadata: rowMeta('c2', 'bash') }), + ]).state; + + const tools = projectedTools(state.items); + assert.deepEqual(tools.map((item) => item.tool.toolCallId), ['c1', 'c2']); + assert.deepEqual(tools.map((item) => item.tool.tool), ['bash', 'bash']); + assert.equal(JSON.stringify(projectLive(state.items)).includes('请求未记录'), false); +}); + +run('WS 带游标重连:直接进 reducer 的回放伪行同样按 call id 判重', () => { + let state = loadTranscriptHistory(createTranscriptState({ isLive: true }), { + messages: [ + { id: 'u1', role: 'user', content: 'start', ts: 1 }, + { id: 'a1', role: 'assistant', content: '', tool_calls: [persistedCall('c1', 'bash', '{"command":"ls"}')], ts: 2 }, + { id: 't1', role: 'tool', content: 'README.md', tool_call_id: 'c1', ts: 3 }, + ], + events: [], + busy: true, + }).state; + const before = state.items.length; + let seq = 0; + for (const payload of [ + { role: 'tool_call', content: '[Tool: bash] {"command":"ls"}', is_tool: true, id: 'w1', metadata: rowMeta('c1', 'bash') }, + { role: 'tool_result', content: 'README.md', is_tool: true, id: 'w2', metadata: rowMeta('c1', 'bash') }, + ]) { + state = reduceTranscriptEvent(state, { type: 'message', payload, seq: ++seq, replayed: true }).state; + } + assert.equal(state.items.length, before); + assert.equal(state.lastSeq, 2); + assert.equal(JSON.stringify(projectLive(state.items)).includes('请求未记录'), false); +}); + +run('实时回合:内容相同的两次调用不按消息 id 合并,结果各自归到自己的工具行', () => { + const live = liveReducer(); + live.emit('message', { role: 'user', content: 'go', id: 'u1' }); + // 旧 daemon 的伪行 id 是内容哈希:两次 git status 的调用行 / 结果行 id 相同。 + emitBashCall(live, 'c1', 'git status', 'clean'); + emitBashCall(live, 'c2', 'git status', 'clean'); + for (const id of ['r1', 'r2', 'r3']) { + live.emit('tool_start', { tool: 'file_read', tool_call_id: id, args: { file_path: `${id}.txt` } }); + } + for (const id of ['r1', 'r2', 'r3']) { + live.emit('message', { role: 'tool_call', content: `[Tool: file_read] {"file_path":"${id}.txt"}`, is_tool: true, id: `call-${id}`, metadata: rowMeta(id, 'file_read') }); + live.emit('tool_end', { tool: 'file_read', tool_call_id: id, success: true, output: 'same' }); + live.emit('message', { role: 'tool_result', content: 'same', is_tool: true, id: 'same-output', metadata: rowMeta(id, 'file_read') }); + } + + const rows = live.state.items.filter((item) => item.kind === 'msg' && item.role !== 'user'); + assert.equal(rows.length, 10); + const tools = projectedTools(live.state.items); + assert.deepEqual(tools.map((item) => item.tool.toolCallId), ['c1', 'c2', 'r1', 'r2', 'r3']); + assert.equal(JSON.stringify(projectLive(live.state.items)).includes('请求未记录'), false); + assert.deepEqual(orphanToolResultCallIds(live.state.items), []); +}); + +run('兜底:请求没送到的工具结果先显示工具名,再用 REST 尾页补回请求', () => { + const live = liveReducer(); + live.emit('message', { role: 'user', content: 'go', id: 'u1' }); + emitBashCall(live, 'c1', 'git status', 'clean'); + // tool_start 与调用伪行在重连缺口里丢了,只剩结果伪行。 + live.emit('message', { + role: 'tool_result', content: "Switched to a new branch 'feat'", is_tool: true, id: 'lost', metadata: rowMeta('c9', 'bash'), + }); + + assert.deepEqual(orphanToolResultCallIds(live.state.items), ['c9']); + const orphan = projectedTools(live.state.items).find((item) => item.tool.toolCallId === 'c9'); + assert.equal(orphan.tool.tool, 'bash'); + assert.match(orphan.tool.output, /请求未记录/); + + const repaired = repairOrphanToolRequests(live.state, [ + { id: 'u1', role: 'user', content: 'go' }, + { id: 'a9', role: 'assistant', content: '', tool_calls: [persistedCall('c9', 'bash', '{"command":"git checkout -b feat"}')] }, + ]); + assert.deepEqual(orphanToolResultCallIds(repaired.items), []); + const restored = projectedTools(repaired.items).find((item) => item.tool.toolCallId === 'c9'); + assert.equal(restored.tool.tool, 'bash'); + assert.deepEqual(restored.tool.args, { command: 'git checkout -b feat' }); + assert.doesNotMatch(restored.tool.output, /请求未记录/); + // 已配好的工具与转录其余部分保持原样。 + assert.equal(repaired.items.length, live.state.items.length + 1); + assert.equal(repairOrphanToolRequests(repaired, []), repaired); +}); + +run('兜底只看当前回合:更早回合的旧记录不触发补拉', () => { + const state = loadTranscriptHistory(createTranscriptState(), { + messages: [ + { id: 'u1', role: 'user', content: 'old', ts: 1 }, + { id: 't1', role: 'tool', content: 'legacy', tool_call_id: 'legacy-call', ts: 2 }, + { id: 'u2', role: 'user', content: 'new', ts: 3 }, + ], + events: [], + }).state; + assert.deepEqual(orphanToolResultCallIds(state.items), []); +}); From 760c5dbd932844059e762593c4591b8b52b48e61 Mon Sep 17 00:00:00 2001 From: tmoonlight Date: Tue, 20 Oct 2026 01:15:03 +0800 Subject: [PATCH 2/2] Prepare ACECode v1.0.13 release Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 2 +- vcpkg.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 4dcbc7cb..109aec82 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -7,7 +7,7 @@ if(CMAKE_HOST_WIN32 AND DEFINED CMAKE_TOOLCHAIN_FILE AND CMAKE_TOOLCHAIN_FILE MA set(VCPKG_TARGET_TRIPLET "x64-windows-static" CACHE STRING "Vcpkg target triplet" FORCE) endif() -project(acecode VERSION 1.0.12 LANGUAGES C CXX) +project(acecode VERSION 1.0.13 LANGUAGES C CXX) include(${CMAKE_SOURCE_DIR}/cmake/acecode_source_guards.cmake) diff --git a/vcpkg.json b/vcpkg.json index 952f69ec..b64dcd89 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -1,6 +1,6 @@ { "name": "acecode", - "version-semver": "1.0.12", + "version-semver": "1.0.13", "dependencies": [ "cpr", {