fix(ui): stream tool results in order and gate approval on real state - #31
Merged
Merged
Conversation
Two regressions from #29, surfaced by the Skills work: tool results were batched at the end of a turn, the approval gate went missing on real pauses, and read-only tools offered an APPROVE button. The tool pump awaited `run.output` whenever a call had no artifact. An absent artifact and a not-yet-arrived one are both `undefined`, so every ordinary tool waited for the entire run to finish. Only `render_chart` produces an artifact, so only it may wait — `artifactTools.ts` carries that positive signal as a zero-import leaf. The approval UI keyed off `index === messages.length - 1`, comparing an index into the deduplicated array against the original array's length — never true once dedup dropped anything. The first bug then made position wrong in both directions, since results and later text follow the call. The server now reports `pendingToolCallIds` (requested but never executed) once the run settles, and `ToolCallDisplay` additionally requires `isMutatingTool` before rendering the amber panel. `streamMessages` moves to its own module: `agentService.ts` reaches Postgres at import time, so a free unit test could not import it, which is why the ordering contract had no test. Removing the guard makes the new tests hang and time out — the bug's real signature. Also key dedup and React keys on `type:id`; ids are unique only within a type. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
Two regressions that shipped in #29 (
render_chart/ artifact delivery) and were surfaced — not caused — by the Skills work.load_skillis the first tool that reliably fires before the real work, so turns now contain two tool rounds instead of one, which is exactly the shape that exposes both.Symptoms: tool results appeared in a block after the final AI message, the APPROVE button sometimes never showed, and read-only tools (
load_skill,run_sql) offered an approval gate they don't have.1. The tool pump blocked on the whole run
run.outputresolves only when the graph finishes. The fallback fired whenever an artifact wasn't already in the map — and for every tool exceptrender_chartthat is always, since only it usesresponseFormat: "content_and_artifact". An absent artifact and a not-yet-arrived one are bothundefined, so the code could not tell "this tool has none" from "keep waiting".So
pumpToolCallsparked on the first result whilepumpMessagesran on unblocked — serializing every turn as all AI text → all tool results.render_chartitself skipped the fallback, so the one tool the feature was built for behaved differently from every other: the bug hid from its own feature's testing.Fixed with a positive signal:
src/lib/agent/artifactTools.ts, a zero-import leaf (same pattern and same reason asmutatingTools.ts) listing the tools that actually produce an artifact.2. The approval gate keyed off list position
indexindexesuniqueMessages(deduplicated) but is compared againstmessages.length(the original). Whenever dedup removed anything the condition was never true for any message — the missing APPROVE button.And position is the wrong predicate regardless: bug 1 meant tool results arrived after the final AI message, so the message carrying the pending call was no longer last. The inverse gave read-only tools a gate, because
ToolCallDisplayopened the amber panel purely fromshowApprovalButtons && id && approvalCallbacks, never consultingisMutatingTool.The server knows the truth and now says it: after the run settles,
streamMessagesre-emits the AI message withpendingToolCallIds— the calls the model requested that never executed, which is exactly what the gate paused.ToolCallDisplayadditionally requiresisMutatingTool(name); two independent conditions, because that panel claims "this writes to your data."3. Dedup and React keys on
type:idIds are unique only within a type — an AI message's synthetic id and a tool message's call id are different namespaces — so keying on id alone could drop a distinct message. This is what made bug 2 intermittent rather than constant.
Testability
streamMessagesmoves tosrc/services/messageStream.ts. Not cosmetic:agentService.tsreaches Postgres at import time, so a test importing it hangs (verified). The ordering contract had no free-testable surface, which is why two regressions shipped clean. The new module has no I/O of its own.Verification
messageStream.test.ts.mydb_dev:5544, port 3101); production on 3100 untouched.Live SSE ordering after the fix — results interleave with their calls, and read-only tools get no gate:
Full gate cycle:
log_expensepaused →AI:PENDING ['toolu_01FQ…']→ DB still 10 rows → approved → result streams, zero pending chunks → 11 rows. Chart artifacts still deliver (spec,columns, rows).Note
render_chartstill lands slightly after its following text — it's the one tool that legitimately waits onrun.output. That's now the intended narrow case rather than every tool's behavior. Tightening it means having the values stream deliver artifacts before the tool result resolves; deliberately out of scope here.🤖 Generated with Claude Code