Skip to content

fix(acp_client): return only the last message of an acp sub-agent turn - #889

Merged
0xKT merged 3 commits into
mainfrom
fix/acp_reply_closing_text
Oct 10, 2026
Merged

0xKT merged 3 commits into
mainfrom
fix/acp_reply_closing_text

Conversation

@LivXue

@LivXue LivXue commented Oct 9, 2026 •

Copy link
Copy Markdown
Member

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

  • 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.

  • 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.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.

  • Security impact considered
  • Backward compatibility considered
  • 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

LivXue and others added 2 commits October 9, 2026 08:47
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>
@LivXue
LivXue requested review from 0xKT and gloryfromca October 9, 2026 09:07

@gloryfromca gloryfromca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread raven/acp_client/acp_agent.py Outdated
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 gloryfromca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@0xKT

0xKT commented Oct 9, 2026

Copy link
Copy Markdown
Member

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 _TurnCollector on this head and on main (main handed the caller text; this head hands it reply):

  1. A steer that arrives while the final message is streaming cuts the returned answer to the part after it. reply (acp_agent.py:788-793) stops its walk at a user event. raven's own ACP server puts the steer frame on the wire as the request arrives (raven/acp/methods.py:874), while the loop reads injected words only at the top of its next iteration (raven/agent/loop/turn_path.py:935-942), so a steer during a tool-free final response lands inside that message. [say "Looking.", a Read call, say "REPORT PART ONE. ", steer "also cover the docs", say "REPORT PART TWO."] hands the caller "REPORT PART TWO."; the same frames without the steer hand "REPORT PART ONE. REPORT PART TWO."; main handed everything. The head of the answer stays in the live stream and the transcript, but the parent's tool result, a Direct Chat record and a DAG node's out.md lose it, with no notice. Counting a steer as a boundary only when a step follows it would keep the message whole.
  2. A report followed by a save step and a sign-off now returns only the sign-off. [say "Looking.", Read, say "FULL REPORT: findings 1-12.", Write, say "Saved."] hands the caller "Saved." (main: everything). That is the contract's "final text result" (raven/contracts/subagent_backend.py:43) and what the raven loop and the cli parsers return, so it is the intended trade, but a DAG node's downstream {{ ref:@nodes/<id>.out.md }} now reads "Saved." in that shape. A sentence in CONTEXT.md's Closing Message entry saying so would make the trade visible to whoever writes a DAG.
  3. Two rules decide where a message ends. The live-stream break and the per-message budget key on _tool_ran (acp_agent.py:322-325), which any tool_call_update sets; reply and closing_text stop only at call and user events. Text that arrives while a call is still open -- [say "Alpha.", Write call, say "while it runs.", completed, say "Saved."] -- streams as three separated messages but hands the caller "while it runs.Saved.", two messages joined with no separator, against CONTEXT.md's "never the narration joined to it". No in-tree capture shows an adapter sending text inside an open call, so reachability is unproven; deciding the boundary once and using it in both places would close it.

Wording left behind by the change: closing_text's docstring (acp_agent.py:752) still calls the closing "the reply proper" next to the new reply, which differs from it; bounded_delta's docstring (raven/agent/subagent/backends/base.py:38) still promises "what streams stays a prefix of what returns", which acp narration no longer does; and the comment at raven/agent/subagent/history.py:414-415 says out.md keeps "the reply the caller was handed", while it keeps the reply before the cap the caller's copy went through.

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).

@gloryfromca

Copy link
Copy Markdown
Member

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 isResolved: true; the clean stance on 4334a84f550d is unchanged.

@0xKT
0xKT merged commit c6ae3b7 into main Oct 10, 2026
27 checks passed
@0xKT
0xKT deleted the fix/acp_reply_closing_text branch October 10, 2026 02:41
@LivXue

LivXue commented Oct 10, 2026

Copy link
Copy Markdown
Member Author

Thanks -- all three reproduced before any change, with your frames fed to _TurnCollector on 4334a84f5 and on b56da140a, the main this PR was cut from:

frames b56da140a returned 4334a84f5 returned
say, Read, say REPORT PART ONE. , steer, say REPORT PART TWO. everything REPORT PART TWO.
the same without the steer everything REPORT PART ONE. REPORT PART TWO.
say, Read, say FULL REPORT: findings 1-12., Write, say Saved. everything Saved.
say Alpha., Write opens, say while it runs., completed, say Saved. everything while it runs.Saved.

The steer path is reachable as you traced it: raven/acp/methods.py puts the user_message_chunk on the wire before turn.send merges the words into the running turn, and the loop reads them only at the top of its next iteration, before its model call.

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 reply is the last message as the live view streamed it. The stream's per-message budget reads the same message starts, so the two cannot drift apart again. A steer is set apart by its break inside the message, renews no budget and spends none. That is your "a steer counts as a boundary only when a step follows it" in effect: when a step follows, the step is the nearer boundary.

One deviation, with its reason: the steer's break stays in the reply (REPORT PART ONE. \n\nREPORT PART TWO.), because that is the message the live view showed; dropping it would also glue the steerable stub's on it to its answer. And closing_text keeps its own rule on purpose: turn_rows attaches text to a call row only when a call follows it, so text said while a call is open reaches the transcript only through the closing, and moving the closing to the reply's boundary would drop that text from the transcript. Its docstring now says it is the transcript's closing row and why it differs from 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 (Saved.), and the steerable stub's assertion back to the whole message, on it\n\nsteered: the docs first. Each fails on main's code. Mutants on the new code, each against tests/test_subagent_acp.py and each killed: a steer opening a message (2 failed), a steer renewing the budget (1), a steer not set apart (2), no fallback past a blank last message (1).

2: the Closing Message entry in CONTEXT.md now says a turn that reports, saves and signs off returns the sign-off, so the report a later node reads through {{ ref:@nodes/<id>.out.md }} has to be the node's last message.

Wording: closing_text's docstring (above); bounded_delta's docstring, which now says each acp message has a budget of its own and that the break is exempt with the notice; and the comment in history.py, which now says out.md keeps the whole reply, before the cap the caller's copy goes through.

The full verification is in #892's description.

LivXue added a commit that referenced this pull request Oct 11, 2026
## 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>
LivXue added a commit that referenced this pull request Oct 12, 2026
#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>
LivXue added a commit that referenced this pull request Oct 12, 2026
## 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants