diff --git a/docs/daemon-api.md b/docs/daemon-api.md index eba8ba44b..873ab94d2 100644 --- a/docs/daemon-api.md +++ b/docs/daemon-api.md @@ -4337,11 +4337,36 @@ shape is persisted under the tool message's `metadata.tool_hunks`. The start of a regular agent turn includes `{"busy":true,"turn_id":"initial-user-message-uuid"}`. That id stays stable across tool calls, model retries, and accepted steering input. For the terminal -transition, `busy_changed` includes -`{"busy":false,"outcome":"completed|error|aborted","turn_id":"..."}` -and the following `done` frame repeats the same `outcome`. Other busy cycles -such as compaction may omit it. Clients should only treat `completed` as a -successful turn. +transition, `busy_changed` includes the turn-wide usage summary: + +```json +{ + "busy": false, + "outcome": "completed", + "turn_id": "initial-user-message-uuid", + "usage": { + "prompt_tokens": 44100, + "completion_tokens": 2100, + "total_tokens": 46200, + "cache_read_tokens": 32000, + "cache_write_tokens": 0, + "reasoning_tokens": 500, + "has_data": true + } +} +``` + +The following `done` frame repeats the same `outcome`, `turn_id`, and `usage`. +The summary adds every accounted model step in the turn, including tool-call +round trips. It is not another incremental delta to add to preceding `usage` +or `model_step_finish` events. `has_data` is true only when at least one model +step was accounted and every included step used provider-reported usage; if +ACECode estimated any included step, counts still include that estimate but +`has_data` is false. `context_breakdown`, when present, is also summed across +included steps. + +Other busy cycles such as compaction may omit `outcome`, `turn_id`, and +`usage`. Clients should only treat `completed` as a successful turn. Transient pure-sampling failures use `agent_progress` rather than transcript messages. While waiting, the payload is: diff --git a/openspec/changes/expose-turn-token-usage/.openspec.yaml b/openspec/changes/expose-turn-token-usage/.openspec.yaml new file mode 100644 index 000000000..563fab5ab --- /dev/null +++ b/openspec/changes/expose-turn-token-usage/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-21 diff --git a/openspec/changes/expose-turn-token-usage/design.md b/openspec/changes/expose-turn-token-usage/design.md new file mode 100644 index 000000000..68f7c07d1 --- /dev/null +++ b/openspec/changes/expose-turn-token-usage/design.md @@ -0,0 +1,25 @@ +## Context + +OpenAI-compatible provider 已把 Codex2API 流中的 `usage` 解析为 `TokenUsage`。`AgentLoop::call_provider_and_collect` 在一次成功 model step 后发出逐 step `usage`,而 `run_agent_with_input` 负责一个用户 turn 内的多次 model step 和最终 `busy_changed` / `done` 边界。 + +## Goals / Non-Goals + +目标是在 daemon API 的 terminal turn event 上提供可直接消费的本 turn 汇总,并保持逐 step 事件兼容。非目标包括计费金额换算、修改 Codex2API、重算历史 turn、增加独立 REST endpoint 或改变 session 累计统计。 + +## Decisions + +在 `run_agent_with_input` 内维护局部 `TokenUsage` accumulator,只在 model step 已通过 provider error/abort 判定并完成现有 usage 入账后累加。这样内部 provider retry 的 provisional usage、失败请求和未入账中止请求不会重复计数。 + +计数字段按 model step 求和:`prompt_tokens`、`completion_tokens`、`total_tokens`、cache read/write 与 reasoning。`context_breakdown` 同样按类别求和,代表本 turn 所有请求的输入构成总和。 + +`has_data` 表示汇总是否完全来自 provider 上报:至少有一个已入账 step 且所有已入账 step 的 `has_data=true` 时为 true;混入 ACECode 估算或完全没有模型请求时为 false。 + +regular turn 的 terminal `busy_changed` 和紧随其后的 `done` 都携带同一 `turn_id` 与 `usage`。重复是有意的:状态型客户端可只监听 `busy_changed`,完成型客户端可只监听 `done`。原有逐 step `usage` / `model_step_finish` 不变。 + +## Risks / Trade-offs + +同一汇总出现在两个 terminal frame 会增加少量 payload,但避免客户端必须耦合某一种终止事件。客户端若自行累加逐 step `usage`,不得再把 terminal `usage` 叠加;文档会明确 terminal usage 是摘要。 + +## Validation + +用脚本 provider 构造同一 turn 的两次模型请求,分别返回 usage,断言 terminal `busy_changed` 与 `done` 的 `turn_id` 一致、字段求和正确且逐 step usage 仍保持两条。再覆盖 provider usage 与估算混合时 `has_data=false`。运行对应 `acecode_unit_tests` 过滤用例、OpenSpec strict validate、`git diff --check`;按用户要求不进行完整编译。 diff --git a/openspec/changes/expose-turn-token-usage/proposal.md b/openspec/changes/expose-turn-token-usage/proposal.md new file mode 100644 index 000000000..360375aee --- /dev/null +++ b/openspec/changes/expose-turn-token-usage/proposal.md @@ -0,0 +1,21 @@ +## Why + +ACEModel(Codex2API)已经在每次模型请求的流式响应中返回 token usage,但一个 ACECode turn 可能包含多次模型请求和工具往返。现有 daemon API 只逐 step 推送 `usage`,turn 结束的 `busy_changed` / `done` 事件没有本 turn 汇总,API 客户端无法在明确的 turn 边界直接读取本轮总消耗。 + +## What Changes + +- 在一个 regular agent turn 内累加所有已入账 model step 的 token usage。 +- turn 结束时,在 terminal `busy_changed` 和 `done` API 事件中返回同一份 `usage` 汇总及 `turn_id`。 +- 保留现有逐 step `usage` 和 `model_step_finish` 事件,避免破坏现有消费者。 +- provider 未返回 usage 时沿用 ACECode 估算值;只要本 turn 有任一步为估算,汇总的 `has_data` 为 `false`。 + +## Capabilities + +### New Capabilities +- `turn-token-usage-api`: daemon session event 在 turn 边界暴露本 turn token 消耗。 + +### Modified Capabilities + +## Impact + +影响 `AgentLoop` 的 turn 级计量聚合、daemon WebSocket session event 契约、对应单元测试与 `docs/daemon-api.md`;不改变 ACEModel/Codex2API 请求格式,不新增 HTTP 请求,也不改变持久化 session 累计用量。 diff --git a/openspec/changes/expose-turn-token-usage/specs/turn-token-usage-api/spec.md b/openspec/changes/expose-turn-token-usage/specs/turn-token-usage-api/spec.md new file mode 100644 index 000000000..6f9e93daa --- /dev/null +++ b/openspec/changes/expose-turn-token-usage/specs/turn-token-usage-api/spec.md @@ -0,0 +1,32 @@ +## ADDED Requirements + +### Requirement: Terminal turn events expose aggregate usage +For every regular agent turn, the daemon session event stream SHALL include a `usage` object on both the terminal `busy_changed` event and the following `done` event. Both events SHALL include the same `turn_id`, `outcome`, and aggregate usage values. + +#### Scenario: Multi-step ACEModel turn completes +- **WHEN** one user turn performs multiple successful model steps because tools are called +- **THEN** terminal `busy_changed` and `done` each contain the stable turn id +- **AND** each event's `usage` equals the sum of all accounted model-step token fields in that turn +- **AND** existing per-step `usage` and `model_step_finish` events remain available + +#### Scenario: Turn uses only provider-reported usage +- **WHEN** every accounted model step includes provider-reported usage +- **THEN** terminal `usage.has_data` is `true` + +#### Scenario: Turn includes estimated usage +- **WHEN** at least one accounted model step lacks provider-reported usage and ACECode estimates it +- **THEN** terminal token counts include the estimate +- **AND** terminal `usage.has_data` is `false` + +#### Scenario: Turn ends before a model request is accounted +- **WHEN** a regular turn is blocked or fails before any model step is accounted +- **THEN** terminal usage contains zero counts +- **AND** terminal `usage.has_data` is `false` + +### Requirement: Aggregate usage is a summary, not an incremental delta +Terminal turn usage SHALL summarize the whole turn and SHALL NOT replace or alter the semantics of incremental per-step usage events. + +#### Scenario: Client already consumes step usage +- **WHEN** a client receives per-step `usage` events followed by terminal turn usage +- **THEN** the terminal value can be used as the authoritative turn summary +- **AND** it is not an additional usage delta to add to the preceding step events diff --git a/openspec/changes/expose-turn-token-usage/tasks.md b/openspec/changes/expose-turn-token-usage/tasks.md new file mode 100644 index 000000000..6d6aca3bc --- /dev/null +++ b/openspec/changes/expose-turn-token-usage/tasks.md @@ -0,0 +1,12 @@ +## 1. 规范与测试 +- [x] 1.1 定义 regular turn terminal event 的 usage 聚合契约。 +- [x] 1.2 添加同 turn 多 model step usage 聚合回归测试。 + +## 2. 实现 +- [x] 2.1 在 AgentLoop turn 生命周期内累加已入账 step usage。 +- [x] 2.2 在 terminal busy_changed 与 done payload 暴露 turn_id 和 usage。 +- [x] 2.3 更新 daemon API 文档。 + +## 3. 验证 +- [x] 3.1 按用户要求不编译;完成 OpenSpec 与静态差异校验,并提交目标回归测试供 CI 执行。 +- [x] 3.2 运行 OpenSpec strict validate、git diff --check 和提交前差异审查。 diff --git a/src/agent_loop.cpp b/src/agent_loop.cpp index 6ec6ba041..d0b141b78 100644 --- a/src/agent_loop.cpp +++ b/src/agent_loop.cpp @@ -216,6 +216,33 @@ nlohmann::json model_step_usage_to_json(const TokenUsage& usage) { return value; } +void accumulate_turn_usage(TokenUsage& aggregate, + bool& initialized, + const TokenUsage& step) { + aggregate.prompt_tokens += step.prompt_tokens; + aggregate.completion_tokens += step.completion_tokens; + aggregate.total_tokens += step.total_tokens; + aggregate.cache_read_tokens += step.cache_read_tokens; + aggregate.cache_write_tokens += step.cache_write_tokens; + aggregate.reasoning_tokens += step.reasoning_tokens; + + auto& total_context = aggregate.context_breakdown; + const auto& step_context = step.context_breakdown; + total_context.system_prompt += step_context.system_prompt; + total_context.project_rules += step_context.project_rules; + total_context.skills += step_context.skills; + total_context.builtin_tools += step_context.builtin_tools; + total_context.mcp_tools += step_context.mcp_tools; + total_context.conversation += step_context.conversation; + total_context.dynamic_context += step_context.dynamic_context; + total_context.has_data = total_context.has_data || step_context.has_data; + + aggregate.has_data = initialized + ? aggregate.has_data && step.has_data + : step.has_data; + initialized = true; +} + std::string provider_error_summary_for_log(const ProviderErrorInfo& info) { std::string message = info.display_message; if (message.empty()) message = info.pretty_json; @@ -1027,6 +1054,10 @@ void AgentLoop::worker_main() { worker_task_active_ = true; worker_task_kind_ = task.kind; } + if (task.kind == WorkerTask::Kind::Chat) { + active_turn_usage_ = TokenUsage{}; + active_turn_usage_initialized_ = false; + } try { switch (task.kind) { case WorkerTask::Kind::Chat: @@ -1109,9 +1140,15 @@ void AgentLoop::recover_worker_task_error(const char* detail, bool chat_task) { attempt([&] { if (chat_task && callbacks_.on_turn_finished) callbacks_.on_turn_finished("error"); }); - const nlohmann::json idle = { + nlohmann::json idle = { {"busy", false}, {"outcome", "error"}, {"turn_id", turn_id}}; - const nlohmann::json done = {{"outcome", "error"}}; + nlohmann::json done = {{"outcome", "error"}}; + if (chat_task) { + const auto usage = model_step_usage_to_json(active_turn_usage_); + idle["usage"] = usage; + done["turn_id"] = turn_id; + done["usage"] = usage; + } attempt([&] { record_terminal_trajectory_events(idle, done); }); attempt([&] { if (callbacks_.on_busy_changed) callbacks_.on_busy_changed(false); @@ -5399,16 +5436,25 @@ void AgentLoop::run_agent_with_input(const UserInput& input, if (callbacks_.on_turn_finished) { callbacks_.on_turn_finished("error"); } - record_terminal_trajectory_events( - {{"busy", false}, {"outcome", "error"}}, - {{"outcome", "error"}}); + const std::string turn_id = generate_uuid(); + const auto usage = model_step_usage_to_json(active_turn_usage_); + const nlohmann::json idle = { + {"busy", false}, + {"outcome", "error"}, + {"turn_id", turn_id}, + {"usage", usage}, + }; + const nlohmann::json done = { + {"outcome", "error"}, + {"turn_id", turn_id}, + {"usage", usage}, + }; + record_terminal_trajectory_events(idle, done); if (callbacks_.on_busy_changed) callbacks_.on_busy_changed(false); record_turn_outcome("error"); busy_ = false; - events_.emit(SessionEventKind::BusyChanged, nlohmann::json{ - {"busy", false}, {"outcome", "error"}}); - events_.emit(SessionEventKind::Done, nlohmann::json{ - {"outcome", "error"}}); + events_.emit(SessionEventKind::BusyChanged, idle); + events_.emit(SessionEventKind::Done, done); maybe_continue_goal(); return; } @@ -5789,11 +5835,16 @@ void AgentLoop::run_agent_with_input(const UserInput& input, estimated_usage.completion_tokens = estimate_message_tokens({estimated_response}); estimated_usage.total_tokens = estimated_usage.prompt_tokens + estimated_usage.completion_tokens; estimated_usage.has_data = false; + estimated_usage.context_breakdown = reconcile_context_usage_breakdown( + bundle.context_usage_estimate, + estimated_usage.prompt_tokens); step_usage = estimated_usage; account_goal_usage(estimated_usage.total_tokens, false); if (callbacks_.on_usage) callbacks_.on_usage(estimated_usage); if (session_manager_) session_manager_->record_token_usage(estimated_usage); } + accumulate_turn_usage( + active_turn_usage_, active_turn_usage_initialized_, step_usage); record_model_response( current_model_step, provider_result, step_usage, "completed"); @@ -6019,11 +6070,19 @@ void AgentLoop::run_agent_with_input(const UserInput& input, if (callbacks_.on_turn_finished) { callbacks_.on_turn_finished(turn_timing_status); } - record_terminal_trajectory_events( - {{"busy", false}, - {"outcome", turn_timing_status}, - {"turn_id", turn_info.active_turn_id}}, - {{"outcome", turn_timing_status}}); + const auto usage = model_step_usage_to_json(active_turn_usage_); + const nlohmann::json idle = { + {"busy", false}, + {"outcome", turn_timing_status}, + {"turn_id", turn_info.active_turn_id}, + {"usage", usage}, + }; + const nlohmann::json done = { + {"outcome", turn_timing_status}, + {"turn_id", turn_info.active_turn_id}, + {"usage", usage}, + }; + record_terminal_trajectory_events(idle, done); if (callbacks_.on_busy_changed) { callbacks_.on_busy_changed(false); } @@ -6035,13 +6094,8 @@ void AgentLoop::run_agent_with_input(const UserInput& input, } record_turn_outcome(turn_timing_status); busy_ = false; - events_.emit(SessionEventKind::BusyChanged, nlohmann::json{ - {"busy", false}, - {"outcome", turn_timing_status}, - {"turn_id", turn_info.active_turn_id}, - }); - events_.emit(SessionEventKind::Done, nlohmann::json{ - {"outcome", turn_timing_status}}); + events_.emit(SessionEventKind::BusyChanged, idle); + events_.emit(SessionEventKind::Done, done); if (terminate_session_after_turn_) { // There must be no provider-visible state left for a deleted session. // The post-turn action owns writer teardown and persistent cleanup. diff --git a/src/agent_loop.hpp b/src/agent_loop.hpp index 23535e33e..b03384cb0 100644 --- a/src/agent_loop.hpp +++ b/src/agent_loop.hpp @@ -816,6 +816,12 @@ class AgentLoop { // Latest server-reported total active-context usage. For providers that do // not return total_tokens, prompt_tokens is used as the fallback. std::atomic last_api_total_tokens_{0}; + // Aggregate usage for the regular turn currently owned by the worker. + // Kept as worker state (rather than a stack local) so the outer worker + // recovery boundary can still publish an accurate terminal summary after + // an exception unwinds run_agent_with_input(). + TokenUsage active_turn_usage_; + bool active_turn_usage_initialized_ = false; // PA 兜底的 episode 进度(见 run_pa_overflow_rescue)。服务端收下请求即 // 清零;回合开始也清零。只在回合线程上读写。 pa::RescueState pa_rescue_state_; diff --git a/src/session/session_client.hpp b/src/session/session_client.hpp index 4563a84c3..6876405c1 100644 --- a/src/session/session_client.hpp +++ b/src/session/session_client.hpp @@ -57,8 +57,8 @@ enum class SessionEventKind { GoalCleared, // payload: {"session_id":"..."} TodoUpdated, // payload: {"session_id":"...", "todos": [...], "summary": {...}} SessionUpdated, // payload: {"session_id":"...", "title":"...", ...} - BusyChanged, // payload: {"busy": bool,"outcome"?:completed|error|aborted} - Done, // payload: {"outcome"?:completed|error|aborted} + BusyChanged, // payload: {"busy":bool,"outcome"?:...,"turn_id"?:...,"usage"?:{...}} + Done, // regular turn: {"outcome":...,"turn_id":"...","usage":{...}} Error, // payload: {"reason":"...", "request_id":"..."(可选)} }; diff --git a/tests/agent_loop/agent_loop_termination_test.cpp b/tests/agent_loop/agent_loop_termination_test.cpp index 4d00e9d45..9a6edfd49 100644 --- a/tests/agent_loop/agent_loop_termination_test.cpp +++ b/tests/agent_loop/agent_loop_termination_test.cpp @@ -836,6 +836,125 @@ TEST(AgentLoopTermination, TransientRetryResetsProvisionalStateAndReportsProgres } } +TEST(AgentLoopTermination, TerminalEventsExposeAggregateTurnUsage) { + AgentLoopHarness h; + + acecode::StreamEvent tool_call; + tool_call.type = acecode::StreamEventType::ToolCall; + tool_call.tool_call = {"usage-call", "noop", "{}"}; + acecode::StreamEvent first_usage; + first_usage.type = acecode::StreamEventType::Usage; + first_usage.usage.prompt_tokens = 100; + first_usage.usage.completion_tokens = 20; + first_usage.usage.total_tokens = 120; + first_usage.usage.cache_read_tokens = 60; + first_usage.usage.reasoning_tokens = 5; + first_usage.usage.has_data = true; + acecode::StreamEvent first_done; + first_done.type = acecode::StreamEventType::Done; + first_done.finish_reason = "tool_calls"; + h.push_events({tool_call, first_usage, first_done}); + + acecode::StreamEvent final_delta; + final_delta.type = acecode::StreamEventType::Delta; + final_delta.content = "finished"; + acecode::StreamEvent second_usage; + second_usage.type = acecode::StreamEventType::Usage; + second_usage.usage.prompt_tokens = 150; + second_usage.usage.completion_tokens = 30; + second_usage.usage.total_tokens = 180; + second_usage.usage.cache_read_tokens = 90; + second_usage.usage.cache_write_tokens = 4; + second_usage.usage.reasoning_tokens = 7; + second_usage.usage.has_data = true; + acecode::StreamEvent second_done; + second_done.type = acecode::StreamEventType::Done; + second_done.finish_reason = "stop"; + h.push_events({final_delta, second_usage, second_done}); + + ASSERT_TRUE(h.submit_and_wait("run two model steps")); + ASSERT_TRUE(h.wait_for_event(acecode::SessionEventKind::Done)); + + int step_usage_events = 0; + nlohmann::json terminal_busy; + nlohmann::json terminal_done; + for (const auto& event : h.snapshot_events()) { + if (event.kind == acecode::SessionEventKind::Usage) { + ++step_usage_events; + } else if (event.kind == acecode::SessionEventKind::BusyChanged && + event.payload.is_object() && + !event.payload.value("busy", true)) { + terminal_busy = event.payload; + } else if (event.kind == acecode::SessionEventKind::Done) { + terminal_done = event.payload; + } + } + + EXPECT_EQ(step_usage_events, 2); + ASSERT_TRUE(terminal_busy.is_object()); + ASSERT_TRUE(terminal_done.is_object()); + ASSERT_TRUE(terminal_busy.contains("usage")); + ASSERT_TRUE(terminal_done.contains("usage")); + EXPECT_FALSE(terminal_busy.value("turn_id", std::string{}).empty()); + EXPECT_EQ(terminal_busy["turn_id"], terminal_done["turn_id"]); + EXPECT_EQ(terminal_busy["usage"], terminal_done["usage"]); + + const auto& usage = terminal_done["usage"]; + EXPECT_EQ(usage.value("prompt_tokens", 0), 250); + EXPECT_EQ(usage.value("completion_tokens", 0), 50); + EXPECT_EQ(usage.value("total_tokens", 0), 300); + EXPECT_EQ(usage.value("cache_read_tokens", 0), 150); + EXPECT_EQ(usage.value("cache_write_tokens", 0), 4); + EXPECT_EQ(usage.value("reasoning_tokens", 0), 12); + EXPECT_TRUE(usage.value("has_data", false)); +} + +TEST(AgentLoopTermination, EstimatedStepMarksAggregateTurnUsageAsEstimated) { + AgentLoopHarness h; + + acecode::StreamEvent tool_call; + tool_call.type = acecode::StreamEventType::ToolCall; + tool_call.tool_call = {"estimated-usage-call", "noop", "{}"}; + acecode::StreamEvent reported_usage; + reported_usage.type = acecode::StreamEventType::Usage; + reported_usage.usage.prompt_tokens = 10; + reported_usage.usage.completion_tokens = 5; + reported_usage.usage.total_tokens = 15; + reported_usage.usage.has_data = true; + acecode::StreamEvent tool_done; + tool_done.type = acecode::StreamEventType::Done; + tool_done.finish_reason = "tool_calls"; + h.push_events({tool_call, reported_usage, tool_done}); + h.push_text("finished without provider usage"); + + ASSERT_TRUE(h.submit_and_wait("run estimated model step")); + ASSERT_TRUE(h.wait_for_event(acecode::SessionEventKind::Done)); + + nlohmann::json terminal_done; + for (const auto& event : h.snapshot_events()) { + if (event.kind == acecode::SessionEventKind::Done) { + terminal_done = event.payload; + } + } + ASSERT_TRUE(terminal_done.contains("usage")); + const auto& usage = terminal_done["usage"]; + EXPECT_GT(usage.value("prompt_tokens", 0), 10); + EXPECT_GT(usage.value("completion_tokens", 0), 5); + EXPECT_GT(usage.value("total_tokens", 0), 15); + EXPECT_FALSE(usage.value("has_data", true)); + ASSERT_TRUE(usage.contains("context_breakdown")); + const auto& context = usage["context_breakdown"]; + const int categorized_prompt = + context.value("system_prompt", 0) + + context.value("project_rules", 0) + + context.value("skills", 0) + + context.value("builtin_tools", 0) + + context.value("mcp_tools", 0) + + context.value("conversation", 0) + + context.value("dynamic_context", 0); + EXPECT_EQ(categorized_prompt, usage.value("prompt_tokens", 0)); +} + // 场景:任务已进入 20 分钟封顶等待时,stop 必须通知 active provider 的 // condition variable,立即结束,而不是等到下一次定时唤醒。 TEST(AgentLoopTermination, AbortWakesTwentyMinuteRetryWaitPromptly) { diff --git a/tests/hooks/hook_agent_loop_test.cpp b/tests/hooks/hook_agent_loop_test.cpp index 12d1da011..21fab57a5 100644 --- a/tests/hooks/hook_agent_loop_test.cpp +++ b/tests/hooks/hook_agent_loop_test.cpp @@ -292,9 +292,42 @@ TEST(HookAgentLoop, UserPromptSubmitBlockPreventsPersistenceAndProviderCall) { }); h.loop->set_hook_manager(&hooks); + struct TerminalEvents { + std::mutex mu; + std::condition_variable cv; + nlohmann::json busy; + nlohmann::json done; + }; + auto terminal_events = std::make_shared(); + h.loop->events().subscribe([terminal_events](const acecode::SessionEvent& event) { + std::lock_guard lk(terminal_events->mu); + if (event.kind == acecode::SessionEventKind::BusyChanged && + !event.payload.value("busy", true)) { + terminal_events->busy = event.payload; + } else if (event.kind == acecode::SessionEventKind::Done) { + terminal_events->done = event.payload; + terminal_events->cv.notify_all(); + } + }); + ASSERT_TRUE(h.submit_and_wait("blocked")); + { + std::unique_lock lk(terminal_events->mu); + ASSERT_TRUE(terminal_events->cv.wait_for(lk, 5s, [&] { + return terminal_events->done.is_object(); + })); + } EXPECT_EQ(provider->turn_count(), 0); EXPECT_TRUE(h.loop->messages().empty()); + ASSERT_TRUE(terminal_events->busy.is_object()); + const std::string turn_id = + terminal_events->done.value("turn_id", std::string{}); + EXPECT_FALSE(turn_id.empty()); + EXPECT_EQ( + terminal_events->busy.value("turn_id", std::string{}), turn_id); + EXPECT_EQ(terminal_events->done["usage"].value("total_tokens", -1), 0); + EXPECT_EQ( + terminal_events->busy["usage"], terminal_events->done["usage"]); } TEST(HookAgentLoop, UserPromptSubmitAdditionalContextReachesNextRequestOnly) {