Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 之间互覆盖。
Expand Down
2 changes: 1 addition & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
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)

Expand Down Expand Up @@ -105,7 +105,7 @@
set(ACECODE_WEB_DIST "${CMAKE_SOURCE_DIR}/web/dist")
set(ACECODE_WEB_EMBED_DIR "${ACECODE_WEB_DIST}")
if(NOT EXISTS "${ACECODE_WEB_DIST}")
message(WARNING

Check warning on line 108 in CMakeLists.txt

View workflow job for this annotation

GitHub Actions / unit-tests (linux-x64)

[acecode] web/dist/ not found — embedding a minimal fallback page. Run
"[acecode] web/dist/ not found — embedding a minimal fallback page. "
"Run `pnpm install && pnpm build` inside web/ before re-configuring for the full UI.")
set(ACECODE_WEB_EMBED_DIR "${CMAKE_BINARY_DIR}/generated/web-dist-fallback")
Expand Down
10 changes: 10 additions & 0 deletions docs/daemon-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <name>] <arguments>`) 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
Expand Down
10 changes: 6 additions & 4 deletions src/engine/agent/tool_exec/tool_batch_scheduler.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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();
Expand All @@ -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;
Expand Down
14 changes: 12 additions & 2 deletions src/engine/agent/tool_exec/tool_result_presenter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -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";
Expand Down
7 changes: 6 additions & 1 deletion src/engine/agent/tool_exec/tool_result_presenter.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,19 @@ 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,
TranscriptWriter& transcript, CallbacksSlot& callbacks, SessionManager* session)
: 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_;
Expand Down
10 changes: 10 additions & 0 deletions src/engine/agent/transcript/transcript_writer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<const std::string&>().empty()) {
tmp.timestamp = "tool_call_id:" + call_id->get<std::string>();
}
}
if (content_parts.is_array() && !content_parts.empty()) {
tmp.content_parts = content_parts;
}
Expand Down
50 changes: 50 additions & 0 deletions tests/agent/agent_loop_tool_lifecycle_events_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<int> reads{0};
std::atomic<int> 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<std::string> expected_ids{"call-r1", "call-r2", "call-w1", "call-w2"};
for (const std::string role : {"tool_call", "tool_result"}) {
std::vector<std::string> call_ids;
std::vector<std::string> 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<int>(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 事件下发,给前端一个
// 权威帧整体替换流式草稿。
//
Expand Down
2 changes: 1 addition & 1 deletion vcpkg.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "acecode",
"version-semver": "1.0.12",
"version-semver": "1.0.13",
"dependencies": [
"cpr",
{
Expand Down
1 change: 1 addition & 0 deletions web/src/lib/runTests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading
Loading