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
Draft
Conversation
…+ 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Auto-resolved batch of open code-review bugs, each self-contained and referencing code that exists on
main.core—agent.tstool dispatchfinish_reason, so alength-truncated turn's calls are answered before the run ends.core+router— usage attributionusageevent so token accounting is priced against the model that actually ran.server+jarvis-ios— WS error handlingfatal; the iOS client only tears down run state on fatal errors, so an advisory diagnostic no longer killsisStreaming/ a pending approval mid-run.#494 — dangling
tool_callsonfinish_reason: "length"isToolCallTurngated the dispatch branch onfinish_reason === "tool_calls", but the assistant message was pushed intoconversation.messagesunconditionally one line earlier. Both the OpenAI and Anthropic providers can return a well-formed, non-emptytool_callsarray alongsidefinish_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 carryingtool_callswith zero matchingrole:"tool"replies. Both providers hard-reject that history shape, so every subsequent turn and every resume400s permanently — silent, unrecoverable corruption triggered by an ordinary token-cap hit.Fix: rename the guard to
hasPendingToolCallsand drop thefinish_reasonconjunct, so tool calls are always dispatched when present. Loop termination is decided separately: on alengthturn the calls are answered first (tool replies appended), then the run ends aslength_limitedwith a valid, resumable history. Applied to both the blocking (runWithUsage) and streaming (runStream) paths.#495 — usage attributed to the pre-routing model
The streaming
usageevent was stamped with the staticconfig.modelrather than the model the request actually ran on. UnderJARVIS_ROUTER_ENABLED,RoutingProviderrewrites the model before delegating, so all routed-turn usage was bucketed and priced (by the webUsagePanel) 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
modelto theusageLlmChunkvariant.RoutingProvider.completeStreamstamps it with the routed tier target (alongside the existingresponse_idre-stamping), and the agent loop preferschunk.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.handlecalledfinishTurnon everyerrorframe, nillingisStreaming/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-sidepending, 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
errorevent and the run-loop catch block) withfatal: true; advisory transport errors omit the flag.ServerEvent.errornow carriesfatal(defaulting false for forward-compat), and the iOShandleonly callsfinishTurnon 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 flippingisStreamingoff 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-existingnode-ptynative-module build issue in this environment (terminal/UI tests), unrelated to this change and reproduced with the diff stashed.ContractSmokeextended for advisory-default + fatal-honoured error decoding (no Swift toolchain in CI env to compile-run).typecheckclean forcore,router,llm,server,jarvis-app; eslint clean on changed TS files.🤖 Generated with Claude Code
https://claude.ai/code/session_01CgmFh7ByaabndVkXh75cPq