Repository navigation
fix(acp_client): return only the last message of an acp sub-agent turn - #889
Conversation
An ACP sub-agent's result joined every agent_message_chunk of its turn, so the notes a model writes beside its tool calls reached the host as part of the answer: a spawn announcement, a DAG node's out.md and a direct chat record all opened with the agent's progress notes. The in-process raven loop already returned only its final response. The collector now hands back its last message: what was said after the last tool call or steer, or, for a turn that ended on a step, the message before that step. The narration stays on the step rows of the run's transcript and in the live stream. The live stream's maxOutputChars budget is now renewed per message, since the reply it guards is one message and narration spending a shared budget would cut the report short on screen with no notice. The span's answer_chars measures the reply, as the cli lane's does. Co-authored-by: Claude (claude-opus-5-5[1m]) <noreply@anthropic.com>
Two RPC readers and three test docstrings still said an acp run's output joins every message of its turn, so it repeats the narration on the step rows. It holds the last message now, so they say what each file holds instead, and the stream-cap test is named for the per-message cap it checks. Co-authored-by: Claude (claude-opus-5-5[1m]) <noreply@anthropic.com>
gloryfromca
left a comment
There was a problem hiding this comment.
No blockers; suggestions only, and they are marked inline.
Reviewed the refreshed github/main...HEAD diff, the ACP collector and completion path, direct-chat streaming caller, persisted spawn/DAG readers, relevant history, compatibility implications, and the changed tests. I also checked the repository rules and canonical terms; the tests were strengthened for the intended last-message behavior rather than weakened.
Verification attempted: uv run pytest tests/test_subagent_acp.py tests/test_subagent_history.py tests/test_rpc_dag.py tests/test_rpc_subagent_calls.py -x. It did not execute the selected tests because all four workers failed in the autouse fixture with ModuleNotFoundError: raven_everos. A focused collector reproduction confirmed the inline edge case. Aside from that narrow live-stream boundary, the change is internally consistent and can merge.
The break between two messages of a turn was streamed through the renewed per-message budget, so it spent two characters of the next message's maxOutputChars: a reply within two characters of the cap lost its last characters on screen while the reply itself fitted, and no notice said so. The break is raven's own text, so it now goes through the unbounded callback, as the partial-turn notice does. Co-authored-by: Claude (claude-opus-5-5[1m]) <noreply@anthropic.com>
gloryfromca
left a comment
There was a problem hiding this comment.
No blockers; this can merge as far as I am concerned.
Reviewed the 112566d1720f..4334a84f550d delta and rechecked the exact github/main...HEAD identity. The fix emits Raven's synthetic separator through the unbounded callback while retaining the renewed bounded callback for agent text, which preserves display order and closes the exact-limit clipping case. The regression test is strengthened to use a cap equal to the reply length.
Coverage this round: repository rules and canonical terms, the changed collector path, its direct-chat streaming caller, persistence/DAG consumers, relevant history, backward compatibility, test assertions, and the existing architecture boundaries. No tests were weakened. Focused exact-head verification in a credential-stripped disposable worktree: test_the_live_budget_is_spent_per_message_like_the_reply and test_two_messages_in_one_turn_are_kept_apart both passed. GitHub lint, repository, and RPC checks pass; three full unit shards were still pending when reviewed.
Risk assessment: mergeable, with moderate impact if wrong because all ACP answer consumers share this contract, low regression likelihood, useful direct protection, and easy revert recovery. The fixed thread is resolved.
|
Not a blocker -- from the acceptance pass on head 4334a84 (merged onto main b56da14). The pass found nothing that holds the merge. What still holds it on the board is the review thread on the message-break budget (raven/acp_client/acp_agent.py): the reviewer confirmed the fix at 4334a84 inside that thread, but the thread is still open, and the repository asks for every thread to be resolved before merge. Severity of this one finding, not a verdict on the pull request. The blocking findings from this pass are review threads on the changed files; GitHub renders those collapsed, as a file name with no text. Three behaviours worth a look, each checked by feeding the same ACP frames to
Wording left behind by the change: Checked on the merged tree: the five lanes' test files pass (tests/test_subagent_acp.py 282 passed; test_subagent_history, test_rpc_dag and test_rpc_subagent_calls 124 passed), lint-imports 10 kept / 0 broken, and the full unit suite's failures are the environment-only and order-dependent ones that fail on main without this PR too (one whatsapp reconnect test flaked once under load and passed on the reruns). |
|
No blockers; this can merge as far as I am concerned. Thanks for catching the board state. GitHub had the settled message-break thread open again even though the earlier resolution mutation returned success. I resolved it again and immediately re-read the thread as |
|
Thanks -- all three reproduced before any change, with your frames fed to
The steer path is reachable as you traced it: This PR merged before the fixes were pushed, so they are in #892: 1 and 3, fixed together, since they are one decision: a message now ends only at a step -- a tool call, its update, or a plan opening, the boundary the live view already used -- and One deviation, with its reason: the steer's break stays in the reply ( Tests: a steer inside the final message (the reply whole, one budget for the message, the transcript still split at the steer), text inside an open call ( 2: the Closing Message entry in Wording: The full verification is in #892's description. |
## Summary ACP replies and live output need the same message boundaries. A steer arriving during a report must not drop its earlier half, and a held prompt's heartbeat must not split that report. Return the last nonblank message while retaining narration in the transcript. - A tool opening, terminal or status-less tool update, or plan opening ends a message. Updates marked `pending` or `in_progress` do not; Raven's held-prompt heartbeat can arrive mid-report. - A steer stays inside its message with one separator. That separator spends the message's budget when it is inside returned text; a separator between messages does not. - Leading whitespace remains visible but spends no capped-message budget because `reply` strips it. Blank chunks and repeated steers before the first nonblank text no longer silently truncate a reply that fits. Whitespace inside substantive message text still counts. - Preserve the distinct closing transcript row and document the intentional report/save/sign-off behavior. Code comments and the ACP parsing design name both nonterminal statuses explicitly. ## Type - [x] Fix - [ ] Feature - [ ] Docs - [ ] CI / tooling - [ ] Refactor - [ ] Other ## Verification - [x] Relevant tests pass locally - [x] Relevant lint / type checks pass locally - [x] User-facing docs or screenshots are updated when needed Linux, Python 3.12, after rebase onto current main: ```text uv run --frozen --all-extras pytest tests/test_subagent_acp.py tests/test_acp_methods.py tests/test_subagent_turn_rows.py tests/test_subagent_history.py tests/test_rpc_dag.py tests/test_rpc_subagent_calls.py tests/test_subagent_openai_steps.py tests/test_subagent_openai_backend.py -q -x --tb=short ``` 618 passed. Coverage includes the real held-prompt heartbeat, tool/plan boundaries, persisted replies, transcript rows, and downstream DAG consumers. Native Windows, Python 3.12: ```text uv run --frozen pytest tests/test_subagent_acp.py -k "blank_prefixes or steered_message or steer_inside or a_steer_sets" -n 0 -q -x --tb=short ``` 23 passed. Nine blank-prefix cases failed before the fix; the final matrix also covers prefixes after a tool boundary and explicitly preserves visible whitespace. Four isolated mutations were caught: charging stripped whitespace, treating every chunk as leading, ending the leading-prefix state on a blank chunk, and dropping visible leading whitespace. Sources were restored after each run. Checks passed: ```text uv run --frozen pre-commit run --from-ref origin/main --to-ref HEAD uv run --frozen ty check raven/acp_client/acp_agent.py raven/agent/subagent/backends/base.py raven/agent/subagent/history.py uv run --frozen lint-imports uv run --frozen python scripts/check_commit_messages.py origin/main..HEAD commitlint --from origin/main --to HEAD uv run --frozen python scripts/check_source_language.py origin/main..HEAD uv run --frozen python scripts/check_large_files.py origin/main..HEAD git diff --check origin/main...HEAD ``` All 10 import contracts kept. Full repository tests, native macOS, and the TUI suite were not rerun for this follow-up. The full ACP subprocess selection ran on Linux; Windows verification covers the collector directly. ## Risk - [x] Security impact considered - [x] Backward compatibility considered - [x] Rollback path is clear for risky changes ACP callers receive the last nonblank message, so a report followed by a save step and "Saved." returns the sign-off; the earlier report remains in the transcript. Leading display-only whitespace and separators between messages can make the raw stream longer than the returned-message cap. Internal separators and substantive text remain capped together. No stored format or configuration changes. Rollback: revert the squash commit. ## Related Issues #889 --------- Co-authored-by: Claude (claude-opus-5-5[1m]) <noreply@anthropic.com>
#889) ## Summary An ACP sub-agent's result joined every `agent_message_chunk` of its turn, so the notes a model writes beside its tool calls ("Three confirmed. Now the research repos.", "All 10 repos confirmed. Now verifying benchmark numbers and ecosystem framing.") reached the host as part of the answer: in a spawn announcement's `Result:`, in a DAG node's `out.md` (what a later task reads through `{{ ref:@nodes/<id>.out.md }}`, and what a terminal node's output inlines), and in a direct chat's record. The in-process raven loop already returned only its final response, and the openai lane returns one message; the acp lane was the one that joined. The collector's new `reply` is the run's last message: what it said after its last tool call or steer, which is the existing `closing_text`. A turn that ended on a step said nothing after it, so its reply is the message before that step: still one message, and not an empty result for a run that finished its work. Whitespace after the last step counts as nothing said. The narration stays where it already was, on the step rows of the run's transcript and in the live stream a direct chat renders. Two consequences ride with it. The live stream's `maxOutputChars` budget is now renewed per message: spent across the turn, narration would use it up and cut the report short on screen with no notice, because the reply itself fits and is never capped. The break between two messages is raven's own text and goes through the unbounded delta callback, as the partial-turn notice does, so it spends neither message's budget. And the span's `answer_chars` measures the reply, as the cli lane's `len(output)` does. Existing tests whose meaning changed, on purpose: three assertions encoded the old rule that the caller receiving the run's answer wants all of it, and are flipped (`test_two_messages_in_one_turn_are_kept_apart`, `test_narration_lands_on_the_step_it_preceded`, `test_a_steer_reaches_the_running_turn_and_is_written_where_it_was_said`); the answer-row test gains an `out.md` assertion. A second commit corrects texts the change falsified: two RPC readers and three test docstrings that said the output repeats the narration, and a stream-cap test renamed for the per-message cap it checks (`test_a_streamed_message_is_capped_like_the_reply`). `CONTEXT.md`'s Closing Message and Reply Streaming entries are rewritten to the new rule. Left as is, deliberately: - A turn's thoughts and tool calls were never in the reply, and still are not. - Raven-Research's research-trail appendix is appended to its final message, so it stays in the reply. Whether it should is a separate decision, made in that agent's `finalShape.processAppendix`. - `closing.md` now repeats `out.md` in the common case. It is left in place, since removing it reaches the two context reads and the RPC. - For a turn that ended on a step, the two context reads still draw `out.md` as the answer row while the instance log draws none. That predates this change, which shrinks the repeat from every message to one. ## Type - [x] Fix - [ ] Feature - [ ] Docs - [ ] CI / tooling - [ ] Refactor - [ ] Other ## Verification - Red before green: the seven new or flipped tests that hold the rule failed first for the expected reason (the joined reply returned, `answer_chars` 27 against 13, the live stream cut to `let me look.` and one character of the report), then passed. The blank-burst test was checked by mutation instead: without its whitespace guard it fails with `'' == 'found it.'`. - Single-construct mutants on `raven/acp_client/acp_agent.py`, each run against `tests/test_subagent_acp.py` and each killed: ignoring the cap (1 failed), not renewing it per message (1), counting the joined text in the span (1), dropping the trailing-step fallback (2), returning the joined text (7). - Review round one: the break between messages spent the renewed budget, so at a cap of exactly the reply's length the screen showed `it is a rep` while the reply was `it is a repo.` (caps 13, 14 and 15 lost two characters, one, and none). The per-message test now runs at that cap, and failed with `it is a rep` before the fix. Mutants on the fix, each killed: counting the break against the budget (1 failed), not renewing the budget (1), not streaming the break (2), ignoring the cap (1). - `uv run --frozen --all-extras --python 3.12 pytest -q -p no:randomly tests/test_subagent_acp.py tests/test_rpc_dag.py tests/test_rpc_subagent_calls.py tests/test_subagent_history.py`: 406 passed. - `uv run --frozen --python 3.12 --all-extras pytest -q -p no:randomly` (the `make test-python` command) on the branch head: 27705 passed, 8 failed, 119 skipped. Seven of the failing IDs fail identically on `origin/main`, run the same day through the same interpreter, so none is introduced: five proxy tests in `tests/test_config_update_providers.py`, `tests/test_install_script.py::test_resolve_node_dir_answers_each_case_it_exists_for`, and `tests/test_subagent_node_runtime.py::test_what_cannot_be_read_names_nothing`. The eighth, `tests/test_agents_research_tools.py::test_a_thin_dataset_card_falls_back_to_the_metadata_the_table_needs`, passed when re-run alone; across the three full runs of this branch it failed in two and passed in one. - Replay: a recorded Raven-Research turn (ACP frame journal, 2026-09-19; 10 message chunks, 26 tool calls, 827 thought chunks) fed through the new collector returns 14,814 chars where the old rule returned 15,565. The 749 dropped are exactly the eight progress notes, and the reply equals `closing_text`. - Gates: `scripts/check_commit_messages.py`, `scripts/check_large_files.py` and `scripts/check_source_language.py` over `origin/main..HEAD`, `ruff check` and `ruff format --check` over the `make lint-python` targets, `lint-imports` (10 kept, 0 broken), and `ty check` over the `make lint-types` targets: all clean. - [x] Relevant tests pass locally - [x] Relevant lint / type checks pass locally - [ ] User-facing docs or screenshots are updated when needed No user-facing docs describe what a sub-agent's result contains; `CONTEXT.md` is updated. ## Risk What changes for a reader: the host's spawn announcement, a DAG node's `out.md` (and so `{{ ref }}` and terminal outputs downstream), and a direct chat's record now carry only the agent's last message. An agent that put a needed fact only in a progress note, and not in its final message, no longer hands that fact to the host; the full turn is still in the run's transcript and frame journal. Every ACP agent is affected: the five shipped agents and any third-party one. The live stream of a direct chat can now show more than `maxOutputChars` in total, at most that much per message; every streamed message is also stored on the transcript. - [x] Security impact considered - [x] Backward compatibility considered - [x] Rollback path is clear for risky changes Security: the reply is one message of the same turn the joined text carried, and it travels the same routes, so no new kind of content reaches the host and no path to a secret changes. Rollback: revert the squash commit; nothing on disk depends on the new shape. ## Related Issues N/A --------- Co-authored-by: Claude (claude-opus-5-5[1m]) <noreply@anthropic.com>
## Summary ACP replies and live output need the same message boundaries. A steer arriving during a report must not drop its earlier half, and a held prompt's heartbeat must not split that report. Return the last nonblank message while retaining narration in the transcript. - A tool opening, terminal or status-less tool update, or plan opening ends a message. Updates marked `pending` or `in_progress` do not; Raven's held-prompt heartbeat can arrive mid-report. - A steer stays inside its message with one separator. That separator spends the message's budget when it is inside returned text; a separator between messages does not. - Leading whitespace remains visible but spends no capped-message budget because `reply` strips it. Blank chunks and repeated steers before the first nonblank text no longer silently truncate a reply that fits. Whitespace inside substantive message text still counts. - Preserve the distinct closing transcript row and document the intentional report/save/sign-off behavior. Code comments and the ACP parsing design name both nonterminal statuses explicitly. ## Type - [x] Fix - [ ] Feature - [ ] Docs - [ ] CI / tooling - [ ] Refactor - [ ] Other ## Verification - [x] Relevant tests pass locally - [x] Relevant lint / type checks pass locally - [x] User-facing docs or screenshots are updated when needed Linux, Python 3.12, after rebase onto current main: ```text uv run --frozen --all-extras pytest tests/test_subagent_acp.py tests/test_acp_methods.py tests/test_subagent_turn_rows.py tests/test_subagent_history.py tests/test_rpc_dag.py tests/test_rpc_subagent_calls.py tests/test_subagent_openai_steps.py tests/test_subagent_openai_backend.py -q -x --tb=short ``` 618 passed. Coverage includes the real held-prompt heartbeat, tool/plan boundaries, persisted replies, transcript rows, and downstream DAG consumers. Native Windows, Python 3.12: ```text uv run --frozen pytest tests/test_subagent_acp.py -k "blank_prefixes or steered_message or steer_inside or a_steer_sets" -n 0 -q -x --tb=short ``` 23 passed. Nine blank-prefix cases failed before the fix; the final matrix also covers prefixes after a tool boundary and explicitly preserves visible whitespace. Four isolated mutations were caught: charging stripped whitespace, treating every chunk as leading, ending the leading-prefix state on a blank chunk, and dropping visible leading whitespace. Sources were restored after each run. Checks passed: ```text uv run --frozen pre-commit run --from-ref origin/main --to-ref HEAD uv run --frozen ty check raven/acp_client/acp_agent.py raven/agent/subagent/backends/base.py raven/agent/subagent/history.py uv run --frozen lint-imports uv run --frozen python scripts/check_commit_messages.py origin/main..HEAD commitlint --from origin/main --to HEAD uv run --frozen python scripts/check_source_language.py origin/main..HEAD uv run --frozen python scripts/check_large_files.py origin/main..HEAD git diff --check origin/main...HEAD ``` All 10 import contracts kept. Full repository tests, native macOS, and the TUI suite were not rerun for this follow-up. The full ACP subprocess selection ran on Linux; Windows verification covers the collector directly. ## Risk - [x] Security impact considered - [x] Backward compatibility considered - [x] Rollback path is clear for risky changes ACP callers receive the last nonblank message, so a report followed by a save step and "Saved." returns the sign-off; the earlier report remains in the transcript. Leading display-only whitespace and separators between messages can make the raw stream longer than the returned-message cap. Internal separators and substantive text remain capped together. No stored format or configuration changes. Rollback: revert the squash commit. ## Related Issues #889 --------- Co-authored-by: Claude (claude-opus-5-5[1m]) <noreply@anthropic.com>
Summary
An ACP sub-agent's result joined every
agent_message_chunkof its turn, so the notes a model writes beside its tool calls ("Three confirmed. Now the research repos.", "All 10 repos confirmed. Now verifying benchmark numbers and ecosystem framing.") reached the host as part of the answer: in a spawn announcement'sResult:, in a DAG node'sout.md(what a later task reads through{{ ref:@nodes/<id>.out.md }}, and what a terminal node's output inlines), and in a direct chat's record. The in-process raven loop already returned only its final response, and the openai lane returns one message; the acp lane was the one that joined.The collector's new
replyis the run's last message: what it said after its last tool call or steer, which is the existingclosing_text. A turn that ended on a step said nothing after it, so its reply is the message before that step: still one message, and not an empty result for a run that finished its work. Whitespace after the last step counts as nothing said. The narration stays where it already was, on the step rows of the run's transcript and in the live stream a direct chat renders.Two consequences ride with it. The live stream's
maxOutputCharsbudget is now renewed per message: spent across the turn, narration would use it up and cut the report short on screen with no notice, because the reply itself fits and is never capped. The break between two messages is raven's own text and goes through the unbounded delta callback, as the partial-turn notice does, so it spends neither message's budget. And the span'sanswer_charsmeasures the reply, as the cli lane'slen(output)does.Existing tests whose meaning changed, on purpose: three assertions encoded the old rule that the caller receiving the run's answer wants all of it, and are flipped (
test_two_messages_in_one_turn_are_kept_apart,test_narration_lands_on_the_step_it_preceded,test_a_steer_reaches_the_running_turn_and_is_written_where_it_was_said); the answer-row test gains anout.mdassertion. A second commit corrects texts the change falsified: two RPC readers and three test docstrings that said the output repeats the narration, and a stream-cap test renamed for the per-message cap it checks (test_a_streamed_message_is_capped_like_the_reply).CONTEXT.md's Closing Message and Reply Streaming entries are rewritten to the new rule.Left as is, deliberately:
finalShape.processAppendix.closing.mdnow repeatsout.mdin the common case. It is left in place, since removing it reaches the two context reads and the RPC.out.mdas the answer row while the instance log draws none. That predates this change, which shrinks the repeat from every message to one.Type
Verification
Red before green: the seven new or flipped tests that hold the rule failed first for the expected reason (the joined reply returned,
answer_chars27 against 13, the live stream cut tolet me look.and one character of the report), then passed. The blank-burst test was checked by mutation instead: without its whitespace guard it fails with'' == 'found it.'.Single-construct mutants on
raven/acp_client/acp_agent.py, each run againsttests/test_subagent_acp.pyand each killed: ignoring the cap (1 failed), not renewing it per message (1), counting the joined text in the span (1), dropping the trailing-step fallback (2), returning the joined text (7).Review round one: the break between messages spent the renewed budget, so at a cap of exactly the reply's length the screen showed
it is a repwhile the reply wasit is a repo.(caps 13, 14 and 15 lost two characters, one, and none). The per-message test now runs at that cap, and failed withit is a repbefore the fix. Mutants on the fix, each killed: counting the break against the budget (1 failed), not renewing the budget (1), not streaming the break (2), ignoring the cap (1).uv run --frozen --all-extras --python 3.12 pytest -q -p no:randomly tests/test_subagent_acp.py tests/test_rpc_dag.py tests/test_rpc_subagent_calls.py tests/test_subagent_history.py: 406 passed.uv run --frozen --python 3.12 --all-extras pytest -q -p no:randomly(themake test-pythoncommand) on the branch head: 27705 passed, 8 failed, 119 skipped. Seven of the failing IDs fail identically onorigin/main, run the same day through the same interpreter, so none is introduced: five proxy tests intests/test_config_update_providers.py,tests/test_install_script.py::test_resolve_node_dir_answers_each_case_it_exists_for, andtests/test_subagent_node_runtime.py::test_what_cannot_be_read_names_nothing. The eighth,tests/test_agents_research_tools.py::test_a_thin_dataset_card_falls_back_to_the_metadata_the_table_needs, passed when re-run alone; across the three full runs of this branch it failed in two and passed in one.Replay: a recorded Raven-Research turn (ACP frame journal, 2026-09-19; 10 message chunks, 26 tool calls, 827 thought chunks) fed through the new collector returns 14,814 chars where the old rule returned 15,565. The 749 dropped are exactly the eight progress notes, and the reply equals
closing_text.Gates:
scripts/check_commit_messages.py,scripts/check_large_files.pyandscripts/check_source_language.pyoverorigin/main..HEAD,ruff checkandruff format --checkover themake lint-pythontargets,lint-imports(10 kept, 0 broken), andty checkover themake lint-typestargets: all clean.Relevant tests pass locally
Relevant lint / type checks pass locally
User-facing docs or screenshots are updated when needed
No user-facing docs describe what a sub-agent's result contains;
CONTEXT.mdis updated.Risk
What changes for a reader: the host's spawn announcement, a DAG node's
out.md(and so{{ ref }}and terminal outputs downstream), and a direct chat's record now carry only the agent's last message. An agent that put a needed fact only in a progress note, and not in its final message, no longer hands that fact to the host; the full turn is still in the run's transcript and frame journal. Every ACP agent is affected: the five shipped agents and any third-party one.The live stream of a direct chat can now show more than
maxOutputCharsin total, at most that much per message; every streamed message is also stored on the transcript.Security: the reply is one message of the same turn the joined text carried, and it travels the same routes, so no new kind of content reaches the host and no path to a secret changes. Rollback: revert the squash commit; nothing on disk depends on the new shape.
Related Issues
N/A