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/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/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/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", { 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), []); +});