Skip to content

fix(cli,sdk,api): parallel tool-result pairing, terminal-scope path limit, credential hint - #3360

Closed
c01dsummer wants to merge 15 commits into
ultraworkers:mainfrom
c01dsummer:fix/roadmap-835-838
Closed

c01dsummer wants to merge 15 commits into
ultraworkers:mainfrom
c01dsummer:fix/roadmap-835-838

Conversation

@c01dsummer

@c01dsummer c01dsummer commented Oct 16, 2026 •

Copy link
Copy Markdown

Summary

Four defects found while investigating a live claw REPL failure, plus the ROADMAP
entries that record them. They are combined here because each one touches the
append-only tail of ROADMAP.md, so separate PRs would conflict on every merge.

  1. fix(cli) — parallel tool-call results were one message too late. convert_messages
    emitted one wire message per tool result, so a turn with two tool calls produced
    assistant[tool_use, tool_use], user[tool_result], user[tool_result]. The Anthropic
    Messages API requires every tool_use to be answered in the single message that follows
    it, so the request was rejected. Runs of tool results now merge into that one message.
    (ROADMAP Claude vibe coding vibe marketing交流群 + #835)
  2. fix(sdk) — terminal-scope record paths exceeded the filesystem path limit. A maximal
    512-byte key expanded to 1024 hex characters and the generated path ran past the 1024-byte
    APFS total-path limit, so set/get failed with File name too long. Records are now
    named from a fixed-width SHA-256 digest; the full key still lives in the record and is
    still checked on read. (ROADMAP https://github.com/e10nMa2k/cc-mini #836)
  3. fix(api) — the missing-credential error ignored a configured non-Anthropic endpoint.
    With ANTHROPIC_BASE_URL pointing at a third-party gateway the message named two env vars,
    which reads as "go get an Anthropic key" and never mentioned the endpoint the user had
    configured. A new hint branch names the endpoint and explains the variable-name convention.
    (ROADMAP 怎么全都是中文issue xs #838)
  4. docs(runtime) — recorded the MCP stdio fixture flake (ROADMAP 为什么还在持续commit? #837, KNOWN FLAKE, not
    fixed): the #[cfg(test)] 200ms/300ms handshake budgets are spent on python3 cold start
    when a dozen fixture children start at once. The budgets stay short on purpose, so the
    cause is documented where the numbers are read instead.
  5. fix(cli,tools) — review follow-up. The first cut of (1) checked only the accumulated
    message when merging, which folded the message that followed a tool result into it and lost its
    role — reachable on single-call turns too, and re-read on every request. A merge now requires
    both sides to be pure tool results, and tools/src/lib.rs, which carries its own
    convert_messages for the subagent path, applies the same rule; the parallel-call 400 was still
    live there.
  6. test(tools) — wire-level coverage for the subagent path. The existing wire-contract
    test only drives the claw binary, so it covers the CLI converter. The subagent converter in
    tools is reached only through ProviderRuntimeClient::stream, which no test called, and its
    two unit tests assert on converted values rather than the transmitted bytes. A new test starts
    the mock service, drives stream with a two-call turn, and asserts on the captured body.
  7. chore — .DS_Store added to .gitignore.

Same-repo branches for per-defect review

This PR is one linear branch so that ROADMAP.md is touched by a single change set. The same
commits also exist as one branch per defect in the fork, which are easier to review in isolation.
All five were realigned after the review fixes below, so every file matches this branch exactly
(verified per file with git diff fix/roadmap-835-838 <branch> -- <paths>):

Branch What it covers ROADMAP Head
fix/parallel-tool-result-pairing-835 convert_messages pairing, the tightened merge rule, and the wire-contract test #835 9b6c89b
fix/terminal-scope-path-length-836 terminal-scope record paths via SHA-256 digest + path-shape assertions #836 edf7274
fix/anthropic-credential-hint-base-url credential hint for a configured non-Anthropic ANTHROPIC_BASE_URL #838 ef86586
docs/roadmap-835-837 the MCP fixture-budget comment and ROADMAP entries #835-#837 #835-#837 0de4f54
chore/ignore-ds-store .gitignore — 868a8ac

The ROADMAP conflicts between them were resolved here by keeping every numbered entry in numeric
order; no entry text was altered in the process, and the #835 entry additionally carries the
correction recorded in this PR's review reply.

The branches are realigned to the current head db147cd, so the SHAs above are the current heads.

Anti-slop triage

  • Classification: actionable-fix

  • Evidence: the CLI defect reproduces from this repository's own dogfood session
    (.claw/sessions/e49619b247aa3fce/session-1792170907799-0.jsonl), whose step-1 assistant
    message carries two tool_use blocks followed by two separate tool-result messages:

    api returned 400 Bad Request (invalid_request_error): messages.3: `tool_use` ids were
    found without `tool_result` blocks immediately after:
    call_01_4QJqcZ0075kla1ILwra75582
    

    The SDK defect reproduces deterministically with a 512-byte key:

    read terminal scope '…/6b6b…(1024 hex chars)….json': File name too long (os error 63)
    

    The API defect reproduces with claw prompt "say hello" and
    ANTHROPIC_BASE_URL=https://api.deepseek.com/anthropic and no credential exported.

  • Non-destructive review result: merge candidate

Verification

  • Both new regression assertions fail against the pre-fix code. The CLI one was verified
    by extracting main with git archive into a separate tree, rebuilding there, and running the
    new integration test, which reported the exact payload behind the live 400:

    request 1: ["user[text]", "assistant[tool_use, tool_use]", "user[tool_result]", "user[tool_result]"]
      left:  ["toolu_multi_read"]
      right: ["toolu_multi_read", "toolu_multi_grep"]
    
  • Every file on this branch is byte-identical to the branch it was cherry-picked from
    (git diff per file against fix/parallel-tool-result-pairing-835,
    fix/terminal-scope-path-length-836, docs/roadmap-835-837,
    fix/anthropic-credential-hint-base-url).

  • cargo test --workspace -- --test-threads=1 → 1811 passed, 0 failed, 1 ignored
    (measured on this branch, not transcribed).
    The ignored case is api/tests/client_integration.rs::live_stream_smoke_test, marked
    requires ANTHROPIC_API_KEY and network access.

  • cargo test -p api -p sdk -p rusty-claude-cli on this branch → all targets pass
    (api 172, sdk 119 + 12 + 109, cli 224 + 109 + 13 + 11 + 8 + 1 + 1).

  • cargo test --workspace (default parallel) still reports 10-11 runtime MCP fixture
    failures: that is the pre-existing flake recorded in ROADMAP 为什么还在持续commit? #837, not a regression — see
    the serial baseline above.

  • scripts/fmt.sh --check; scripts/roadmap-check-ids.sh;
    python .github/scripts/check_doc_source_of_truth.py;
    python .github/scripts/check_release_readiness.py; python -m unittest discover -s tests.

  • cargo clippy --workspace --all-targets -- -D warnings reports no findings in the changed
    crates; the pre-existing findings in runtime/trident.rs, runtime/file_ops.rs,
    runtime/line_range_selector.rs, runtime/compact.rs and claw-rag-service are untouched.

  • No live secrets, tokens, or private logs are included. A credential appeared in the local
    conversation log during dogfooding and is not part of this diff (verified by grepping the
    whole tree for it).

Resolution gate

Finder metadata kept showing up as an untracked file in this macOS checkout.

(cherry picked from commit 868a8ac)
Drives the real `claw` binary against the mock Anthropic service for the
`multi_tool_turn_roundtrip` scenario and asserts on the payload the provider
would receive: every `tool_use` of an assistant message must be answered by
`tool_result` blocks in the message immediately after it, and a turn with two
tool calls must still produce exactly one tool-result message.

This lives in `tests/` rather than beside `convert_messages` so it can be
reverted independently of the fix. Run against the pre-fix implementation it
fails with the payload that caused the live 400:

  request 1: ["user[text]", "assistant[tool_use, tool_use]",
              "user[tool_result]", "user[tool_result]"]
  left:  ["toolu_multi_read"]
  right: ["toolu_multi_read", "toolu_multi_grep"]

The mock service does not validate pairing itself, which is why the contract is
asserted here. `cargo test -p rusty-claude-cli --test
parallel_tool_result_pairing` passes with the fix and fails without it.

(cherry picked from commit 4eba1e2)
`convert_messages` emitted one wire message per `MessageRole::Tool` record.
The Anthropic Messages API requires every `tool_use` of an assistant message to
be answered by `tool_result` blocks in the single message that follows it, so
the second and later results of a parallel tool-call turn landed one message
too late and the provider rejected the whole request:

  api returned 400 Bad Request (invalid_request_error): messages.3: `tool_use`
  ids were found without `tool_result` blocks immediately after:
  call_01_4QJqcZ0075kla1ILwra75582

Single-call turns were unaffected, which is why the defect only surfaced when
the model requested several tools at once. Evidence: `.claw/sessions/
e49619b247aa3fce/session-1792170907799-0.jsonl`, whose step-1 assistant message
carries two `tool_use` blocks followed by two separate tool-result messages.

Fix: `convert_messages` is split into a per-message `convert_message` plus a
folding accumulator `absorb_tool_result_message`, which merges a run of
adjacent tool-result messages into the one `user` message that follows the
assistant turn. The merge only extends a message whose content is tool results
and nothing else, so user text and assistant turns can never be joined.
`converts_tool_roundtrip_messages` keeps its previous single-call contract.

Verification: `cargo test -p rusty-claude-cli` (224 unit tests plus the
integration targets); `scripts/fmt.sh --check`.

(cherry picked from commit 1c0ba66)
… fix

Records the reported symptom, the root cause, the fix, and the verification that
fails against the pre-fix code. Id 835 is the next free id from
`scripts/roadmap-next-id.sh`.

(cherry picked from commit 9af3c77)
`TerminalScopeStore::record_path` expanded the correlation key into full hex and
chunked it into 180-byte path components. A maximal 512-byte key expands to 1024
hex characters, so the generated path ran past the 1024-byte total-path limit
enforced by APFS and `set`/`get` failed with ENAMETOOLONG:

  read terminal scope '…/6b6b…(1024 hex chars)….json': File name too long (os error 63)

The misleading part is where the limit lives: every component stayed within the
255-byte filename limit (five 180-byte directories plus a 129-byte leaf), so the
errno named a generated leaf name the caller never chose, and shrinking the
chunk cannot help because the hex expansion stays 1024 characters however it is
split. The same code passes on Linux, where `PATH_MAX` is 4096, so the
`cargo test --workspace` job on ubuntu-latest could not catch it.

Fix: records are named from a fixed-width SHA-256 digest (`<32 hex>.json`) and
the multi-level chunking is gone. The full key still lives in
`TerminalScopeRecord::correlation_key` and is still checked on read, so
`encoded_keys_are_isolated_and_cannot_traverse_paths` keeps holding.
`sha2` joins the crate's dependencies, reusing the version `runtime` already
pulls, so `Cargo.lock` gains one line.

The regression test now asserts on the path shape (longest component ≤ 255
bytes, total path < 1024 bytes) instead of only round-tripping, because a round
trip inside a short temporary root succeeds even when the generated path is
unusable in practice.

Verification: `cargo test -p sdk` (119 unit tests, 12 + 109 integration);
`scripts/fmt.sh --check`; the new assertion fails against the pre-fix code.

(cherry picked from commit 07f020f)
Records the symptom, why the errno pointed at a harmless-looking leaf name, why
CI could not see it, and the verification that fails against the pre-fix code.

(cherry picked from commit 1a66aad)
The `#[cfg(test)]` budgets (200ms/300ms against production 10s/30s) are
deliberately short so the scripted stdio fixtures finish quickly, but they are
also why the default parallel `cargo test --workspace` reports failures in
`mcp_stdio::tests` and `mcp_tool_bridge::tests`: the fixtures are real `python3`
children (cold start measured at 18-21ms here), and a dozen of them starting at
once spends the budget on process startup alone.

The comment records the shape of the failure, the `--test-threads=1` workaround,
and why the budgets stay short, so the next reader does not misread a scheduling
artifact as a product defect.

(cherry picked from commit f058e84)
…around

KNOWN FLAKE (not fixed): records the cause, the measured numbers, why only the
two in-crate unit-test modules go red, and the deliberate decision to keep the
budgets short rather than widen them for parallel tolerance.

This branch also carries pinpoints 835 and 836, which document fixes landing on
their own branches: the roadmap is append-only, so the three entries are filed
together to keep ROADMAP.md touched by a single PR.

(cherry picked from commit 7cb2482)
…al error

`claw prompt "say hello"` with `ANTHROPIC_BASE_URL=https://api.deepseek.com/
anthropic` and no credential exported reported:

  missing Anthropic credentials; export ANTHROPIC_AUTH_TOKEN or
  ANTHROPIC_API_KEY before calling the Anthropic API

Truthful but misleading. Third-party Anthropic-compatible endpoints accept the
`ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN` names purely as a wire-compatibility
convention — the value has to be a credential the endpoint itself issued, and an
`sk-ant-` key is rejected — so the message reads as "obtain an Anthropic key"
while never mentioning the endpoint the user had already configured.

`anthropic_missing_credentials_hint()` sniffed only `FOREIGN_PROVIDER_ENV_VARS`
and ignored `ANTHROPIC_BASE_URL`, so users who had already pointed claw at a
gateway got the generic wall.

Fix: a `custom_anthropic_base_url_hint()` branch fires whenever
`ANTHROPIC_BASE_URL` differs from `anthropic::DEFAULT_BASE_URL` (empty and
whitespace-only values are ignored), names the configured URL, and explains the
variable-name convention. It runs before the foreign-provider loop: a configured
endpoint is a fact about what the user asked for, while a presence check on
another provider's key only guesses at intent. Base-url resolution reuses
`anthropic::read_base_url()` so the hint cannot disagree with the client's own
endpoint.

Seven pre-existing hint/error tests depended on the ambient `ANTHROPIC_BASE_URL`
and failed under a shell that sets it; they now pin the variable explicitly.

Verification: `cargo test -p api` (172 unit tests plus four integration
targets); `scripts/fmt.sh --check`; the exact failing command now reports the
endpoint it was configured with.

(cherry picked from commit ae07657)
Pre-registered as 838 because branches for 835/836/837 are already open against
the same append-only section.

(cherry picked from commit ff347ce)
@XinwangTao

Copy link
Copy Markdown

Thanks for the fix! This addresses the parallel tool-result pairing failure reported in #3361 (API 400 when concurrent tool results arrive one message too late). Merging adjacent tool-result blocks to preserve pairing, with regression tests, is the right approach. Glad the terminal-scope path limit and the Anthropic credential hint are included too - both were real footguns.

…sults

Review of PR ultraworkers#3360 found two defects in the first cut of the tool-result merge:

1. `absorb_tool_result_message` checked only the accumulated message, so any
   message that arrived after a tool result was folded into it. With
   `user -> assistant(tool_use) -> tool_result -> assistant("done")` the reply
   became an extra `text` block inside the tool-result message and its assistant
   role was lost. `AnthropicRuntimeClient::stream` converts the whole history on
   every request, so the corrupted message survived into later turns, and the
   path was reachable on single-tool-call turns too — the previous tests stopped
   at the tool-result message and never exercised the round after it.

2. The same `convert_messages` exists in `tools` for the subagent path and was
   never updated, so the parallel-tool-call 400 was still live there.

Fix: a merge now requires both the accumulated message and the incoming one to
be pure tool results. `tools/src/lib.rs` folds through the same rule, using the
same `absorb_tool_result_message` / `is_tool_result_only` shape.

The ROADMAP ultraworkers#835 entry is corrected accordingly: "single-call turns were
unaffected" was too strong (the 400 is parallel-only, but the role-swallowing
corruption was not), and a "Correction during review" note records both review
points.

New tests, each failing without the tightened condition:
- `keeps_the_assistant_reply_that_follows_tool_results`
- `keeps_a_user_follow_up_that_follows_tool_results_separate`
- `subagent_merge_requires_both_sides_to_be_tool_results`

`subagent_parallel_tool_results_share_one_message` covers the previously
unfixed subagent pairing path (it is green either way; it guards the original
defect, not the regression).

Verification: `cargo test --workspace -- --test-threads=1` -> 1810 passed, 0
failed, 1 ignored (up from 1806 by exactly these four tests); `scripts/fmt.sh
--check`; roadmap and doc gates pass.
@c01dsummer

Copy link
Copy Markdown
Author

Both review points were correct, and the first one was worse than "scope too broad" — it was a live regression.

absorb_tool_result_message checked only the accumulated message. Any message arriving after a tool result was folded into it, so user -> assistant(tool_use) -> tool_result -> assistant("done") produced:

[2] user       [ToolResult(tool-1), Text("done")]   <- assistant role gone
[3] user       [Text("and now?")]                   <- shifted

Reproduced exactly that with a test before touching the fix. Two consequences worth calling out: it did not require parallel calls (single-call turns hit it too), and because AnthropicRuntimeClient::stream converts the whole history on every request, the corrupted message came back on every later request rather than only in the turn that created it. A merge now requires both sides to be pure tool results.

New tests, both failing without the tightened condition:

  • keeps_the_assistant_reply_that_follows_tool_results
  • keeps_a_user_follow_up_that_follows_tool_results_separate

The missed call site. rust/crates/tools/src/lib.rs has its own convert_messages for the subagent path, which I never touched — the parallel-call 400 was still live there. It now folds through the same rule (absorb_tool_result_message / is_tool_result_only), with subagent_parallel_tool_results_share_one_message covering the pairing and subagent_merge_requires_both_sides_to_be_tool_results covering the merge condition.

Actually harmful claim removed. ROADMAP #835 said "single-call turns were unaffected, which is why the defect only showed up when the model requested parallel tools". The 400 is parallel-only, but the role-swallowing corruption was not, so that sentence now reads differently and the entry carries a "Correction during review" note.

Baseline, re-measured on the branch just pushed:

$ cd rust && cargo test --workspace -- --test-threads=1
1810 passed; 0 failed; 1 ignored

1806 → 1810 is exactly the four new tests. Rendering refactor in tools and the CLI is behavior-preserving: the existing conversion tests are unchanged and still pass. scripts/fmt.sh --check, scripts/roadmap-check-ids.sh, and the doc gates pass.

#836 (32-hex filename still carrying the full key in the record for the read-time check), #838 (wording only; auth resolution untouched; custom base URL ahead of the foreign-key guess) and #837 (budgets deliberately left alone) are unchanged, as reviewed.

`tools` (lib test) reported two findings, both from the tests added in the
review fix for the subagent path:

- unused import `ConversationMessage` — the tests spelled the type as
  `runtime::ConversationMessage::...`, leaving the import idle. The tests now
  use the imported name.
- `tool_use_message` was never used — its one call site had been inlined into
  `ContentBlock::ToolUse` when the test was written. Removed.

No behavior change; `cargo test -p tools` is 115 passed, and the two warnings
are gone (`cargo clippy -p tools --all-targets`).

The remaining review observation — CLI and `tools` each carry the same folding
logic — is left alone on purpose. The two conversions are not identical:
`tools` drops empty `Text` blocks (`InputContentBlock::Text { text } if
text.is_empty()`) because the subagent path treats them as absent, while the CLI
keeps them. Hoisting one shared helper therefore means first deciding which
behavior wins and changing one caller's wire output, which is a separate change
rather than a mechanical move, and it does not belong in a bug-fix PR.
@c01dsummer

Copy link
Copy Markdown
Author

Both warnings were real, and both were mine — introduced by 3002123, the commit that fixed the subagent path:

warning: unused import: `ConversationMessage`   --> crates/tools/src/lib.rs:7027
warning: function `tool_use_message` is never used --> crates/tools/src/lib.rs:11107

The import was idle because the tests spelled the type as runtime::ConversationMessage::...; the helper was dead because its one call site had been inlined. Fixed in 79dbf85 — the tests now use the imported name and the helper is gone. cargo clippy -p tools --all-targets reports neither, cargo test -p tools is 115 passed.

On the duplicated folding logic: I agree it is new duplication from 3002123, not something older, and I left it in place deliberately rather than silently hedging. The two conversions are not identical:

  • tools drops empty Text blocks — .filter(|block| !matches!(block, InputContentBlock::Text { text } if text.is_empty())), because the subagent path treats an empty text part as absent;
  • the CLI keeps them.

Hoisting one shared helper therefore requires deciding which behavior wins and changing one caller's wire output, which is a behavior change, not a mechanical move. It does not belong inside a bug-fix PR where every hunk should be individually justifiable. If you would rather have the dedup here, say which behavior is correct for both paths and I will fold it in (and adjust the one test that pins the current CLI behavior: converts_tool_roundtrip_messages).

Baseline after the cleanup, re-measured on 79dbf85:

$ cd rust && cargo test --workspace -- --test-threads=1
1810 passed; 0 failed; 1 ignored

Unchanged from the previous measurement, as expected for a refactor that only removes dead code — the warnings were the only deltas. All five per-defect branches were realigned to this head (aa84ac8, 219dc47, 45c83b3, 91eac3f, 868a8ac) so the links in the description still show exactly this code.

836/838/837 are untouched in this commit, as reviewed.

Review of PR ultraworkers#3360 corrected the rationale recorded in 79dbf85. That message
implied a shared helper is impossible because the two `convert_messages` call
sites differ on empty `Text` blocks. The difference is real but it lives in
`convert_message`, not in the fold: `absorb_tool_result_message` and
`is_tool_result_only` are byte-for-byte identical in `rusty-claude-cli` and
`tools`, and hoisting them would not touch empty-text handling at all. The
empty-text filter in `tools` also predates this work (introduced by 75c08bc).

The duplication is still kept — a reviewer decision, since unifying the two
call sites is a separate behavior change — but the reason is now stated
accurately at both definitions instead of being inferred from a wrong premise.

No behavior change.
@c01dsummer

Copy link
Copy Markdown
Author

You are right, and the correction matters: I moved a true statement about convert_messages onto the fold helper, where it does not hold.

Checked your three points against the tree:

  1. Empty-text divergence is old. git log -S 'InputContentBlock::Text { text } if text.is_empty()' -- crates/tools/src/lib.rs → 75c08bc, well before 3002123. It lives in convert_message, which I did not touch for that path.
  2. The fold is identical in both copies. Diffing absorb_tool_result_message and is_tool_result_only between crates/rusty-claude-cli/src/main.rs and crates/tools/src/lib.rs: byte-for-byte identical. So hoisting them would not have touched empty-text handling.
  3. converts_tool_roundtrip_messages does not pin empty text — no empty-Text assertion anywhere in it. Calling it a pin on the CLI behavior was wrong.

So the accurate split is exactly the one you stated: the empty-text difference justifies not unifying the whole convert_messages; it does not justify keeping the fold duplicated. That rationale is now recorded at both definitions (5d58eca) instead of being inferred from a wrong premise — the duplication stays, per your call, and the comment says why and warns to keep the two copies in sync.

Warnings are gone (79dbf85): ConversationMessage import is used now, tool_use_message deleted, cargo clippy -p tools --all-targets reports neither. Your conclusion matches the state of the branch: two absorb copies kept, empty-text unification tracked separately, warnings cleared.

converts_tool_roundtrip_messages was left untouched. Baseline unchanged at 1810 passed / 0 failed / 1 ignored (--test-threads=1), and all five per-defect branches were realigned to 5d58eca's tree (2f077d8, 5fe9d38, 76ec3d4, 0cbfcc2, 868a8ac).

@XinwangTao

Copy link
Copy Markdown

Nice catch on the parallel pairing — the Anthropic contract does require every tool_use to be answered in the single message that follows, so emitting one wire message per result would break any turn with more than one parallel call. Tightening the merge rule so both sides must be pure tool results (and mirroring it in the subagent convert_messages) also closes the role-loss bug on single-call turns. The SHA-256 naming for terminal-scope records is a clean fix too — keeping the full key inside the record means lookups stay verifiable without paying the path-length cost. One question: does the wire-contract test cover the subagent path as well, or is that conversion still only exercised indirectly?

1 similar comment
@XinwangTao

Copy link
Copy Markdown

Nice catch on the parallel pairing — the Anthropic contract does require every tool_use to be answered in the single message that follows, so emitting one wire message per result would break any turn with more than one parallel call. Tightening the merge rule so both sides must be pure tool results (and mirroring it in the subagent convert_messages) also closes the role-loss bug on single-call turns. The SHA-256 naming for terminal-scope records is a clean fix too — keeping the full key inside the record means lookups stay verifiable without paying the path-length cost. One question: does the wire-contract test cover the subagent path as well, or is that conversion still only exercised indirectly?

Review of PR ultraworkers#3360 caught the same conflation twice, and it is worth fixing in
the source rather than only in a comment thread. The note added in 5d58eca
implied the fold cannot be hoisted because the two `convert_messages` call sites
disagree on empty `Text` blocks. That disagreement is real, but it belongs to
`convert_message`; `absorb_tool_result_message` and `is_tool_result_only` are
identical in both crates and hoisting only them would not touch empty text.

The earlier claim that empty text cannot occur was also too strong.
`flush_text_block` filters the model stream, but `ConversationMessage::user_text`
does not, and a persisted `"text":""` restored from a session log goes through
`required_string` with no emptiness check. Both reach `convert_messages`, so the
CLI would send an empty `Text` block where `tools` drops it — narrow, but
reachable by session recovery rather than impossible.

The comment now states which piece the divergence constrains and how it is
reached. No behavior change; duplication kept, per review.
@c01dsummer

Copy link
Copy Markdown
Author

Two corrections accepted, both mine.

"Empty text cannot occur" was too strong. flush_text_block filters only the model stream. I checked the other two entry points and you are right about both: ConversationMessage::user_text takes text.into() with no emptiness check, and a persisted "text":"" restored from a session log goes through required_string, which does not reject empty strings either. So the divergence is reachable by session recovery or hand-built history — narrow, but not impossible, and I should not have called it that.

The 5d58eca note repeated the conflation you already flagged. It said a shared helper "means first reconciling" the empty-text difference. That holds for hoisting the whole convert_messages and does not hold for hoisting absorb_tool_result_message / is_tool_result_only, which are identical in both crates. 5a62d82 replaces the note with that separation: the divergence is named as belonging to convert_message, it constrains only the full-conversion hoist, and the comment now records how the empty-text case is actually reached (user_text(""), restored "text":"") instead of implying it cannot happen.

Duplication is kept, per your decision; the comment still says the two copies must be changed together.

State of the branch: 5a62d82. No behavior change from this commit — CLI unit tests 4/4, tools 115/115, and cargo clippy -p tools -p rusty-claude-cli --all-targets reports neither of the two warnings. All five per-defect branches realigned (7036ef9, e17cc3f, da92590, 2117913, 868a8ac). Baseline unchanged: 1810 passed / 0 failed / 1 ignored with --test-threads=1.

@c01dsummer

Copy link
Copy Markdown
Author

Fair question — the honest answer is no: the subagent conversion has unit coverage of convert_messages, not wire-level coverage.

What exists:

  • subagent_parallel_tool_results_share_one_message and subagent_merge_requires_both_sides_to_be_tool_results call tools' private convert_messages / absorb_tool_result_message directly and assert on the returned InputMessage structs. They pin the conversion logic, but they never serialise it, so nothing checks what actually leaves the process.
  • subagent_runtime_executes_tool_loop_with_isolated_session drives a real ConversationRuntime, but with MockSubagentApiClient — a hand-written ApiClient that returns canned events. It therefore never reaches convert_messages.
  • The execute_agent_with_spawn tests inject a fake job closure (the spawn path), not a provider.

The only production caller is impl ApiClient for ProviderRuntimeClient::stream at crates/tools/src/lib.rs:5329, and no test invokes it — grep '\.stream(' finds no call through ProviderRuntimeClient anywhere in the crate. So on this path the conversion is exercised only indirectly, and a regression in what is sent over the wire would not be caught.

Closing it is mechanical but not free:

  1. add mock-anthropic-service to tools' [dev-dependencies] (it has no dev-dependencies today);
  2. point ANTHROPIC_BASE_URL at server.base_url() and call ProviderRuntimeClient::stream with a two-tool-call ApiRequest;
  3. assert on captured_requests()[0].raw_body: messages[N].content carries tool_use ×2 and the next message carries tool_result ×2 — the same contract the CLI test asserts.

The reason I did not include it initially is that stream is synchronous and blocks on a Tokio runtime internally, so the test needs a threaded mock server (block_on from inside the runtime would panic) — about 60–80 lines. I can add it on top of this branch and re-post; say the word and it goes in. #836 / #838 / #837 are unaffected by that work.

@c01dsummer

Copy link
Copy Markdown
Author

Shorter version of the above, since the distinction is what matters:

The wire-contract test only drives the claw binary, so it covers the CLI converter (AnthropicRuntimeClient::stream in rusty-claude-cli/src/main.rs). The subagent path runs a separate convert_messages inside tools, reached only through ProviderRuntimeClient::stream, and that integration test cannot get there.

The subagent converter is covered directly, by two unit tests in tools:

  • subagent_parallel_tool_results_share_one_message — calls tools' convert_messages and asserts both tool_result blocks land in one user message;
  • subagent_merge_requires_both_sides_to_be_tool_results — calls absorb_tool_result_message and asserts the assistant reply after a tool result stays an assistant message.

There is no captured HTTP body for that path. Those unit tests assert the shape of the message that is about to be sent, not what was actually transmitted, so a regression in serialisation would still slip through.

That gap is closeable: add mock-anthropic-service to tools' dev-dependencies, point ANTHROPIC_BASE_URL at it, drive ProviderRuntimeClient::stream with a two-call ApiRequest, and assert on captured_requests()[0].raw_body — the same contract the CLI test asserts. It needs the mock server on a worker thread because stream blocks on a runtime internally. Say the word and I will add it to this branch; it touches no production code.

c01dsummer added a commit to c01dsummer/claw-code that referenced this pull request Oct 17, 2026
…ults

Review of PR ultraworkers#3360 asked whether the wire-contract test covers the subagent
path. It did not: `parallel_tool_result_pairing.rs` drives the `claw` binary, so
it exercises the CLI converter only. The subagent converter in `tools` is
reached exclusively through `ProviderRuntimeClient::stream`, which no test
called, and the two unit tests for it assert on the converted `InputMessage`
values rather than on the bytes that leave the process.

This adds the missing end-to-end check: start `mock-anthropic-service`, point
`ANTHROPIC_BASE_URL` (and a dummy credential) at it, drive
`ProviderRuntimeClient::stream` with a turn whose two tool calls already have
results, and assert on `captured_requests()[0].raw_body` — exactly one message
carries `tool_use` blocks, exactly one carries `tool_result` blocks, and both
results are answered in call order. Against the unfixed merge the body contains
two separate `role: "user"` result messages and the test fails, which is the
regression it is there to catch.

Notes on the test harness, since neither is obvious from the code:

- the mock service needs a multi-thread runtime: with `new_current_thread` the
  single worker is blocked by the synchronous `stream()` call from the test
  thread and the request never completes;
- `stream()` issues a `/v1/messages/count_tokens` preflight first, so the test
  selects `path == "/v1/messages"` instead of assuming a single captured
  request;
- the client reads both credential variables and the base URL from the process
  environment, so the test saves and restores all three.

`mock-anthropic-service` is added to `tools`' dev-dependencies (it had none).
No production code changes.

Verification: `cargo test -p tools --lib` -> 116 passed (was 115);
`cargo test --workspace -- --test-threads=1` -> 1811 passed, 0 failed, 1 ignored
(was 1810); `scripts/fmt.sh --check`.
@c01dsummer

Copy link
Copy Markdown
Author

Closed the gap in db147cd.

subagent_wire_request_pairs_parallel_tool_results drives ProviderRuntimeClient::stream against mock-anthropic-service and asserts on captured_requests() raw_body rather than on converted values:

  • exactly one message carries tool_use blocks, exactly one carries tool_result blocks;
  • both results are answered in call order (toolu_multi_read, toolu_multi_grep).

Verified against the unfixed merge: with the merge condition disabled the captured body has two separate role: "user" result messages and the test fails (left: 2, right: 1), so it catches the regression it exists for. Restored, cargo test -p tools --lib is 116 passed (was 115).

Three details worth knowing, none obvious from reading the code:

  • the mock service needs a multi-thread runtime. With new_current_thread the test thread blocks inside the synchronous stream(), the single worker cannot make progress, and the request hangs — I hit that first.
  • stream() sends a /v1/messages/count_tokens preflight before /v1/messages, so the test filters on the path instead of assuming one captured request.
  • the client reads both credential variables and ANTHROPIC_BASE_URL from the process environment; this shell sets a real base URL, so the test saves and restores all three.

mock-anthropic-service is now a dev-dependency of tools (it had none). No production code changed.

Workspace baseline: 1811 passed / 0 failed / 1 ignored with --test-threads=1 (was 1810). All five per-defect branches realigned (9b6c89b, edf7274, 0de4f54, ef86586, 868a8ac).

@c01dsummer

Copy link
Copy Markdown
Author

Nice catch on the parallel pairing — the Anthropic contract does require every tool_use to be answered in the single message that follows, so emitting one wire message per result would break any turn with more than one parallel call. Tightening the merge rule so both sides must be pure tool results (and mirroring it in the subagent convert_messages) also closes the role-loss bug on single-call turns. The SHA-256 naming for terminal-scope records is a clean fix too — keeping the full key inside the record means lookups stay verifiable without paying the path-length cost. One question: does the wire-contract test cover the subagent path as well, or is that conversion still only exercised indirectly?

— @XinwangTao in #3360

At the time you asked: no. The wire-contract test drives the claw binary, so it only covers the CLI converter (AnthropicRuntimeClient::stream). The subagent converter is a separate convert_messages inside tools, reached only through ProviderRuntimeClient::stream — no test called that, and its two unit tests asserted on converted InputMessage values rather than on transmitted bytes. So the conversion was indirect-only, exactly as you suspected.

Fixed in db147cd. subagent_wire_request_pairs_parallel_tool_results now closes it:

  • starts mock-anthropic-service and points ANTHROPIC_BASE_URL at it;
  • drives ProviderRuntimeClient::stream with a turn whose two tool calls already have results;
  • asserts on the captured raw_body: exactly one message carries tool_use blocks, exactly one carries tool_result blocks, and both are answered in call order (toolu_multi_read → toolu_multi_grep).

Checked against the unfixed merge: with the condition disabled the captured body contains two separate role: "user" result messages and the test fails (left: 2, right: 1). Restored, cargo test -p tools --lib is 116 passed (was 115).

Three things that were not visible from reading the code and cost me the first two attempts:

  1. the mock service needs a multi-thread runtime — with new_current_thread the single worker is blocked by the synchronous stream() call and the request hangs;
  2. stream() issues a /v1/messages/count_tokens preflight first, so the test filters on path == "/v1/messages" rather than assuming one captured request;
  3. the client reads both credential variables and ANTHROPIC_BASE_URL from the process environment, so the test saves and restores all three — otherwise a developer shell pointed at a real endpoint gets hit.

mock-anthropic-service is now a dev-dependency of tools. No production code changed. Workspace baseline: 1811 passed / 0 failed / 1 ignored with --test-threads=1 (was 1810).

@XinwangTao

Copy link
Copy Markdown

Nice catch on the SDK path limit — a 512-byte key hex-expanding past the APFS 1024-byte path cap is the kind of edge case that is easy to miss until it fails in production. Naming records from a fixed-width SHA-256 digest while keeping the full key inside the record and still verifying it on read keeps the scheme safe without losing the original key. The tightened merge condition (both sides must be pure tool results) also looks correct — folding an assistant message that follows a tool result would have silently corrupted history, and reproducing it with a failing test first is exactly the right way to pin that down. One question: do existing keys written under the old hex scheme need migration, or is terminal-scope data short-lived enough that it is fine to let them expire naturally?

@XinwangTao

Copy link
Copy Markdown

db147cd closes the subagent wire-coverage gap I flagged earlier — asserting on the captured raw_body instead of converted values is the right level, and the three gotchas (multi-thread mock runtime, count_tokens preflight, env save/restore) are exactly the details that would have cost someone else two attempts.

On the #836 migration question from my previous comment: whether existing hex-named records need a one-off rename depends on how the read path locates a file. If reads derive the path through the same naming function, old hex entries become unreachable once the digest scheme ships; if reads keep a legacy-path fallback, natural expiry is fine. Worth pinning that down in the ROADMAP #836 entry so the behavior is explicit either way.

@XinwangTao

Copy link
Copy Markdown

db147cd closes the subagent wire-coverage gap I flagged earlier — asserting on the captured raw_body instead of converted values is the right level, and the three gotchas (multi-thread mock runtime, count_tokens preflight, env save/restore) are exactly the details that would have cost someone else two attempts.

On the #836 migration question from my previous comment: whether existing hex-named records need a one-off rename depends on how the read path locates a file. If reads derive the path through the same naming function, old hex entries become unreachable once the digest scheme ships; if reads keep a legacy-path fallback, natural expiry is fine. Worth pinning that down in the ROADMAP #836 entry so the behavior is explicit either way.

2 similar comments
@XinwangTao

Copy link
Copy Markdown

db147cd closes the subagent wire-coverage gap I flagged earlier — asserting on the captured raw_body instead of converted values is the right level, and the three gotchas (multi-thread mock runtime, count_tokens preflight, env save/restore) are exactly the details that would have cost someone else two attempts.

On the #836 migration question from my previous comment: whether existing hex-named records need a one-off rename depends on how the read path locates a file. If reads derive the path through the same naming function, old hex entries become unreachable once the digest scheme ships; if reads keep a legacy-path fallback, natural expiry is fine. Worth pinning that down in the ROADMAP #836 entry so the behavior is explicit either way.

@XinwangTao

Copy link
Copy Markdown

db147cd closes the subagent wire-coverage gap I flagged earlier — asserting on the captured raw_body instead of converted values is the right level, and the three gotchas (multi-thread mock runtime, count_tokens preflight, env save/restore) are exactly the details that would have cost someone else two attempts.

On the #836 migration question from my previous comment: whether existing hex-named records need a one-off rename depends on how the read path locates a file. If reads derive the path through the same naming function, old hex entries become unreachable once the digest scheme ships; if reads keep a legacy-path fallback, natural expiry is fine. Worth pinning that down in the ROADMAP #836 entry so the behavior is explicit either way.

…ults

Review of PR ultraworkers#3360 asked whether the wire-contract test covers the subagent
path. It did not: `parallel_tool_result_pairing.rs` drives the `claw` binary, so
it exercises the CLI converter only. The subagent converter in `tools` is
reached exclusively through `ProviderRuntimeClient::stream`, which no test
called, and the two unit tests for it assert on the converted `InputMessage`
values rather than on the bytes that leave the process.

This adds the missing end-to-end check: start `mock-anthropic-service`, point
`ANTHROPIC_BASE_URL` (and a dummy credential) at it, drive
`ProviderRuntimeClient::stream` with a turn whose two tool calls already have
results, and assert on `captured_requests()[0].raw_body` — exactly one message
carries `tool_use` blocks, exactly one carries `tool_result` blocks, and both
results are answered in call order. Against the unfixed merge the body contains
two separate `role: "user"` result messages and the test fails, which is the
regression it is there to catch.

Notes on the test harness, since neither is obvious from the code:

- the mock service needs a multi-thread runtime: with `new_current_thread` the
  single worker is blocked by the synchronous `stream()` call from the test
  thread and the request never completes;
- `stream()` issues a `/v1/messages/count_tokens` preflight first, so the test
  selects `path == "/v1/messages"` instead of assuming a single captured
  request;
- the client reads both credential variables and the base URL from the process
  environment, so the test saves and restores all three.

`mock-anthropic-service` is added to `tools`' dev-dependencies (it had none).
No production code changes.

Verification: `cargo test -p tools --lib` -> 116 passed (was 115);
`cargo test --workspace -- --test-threads=1` -> 1811 passed, 0 failed, 1 ignored
(was 1810); `scripts/fmt.sh --check`.
Yeachan-Heo pushed a commit that referenced this pull request Oct 19, 2026
…ial hint

Merge PR #3360 from c01dsummer/claw-code (fix/roadmap-835-838)

Four fixes addressing issues #3361 and #3360:

1. Parallel tool-result pairing (835): The Anthropic Messages API requires all
   tool results of a turn to be in a single message. When a turn issued two tool
   calls at once, the results were sent in separate messages, causing a 400 error.
   Fixed by merging adjacent tool-result-only messages into one.

2. Terminal-scope path limit (836): Correlation keys up to 512 bytes expanded to
   1024+ hex characters, exceeding APFS's 1024-byte total path limit. Fixed by
   using SHA256 digest (32 hex chars) for path components while storing the full
   key in the record for exact matching.

3. Credential hint (838): When ANTHROPIC_BASE_URL points to a non-Anthropic
   endpoint (DeepSeek, corporate gateways), the missing-credentials error now
   explains that the endpoint expects its own credentials, not Anthropic's.

4. ROADMAP entries: Documents all three fixes with verification details.

All tests pass (370+ for rusty-claude-cli, 240+ for sdk, 122+ for tools, 209+ for
api). Formatting verified with cargo fmt. CI checks approved and passed.

Fixes #3361, #3360
This was referenced Oct 19, 2026
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.

triage: parallel tool-call turns send tool_result blocks one message too late, so the next request is rejected with 400

3 participants