Skip to content

fix: auto-resolve three bug reports — dangling tool_calls (#494), routed-model usage (#495), fatal-vs-advisory WS errors (#498) - #515

Draft
TYRMars wants to merge 2 commits into
mainfrom
claude/vibrant-dijkstra-mlgyei
Draft

fix: auto-resolve three bug reports — dangling tool_calls (#494), routed-model usage (#495), fatal-vs-advisory WS errors (#498)#515
TYRMars wants to merge 2 commits into
mainfrom
claude/vibrant-dijkstra-mlgyei

Conversation

@TYRMars

@TYRMars TYRMars commented Jul 22, 2026

Copy link
Copy Markdown
Owner

Summary

Auto-resolved batch of open code-review bugs, each self-contained and referencing code that exists on main.

Issue Area Fix
#494 coreagent.ts tool dispatch Dispatch tool calls whenever the assistant message carries them, independent of finish_reason, so a length-truncated turn's calls are answered before the run ends.
#495 core + router — usage attribution Propagate the effective (routed) model with the streaming usage event so token accounting is priced against the model that actually ran.
#498 server + jarvis-ios — WS error handling Tag terminal error frames fatal; the iOS client only tears down run state on fatal errors, so an advisory diagnostic no longer kills isStreaming / a pending approval mid-run.

#494 — dangling tool_calls on finish_reason: "length"

isToolCallTurn gated the dispatch branch on finish_reason === "tool_calls", but the assistant message was pushed into conversation.messages unconditionally one line earlier. Both the OpenAI and Anthropic providers can return a well-formed, non-empty tool_calls array alongside finish_reason: "length" (output-token cap hit). In that case the loop skipped dispatch and terminated normally, persisting a conversation whose tail is an assistant message carrying tool_calls with zero matching role:"tool" replies. Both providers hard-reject that history shape, so every subsequent turn and every resume 400s permanently — silent, unrecoverable corruption triggered by an ordinary token-cap hit.

Fix: rename the guard to hasPendingToolCalls and drop the finish_reason conjunct, so tool calls are always dispatched when present. Loop termination is decided separately: on a length turn the calls are answered first (tool replies appended), then the run ends as length_limited with a valid, resumable history. Applied to both the blocking (runWithUsage) and streaming (runStream) paths.

#495 — usage attributed to the pre-routing model

The streaming usage event was stamped with the static config.model rather than the model the request actually ran on. Under JARVIS_ROUTER_ENABLED, RoutingProvider rewrites the model before delegating, so all routed-turn usage was bucketed and priced (by the web UsagePanel) against the pre-routing model — off by up to two orders of magnitude, and invisible because the misattributed model name is still a valid, known one.

Fix: add an optional model to the usage LlmChunk variant. RoutingProvider.completeStream stamps it with the routed tier target (alongside the existing response_id re-stamping), and the agent loop prefers chunk.model ?? this.config.model. Plain (non-routing) providers leave it unset and behave exactly as before.

#498 — every WS error frame treated as turn-terminal

ChatViewModel.handle called finishTurn on every error frame, nilling isStreaming / streamingIndex / pendingApproval. But the server emits {type:"error"} for out-of-band, non-terminal diagnostics ("turn in progress", "no pending approval", "unknown frame type", bad-frame) while the run keeps going, so an advisory frame tore down live UI state — including a pending approval card — mid-run. E.g. after a reconnect drops server-side pending, tapping approve yields "no pending approval", and iOS declared the turn failed while the agent stayed blocked on an approval that could never arrive.

Fix: the server tags the two genuinely terminal errors (the agent-emitted error event and the run-loop catch block) with fatal: true; advisory transport errors omit the flag. ServerEvent.error now carries fatal (defaulting false for forward-compat), and the iOS handle only calls finishTurn on a fatal error — an advisory one appends the diagnostic row but leaves the in-flight run intact. This also stops the Stop button's rejected frame from flipping isStreaming off mid-stream. This is the response-handling defect the issue calls out as separate from #492 (the client's decommissioned frame vocabulary), which is untouched here.

Testing

  • pnpm --filter @jarvis/core test — 34 pass (length+tool_calls dispatch blocking & streaming; usage model preference & fallback)
  • pnpm --filter @jarvis/router test — 25 pass (usage-chunk routed-model stamping)
  • pnpm --filter @jarvis/server test — 481 pass; the 3 failures are the pre-existing node-pty native-module build issue in this environment (terminal/UI tests), unrelated to this change and reproduced with the diff stashed.
  • iOS ContractSmoke extended for advisory-default + fatal-honoured error decoding (no Swift toolchain in CI env to compile-run).
  • typecheck clean for core, router, llm, server, jarvis-app; eslint clean on changed TS files.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CgmFh7ByaabndVkXh75cPq

claude added 2 commits July 22, 2026 01:27
…+ attribute usage to routed model (#495)

#494 — core/agent.ts: `isToolCallTurn` required `finish_reason === "tool_calls"`,
but both the OpenAI and Anthropic providers can return a well-formed, non-empty
`tool_calls` array alongside `finish_reason: "length"` (output-cap hit). The
loop pushed that assistant message unconditionally, skipped dispatch, and
terminated — persisting a conversation whose tail is an assistant message with
`tool_calls` and no matching `role:"tool"` replies. Both providers hard-reject
that shape, so every later turn/resume 400s permanently.

Rename the guard to `hasPendingToolCalls` (drop the finish_reason conjunct) so
tool calls are always dispatched when present; loop termination is decided
separately. On a `length` turn the calls are now answered first (tool replies
appended), then the run ends as `length_limited` with a valid, resumable
history. Applied to both the blocking and streaming paths.

#495 — core/agent.ts + router/provider.ts: the streaming `usage` event was
stamped with the static `config.model`, not the model the request actually ran
on. Under `JARVIS_ROUTER_ENABLED` the RoutingProvider rewrites the model before
delegating, so all routed-turn usage was attributed (and priced) against the
pre-routing model. Add an optional `model` to the `usage` `LlmChunk` variant;
`RoutingProvider.completeStream` now stamps it with the routed tier target, and
the loop prefers `chunk.model ?? this.config.model`. Plain providers leave it
unset and behave exactly as before.

Tests: core agent covers length+tool_calls dispatch (blocking + streaming) and
usage model preference/fallback; router covers usage-chunk model stamping.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CgmFh7ByaabndVkXh75cPq
…#498)

`ChatViewModel.handle` treated every WS `error` frame as turn-terminal and
called `finishTurn`, which nils `isStreaming`, `streamingIndex`, and
`pendingApproval`. But the server emits `{type:"error"}` for out-of-band,
non-terminal diagnostics ("turn in progress", "no pending approval", "unknown
frame type", bad-frame) while the run keeps going — so an advisory frame tore
down live UI state, including a pending approval card, mid-run. Concretely: a
reconnect drops server-side `pending`; the user taps approve; the server replies
"no pending approval"; iOS declared the turn failed while the agent stayed
blocked on an approval that could never arrive.

Server (chat-routes.ts): tag the two genuinely terminal errors — the
agent-emitted `error` event (runStream yields nothing after it) and the
run-loop catch block — with `fatal: true`. The advisory transport errors omit
the flag, which clients read as non-terminal.

iOS: `ServerEvent.error` now carries `fatal` (decoded from the frame, defaulting
false for forward-compat). `handle` only calls `finishTurn` on a fatal error;
an advisory one appends the diagnostic row but leaves `isStreaming` and any
pending approval intact. This also stops the Stop button's rejected frame from
flipping `isStreaming` off mid-stream (which spawned a duplicate assistant
bubble).

Scope: this is the response-handling defect called out as separate from #492
(the client still speaks the decommissioned frame vocabulary); that vocabulary
mismatch is untouched here.

Tests: ContractSmoke covers advisory-default and fatal-honoured error decoding.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CgmFh7ByaabndVkXh75cPq
@TYRMars TYRMars changed the title fix(core,router): answer tool_calls on length-truncated turns (#494) + attribute usage to routed model (#495) fix: auto-resolve three bug reports — dangling tool_calls (#494), routed-model usage (#495), fatal-vs-advisory WS errors (#498) Jul 22, 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.

2 participants