Skip to content

fix(stream): pair and close every agent tool call - #183

Merged
surlymochan merged 1 commit into
surlymochan:mainfrom
MAOLVLHL:fix/agent-tool-call-lifecycle
Nov 12, 2026
Merged

surlymochan merged 1 commit into
surlymochan:mainfrom
MAOLVLHL:fix/agent-tool-call-lifecycle

Conversation

@MAOLVLHL

Copy link
Copy Markdown
Contributor

What this changes

  • Claude Code's tool_result line now becomes a tool_end for the call it answers, carrying status: "error" when the CLI sets is_error.
  • Both adapters now emit the CLI's own id as tool_call_id — tool_use.id / tool_use_id on Claude, item.id on Codex.
  • Every record carries an agent_run_id, so two runs streaming into one log stay distinguishable even when a CLI hands out the same tool-call id twice.
  • close() closes any call that is still open as status: "abandoned", so a killed run no longer leaves a call that looks like it is still running.
  • The workflow manual documents the lifecycle.

Nothing downstream changed. The cockpit view and the --progress formatter render records generically, so the new fields are additive for both.

Why

Fixes #182. A Claude Code run produced tool_start records with no matching tool_end: the adapter treated the user line as output echo, which is where stream-json delivers tool_result blocks. The result itself carries only tool_use_id, so recording the CLI's id was a prerequisite for pairing anything at all — and it also lets a log separate two concurrent runs instead of collapsing them when the CLI reuses an id.

I did not infer the tool name inside the mapper, because a tool_result never names the tool; the sink remembers the tool_use it saw, and an unmatched result still lands as a readable tool_end with tool: null rather than being dropped. I also did not add a read-side join pass: the log has to carry ids for any consumer to pair calls, and a post-processing pass would still have no way to tell a killed call from a slow one. The abandoned status comes from the sink because it is the only place that knows a run ended.

The 300s default agent timeout is the case that motivated the last part: the CLI never emits its final result, so without closing open calls on shutdown the log claims a tool ran forever.

Tests

  • npm test run locally — everything passes except swebench harness isolation (evidence diff filter + per-run uv cache + predictions), which fails identically on unmodified main (405ffa5) on this machine for want of uv/docker. That failure is pre-existing and environmental, not related to this change.
  • New or updated tests cover this change — five cases added to test/agent-stream.test.js, all driven by the existing claude.ndjson fixture: result pairing, is_error mapping to error, an orphan result staying readable, shutdown closing an open call as abandoned, and two concurrent sinks not colliding on the same CLI id. node --test test/agent-stream.test.js → 17 pass, 0 fail.
  • npm run drift:scan — not applicable: no workflow or atom changed.

AI assistance

Written with an AI coding agent (DeepSeek Harness) working from my own read of the streaming path. I reviewed the full diff and ran the suite locally before opening; the one failing test was confirmed pre-existing by re-running it against unmodified main.

The claude adapter dropped every `tool_result` line as an echo, so a tool call
started and never ended. Neither adapter carried the CLI's own tool-call id, so
nothing downstream could pair the two even when both were present, and a run
killed mid-call left a call that stayed "open" forever in execution.ndjson.

Emit `tool_call_id` on tool_start/tool_end (claude `tool_use.id` and
`tool_use_id`, codex `item.id`), map claude `tool_result` blocks to a
closing event with status `error` when `is_error` is set, tag every record
with an `agent_run_id` so concurrent runs cannot collide on a CLI id, and have
the sink close any still-open call as `abandoned` on shutdown.

Records gain fields, none change shape or meaning, so the two consumers
(cockpit describeEntry, --progress formatter) render them unchanged.
@surlymochan
surlymochan merged commit 37a1b1a into surlymochan:main Nov 12, 2026
2 checks passed
@surlymochan

Copy link
Copy Markdown
Owner

Merged — thank you! Squash commit 37a1b1a, CI green (verify + ledger). Verified independently before merging: checked out the PR head and ran test/agent-stream.test.js 17/17 locally, and reviewed the diff (additive record fields only, 5 new tests, docs updated). The pairing fix is exactly what the issue described. 🙏

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.

fix(stream): claude tool calls never emit a closing event

2 participants