Skip to content

12 · Move the SDK and binary pins together and register the missing tools (issue #14) - #69

Open
arena-ai-coding-agent[bot] wants to merge 60 commits into
arena/01a097c4-mauscodefrom
arena/01a0cbec-mauscode
Open

arena-ai-coding-agent[bot] wants to merge 60 commits into
arena/01a097c4-mauscodefrom
arena/01a0cbec-mauscode

Conversation

@arena-ai-coding-agent

@arena-ai-coding-agent arena-ai-coding-agent Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

User description

Step 12 of the roadmap, implemented from .dump/app/roadmap/12-sdk-and-pins.md, which is newer than the issue body and wins where the two disagree. This is the only step allowed to touch package.json and bun.lock, so the two dependency exceptions the human ratified land here rather than in the steps that consume them.

The Claude Agent SDK moves from 0.2.45 to 0.3.270, the Claude CLI the download scripts fetch moves from 2.1.45 to 2.1.270, and Codex moves from 0.137.0 to 0.154.0. Those three are one decision and the pairing is checkable rather than assumed: the SDK's own manifest.json lists linux-x64 at size 223981040 with checksum 3a624a5a7cd79bbad4d32bd7db36f1197ecf458bc5bf1e2aed81834a01ad3ef0, which is the sha256sum of the binary that install places in node_modules/@anthropic-ai/claude-agent-sdk-linux-x64/claude. The download script's offline fallback carries the same version as its script argument, so a retry after a failed manifest lookup cannot silently fetch 2.1.45.

Closes #14

Upstream behaviour changes across the range, and what this PR does about each

Read from the SDK changelog for every release between the pins. The full list, with the evidence for each row, is in .dump/app/research/2026-09-13-sdk-0-3-bump.md.

Version Change What it means here
0.2.47 promptSuggestion() on Query the request half of the suggestion surface wired below
0.2.49 ModelInfo gains supportsEffort, supportedEffortLevels, supportsAdaptiveThinking the per-model discovery that would retire the static effort list, filed as a follow-up
0.2.69 system:init and result emit Task again, reverted from Agent, with a note that the wire name migrates to Agent in the next minor why two spellings of one tool exist, and why the registry keeps both keys on one meta
0.2.84 exported EffortLevel the vocabulary src/shared/effort.ts now mirrors
0.2.111, 0.2.113 options.env changed to overlay process.env, then back to replacing it the two flips cancel out, because the old pin predates both. Worth knowing: claude.ts deletes ambient ANTHROPIC_* keys when an OAuth token exists, and buildClaudeEnv strips its own list, and neither deletion would survive overlay semantics
0.2.113 spawns a native Claude Code binary through a per-platform optional dependency the 214 MB packaging weight this PR excludes
0.2.136, 0.3.142 TodoWrite deprecated, then headless and SDK sessions use the Task tools the background task family the registry and TASK_TOOLS already name, and the reason Task* is not the sub-agent family
0.3.149 fixed options.env dropping CLAUDE_AGENT_SDK_VERSION when a custom environment is supplied this app always supplies one, so its User-Agent and telemetry were incomplete before this pin
0.3.178 spawn failures on an existing native binary explain a libc mismatch and point at pathToClaudeCodeExecutable the evidence that the packaging exclusion is safe
0.3.179 optional tool_use_meta sidecar with display friendly names for tool calls a follow-up for the registry, which hand-writes titles the CLI can now supply
0.3.193 promptSuggestions option the option the new preference sends
0.3.214 effortLevel accepts max in the TypeScript type the fifth level in the shared vocabulary
0.3.234 SDKSystemMessage gains an optional effort field the applied effort, readable on init if a surface ever wants to show it

What changed, one commit per contract

  1. The three pins together, with bun.lock regenerated by bun install and bun install --frozen-lockfile clean on the result.
  2. The whole stream dialect classified. At 0.2.45 the SDK could not type its own boundary: SDKRateLimitEvent was never declared and the assistant, user and stream-event payloads resolved to any because @anthropic-ai/sdk was not in the tree. At 0.3.270 it is, so the local read contracts in types.ts are now derived from the SDK types instead of guessed beside them: each block union keeps the shapes the translator reads and adds everything else the SDK can send through Exclude, which keeps a fixture small while a real payload still satisfies the contract. ClaudeStreamMessage covers the rest of the dialect by exclusion from SDKMessage, so a member a future release adds joins the union on the bump and fails the two AssertNever classification guards until someone decides what it means. Those guards now cover 11 top-level types and 28 system subtypes. Two events gain a consumer: api_retry maps to the existing retry-notification chunk both transports already toast, so a backoff reads as a retry instead of a stalled stream, and prompt_suggestion maps to a new prompt-suggestion chunk, trimmed and bounded to 2000 characters because the string is provider-authored and crosses IPC. Two fields had to widen to stay supertypes: a tool result's content is optional and can hold a document, a search result or a browser state, and usage cache counters can be null.
  3. The renamed tools registered. Grepping the 2.1.270 platform binary for its emitted tool table returns TaskCreate, TaskGet, TaskList, TaskUpdate, TaskStop, TaskOutput, Agent, TodoWrite, with a normalization table mapping KillShell and KillBash to TaskStop and BashOutput, BashOutputTool, AgentOutput, AgentOutputTool to TaskOutput. tool-Agent shares one meta object with tool-Task, all four legacy output spellings share the tool-TaskOutput meta, and KillShell and KillBash share the shell-stopping one while TaskStop keeps its task wording, so a rename cannot drift into two entries with different wording. isSubagentToolType in a new shared module is the single predicate grouping, dispatch and the nested-tool lookup read, and assistant-message-item.tsx no longer suppresses TaskOutput rows in either of the two places that did, which is why background output had no home in the transcript.
  4. The three new option surfaces made reachable. One effort vocabulary in src/shared/effort.ts backs both pickers, with a satisfies proof on Codex's four levels and its own label function removed in favour of formatEffortLabel. The sub-menu Codex already had is generalized to EffortSubMenu and gains an optional row that clears the pick, which Claude needs so a chat that never opened the picker keeps the model's own default instead of a level this app guessed. Visibility comes from the capability profile rather than a provider name in the renderer: features.effort, features.adaptiveThinking and features.promptSuggestions are declared for all ten backends and true only where a turn can carry the value end to end. The transport sends thinking as adaptive when the Extended thinking switch is on and disabled when it is off, which is the first time off has meant off, because the deprecated maxThinkingTokens it replaced meant "adaptive" when non-zero and let the model choose when absent. Prompt suggestions are off by default behind a new preference, carried per sub-chat, and rendered as one clickable row above the composer.
  5. The SDK's native binary kept out of the packaged app. electron-builder copies production node_modules even with an explicit files list, so each target would have grown by its own platform binary, 197.9 MB to 216.5 MB. The app never asks the SDK for it: every query passes pathToClaudeCodeExecutable from resources/bin at the single call site in the claude router.
  6. @dnd-kit core 6.3.1, sortable 10.0.0, utilities 3.2.2, exact pins, own commit, no importer yet, which is the point of the ratified exception: steps 17, 18 and 37 share one DndContext and this is the only step allowed to touch the lockfile.
  7. sharp 0.35.4 as an exact devDependency. scripts/generate-icon.mjs:21 imports it and package.json declared nothing. It only ever ran on a hoisted transitive copy, and the pin bump removed the fifteen @img/sharp-* entries that provided one, so a fresh install had no sharp at all. Verified by importing it here, which reports libvips 8.18.6.
  8. Both records the step asks for: the research file above and .dump/app/benchmarks/2026-09-23-sdk-0-3-pins.md.

What the review round changed

The round is recorded finding by finding, with what each one was worth, in .dump/app/decisions/2026-09-23-review-and-sonar-remediation.md.

  1. eb4a2bf — a prompt suggestion stays inside the turn that produced it. Both Sourcery findings were real: a suggestion survived into the next turn unless the user dismissed it, and suggestions were stored by sub-chat id alone, so a late one from an aborted or older session could overwrite the current turn's. Starting a turn clears the atom, and a suggestion is dropped unless its session id is the one this stream reported in its own metadata.
  2. df4b974 — every name the pinned CLI folds into TaskOutput and TaskStop is registered. CodeAnt caught a real gap: the registry carried two of the six legacy spellings while its own comment named all six, so KillBash, BashOutputTool, AgentOutput and AgentOutputTool in a pre-bump transcript rendered as unnamed generic rows. Each alias now maps to the meta of the name it normalizes to, and the test lists the six itself so dropping a key fails the suite instead of shrinking the assertion.
  3. 57e8cd3 — the prompt-suggestion generator moves to module scope, where the helpers that build the retry line and the tool-result text already live; it reads only the message it is handed, so holding a slot in the transformer closure rebuilt it per stream.
  4. 3306846 — the effort sub-menu's props are read-only, which is what its body already did.
  5. 685aebd — the turn-control default is stated once. Ten manifests had each grown the same six lines, eight of them byte-identical, and that copy-paste is what SonarCloud's duplication measure read when it failed the quality gate at 4.2% against a 3% limit. TURN_CONTROLS_OFF in src/shared/provider-capabilities.ts holds the "when in doubt, false" answer where the rule lives; a manifest spreads it and names only what its own backend carries, so Codex overrides effort and Claude, which turns all three on, inherits nothing. A fourth flag in the schema now fails typecheck in one place instead of going missing from one manifest.
  6. dc182be — one handler per chunk side effect in the transport. onData was a 256-line flat chain at a cognitive complexity of 43, and this step had added two links to it. Each side effect is now a module-scope function over one ChunkContext, routeChunk calls them in the order the stream needs them, and a returned outcome keeps a single enqueue path; the four copies of "close unless it is already closed" become one closeQuietly shared with onError, onComplete and the abort listener. The sequence, the early returns and the set of chunks that reach the SDK are unchanged.
  7. fe09b5a — the transport takes the helpers that were extracted from it. chat-chunk-atoms.ts says it was extracted from this transport so the native runtime transport could reuse identical question, compacting and prompt-extraction behaviour, and then this transport kept its own copies, including two private methods byte-identical to the shared extractors. 172 lines go, and the two transports cannot drift on the question lifecycle again. session-init stays per transport on purpose: the native runtime reads a cached snapshot and fills the gaps, the CLI reports the full set on init.
  8. 2c8be15 — the record itself.

The round's result on head 2c8be15. SonarCloud's quality gate now passes: duplication on new code is 1.0% against the 3% limit it had failed at 4.2%, security hotspots are 0, and the 2 new issues it reports are the two complexity findings in the section below, the other three being fixed. CodeAnt's gate passes with 0.4% duplication, an S rating and no bugs, and no security issues; CodeAnt verified its own registry finding was addressed, resolved the thread and approved. Every GitHub Actions check is green on this head — Build on ubuntu-24.04, macos-14 and windows-2022, Package unsigned on ubuntu-24.04 and macos-14, the lint/test/typecheck quality gates, the security gates, and both Socket reports.

What the duplication sweep after that head changed

The instruction after 2c8be15 was to eliminate all code duplication correctly, so round 1's default was applied to the whole object instead of to the slice that had just grown, and the two remaining copy-paste sources were given real abstractions rather than a smaller measure. Recorded in the same decision file under "Round 2", including what was left standing and why.

  1. 43ff64d — the feature flags default all-off, not four-off. TURN_CONTROLS_OFF named four of the thirteen flags, so every manifest still restated the other nine and the shared default covered a third of the object. FeatureFlags is now z.infer<typeof featureFlagsSchema> rather than a hand-named partial, ALL_FEATURES_OFF spells all thirteen false under satisfies FeatureFlags, and each manifest reads features: { ...ALL_FEATURES_OFF, <what this CLI proved> }. A flag that is off because nobody proved it is no longer written; a flag that is off for a reason keeps its reason inline. The fail-safe direction did not move — the new default is a superset of the old one, so no provider gained a control it did not have — and that was verified rather than assumed: the effective flag matrix parsed out of every manifest at 2c8be15 was compared key by key with the rewritten tree, 130 values across 10 providers, 0 differences. −77/+47 lines across eleven files.
  2. e57175b — one hook for the Claude half of the model picker. chat-input-area.tsx and new-chat-form.tsx each carried a byte-identical 37-line useAvailableModels, an identical connection test and an identical 26-line claude={{ ... }} block, about 110 duplicated lines that had already drifted once when the effort rows were added to both by hand. hooks/use-claude-model-picker.ts now owns the model list and the offline-Ollama overlay, the custom-config test, the connection test, the resolved Ollama model, extended thinking and the capability-driven effort rows, and returns the list plus a ready props object typed against AgentModelSelectorProps["claude"], exported from the selector for the purpose, so adding a prop to the selector fails typecheck in the shared object until it supplies one. selectedModelId and onSelectModel stay with each surface: they are the only lines that differ, the composer also stamping the sub-chat model id, and pulling them in would mean handing callbacks back out. −177/+22 lines across the two surfaces.
  3. dbb9457 — both transports take the SDK's own options type. Each restated sendMessages(options: { messages: UIMessage[]; abortSignal?: AbortSignal }) and then repeated the same three lines reading the last user message. Both classes implements ChatTransport<UIMessage>, so the shape was the library's all along, and restating it narrowed it: the real options carry trigger, chatId, messageId and ChatRequestOptions, which neither transport could see. chat-chunk-atoms.ts, which already holds the shared transport helpers, now also holds SendMessagesOptions derived from that contract and lastUserPrompt(messages). No direct sendMessages caller exists in the repo — the AI SDK's Chat calls it — so widening the accepted options changes no call site.
  4. b75064c — the round-2 record, with the scan methodology and the four classes of duplication deliberately left standing: import statements, which are references and cannot drift; declarative manifest data such as latencyClass: "cloud", which has no fail-safe default and would let a new provider inherit a wrong value silently where an off flag is a safe one; the call sites of the new abstractions, since two call sites of one hook are the point and not the residue; and coincidental short windows with pre-existing code, such as a break / default: / break switch tail in transform.ts that matches three unrelated files.

The measure is a windowed scan over the whole tree at 4- and 6-line windows keeping only blocks whose copies contain lines this PR added, which is stricter than the gate it feeds: Sonar's CPD threshold for TypeScript is about 100 tokens, and a six-line window of short object entries sits well under it. Before this sweep that scan reported 21 duplicated groups touching new code; after it, none remain except the manifest data and the call sites itemised above.

The sweep's result on head b75064c. SonarQube Cloud's quality gate passes with duplication on new code at 0.5% — 7 duplicated lines out of 1284 new ones — against the 3% limit, the 4.2% that failed it and the 1.0% round 1 reached, with 0 security hotspots and 1 new issue where round 1 left 2: e57175b moved the ternary that fed the effort rows and the || chains that resolved the custom config, the connection state and the Ollama model out of NewChatForm, and its cognitive-complexity finding is gone, while renderPart at 70 against 15 stands untouched. Sonar's per-file measure puts all 7 remaining duplicated lines in provider manifests, one each in cline, codex, cursor, grok, hermes, opencode and qwen, and reports zero duplicated new lines for every file an abstraction touched — both surfaces, the new hook, the selector, both transports, the shared chunk helpers and provider-capabilities.ts. The two manifests at zero are openclaw and roo, the two whose feature blocks carry reasoned false flags with comments, which breaks the window. CodeAnt's five gates pass and it approved this head; all fifteen substantive GitHub Actions checks pass — Build on ubuntu-24.04, macos-14 and windows-2022, Package unsigned on ubuntu-24.04 and macos-14, the lint/test/typecheck quality gates, the security gates, both Socket reports and CodeRabbit — with Buoy, Sourcery and DeepSource skipping as before. Same tree, local battery: biome check . 978 files 0 findings, npm run lint 923 files no findings, tsc --noEmit and ratchet:typecheck 0 errors, vitest run 1891 passed 1 skipped, test:node 59 passed, test:contracts 382 passed, ratchet:audit passed, skills:verify 50 of 50 locked skills verified.

What the third round changed

Recorded finding by finding, with the evidence behind each decline, in .dump/app/decisions/2026-09-23-review-and-sonar-remediation.md under round 3.

  1. 41688f4 — the picker's selection now comes from the list the picker shows. CodeAnt found it twice and both were right: round 2's shared hook returned the unfiltered availableModels alongside the filtered props.models, and each surface read the unfiltered one for its selection, its trigger label and the id it wrote to per-sub-chat storage — the drift the hook was extracted to end, reintroduced one level up by returning two lists. The hook now filters once and returns one, and both surfaces derive their selection from it the way every other provider in both files already does, replacing a useState plus a sync effect that could only ever move towards a model it could find. −24/+31.
  2. e5b602a — one probe runner for ten manifests. Sonar's remaining duplication on the manifests was not the capability data. It was a sixteen-line execFile wrapper all ten carried byte-identically, eight as runBinary and two as runLaunch, differing only in the bound: fifteen seconds in eight, thirty in openclaw and roo. providers/probe-command.ts holds the rule once — run a CLI once, bounded, and resolve rather than reject, because the ordinary failure is a binary that is not installed — with the bound exported and the two longer-bound manifests passing it explicitly. −193/+74 across eleven files.
  3. cacd3db — the probe's exit code stated in one place. The extraction drew typescript:S3358 on error ? (typeof error.code === "number" ? error.code : null) : 0, three outcomes in one expression with the reason for the middle test sitting in a comment above it. exitCodeOf(error) holds the rule and its explanation together: execFile reports a spawn failure as a string errno and a non-zero exit as a number, so only the number is an exit code and null means the binary is not there.
  4. 5a98fb4 — the transcript renderer's output, written down before it is restructured. 28 snapshots, one message per branch of renderPart: text and whitespace-only text, step-start, a part that is neither, Bash success and failure, reasoning, a completed thinking tool, Edit with its patch, Write, a plan file as a card, a second plan operation as a mini indicator, web search, web fetch, PlanWrite, ExitPlanMode, a todo list, a question awaiting an answer, a registry tool, the renamed TaskOutput, a sub-agent task with nested tools, an orphaned nested group, an MCP call, an unregistered tool, the collapsed-steps path, the streaming path, the exploring group and the usage badges. Nothing in the suite rendered any of it before, because the vitest environment is node. jsdom 30.1.1, @testing-library/react 16.3.3 and its @testing-library/dom 10.4.2 peer join as exact devDependencies and *.test.tsx joins the include list; the environment stays node globally and this one file declares jsdom. No child component reaches for trpc, the router, Sentry or electron, so the only context a render needs is the tooltip provider the agents layout already supplies. Baseline stability was checked rather than assumed: 28 written, then a second run with 0 written and 0 obsoleted.
  5. 1900d14 — the dispatcher comes out of its closure. SonarQube measured renderPart at a cognitive complexity of 70 against a threshold of 15. Its branch bodies move to module scope unchanged, one function per shape, each taking the part, its index and one PartRenderContext carrying what only the component knows; renderMessagePart dispatches in the order it always did, which is the part that carries meaning, since a sub-agent Task and a Write to a plan file both claim a type the dispatch table also names and they have to win. Two things fall out: Write and Edit rendered the identical AgentEditTool in two branches, so one function serves both table entries, and the closure's eighteen-entry deps list becomes a useMemo around the context. The evidence is the absence of a diff — git diff reports no change to the .snap file and vitest wrote 0 and obsoleted 0 — which is the only reason this is a refactor rather than a rewrite.
  6. 97f4327 — the nested ternary the split surfaced. Moving a line makes it new to Sonar, so the analysis that dropped the S3776 report raised S3358 on the plan indicator: a ternary inside a ternary inside JSX, choosing between four strings, which had sat unreported inside renderPart for as long as the function existed because those lines were old. planOperationLabel(isWrite, isOpStreaming) holds the four strings and the two questions, the JSX keeps one ternary, and two snapshots pin the two strings no fixture covered — "Updating plan..." and "Updated plan" — so all four are recorded. The snapshot diff is additions only, 0 removed lines, and the file is at 30 tests.

Three findings were declined, each with its evidence in the thread.

  • CodeAnt, "the materialize effect leaves the old model ID stored, so the next request still sends a hidden model" (reply r4086282209). The effect is byte-identical at this PR's base, early return included. Neither transport consults hiddenModels — both are untouched here and both resolve MODEL_ID_MAP[selectedModelId] || MODEL_ID_MAP.opus — so in the state described, where every Claude model is hidden, a cleared id resolves to opus, also hidden, and no value the effect could store makes the next request send a visible one. The early return is load-bearing as well: models is a synchronous filter over a static list, so it is empty only when the user hid them all, and leaving storage alone is what lets a hide-everything → unhide cycle land back on the model they chose instead of models[0]. What 41688f4 did change is the part that was wrong — at base a hidden model stayed selected, stayed in the label and was written back on mount; now the label reads "Select model" and nothing is written. The gap the finding points at is real and sits one level down, and is named as a follow-up below.
  • Buoy, min-h-[32px] → min-h-8, re-posted on lines this PR does not touch (replies r4086282510, r4086282751). tailwindcss is ^3.4.17, tailwind.config.js only extends, and nothing sets a root font-size, so min-h-8 is 2rem is 32px and the swap changes no computed style. min-h-[32px] appears 16 times under src/, min-h-8 appears 0 times, and 5 of the 16 are in the flagged file, so taking the token on two lines leaves 14 arbitrary sites and introduces a second spelling of one value. Both classNames also carry py-[5px] and w-[calc(100%-8px)], neither of which has a token form, so the swap does not even buy a token-only line. A repo-wide move to spacing tokens is a styling sweep of its own.
  • SonarCloud's seven duplicated manifest lines. What matches across those files is not a value stated twice — the values differ per provider — but the field names and their order, which the ProviderCapability type mandates because that is what makes ten manifests one table. A shared spread would hand a new provider latencyClass: "cloud" and usageSurface: "native" silently, and unlike an off feature flag neither is a safe default. Seven lines out of 2239, 0.31% against a 3% limit, is what is left when each decision is stated once and the shape of the table is what repeats.

Follow-ups this round names rather than folds in. Hiding a model hides it from the picker and not from the wire: closing that means filtering at send time in ipc-chat-transport.ts and native-chat-transport.ts, which changes which model runs for every existing chat, mid-session, and is a contract of its own rather than a line in a pins PR. This token cannot open an issue — 403 on both create and comment — so it is handed off in writing here and in the decision record. Alongside it stand the two SDK discoveries already recorded above: tool_use_meta display names, which the registry hand-writes, and per-model supportsEffort discovery, which would retire the static effort list.

SonarQube Cloud on head 97f4327: quality gate passed — 0 new issues, 0 minutes of new technical debt, 0 security hotspots, and duplication on new code at 0.3%, which is 7 lines out of 2302 against the 3% limit, the 4.2% that failed it in round 1, the 1.0% round 1 reached and the 0.5% round 2 reached. The seven lines are the manifest shape argued above. Round 3 took the issue count from 1 to 0 and the debt from 60 minutes to none: renderPart's S3776 at 70 against 15 went with the split, and both S3358 nested ternaries went with cacd3db and 97f4327.

GitHub Actions on head 97f4327: all ten substantive checks pass — Build on ubuntu-24.04, macos-14 and windows-2022, Package unsigned on ubuntu-24.04 and macos-14, the lint/test/typecheck quality gates, the security gates and both Socket reports — with CodeRabbit passing and Buoy, Sourcery and DeepSource skipping as before. Same tree, local battery: biome check . 980 files 0 findings, npm run lint 925 files no findings, ratchet:typecheck 0 errors against a 0 baseline, vitest run 104 files 1921 passed 1 skipped, test:node 59 passed, test:contracts 382 passed, ratchet:audit passed, skills:verify 50 of 50.

What the fourth round changed

A fourth reviewer — kilo-code-bot — opened 21 inline threads against head 5ea0b64 with a CHANGES_REQUESTED review, plus three addenda. Every claim was checked against the pinned SDK's own .d.ts, the installed bundle, this repo's code and its recorded snapshots before anything was touched. Disposition: 16 fixed, 4 declined with evidence (each thread carries its own reply; the full table lives in .dump/app/decisions/2026-09-23-review-and-sonar-remediation.md § Round 4).

Fixed across five commits pushed after 5e24490:

  1. 50c6b2c — the prompt-suggestions Switch is named. aria-label="Prompt Suggestions" plus a role/name test (getByRole("switch", { name })).
  2. 29a11b1 — effort is per sub-chat. subChatClaudeEffortAtomFamily + lastSelected keeps the old storage key; in not ?? so an explicit null stays this chat's answer; the composer passes subChatId into the picker; both transports read the family. Five tests including migration.
  3. 79294c9 — the Native transport sends effort. Contract verified first (setReasoningEffort → set_reasoning_effort in the pinned runtime-client); router validates z.enum(EFFORT_LEVELS) and applies best-effort after set_model.
  4. 5bbd966 — the docs the review proved wrong. NaN claim corrected; benchmark row labelled pre-harness; versions moved to 0.3.270 / 2.1.270 / 0.154.0; probe null comment narrowed; roadmap §16 ratifies every lockfile addition.
  5. 3018d7c — the round-4 decision record. The 21-row disposition table, committed to .dump.

Also fixed before the token expired (already in the 34 pushed commits): MCP peer pinned exact 1.30.1 (dfe7044); transform handler guards + qwen boundary catch (ead68c1); launch labels + nested ancestry + recursive AgentTaskTool (6804c04); inert subtitle semantics + shell_id (b4c9ebb); suggestion ownership {text, turn, engine} + preference authority (34ea81b); draft-preserving suggestion click (5e24490).

Four declines, evidence in each reply: conversation_reset (client-side /clear builtin — the SDK event is unreachable from any owned path); per-model capability matrix (effort.ts files the follow-up; the SDK clamps; the "hidden control" premise is false — efforts are never hidden); TaskOutput in READ_ONLY_TOOLS (sibling lane arena/01a0bb80-mauscode owns classifier.ts, still BashOutput-only — handoff recorded); packaged artifact size (rows owned by CI / step 30 — this sandbox has no artifact and will not invent one).

Gates re-run in full at head 3018d7c: biome 987 files 0 findings; typecheck ratchet 0 errors ≤ 0 baseline; lint 932 files clean; vitest 109 files, 1951 passed, 1 skipped (29 new tests across the round); test:node 59; test:contracts 382; ratchet:audit at the 3-critical baseline; skills:verify 50 of 50.

Round 5 (same session)

Five new Kilo threads against 5e24490: all five fixed — qwen child reaped on malformed settle (c84a571), provider-error chunks withdraw the suggestion (e429440), preference gates the render (3c3500e), nesting-map content comparison restores task-row memo (faae395), tooltip-only subtitles get a tab stop without a button role (8b0ee3d). Sonar's 10 open PR findings: 9 cleared in 1fe7cf9, S6845 left open as a documented false positive (tabIndex as tooltip keyboard entry). See comments on the PR for the full tables.

Rounds 6 and 7 (same session)

Sonar's re-analysis after the round-5 heads reported five S4782s on the extracted openNativeTurnSession input — model?: string | undefined and three siblings stating the option twice — all cleared in 1e415ee, leaving one open PR issue: S6845, the documented false positive above, with the quality gate passing (0.6% duplication, 0 hotspots).

Kilo's seventh round then found a real regression in round 5's own fix: nestedMapsEqual walked every map part through arePartsEqual, which advances the module-level toolStateCache, so the first task row's comparator consumed all in-place mutations and every later row saw a clean cache and skipped re-rendering a grandchild that had changed. Fixed in 5683525: nestingFingerprintOf snapshots the message-level map once per render into one immutable string (the same fields the tool-state snapshot records, no cache writes), and areTaskToolPropsEqual compares it by ===, so every row answers from the same value. Nine tests in a new agent-tool-utils.test.ts pin it, including the regression itself — the same before/after pair rejects twice in a row, where the old comparator's second call returned true because the first had eaten the change. All 27 Kilo threads are now answered.

Sonar's read of that fix itself raised two more (S5906 on a redundant expect(a === b).toBe(true), S6551 on a .join() over an unknown state that could fold two objects into one [object Object]); both cleared in 172fbb2, and the re-analysis is back to 1 open issue — the accepted S6845 — with the gate passing (0.6% duplication, 0 hotspots).

Round 8 (same session)

Kilo then found the boundary the fingerprint could not cross: the OUTER message memo, areMessagePropsEqual, snapshots text lengths, every part's state, and only the last part's input — so a non-last nested tool mutating input/output in place with an unchanged state skipped the render, and every row memo behind it (fingerprint included) never ran. The gap pre-dates this PR (the last-part-only tracking is old), but it hid exactly the updates round 7 shipped to catch. 6e90855 replaces that tracking with partIOJsons — every part's input and output, stringified, short-circuiting to undefined for parts that have neither — and adds the component-level test the thread asked for: an expanded Task, a nested Read, a trailing tool holding the last-part slot, an in-place mutation flipping the row from one.ts to two.ts, proven red with the fix stashed. Reply on the thread; all 28 Kilo roots answered.

What is deliberately not in this PR

  • MultiEdit is not registered, because the criterion naming it is stale. It appears in the 2.1.270 binary only in permission, deny-rule, display-label and legacy-alias tables, for example Edit:"Editing",MultiEdit:"Editing" and toolName==="MultiEdit"?"Edit", and never in the emitted tool set. The SDK's tool types agree, declaring AgentInput and AgentOutput with no TaskInput. A row for it would be unreachable. This was the human's decision during the step and the evidence is posted on the issue.

  • The dead variant field on ToolMeta was removed rather than extended. Nothing in the repo read it and the structural registry type in isolated-message-group.tsx asks only for icon and title, so giving TaskOutput a collapsible value would have described behaviour this codebase does not have.

  • rate_limit_event and permission_denied are classified internal, with reasons in the transform comment: the retry toast is the only rate-limit signal a UI owns today, and the gate already reports a denial in chat, so a second differently-shaped report is a design question rather than an obvious yes.

  • No actionable SonarCloud finding is left open on this PR (only the documented S6845 false positive), and one component is still over the complexity limit. NewChatForm came under the threshold when e57175b moved its picker wiring into the shared hook, and renderPart was split in round 3 against a harness written first, so new-code issues are 0 with 0 minutes of debt. NewChatForm itself is still a 2300-line component whose remaining complexity is spread through its JSX body as && and ternary expressions rather than sitting in one block; decomposing it is a renderer contract of its own, and Sonar no longer reports it here because it measures new code. The dispatcher split is the model for doing it: one function per decision, one explicit context, and snapshots that record the output before anything moves.

  • The permission classifier's tool table is not updated for the renames. READ_ONLY_TOOLS in src/shared/permissions/classifier.ts still lists BashOutput, which 2.1.270 no longer emits, so TaskOutput falls through to unclassified-tool, the middle tier. The drift is fail-safe rather than permissive, and that table belongs to the permission-floor work of steps 10 and 11, which another lane owns, so it is recorded for whoever owns the classifier instead of edited here.

What could not be verified here, and who verifies it

This sandbox has 2 CPUs and 3.9 GB RAM and intercepts egress to the binary hosts.

Item Status
bun run build at a 4 GB heap ran on CI and passed on all three targets: ubuntu-24.04 1m37s, macos-14 2m0s, windows-2022 3m56s
bun run package:linux, and the artifact size with and without the build.files exclusion packaging ran on CI and passed: ubuntu-24.04 unsigned 2m48s, macos-14 unsigned 4m51s, so the exclusion does not break the asar. The artifact byte delta itself is still unmeasured and belongs to step 30
bun run claude:download and codex:download integrity runs NOT RUN, egress intercepted. The integrity path is verified instead by the checksum match above
Bundle bytes per target with and without the bump and with and without @dnd-kit NOT MEASURED, needs a build. Step 30 owns the renderer budget
Turn latency for one scripted prompt NOT MEASURED, needs a packaged app and credentials
A captured transcript showing tool-Agent and tool-TaskOutput rendering NOT CAPTURED, needs a live provider session. The registry entries have unit tests over fixtures instead

Gates

Gate Result
bun install --frozen-lockfile lockfile accepted, tree clean, including after the three round-3 devDependencies: the lock was regenerated by bun 1.4.2, the version CI pins, and no existing dependency changed version. One unrelated dependency postinstall (21st-desktop) fails in this sandbox on network access
bun x biome check . passed, 980 files, no findings
npm run typecheck passed, 0 errors
npm run ratchet:typecheck passed, 0 errors against a 0 baseline
npm run test passed, 104 files, 1921 tests, 1 skipped
npm run test:node passed, 59 tests
npm run test:contracts passed, 382 tests
npm run lint passed, 925 files checked. origin/main has no merge base in this clone, so the gate fell back to the whole tree instead of the changed files
npm run ratchet:audit passed, no new critical advisories against the baseline of 3
npm run skills:verify 50 of 50 locked skills verified, 2 project-owned skills unrecorded, both pre-existing
bun run build (ubuntu, macOS, windows) passed on CI, see the table above
package:linux and package:mac, unsigned passed on CI, see the table above
bun run claude:download, codex:download not run here, egress intercepted; CI packages from resources/bin and both jobs are green
the render harness on its own vitest run assistant-message-item.test.tsx: 30 snapshots, stable across runs. The 28 written before the dispatcher split pass byte-identically after it, and the two added with the plan-label extraction are additions only — 0 removed lines in the snapshot file

Decisions taken with the human during this step

  • Prompt suggestions ship as a full surface: a preference that is off by default, the SDK option, a new chunk, and one clickable suggested next prompt in the composer.
  • The new transitive @anthropic-ai/sdk was accepted and recorded rather than pinned: 14 MB on disk, nothing in src/ imports it, and types.ts reaches its block types through the SDK's own message types by indexed access. Section 8 of the step file forbids an npm overrides block to force a pin.
  • MultiEdit was dropped as stale, with the evidence recorded in .dump and posted on the issue.
  • The effort and adaptive thinking UI mirrors the existing Codex effort sub-menu, with one shared effort vocabulary across providers, driven by the capability profile, and no new visual language.
  • The renderPart complexity finding was taken rather than deferred, on the human's choice between deferring it and splitting it with tests first: jsdom, @testing-library/react and @testing-library/dom join as exact devDependencies — a lockfile change in a PR that otherwise only moves pins, accepted deliberately — so the dispatcher's output could be recorded before it was restructured.
  • Two findings were left to this agent's judgment and are declined with their evidence below: Buoy's min-h token suggestion, and the seven duplicated manifest data lines.

MausAgent | Filed by the Arena.ai agent, with @Owie6789, on 2026-09-23

Summary by Sourcery

Upgrade the Claude and Codex integrations to their current pinned versions while exposing the new reasoning, retry, prompt-suggestion, and tool-rendering capabilities.

New Features:

  • Add Claude adaptive/disabled thinking controls, shared effort-level selection, and optional clickable prompt suggestions.
  • Register current and legacy Claude agent, task-output, and task-stop tool names for consistent transcript rendering.

Bug Fixes:

  • Surface Claude API retry progress with readable status, attempt, delay, and HTTP error details instead of appearing stalled.
  • Preserve and validate expanded Claude SDK stream payloads, tool-result content, usage values, and prompt-suggestion lifecycle across transports.

Enhancements:

  • Drive turn-control visibility from provider capabilities and share effort vocabulary across Claude and Codex.
  • Refactor shared chat-chunk handling and exclude the SDK's native platform binaries from packaged applications.

Build:

  • Update Claude Agent SDK, Claude CLI, and Codex binary pins; add exact @dnd-kit and sharp dependencies and regenerate the lockfile.

Deployment:

  • Prevent SDK platform binaries from being included in electron-builder artifacts while continuing to use the packaged Claude binary.

Documentation:

  • Record SDK upgrade research, benchmark measurements, and review remediation decisions.

Tests:

  • Add coverage for retry and prompt-suggestion translation, shared effort levels, and renamed tool registry behavior.

CodeAnt-AI Description

Upgrade the agent runtimes and add clearer, safer controls for Claude turns

What Changed

  • Claude, Codex, and their bundled CLI versions now stay aligned with the updated runtime packages.
  • Claude supports adaptive or disabled thinking, shared reasoning-effort levels, and optional next-prompt suggestions.
  • Prompt suggestions appear above the composer only when enabled, are limited to 2,000 characters, expire when a newer turn or engine takes over, and append to existing drafts instead of replacing them.
  • Claude API retries now show readable notifications with the failure reason, HTTP status, attempt count, and wait time.
  • Provider capability information now reports effort, adaptive thinking, and prompt-suggestion support consistently; unsupported features remain off by default.
  • Agent transcripts recognize renamed and nested sub-agent/background-task tools, distinguish launched work from completed work, and refresh when streamed tool data changes.
  • CLI probes use one bounded execution path, and malformed provider messages now end the affected turn with a user-visible error instead of escaping the stream handler.
  • Settings and model pickers expose the new options with accessible controls, per-chat effort selection, and coverage for the updated behavior.

Impact

✅ Optional next-prompt suggestions above the composer
✅ Clearer retry and malformed-provider errors
✅ Thinking and effort controls that match the selected backend
✅ More reliable nested-agent and streamed-tool updates

💡 Usage Guide

Checking Your Pull Request

Every time you make a pull request, our system automatically looks through it. We check for security issues, mistakes in how you're setting up your infrastructure, and common code problems. We do this to make sure your changes are solid and won't cause any trouble later.

Talking to CodeAnt AI

Got a question or need a hand with something in your pull request? You can easily get in touch with CodeAnt AI right here. Just type the following in a comment on your pull request, and replace "Your question here" with whatever you want to ask:

@codeant-ai ask: Your question here

This lets you have a chat with CodeAnt AI about your pull request, making it easier to understand and improve your code.

Example

@codeant-ai ask: Can you suggest a safer alternative to storing this secret?

Preserve Org Learnings with CodeAnt

You can record team preferences so CodeAnt AI applies them in future reviews. Reply directly to the specific CodeAnt AI suggestion (in the same thread) and replace "Your feedback here" with your input:

@codeant-ai: Your feedback here

This helps CodeAnt AI learn and adapt to your team's coding style and standards.

Example

@codeant-ai: Do not flag unused imports.

Retrigger review

Ask CodeAnt AI to review the PR again, by typing:

@codeant-ai: review

Check Your Repository Health

To analyze the health of your code repository, visit our dashboard at https://app.codeant.ai. This tool helps you identify potential issues and areas for improvement in your codebase, ensuring your repository maintains high standards of code health.

Owie6789 and others added 8 commits September 23, 2026 12:25
Three pins decide which agent runtime ships: the Claude Agent SDK version, the
Claude CLI version the download scripts fetch for bundling, and the Codex
version. They were set independently, so the SDK and the bundled binary could
drift apart. Move all three in one change: SDK 0.2.45 to 0.3.270, CLI 2.1.45 to
2.1.270, Codex 0.137.0 to 0.154.0.

0.3.270 bundles CLI 2.1.270: its manifest.json lists linux-x64 with checksum
3a624a5a7cd79bbad4d32bd7db36f1197ecf458bc5bf1e2aed81834a01ad3ef0, which is the
sha256 of the binary that install places in
node_modules/@anthropic-ai/claude-agent-sdk-linux-x64/claude. The download
script's offline fallback carries the same version as the package.json script so
a retry after a failed manifest lookup cannot silently fetch 2.1.45.

The lockfile gains the SDK's eight per-platform native packages,
@anthropic-ai/sdk 0.128.0 and its transitive dependencies, and loses the
fifteen @img/sharp-* entries that only the old SDK pulled in. sharp is now
unreachable transitively while scripts/generate-icon.mjs still imports it, so
declaring it as a devDependency is a separate change.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
At 0.2.45 the SDK could not type its own stream boundary: SDKRateLimitEvent was
never declared and the assistant, user and stream-event payloads resolved to any
because @anthropic-ai/sdk was not in the tree. The local read contracts in
types.ts were therefore guesses. At 0.3.270 that package is a dependency and all
39 message members are declared, so the read contracts are now derived from the
SDK types instead of spelled out beside them: each local block union keeps the
shapes the translator reads and adds everything else the SDK can send through
Exclude, which keeps a fixture small while a real payload still satisfies the
contract. Two fields had to widen to stay supertypes: a tool result's content is
optional and can hold a document, a search result or a browser state rather than
only text and image, and usage cache counters can be null for a tier that did
not apply.

ClaudeStreamMessage covers the rest of the dialect by exclusion from SDKMessage
rather than by a hand-written import list, so a member a future release adds
joins the union on the bump and fails the classification guards until someone
decides what it means. Those guards now cover 11 top-level types and 28 system
subtypes, each handled or internal with a named reason in the comment above
them.

Two events gain a consumer. The api_retry system subtype maps to the existing
retry-notification chunk, which both chat transports already toast, so a
backoff reads as a retry instead of a stalled stream. prompt_suggestion maps to
a new prompt-suggestion chunk carrying the suggestion and the session id,
trimmed and bounded to 2000 characters because the string is provider-authored
and crosses IPC into the composer.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
The pinned Claude CLI emits Agent where it used to emit Task, and TaskOutput and
TaskStop where it used to emit BashOutput and KillShell. Grepping the 2.1.270
platform binary for its emitted tool table returns TaskCreate, TaskGet,
TaskList, TaskUpdate, TaskStop, TaskOutput, Agent and TodoWrite, with a
normalization table mapping KillShell and KillBash to TaskStop and BashOutput,
BashOutputTool, AgentOutput and AgentOutputTool to TaskOutput. The registry knew
only the old names, so a current session rendered those steps as unstyled
generic tool calls, and assistant-message-item suppressed TaskOutput rows the
CLI now emits under a name it did not suppress, which is why background output
had no home in the transcript.

One module names the sub-agent tool types so grouping, dispatch and the nested
tool lookup read one list instead of three string comparisons. tool-Task and
tool-Agent share one meta object, and TaskOutput shares its meta with
BashOutput, so the rename cannot drift into two entries with different wording.
TaskStop joins KillShell the same way, which is one entry beyond the two the
roadmap names and comes from the same normalization table. A single subtitle
reader accepts pid, task_id and the persisted taskId spelling.

MultiEdit is deliberately not registered. It appears in that binary only in
permission, deny-rule, display-label and legacy-alias tables and never in the
emitted tool set, so a row for it would be unreachable; the SDK's tool types
agree, declaring AgentInput and AgentOutput with no TaskInput. The evidence is
in .dump/app/research/2026-09-13-sdk-0-3-bump.md and on the issue.

The dead variant field and its ToolVariant type go with this: nothing in the
repo read them, and the structural registry type in isolated-message-group only
asks for icon and title, so giving TaskOutput a collapsible value would have
described behaviour this codebase does not have.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
…chable

The pinned SDK adds three options this app could not send. `Options.effort` is
low, medium, high, xhigh or max. `Options.thinking` is adaptive, enabled with a
budget, or disabled, and supersedes the deprecated `maxThinkingTokens`, which on
a model that supports adaptive thinking only ever meant "adaptive" when non-zero
and meant the model chose a budget anyway when absent, so the Extended thinking
switch could not turn thinking off. `Options.promptSuggestions` asks for one
suggested next prompt after the result, which transform.ts already classified
but nothing could request and nothing rendered.

One vocabulary in src/shared/effort.ts now backs both pickers. Codex's four
levels carry a satisfies proof against it and its own label function is gone in
favour of formatEffortLabel, so the two backends cannot drift to two names for
one concept. The sub-menu Codex already had is generalized to EffortSubMenu and
gains an optional row that clears the pick: Claude needs a Default row because a
chat that never opened the picker must keep the model's own default rather than
a level this app guessed, while Codex always sends a concrete level and passes no
clearer, so its rows and its behaviour are unchanged.

Whether the row appears comes from the backend's capability profile rather than
a provider name in the renderer. features.effort, features.adaptiveThinking and
features.promptSuggestions are declared for all ten backends and true only where
a turn can actually carry the value end to end, which today is effort on Claude
and Codex and both other flags on Claude.

The transport sends thinking as adaptive when the switch is on and disabled when
it is off, which is the first time off has meant off. A prompt suggestion is
asked for only when the new preference is on, which is off by default, and the
chunk it produces goes to a per-sub-chat atom instead of the message stream, so
split panes do not share a suggestion and the AI SDK never sees a chunk type it
does not know. The composer renders it as one row: clicking fills the draft and
focuses the editor, Dismiss clears it. The atom is not persisted, because a
suggestion belongs to the turn that produced it.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
Steps 17, 18 and 37 all need the same drag interaction: panes, sub-chat rows and
the queue. The human approved @dnd-kit as an exception to the no-new-dependency
rule so those three do not grow three hand-rolled implementations, and this is
the only step allowed to touch package.json and bun.lock, so the packages land
here rather than in the step that first imports one. Ratified 2026-09-13 in
.dump/global/decisions.md and recorded in
.dump/app/plans/release-parity-v0.0.75-0.0.84-plan.md section 5.2, which withdrew
the native HTML5 recommendation this file argued for.

Exact pins, no carets: core 6.3.1, sortable 10.0.0, utilities 3.2.2. The
lockfile adds those three plus the transitive @dnd-kit/accessibility 3.1.1 and
tslib. Nothing imports them yet, which is the point of the exception and the
reason the bundle delta belongs to step 30 rather than to a feature commit.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
scripts/generate-icon.mjs imports sharp at line 21 and package.json declared
nothing, so the script only ran when a transitive copy happened to be hoisted.
There is no such copy any more: the old Claude Agent SDK pulled the @img/sharp-*
platform binaries in as optional dependencies, the pin bump dropped all fifteen
from the lockfile, and a fresh install has no sharp at all. The icon script is
part of the release path, so this declares it instead of leaving it to hoisting.

Exact pin as a devDependency, 0.35.4, because it is a build-time tool and never
ships in the app. The lockfile gains sharp and its @img platform binaries for
every target. Verified by importing it in this checkout, which resolves and
reports its libvips version. This is the second and last approved dependency
exception after the drag and drop packages.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
The pin bump made the Claude Agent SDK ship its own compiled CLI as eight
optional per-platform packages. The manifest lists them between 197.9 MB and
216.5 MB, and a Linux dev install holds two of them, 214 MB and 208 MB.
electron-builder copies production node_modules into the app even with an
explicit files list, so without this every packaged target would have grown by
its own platform binary.

The app never asks the SDK for that binary: every query passes
pathToClaudeCodeExecutable, resolved from resources/bin through extraResources
at the single call site in the claude router, and the SDK's own failure text for
a missing override asks for a valid path there rather than falling back to its
native package, whose resolver is a lazy existsSync lookup over candidate paths.
The glob excludes the platform packages and keeps the 5 MB JS SDK, whose
directory it does not match. package:linux cannot run in a 3 GB sandbox, so CI is
the verifier for the artifact size.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
The step file's handoff notes ask for the upstream behaviour list at this exact
research path and for a benchmark record, and both are the difference between a
version move someone can audit and one someone has to re-derive. The research
file carries the checksum proof that the SDK pin and the CLI pin are one
artifact, the 39-member dialect the classification guards now cover, the tool
table read out of the 2.1.270 binary that makes the MultiEdit acceptance
criterion stale, the changelog entries across the whole range with the versions
that matter here, the environment-semantics flip that cancels out inside the
range, the options the bump adds and the deprecated field they replace, the
packaging weight the native binary brings, and the three rejected approaches to
the stream types so nobody retries them. The benchmark file separates what was
measured in a 2 CPU, 3 GB sandbox from what needs a build, and leaves the five
measurements that need CI named rather than implied.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
@codeant-ai

codeant-ai Bot commented Sep 23, 2026

Copy link
Copy Markdown

Skipping PR review because a bot author is detected.

If you want to trigger CodeAnt AI, comment @codeant-ai review to trigger a manual review.

@sourcery-ai

sourcery-ai Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

This PR upgrades the Claude SDK/CLI and Codex pins together, hardens stream typing and translation against the SDK’s expanded protocol, registers renamed task tools, adds capability-driven effort/thinking and prompt-suggestion UX, excludes the SDK’s native binaries from packaged apps, and records the supporting research and benchmark evidence. Review the unrun build/package/download gates separately, since the sandbox only verified typechecks, tests, lint, lockfile acceptance, and the installed binary checksum.

Sequence diagram for Claude prompt suggestions and retry notifications

sequenceDiagram
    participant ClaudeSDK
    participant Transformer as createTransformer
    participant IPC as IPCChatTransport
    participant Composer

    ClaudeSDK-->>Transformer: api_retry
    Transformer->>Transformer: apiRetryMessage
    Transformer-->>IPC: retry-notification
    IPC-->>Composer: toast retry notification

    ClaudeSDK-->>Transformer: prompt_suggestion
    Transformer->>Transformer: handlePromptSuggestion
    Transformer-->>IPC: prompt-suggestion
    IPC->>Composer: set subChatPromptSuggestionAtomFamily
    Composer-->>Composer: render clickable suggestion
Loading

Sequence diagram for Claude options and external binary execution

sequenceDiagram
    participant Composer
    participant Transport as IPCChatTransport
    participant Router as claudeRouter
    participant ClaudeSDK
    participant Binary as resources/bin Claude CLI

    Composer->>Transport: send turn with thinking, effort, promptSuggestions
    Transport->>Router: claude request options
    Router->>ClaudeSDK: query(options)
    ClaudeSDK->>Binary: pathToClaudeCodeExecutable
    Binary-->>ClaudeSDK: Claude stream messages
    ClaudeSDK-->>Router: SDK stream
    Router-->>Transport: UIMessageChunk stream
Loading

File-Level Changes

Change Details Files
Move the Claude SDK, Claude CLI, and Codex pins together and update dependency/package metadata.
  • Bump the three runtime/download versions and regenerate the lockfile.
  • Add exact @dnd-kit packages and declare sharp as a devDependency.
  • Exclude SDK platform-native binaries from packaged Electron artifacts while using the externally downloaded executable.
package.json
bun.lock
scripts/download-claude-binary.mjs
Reconcile Claude stream contracts with SDK 0.3.270 and expose newly handled stream events.
  • Derive local message and content-block contracts from SDK types while preserving narrow translator read shapes and exhaustive classification guards.
  • Handle API retry notifications and bounded prompt-suggestion chunks; widen tool-result content and nullable usage counters.
  • Add tests for retry and prompt-suggestion translation behavior.
src/main/lib/claude/types.ts
src/main/lib/claude/transform.ts
src/main/lib/claude/transform.test.ts
Register current and legacy task/sub-agent tool names consistently in the transcript UI.
  • Share registry metadata across Agent/Task, TaskOutput/BashOutput, and TaskStop/KillShell aliases.
  • Centralize sub-agent detection and render TaskOutput rows instead of suppressing them.
  • Remove the unused ToolMeta variant field and add registry coverage for aliases, titles, subtitles, and grouping.
src/renderer/features/agents/lib/subagent-tool-types.ts
src/renderer/features/agents/main/assistant-message-item.tsx
src/renderer/features/agents/ui/agent-tool-registry.tsx
src/renderer/features/agents/ui/agent-tool-registry.test.ts
Add shared effort, adaptive-thinking, and prompt-suggestion option surfaces driven by provider capabilities.
  • Share effort levels and labels between Claude and Codex, including Claude default clearing.
  • Send the new thinking, effort, and promptSuggestions options through the Claude router and transport.
  • Add capability flags, preferences, per-sub-chat suggestion state, settings UI, effort pickers, and clickable composer suggestions.
src/shared/effort.ts
src/shared/effort.test.ts
src/shared/codex-model-id.ts
src/shared/provider-capabilities.ts
src/main/lib/trpc/routers/claude.ts
src/main/lib/providers/claude.ts
src/main/lib/providers/cline.ts
src/main/lib/providers/codex.ts
src/main/lib/providers/cursor.ts
src/main/lib/providers/grok.ts
src/main/lib/providers/hermes.ts
src/main/lib/providers/openclaw.ts
src/main/lib/providers/opencode.ts
src/main/lib/providers/qwen.ts
src/main/lib/providers/roo.ts
src/renderer/lib/atoms/index.ts
src/renderer/features/agents/atoms/index.ts
src/renderer/features/agents/lib/ipc-chat-transport.ts
src/renderer/features/agents/lib/models.ts
src/renderer/features/agents/components/agent-model-selector.tsx
src/renderer/features/agents/main/chat-input-area.tsx
src/renderer/features/agents/main/new-chat-form.tsx
src/renderer/components/dialogs/settings-tabs/agents-preferences-tab.tsx
Record SDK research, dependency/installation weight, integrity evidence, and verification gaps.
  • Document upstream behavior changes, pin pairing, stale MultiEdit criterion, and deliberate exclusions.
  • Capture install-time size and checksum measurements while marking build, packaging, download, and live-session checks as open.
.dump/app/research/2026-09-13-sdk-0-3-bump.md
.dump/app/benchmarks/2026-09-23-sdk-0-3-pins.md

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@codeant-ai

codeant-ai Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

🏁 CodeAnt Quality Gate Results

Commit: baa88840
Scan Time: 2026-09-25 13:00:55 UTC

✅ Overall Status: PASSED

Quality Gate Details

Quality Gate Status Details
Secrets ✅ PASSED 0 secrets found
Duplicate Code ✅ PASSED 1.6% duplicated
SAST ✅ PASSED No security issues
Bugs ✅ PASSED Rating B: 6 bugs (6 medium)
IAC ✅ PASSED Rating S: No issues

View Full Results

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="src/renderer/features/agents/lib/ipc-chat-transport.ts" line_range="353-362" />
<code_context>
               }

               // Handle retry notification - show friendly toast instead of scary error
+              // A suggestion is not part of the assistant message, so it goes
+              // to the composer atom for this sub-chat and is never enqueued as
+              // a stream chunk the AI SDK would not recognize.
+              if (chunk.type === "prompt-suggestion") {
+                appStore.set(
+                  subChatPromptSuggestionAtomFamily(this.config.subChatId),
+                  chunk.suggestion,
+                )
+                return
+              }
+
               if (chunk.type === "retry-notification") {
</code_context>
<issue_to_address>
**issue (bug_risk):** A prompt suggestion remains in the sub-chat atom after the user starts a new turn unless they explicitly dismiss or use it, so the composer continues displaying a suggestion belonging to the previous turn and can insert it into the new request.

**Triggers:** When a user starts another turn while an earlier suggestion is still visible.

**Suggested fix:** Clear the sub-chat suggestion atom when a new request begins, and optionally associate incoming suggestions with the active session before storing them.
</issue_to_address>

### Comment 2
<location path="src/renderer/features/agents/lib/ipc-chat-transport.ts" line_range="353-362" />
<code_context>
               }

               // Handle retry notification - show friendly toast instead of scary error
+              // A suggestion is not part of the assistant message, so it goes
+              // to the composer atom for this sub-chat and is never enqueued as
+              // a stream chunk the AI SDK would not recognize.
+              if (chunk.type === "prompt-suggestion") {
+                appStore.set(
+                  subChatPromptSuggestionAtomFamily(this.config.subChatId),
+                  chunk.suggestion,
+                )
+                return
+              }
+
               if (chunk.type === "retry-notification") {
</code_context>
<issue_to_address>
**issue (bug_risk):** The transport stores every arriving suggestion solely by sub-chat ID and ignores its `sessionId`, so a delayed suggestion from an aborted or older session can overwrite the suggestion produced by the current turn.

**Triggers:** When a previous Claude session emits a late `prompt-suggestion` after another turn has started in the same sub-chat.

**Suggested fix:** Track the active session/turn and discard suggestions whose `sessionId` does not match it.
</issue_to_address>

Sourcery assessment

Needs a human reviewer. 2 findings to address first, and this changes the SDK and bundled CLI versions, stream-message typing and translation, thinking defaults, prompt suggestions, and packaging rules. If the pin pairing, binary exclusion, or new runtime behavior is wrong, released builds could fail or users could see incorrect controls and notifications; reverting the source fixes future builds, but already-distributed artifacts would need a rebuilt release.

Blocking findings: src/renderer/features/agents/lib/ipc-chat-transport.ts:362, src/renderer/features/agents/lib/ipc-chat-transport.ts:362


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

Comment thread src/renderer/features/agents/lib/ipc-chat-transport.ts Outdated
Comment thread src/renderer/features/agents/lib/ipc-chat-transport.ts Outdated
@socket-security

socket-security Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

@arena-ai-coding-agent

Copy link
Copy Markdown
Author

Two notices that belong on issue #14, posted here because this token cannot comment on issues: POST /repos/maus-inc/mausCode/issues/14/comments returns 403 Resource not accessible by integration, while the same call on this pull request succeeds. Issue #14 is open and unlocked, so this is the installation's permission set rather than the issue's state. Whoever has write access can move both notices across; until then the link from the issue side is the closing reference in the body above, which GitHub resolves from Closes #14.


Body drift, 2026-09-14. Read this before the issue body.

The truth is .dump/app/roadmap/12-sdk-and-pins.md. Three ratified answers landed on this step, and it is now the only step allowed to touch package.json and bun.lock:

  • Target line is Claude Agent SDK 0.3.270 with Claude CLI 2.1.270, gated by its own 0.2 to 0.3 spike. Steps 13, 19, 20, 23, 24 and 35 unblock behind that issue, so do not let it sit.
  • @dnd-kit/core, @dnd-kit/sortable and @dnd-kit/utilities are added here in their own commit, exact pins, with bundle bytes recorded per target with and without them, because steps 17, 18 and 37 share one DndContext at the agents layout root instead of each growing a drag implementation.
  • sharp is declared as a devDependency. scripts/generate-icon.mjs:21 imports it while package.json declares nothing, so the icon script currently runs only when a transitive copy happens to be hoisted, and a fresh npm install --ignore-scripts cannot run it at all. This is the second and last approved dependency exception.

Where the issue body and the step file disagree, the file wins.


One acceptance criterion in the issue body is stale, and the pinned binary is the evidence.

The body asks for MultiEdit, tool-Agent and TaskOutput in the tool registry. At Claude CLI 2.1.270, the version the ratified SDK pin 0.3.270 bundles, MultiEdit is not a tool the runtime emits. Grepping the installed platform binary for its emitted tool table returns TaskCreate, TaskGet, TaskList, TaskUpdate, TaskStop, TaskOutput, Agent and TodoWrite. MultiEdit appears in that binary only in permission, deny-rule, display-label and legacy-alias tables, for example Edit:"Editing",MultiEdit:"Editing" and toolName==="MultiEdit"?"Edit", which is exactly the shape this app's own classifier mirrors, so registering a tool-MultiEdit row would add an entry no transcript can reach. The SDK's tool types agree on the sub-agent: they declare AgentInput and AgentOutput and no TaskInput, and the SDK changelog at 0.2.69 records the wire-name revert with the note that it "will migrate to Agent in the next minor release".

tool-Agent and tool-TaskOutput are registered, with tool-Task and tool-BashOutput kept as the legacy spellings sharing the same meta objects, so the rename cannot drift into two entries with different wording. tool-TaskStop joins tool-KillShell the same way: one entry beyond the two the issue body names, from the same normalization table in the binary, which maps KillShell and KillBash to TaskStop and BashOutput, BashOutputTool, AgentOutput and AgentOutputTool to TaskOutput.

Dropping MultiEdit was the human's decision during this step. The reasoning, the full tool-name evidence and the rest of the bump's behaviour list are in .dump/app/research/2026-09-13-sdk-0-3-bump.md in this branch.


Linked PR: #69

Two review findings on the suggestion row were both real. A suggestion stayed in
the sub-chat atom after the next turn started, so the composer kept offering a
previous request's next step and clicking it inserted that into the new prompt.
And the transport stored every arriving suggestion by sub-chat id alone, so a
late one from an aborted or older run in the same sub-chat could overwrite the
current turn's.

Starting a turn now clears the atom, and a suggestion is dropped unless its
session id is the one this stream reported in its own metadata. The metadata
arrives on the result message and the suggestion after it, so the session is
always known by the time one lands; a stream that never reports one stores the
suggestion rather than silently losing it. The subscription types that metadata
as unknown, so it is read through a predicate instead of a cast at the use site.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
@arena-ai-coding-agent

Copy link
Copy Markdown
Author

On the two min-h-[32px] findings in EffortSubMenu: declined, and the reason is the codebase rather than the suggestion. min-h-[32px] appears 14 times across src/**/*.tsx and min-h-8 appears zero times, so the token form has no precedent here. One of the two flagged lines is pre-existing Codex code that this PR only generalized, and adopting the token in this component alone would make it the single exception in the renderer while leaving the other 14 arbitrary values in place. A repo-wide move to spacing tokens is a formatting contract of its own and not part of a dependency step, so it is left for whoever owns that sweep.

@Owie6789

Copy link
Copy Markdown
Member

@CodeAnt-AI review

@codeant-ai

codeant-ai Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

🤖 CodeAnt AI — Review Status

Status Commit Started (UTC) Finished (UTC)
✅ Reviewed your PR baa8884 Sep 25, 2026 · 12:56 13:00
✅ Reviewed your PR fe08956 Sep 24, 2026 · 21:40 21:45
✅ Reviewed your PR e5b602a Sep 23, 2026 · 18:57 19:01
✅ Reviewed your PR 19ffbe1 Sep 23, 2026 · 18:11 18:16
✅ Reviewed your PR eb4a2bf Sep 23, 2026 · 14:12 14:17

@Owie6789

Copy link
Copy Markdown
Member

@CodeRabbit review

@codeant-ai codeant-ai Bot added the size:XXL This PR changes 1000+ lines, ignoring generated files label Sep 23, 2026
@Owie6789 Owie6789 linked an issue Sep 23, 2026 that may be closed by this pull request
5 tasks
Comment thread src/renderer/features/agents/ui/agent-tool-registry.tsx
Owie6789 and others added 5 commits September 23, 2026 14:57
The registry carried two of the six legacy spellings while its own comment
named all six, and CodeAnt caught the gap on review. A transcript persisted
before the bump can hold KillBash, BashOutputTool, AgentOutput or
AgentOutputTool, and with no entry each one fell through to the generic row
that prints the raw tool name instead of "Got output" or "Stopped shell".

The names are not guessed. The pinned SDK bundle carries the CLI's own
normalization table verbatim: Task to Agent, KillShell and KillBash to
TaskStop, and BashOutput, BashOutputTool, AgentOutput and AgentOutputTool to
TaskOutput. Each alias here therefore maps to the meta of the name it
normalizes to. KillBash keeps the shell wording, because what that name killed
was a background bash, while TaskStop can stop a sub-agent.

The test lists the aliases itself rather than deriving them from the registry,
so dropping a key fails instead of quietly shrinking the assertion.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
SonarCloud flagged the nested generator on new code, and it is right on the
merits: handlePromptSuggestion reads only the message it is handed, so holding
a slot in createTransformer's closure rebuilt it for every stream and set it
apart from the module-scope helpers that already build the retry line and the
tool-result text.

It now sits with them. Behaviour is unchanged and the transform's 18 tests,
which cover the suggestion path, still pass.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
SonarCloud's read-only props rule fired on the one component this step added.
The sub-menu never writes to its props: it renders the levels it is handed and
calls back with the one the user picked, so the type now says that instead of
leaving a reader to check the body for it.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
The pin added three turn-shaping features to the capability manifest, and ten
backends grew the same six lines for it: a three-line comment pointing at the
research record, then effort, adaptiveThinking and promptSuggestions set to
false. Eight of the ten were byte-identical, and that copy-paste is what
SonarCloud's duplication measure on new code was reading when it failed this
PR's quality gate at 4.2% against a 3% limit.

TURN_CONTROLS_OFF now holds the "when in doubt, false" answer in the module
that owns the rule, and a manifest spreads it and names only what its own
backend carries end to end: Codex overrides effort, Claude turns all three on
and so inherits nothing. A fourth flag added to the schema now fails typecheck
in one place instead of being silently missing from whichever manifest someone
forgot, and the evidence comment lives where the doctrine does.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
onData had grown into a 256-line flat chain at a cognitive complexity of 43,
and this step added two more links to it for the session and suggestion
handling. SonarCloud reports the function as a new-code failure because the PR
touched it, but the shape predates the pin, and leaving it longer than the step
found it was the worse option.

Each side effect is now its own module-scope function over one ChunkContext,
and routeChunk calls them in the order the stream needs them: questions first,
so the stale-question clear sees the one just asked, then compaction and
session info, then the chunks that end the turn or belong to this app rather
than to the AI SDK. A handler returns an outcome, so onData keeps a single
enqueue path, and the four copies of "close unless it is already closed" become
one closeQuietly shared with onError, onComplete and the abort listener. The
error chunk keeps its own two pieces: the log and Sentry report, and the toast
copy with the text its copy action hands over, which now receives the category
and debug payload the caller already read instead of deciding the UNKNOWN
fallback twice.

The sequence, the early returns and the set of chunks that reach the SDK are
unchanged. Typecheck is clean and the full suite passes at 103 files and 1891
tests.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
Owie6789 and others added 2 commits September 23, 2026 15:05
chat-chunk-atoms.ts says it was extracted from the Claude IPC transport so the
native runtime transport could reuse identical question, compacting and
prompt-extraction behaviour, and then this transport kept its own copies:
applyQuestionChunks, applyCompactingChunks, clearStalePendingQuestion,
extractPromptText and extractPromptImages were called only from
native-chat-transport.ts, while this file carried the same logic inline plus
two private methods byte-identical to the shared extractors.

So the handlers the previous commit lifted out of onData are deleted rather
than kept, and routeChunk calls the shared module in the order the native
transport already uses. session-init stays here, because the shared module
leaves it per transport on purpose: the native runtime reads a cached snapshot
and fills the gaps, while the CLI reports the full set on init. ChunkContext
now widens the shared ChatChunkContext instead of restating its two ids, the
local ImageAttachment type gives way to the exported one, and the transplant
note points at where the code lives now.

172 lines go, and the two transports cannot drift on the question lifecycle
again. Typecheck is clean and the suite still passes at 103 files and 1891
tests.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
Six findings landed as code and two did not, and the difference is the part worth
writing down. The duplication that failed SonarCloud's quality gate was this
step's own copy-paste across ten capability manifests. The transport's private
copies of helpers that had been extracted from it were pre-existing, and only
became visible once onData was taken apart. The two cognitive-complexity reports
that remain are pre-existing conditions in legacy renderer components this PR
touched with three and eleven lines, and the record says what clearing each one
would take instead of leaving a reviewer to guess why they were skipped.

It also keeps the classifier drift the pin causes and this step must not fix:
READ_ONLY_TOOLS still lists BashOutput, which the 2.1.270 binary no longer emits,
and the fallback is fail-safe but not free. The permissions lane owns that table.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
`model?: string | undefined` (and the three siblings) state the option
twice; the `?` already carries it. Sonar S4782 flagged all four on the
extracted openNativeTurnSession parameter.

Evidence: biome 987 files 0 findings; typecheck ratchet 0 errors ≤ 0
baseline; lint 932 files clean; vitest 109 files 1953 passed 1 skipped;
test:node 59; test:contracts 382; ratchet:audit at the 3-critical
baseline; skills:verify 50 of 50.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
const nextParts = next.get(id)
if (nextParts?.length !== prevParts.length) return false
for (let i = 0; i < prevParts.length; i++) {
if (!arePartsEqual(prevParts[i] as ToolPartLike, nextParts[i] as ToolPartLike)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Shared mutation cache can hide nested task updates

nestedMapsEqual calls arePartsEqual for every part in the message-level map, and that comparator advances the module-level toolStateCache as soon as it detects an in-place AI SDK mutation. Every AgentTaskTool in the message compares the same map. The first comparator can consume the change and return false, while a later comparator sees the already-advanced cache, returns true, and skips rendering its nested child even though that child changed. The existing three-level fixture starts from completed parts and does not cover a streamed mutation followed by a parent re-render.

Keep the descendant comparison pure per comparator, or pass an immutable snapshot of the prior map into the comparison so one row cannot consume the change for another. Add a regression test that mutates a streaming grandchild and verifies every affected task row re-renders.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 5683525.

Your read is exactly right: nestedMapsEqual walked every map part through arePartsEqual → hasToolStateChanged, which advances the module-level toolStateCache, so the first task row's comparator consumed all in-place mutations and every later row saw a clean cache and skipped re-rendering its subtree. That was a regression from faae395 (the full message-level map shared across all rows).

The fix takes your first suggestion — a pure descendant comparison:

  • nestingFingerprintOf (agent-tool-utils.ts) snapshots the map once per render in AssistantMessageItem into one immutable string (state/input/output plus identity fields — the same fields the tool-state snapshot records), with no cache interaction.
  • areTaskToolPropsEqual compares that string by ===. Every row answers from the same value, because neither of them writes anything.
  • arePartsEqual stays for each row's own part/nestedTools only, where each toolCallId has exactly one consuming row by construction.

Regression tests (new agent-tool-utils.test.ts, 9 cases): a streaming grandchild mutated in place changes the fingerprint; a rebuild with equal content does not; and — the regression itself — the same before/after pair rejects twice in a row, where the old comparator's second call returned true because the first had eaten the change.

Gates on the head commit: biome 988/0, typecheck ratchet 0 ≤ 0, lint 933 clean, vitest 110 files 1962 passed 1 skipped (includes the 9 new), test:node 59, contracts 382, audit ratchet, skills 50/50.

Kilo found a real regression in the message-level map comparison
(faae395): nestedMapsEqual walked every map part through
arePartsEqual -> hasToolStateChanged, which advances the module-level
toolStateCache. The first task row's comparator consumed every
streaming grandchild's mutation, so the rows after it saw a clean
cache, judged the tree unchanged, and skipped re-rendering descendants
that had in fact moved.

nestingFingerprintOf snapshots the map once per render in
AssistantMessageItem — state/input/output plus the identity fields,
the same fields the tool-state snapshot records — into one string.
areTaskToolPropsEqual compares that string by ===: pure, shared, and
unconsumable. Every row answers "did anything under the tree move?"
the same way because neither writes anything. The cache-backed
arePartsEqual stays for each row's own part and nestedTools, where
each id has exactly one consumer as before.

Nine new tests pin it: a grandchild mutated in place changes the
fingerprint, equal rebuilds do not, and — the regression itself —
the same before/after pair rejects twice in a row instead of letting
the first call eat the change.

Evidence: biome 988 files 0 findings; typecheck ratchet 0 errors <= 0
baseline; lint 933 files clean; vitest 110 files 1962 passed 1
skipped; test:node 59; test:contracts 382; ratchet:audit at the
3-critical baseline; skills:verify 50 of 50. tsgo ran as
`--singleThreaded` after the sandbox's 3.7 GiB cgroup OOM-killed the
default parallel mode twice; the gate itself passed in default mode
earlier today on this tree, and the single-threaded run was
sanity-checked against a deliberate type error.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
// `nestingFingerprintOf`: comparing the map through arePartsEqual would
// advance the shared tool-state cache, and the first row to walk it would
// consume the change a later row needed to see.
const nestingFingerprint = useMemo(() => nestingFingerprintOf(nestedToolsMap), [nestedToolsMap])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: The outer message memo prevents this fingerprint from seeing nested stream updates

AssistantMessageItem is already wrapped in memo(..., areMessagePropsEqual), and that comparator tracks text lengths, part states, the part count, and only the last part's input. A nested tool can mutate its input or output in place while its state, text length, part count, and last-part input stay unchanged. In that case the outer comparator returns true, this component does not render, and nestingFingerprintOf never recomputes, so the new task-row fingerprint remains stale and the row can still miss the streamed update.

Include the nested part snapshots in the outer message memo's state, or move the fingerprint comparison outside that memo boundary. Add a component-level update test that mutates a non-last nested tool's output or input in place and proves the expanded task row updates.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 6e90855.

Your read of the boundary is correct: areMessagePropsEqual snapshots text lengths, every part's state, and only lastPartInputJson, so a non-last nested tool mutating input or output in place with an unchanged state returned true from the outer memo, skipped the render, and left the task-row fingerprint — and every row memo behind it — never evaluated. The round-7 fingerprint fix was unreachable for exactly the updates it exists to catch.

Taking your first suggestion: the snapshot now carries partIOJsons — every part's input and output, stringified — replacing lastPartInputJson outright (the last part is covered the same way, and one array is not two rules). Parts with neither field short-circuit to undefined without a stringify, so the cost lands only on tool parts, which the row comparators already stringify on the renders this unlocks. No change was needed downstream: messageParts is deliberately rebuilt every render (no useMemo — the comment in the component says why), so once the outer memo passes, the nesting map and fingerprint both recompute.

Component-level regression test added as you asked: an expanded Task with a nested Read whose subtitle prints its file_path, a trailing tool holding the old last-part slot, then an in-place input mutation with state untouched — asserted to flip the row from one.ts to two.ts. Verified red against the old snapshot (fix stashed) and green with it.

Gates on the head commit: biome 988/0, typecheck ratchet 0 ≤ 0, lint 933 clean, tsgo 0 errors, vitest 110 files 1963 passed 1 skipped (the new test included), test:node 59, contracts 382, audit ratchet, skills 50/50.

Owie6789 and others added 4 commits September 24, 2026 18:59
The S4782s the S3776 split moved (four unions dropped in 1e415ee, Sonar
down to the accepted S6845), and the round-7 regression thread: what
nestedMapsEqual consumed out of the shared tool-state cache, the pure
fingerprint replacement in 5683525, and the two-warm-up-pass quirk the
regression tests had to learn.

Evidence: biome 988 files 0 findings; skills:verify 50 of 50. Markdown
only — the code research gate is exempt by AGENTS.md; CI runs the full
battery on this head.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
Sonar's re-analysis of the round-7 fix raised two new issues, both real:

- S5906 (test): `expect(a === b).toBe(true)` restated the `.toBe` on the
  line above it; the dedicated matcher already pins the claim, so the
  generic assertion goes and the comment moves to sit on it.
- S6551 (nestingFingerprintOf): `.join()` over a tuple whose `state`
  member is `unknown` can fall back to Object's default stringification,
  collapsing two different state objects into one "[object Object]" and
  hiding a change behind exactly the collision the fingerprint exists to
  detect. The tuple is JSON.stringify'd as a whole instead — every
  member goes through the same serializer the inputs and outputs already
  used, and no default stringification remains.

S6845 (tabIndex on the tooltip-only span) stays open as the documented
false positive it was before this round.

Evidence: biome 988 files 0 findings; typecheck ratchet 0 errors <= 0
baseline; lint 933 files clean; tsgo --singleThreaded 0 errors; vitest
110 files 1962 passed 1 skipped; test:node 59; test:contracts 382;
ratchet:audit at the 3-critical baseline; skills:verify 50 of 50.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
S5906 and S6551 on 5683525's own lines, cleared in 172fbb2; the
re-analysis is back to the accepted S6845 alone with the gate passing.

Evidence: biome 988 files 0 findings; skills:verify 50 of 50. Markdown
only — the code research gate is exempt by AGENTS.md.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
Kilo's round-8 thread was right: areMessagePropsEqual snapshots text
lengths, every part's state, and only the LAST part's input, so a
non-last nested tool could mutate its input or output in place with an
unchanged state, the memo would return true, the component would not
render — and every row memo behind it, the nesting fingerprint
included, would never run. The round-7 fix was unreachable for exactly
the updates it exists to catch.

The snapshot now carries partIOJsons: every part's input and output,
stringified, replacing lastPartInputJson outright — the last part is
covered the same way, and one array is not two rules. Parts with
neither field (text, reasoning, markers) short-circuit to undefined
without a stringify, so the added cost lands only on tool parts, which
the row comparators already stringify on the renders this unlocks.

The component-level test reproduces the claim end to end: a nested
Read under an expanded Task, a trailing tool holding the last-part
slot, an in-place input mutation with state untouched. It fails
against the old snapshot (verified by stashing the fix) and passes
with it.

Evidence: biome 988 files 0 findings; typecheck ratchet 0 errors <= 0
baseline; lint 933 files clean; tsgo --singleThreaded 0 errors; vitest
110 files 1963 passed 1 skipped; test:node 59; test:contracts 382;
ratchet:audit at the 3-critical baseline; skills:verify 50 of 50.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
lastPartInputJson: lastPart?.input ? JSON.stringify(lastPart.input) : undefined,
// Track every part's input AND output — tool streaming arrives as in-place
// mutation of both, on non-last parts too (parallel calls, nested tools).
partIOJsons: nextParts.map((p) =>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING]: Serializing every tool output can make the outer memo path quadratic in transcript size

areMessagePropsEqual now calls JSON.stringify for every part with input or output, on every memo comparison. Tool outputs can contain complete Bash stdout or file contents, so a parent message list update repeatedly copies and serializes those payloads merely to detect an in-place mutation. Large transcripts can therefore spend substantial CPU and allocate transient strings on each stream tick, despite the memo being intended to reduce rendering work. Keep the mutation check bounded to the fields the renderer actually consumes, or use a size-bounded/stable digest or an explicit per-part mutation signal instead of serializing every full output.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair hit — fixed in 646b8e8.

areMessagePropsEqual was stringifying every part's input+output on every memo comparison; during streaming that is a transient full-output string per part per tick, and over a finished transcript it is exactly the quadratic-in-transcript cost you describe.

The snapshot now carries a per-part entry {state, input ref, output ref, json} instead of a bare string. On comparison: if the state string is terminal (output-available/output-error/result/error, via the new shared isTerminalStateString — state string alone, so a part whose output arrived before its state string caught up stays live) and both references are unchanged, the cached json is reused in O(1) and nothing is serialized. Only live parts (streaming input, growing output) stringify per comparison — bounded by the active tool's payload, not the transcript's. A moved reference or changed state re-serializes immediately, so the round-7/8 blind-spot guarantees still hold on their actual shape (a replacement assigns a new object; deep SDK growth happens while streaming, i.e. non-terminal).

Evidence: full battery green — biome 0 findings, typecheck 0 errors, tsgo --singleThreaded 0, vitest 1964 passed / 1 skipped (110 files, including the round-8 component test that fails against the old snapshot), test:node 59, contracts 382, audit ratchet, skills 50/50, typecheck ratchet 0 <= 0. New test pins the settled boundary: a terminal part reuses its segment until a reference moves, and a streaming grandchild deep-mutating through one input object still changes the fingerprint.

The last-part-only input snapshot that hid non-last nested tool IO
from the row memos, the partIOJsons replacement in 6e90855, and the
PR-triggered CI flake whose merge ref is byte-identical to the push
run that passed.

Evidence: biome 988 files 0 findings; skills:verify 50 of 50. Markdown
only — the code research gate is exempt by AGENTS.md; CI runs the full
battery on this head.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
@Owie6789

Copy link
Copy Markdown
Member

@CodeAnt-AI review

Comment on lines +647 to +651
partIOJsons: nextParts.map((p) =>
p.input === undefined && p.output === undefined
? undefined
: JSON.stringify([p.input, p.output]),
),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Every memo comparison serializes every part's input and output, so large tool results are repeatedly copied and allocated during streaming, causing avoidable CPU and memory pressure.

Assessment: 🟠 Major · 🔁 Occurrence: Sometimes · 🏷️ Performance

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/renderer/features/agents/main/assistant-message-item.tsx
**Line:** 647:651
**Comment:**
	*Performance: Every memo comparison serializes every part's input and output, so large tool results are repeatedly copied and allocated during streaming, causing avoidable CPU and memory pressure.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 646b8e8 — same site you flagged (647-651), same cause as the round-8 follow-ups: the memo serialized each part's full input+output on every comparison, allocating large transient strings repeatedly while a big tool result sits in the transcript.

Per-part snapshot entries now hold {state, input ref, output ref, json}; a part that is terminal (isTerminalStateString: output-available/output-error/result/error) with unchanged input/output references reuses its cached string in O(1) instead of serializing. Serialization happens only when the part is live (streaming) or its state/references moved — so steady-state cost is reference compares plus one active tool's payload, not a copy of every result in the message. The comment on MessageStateSnapshot.partIO documents the rule and why it is safe (the SDK does not reopen a completed part).

Detection guarantees are unchanged and test-covered: the round-8 component test still catches an in-place input mutation behind the memo, and the fingerprint tests cover streaming deep mutation (changes) vs settled reuse (reference compare) — 1964 vitest cases green, full battery (biome 0, tsc 0, tsgo 0, node 59, contracts 382, audit, skills, ratchets) green.

Comment on lines +167 to +169
segments.push(
JSON.stringify([id, part.type, part.toolCallId, part.state, part.input, part.output]),
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: nestingFingerprintOf serializes every nested tool's full input and output on each message render, causing large transcripts to perform expensive repeated serialization.

Assessment: 🟠 Major · 🔁 Occurrence: Sometimes · 🏷️ Performance

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/renderer/features/agents/ui/agent-tool-utils.ts
**Line:** 167:169
**Comment:**
	*Performance: `nestingFingerprintOf` serializes every nested tool's full input and output on each message render, causing large transcripts to perform expensive repeated serialization.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 646b8e8.

nestingFingerprintOf re-stringified every nested tool's state/input/output on each render — your 167-169 call is exactly where that loop ran. It now shares the outer memo's settled-part rule: a private segment cache keyed by toolCallId stores the last JSON.stringify tuple per part, and the loop reuses it in O(1) when the state string is terminal and the input/output references are unchanged (serialized only when state or a reference moves, or the part is still live). A finished transcript costs reference compares; only the active tool's payload serializes.

Cache hygiene: entries are dropped in clearToolStateCachesByToolCallIds alongside toolStateCache/askUserStateCache, so rewritten/replayed tool calls never read stale segments; parts without a toolCallId are never cached (always serialized). The reuse is written once per render in the message component — row comparators only read the returned string — preserving the single-consumer property from round 7.

Evidence: targeted tests 48/48 (including new coverage: streaming grandchild deep-mutating through one input object changes the fingerprint; a settled terminal part holds its segment until a reference moves), full vitest 1964 passed / 1 skipped, full battery green (biome 0 findings, tsc 0, tsgo --singleThreaded 0, node 59, contracts 382, audit ratchet, skills 50/50, typecheck ratchet 0 <= 0).

Owie6789 and others added 4 commits September 24, 2026 22:09
S6845 flagged the bare tabIndex="0" that made every tool-call header
subtitle a tab stop, including plain-text ones with nothing to reach.
The span now appears only when it has neither an onClick nor a
tooltip: with no handler there is no action to reach, and a
tooltip-only subtitle is opened by pointer as before. Otherwise the
subtitle is a native <button type="button"> — same tab stops a
keyboard user needs, zero tabIndex anywhere, no role overrides —
with cursor-default when it only opens a tooltip, so the pointer
cursor stays honest about the click having no effect.

This deliberately reverses the round-4 ruling that kept bare spans:
that call assumed a click handler existed to protect, and it rests
on native buttons never overriding appearance — Radix's TooltipTrigger
default is a button, which the snapshot change records.

Evidence: biome full 0 findings; lint-changed clean; typecheck
0 errors; tsgo --singleThreaded 0 errors; vitest 1964 passed
1 skipped (110 files); test:node 59 pass; contracts 382 passed;
audit ratchet clean; skills 50 of 50; typecheck ratchet 0 <= 0.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
Round 9's three perf reviews all landed on the same cost the round-8
blind-spot fix introduced: areMessagePropsEqual stringified every
part's input+output on every memo comparison, and
nestingFingerprintOf stringified every nested tool's input+output on
every render — quadratic in transcript size for a finished session.

Both sites now share one settled-part rule. A part whose state string
is terminal (output-available, output-error, result, error — the new
isTerminalStateString, the state string alone, so an output that
arrived before its state caught up stays live) and whose input and
output REFERENCES are unchanged has its cached string reused in O(1);
the SDK does not reopen a completed part, so the string still
describes it. Only live parts — streaming input, growing output —
serialize per comparison, bounded by the active tool's payload
instead of the transcript's. Reference or state changes still
serialize, so the round-7/8 guarantees hold on their real shape.

The fingerprint's reuse lives in a private segment cache keyed by
toolCallId, cleared alongside the other per-tool caches, written once
per render in the message component; row comparators only read the
returned string, preserving round 7's single-consumer property.

Tests pin both halves: a streaming grandchild deep-mutating through
one input object still changes the fingerprint, and a settled
terminal part reuses its segment until a reference moves.

Evidence: biome full 0 findings; lint-changed clean; typecheck
0 errors; tsgo --singleThreaded 0 errors; vitest 1964 passed
1 skipped (110 files); test:node 59 pass; contracts 382 passed;
audit ratchet clean; skills 50 of 50; typecheck ratchet 0 <= 0.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
The S6845 span/button reversal of round 4, the three round-9 perf
reviews verified real against round 8's unbounded serialization, the
settled-part rule that bounds both sites, and the fifth sandbox reset
that took node_modules, bun, and the runtime-client dist.

Evidence: Markdown only — the code research gate is exempt by
AGENTS.md; the code commits carry the full battery.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
The 22:09 re-analysis cleared S6845 (the fix in 920a934 holds) and
raised two on the round-9 cache code itself: S3776, cognitive
complexity 17 against the 15 allowed once the settled-reuse branch
landed inside the loop, and S6582, the key!==undefined ternary
guarding the cache read. fingerprintSegmentOf now owns the per-part
rule — settle check, tuple stringify, cache write — leaving
nestingFingerprintOf with just the two loops and the join, and the
cache read keys unconditionally: a part without a usable toolCallId
keys under "", which is never stored and so never reads a segment,
the same serialize-always path it had.

Evidence: biome full 0 findings; lint-changed clean; typecheck
0 errors; tsgo --singleThreaded 0 errors; vitest 1964 passed
1 skipped (110 files); test:node 59 pass; contracts 382 passed;
audit ratchet clean; skills 50 of 50; typecheck ratchet 0 <= 0.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
prev.state === part.state &&
prev.input === part.input &&
prev.output === part.output &&
isTerminalStateString(prev.state)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING]: A terminal part can still mutate its payload in place, so reusing the cached segment hides a real update

The cache treats a terminal state plus unchanged input and output references as immutable, but the AI SDK mutates part payloads in place. If a completed part's output or input object is enriched or corrected without replacing its reference, this branch returns the old segment and the task-row fingerprint stays equal, so the nested tool does not re-render. The previous full serialization path detected that mutation; add a bounded mutation signal or another immutable/versioned payload reference before reusing a terminal segment.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

The split in b9861ae took S3776 down but left S6582 on the explicit
prev !== undefined guard beside the property reads. The chain now
opens with prev?.mapKey === mapKey — a string comparison that cannot
hold for an absent entry, so it short-circuits the same way and lets
every following read chain too; isTerminalStateString takes unknown
and returns false for the undefined that produces. Same semantics,
no nullish ternary left to prefer a chain over.

Evidence: biome full 0 findings; lint-changed clean; typecheck
0 errors; tsgo --singleThreaded 0 errors; vitest 1964 passed
1 skipped (110 files); test:node 59 pass; contracts 382 passed;
audit ratchet clean; skills 50 of 50; typecheck ratchet 0 <= 0.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
prev?.output === part.output &&
isTerminalStateString(prev?.state)
) {
return prev.segment // settled: same terminal state, same references

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING]: Optional chaining does not make a settled segment safe to reuse

The condition still returns the cached segment when a terminal part keeps the same state and input/output references. The AI SDK mutates those payload objects in place, so a completed part can gain or correct data without replacing either reference; this branch then returns the old fingerprint and areTaskToolPropsEqual skips the nested row update. The added ?. only changes how the cache absence is tested, not the stale-cache path. Keep a mutation signal or versioned payload reference that detects in-place changes, and add a regression test that changes a terminal payload through the same object reference.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Self-review of the round-9 work found the settled-part rule only
converged on a difference: every return path that found one recorded
the fresh snapshot, but the equal path did not. A history refresh
that hands back new part objects with identical content therefore
re-serialized the whole transcript on every memo comparison forever
— content equal, refs never recorded — which is the per-tick
quadratic cost the perf reviews flagged, resurfacing through the
back door after the fix that was meant to remove it.

The equal path now records the snapshot too, so the new references
settle after one comparison instead of never. No behavioural change
to what renders: the comparison outcome is identical; only the
cached identity behind it advances.

Evidence: biome full 0 findings; lint-changed clean; typecheck
0 errors; tsgo --singleThreaded 0 errors; vitest 1964 passed
1 skipped (110 files); test:node 59 pass; contracts 382 passed;
audit ratchet clean; skills 50 of 50; typecheck ratchet 0 <= 0.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
@Owie6789

Copy link
Copy Markdown
Member

@sourcery-ai review

@sourcery-ai

sourcery-ai Bot commented Sep 25, 2026

Copy link
Copy Markdown

Sorry @Owie6789, your pull request is larger than the review limit of 150,000 diff characters

@Owie6789

Copy link
Copy Markdown
Member

@CodeRabbit review

@Owie6789

Copy link
Copy Markdown
Member

@CodeAnt-AI review

Comment on lines +200 to +206
const segment = JSON.stringify([
mapKey,
part.type,
part.toolCallId,
part.state,
part.input,
part.output,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The fingerprint excludes result, although tool parts support it and lifecycle state treats it as terminal, so result-only updates never invalidate nested task rows.

Assessment: 🟠 Major · 🔁 Occurrence: Rarely · 🏷️ Logic error

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/renderer/features/agents/ui/agent-tool-utils.ts
**Line:** 200:206
**Comment:**
	*Logic Error: The fingerprint excludes `result`, although tool parts support it and lifecycle state treats it as terminal, so result-only updates never invalidate nested task rows.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 5f16a60.

You're right that result was outside the fingerprint: the tuple carried state/input/output only, and the settled-part gate compared just those three references — so a wholesale result replacement with the other three sitting still produced an identical segment and the task rows never invalidated. That matters because getToolLifecycleState treats a present result as terminal and the ask card renders its answers straight from part.result.

The tuple now carries result, error, and errorText, and the settle gate compares all three references — every writer in this repo assigns them wholesale alongside the state flip (claude.ts's three result sites), never the in-place deep mutation the SDK performs on input/output, which is the evidenced reason they ride by reference rather than by re-serialization. ToolPartLike gained the two structural fields message-store already read.

Pinned by a test proven red against baa8884: a terminal ask part whose result is replaced must change the fingerprint (alongside the existing streaming/settled pair). Full battery green — biome 0 findings, tsc 0, tsgo --singleThreaded 0, vitest 1967 passed / 1 skipped (110 files), node 59, contracts 382, audit, skills 50 of 50, typecheck ratchet 0 <= 0.

Comment on lines +223 to +228
for (const [id, parts] of map) {
for (const part of parts) {
segments.push(fingerprintSegmentOf(id, part))
}
}
return segments.join("\u0001")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: nestingFingerprintOf walks and serializes every nested part on each changed message render, making large transcripts repeatedly process the entire nested payload even when one task changed.

Assessment: 🟠 Major · 🔁 Occurrence: Rarely · 🏷️ Performance

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/renderer/features/agents/ui/agent-tool-utils.ts
**Line:** 223:228
**Comment:**
	*Performance: `nestingFingerprintOf` walks and serializes every nested part on each changed message render, making large transcripts repeatedly process the entire nested payload even when one task changed.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Validated against the current head — partly stale, no further code change needed for this one.

The serialization half has not been true since 646b8e8: nestingFingerprintOf runs through fingerprintSegmentOf, which reuses a cached segment in O(1) whenever the part is settled (terminal state string plus unchanged input/output/result/error/errorText references). A render where one task changed serializes that task's part only; every other nested part is a handful of reference compares, and baa8884 makes the snapshot advance on equal comparisons so the cache cannot fall behind. Transient per-tick strings are bounded by the one live tool's payload, not the transcript's.

The walk half is real but is the design itself, not a defect: round 7 replaced per-row cache-walking comparators (which had a genuine read-consumes-mutation bug) with one snapshot string computed once per render in the message component, so every row answers from the same value. Building that string requires visiting each nested part once — an O(n) pass of reference compares plus live-part serializes per changed render, which is the cost the row memos' correctness depends on. Tests cover both directions (streaming deep-mutation changes it; settled reuse holds until a reference moves); full suite 1967 passed / 1 skipped, battery green.

Comment on lines +674 to +682
return {
state: p.state,
input: p.input,
output: p.output,
json:
p.input === undefined && p.output === undefined
? undefined
: JSON.stringify([p.input, p.output]),
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The snapshot does not include result, error, or errorText, so changes to these rendered fields can be skipped when state, input, and output remain unchanged.

Assessment: 🟠 Major · 🔁 Occurrence: Rarely · 🏷️ Stale reference

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/renderer/features/agents/main/assistant-message-item.tsx
**Line:** 674:682
**Comment:**
	*Stale Reference: The snapshot does not include `result`, `error`, or `errorText`, so changes to these rendered fields can be skipped when state, input, and output remain unchanged.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 5f16a60.

The snapshot tracked state, input, and output while the ask card renders its answers from part.result and its failure line from errorText/error — a wholesale result replacement with those three unchanged compared equal and the card kept the stale answer.

PartIOSnapshot now carries result, error, and errorText: the settled-part gate compares all three references, and the serialized tuple includes them, so both a reference move and a deep rewrite under the same reference are visible. The same gap existed at the two sibling depths — the nesting fingerprint's segment and the row-level getToolStateSnapshot — and both were closed in the same commit (see the replies on 4104721117 and 4104721137), so one change covers the whole path from part to DOM.

Proven load-bearing against baa8884: a component test renders a completed question's card, replaces part.result alone, and requires the answer to flip — red without the fix, green with it, 51/51 targeted. Full battery: biome 0, tsc 0, tsgo 0, vitest 1967 passed / 1 skipped (110 files), node 59, contracts 382, audit, skills 50 of 50, ratchet 0 <= 0.

CodeAnt's two Major findings from the 13:00 review are the same gap
at three depths: the ask card renders answers from part.result and
its failure line from errorText/error, but the outer memo's snapshot,
the nesting fingerprint, and the row-level tool snapshot each compared
only state, input, and output — so a wholesale result replacement with
those three sitting still never reached the DOM.

All three sites now carry the three fields. The memo and the
fingerprint add them to the tuple and to the settled-part gate by
reference — every writer in this repo assigns them wholesale next to
the state flip (claude.ts's three result sites), never the in-place
deep mutation the SDK performs on input and output, which is why the
memo's json and the fingerprint's segment serialize them while the
row snapshot compares references. ToolPartLike gains the two fields
its structural view was missing; message-store already read both.

Each fix is proven load-bearing: against baa8884 the three new tests
fail (result replaced → stale row, stale fingerprint, stale card) and
pass with the fix; 51/51 targeted, full vitest 1967 passed / 1
skipped.

Evidence: biome full 0 findings; lint-changed clean; typecheck
0 errors; tsgo --singleThreaded 0 errors; vitest 1967 passed
1 skipped (110 files); test:node 59 pass; contracts 382 passed;
audit ratchet clean; skills 50 of 50; typecheck ratchet 0 <= 0.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
state: typeof part.state === "string" ? part.state : undefined,
inputJson: JSON.stringify(part.input || {}),
outputJson: JSON.stringify(part.output || {}),
result: part.result,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Comparing synthetic thinking results by identity defeats their memo

toThinkingToolPart creates a fresh completedResult and output object on every call when the raw part has no result, then AgentThinkingTool uses this comparator. Adding result to the identity snapshot makes that synthetic value differ on every parent render, so completed thinking rows re-render whenever another part streams even though their rendered content is unchanged. Reuse a stable completed sentinel or compare the normalized result by value.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL This PR changes 1000+ lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

12 · Move the SDK and binary pins together and register the missing tools

1 participant