12 · Move the SDK and binary pins together and register the missing tools (issue #14) - #69
arena-ai-coding-agent[bot] wants to merge 60 commits into
Conversation
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>
|
Skipping PR review because a bot author is detected. If you want to trigger CodeAnt AI, comment |
Reviewer's GuideThis 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 notificationssequenceDiagram
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
Sequence diagram for Claude options and external binary executionsequenceDiagram
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
File-Level Changes
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
🏁 CodeAnt Quality Gate ResultsCommit: ✅ Overall Status: PASSEDQuality Gate Details
|
There was a problem hiding this comment.
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
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
|
Two notices that belong on issue #14, posted here because this token cannot comment on issues: Body drift, 2026-09-14. Read this before the issue body. The truth is
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
Dropping 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>
|
On the two |
|
@CodeAnt-AI review |
🤖 CodeAnt AI — Review Status
|
|
@CodeRabbit review |
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>
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)) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 inAssistantMessageIteminto one immutable string (state/input/output plus identity fields — the same fields the tool-state snapshot records), with no cache interaction.areTaskToolPropsEqualcompares that string by===. Every row answers from the same value, because neither of them writes anything.arePartsEqualstays for each row's ownpart/nestedToolsonly, where eachtoolCallIdhas 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]) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
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) => |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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>
|
@CodeAnt-AI review |
| partIOJsons: nextParts.map((p) => | ||
| p.input === undefined && p.output === undefined | ||
| ? undefined | ||
| : JSON.stringify([p.input, p.output]), | ||
| ), |
There was a problem hiding this comment.
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 fixThere was a problem hiding this comment.
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.
| segments.push( | ||
| JSON.stringify([id, part.type, part.toolCallId, part.state, part.input, part.output]), | ||
| ) |
There was a problem hiding this comment.
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 fixThere was a problem hiding this comment.
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).
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) |
There was a problem hiding this comment.
[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 |
There was a problem hiding this comment.
[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>
|
@sourcery-ai review |
|
Sorry @Owie6789, your pull request is larger than the review limit of 150,000 diff characters |
|
@CodeRabbit review |
|
@CodeAnt-AI review |
| const segment = JSON.stringify([ | ||
| mapKey, | ||
| part.type, | ||
| part.toolCallId, | ||
| part.state, | ||
| part.input, | ||
| part.output, |
There was a problem hiding this comment.
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 fixThere was a problem hiding this comment.
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.
| for (const [id, parts] of map) { | ||
| for (const part of parts) { | ||
| segments.push(fingerprintSegmentOf(id, part)) | ||
| } | ||
| } | ||
| return segments.join("\u0001") |
There was a problem hiding this comment.
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 fixThere was a problem hiding this comment.
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.
| return { | ||
| state: p.state, | ||
| input: p.input, | ||
| output: p.output, | ||
| json: | ||
| p.input === undefined && p.output === undefined | ||
| ? undefined | ||
| : JSON.stringify([p.input, p.output]), | ||
| } |
There was a problem hiding this comment.
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 fixThere was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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.
|



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 touchpackage.jsonandbun.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.45to0.3.270, the Claude CLI the download scripts fetch moves from2.1.45to2.1.270, and Codex moves from0.137.0to0.154.0. Those three are one decision and the pairing is checkable rather than assumed: the SDK's ownmanifest.jsonlistslinux-x64at size223981040with checksum3a624a5a7cd79bbad4d32bd7db36f1197ecf458bc5bf1e2aed81834a01ad3ef0, which is thesha256sumof the binary that install places innode_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 fetch2.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.0.2.47promptSuggestion()onQuery0.2.49ModelInfogainssupportsEffort,supportedEffortLevels,supportsAdaptiveThinking0.2.69system:initandresultemitTaskagain, reverted fromAgent, with a note that the wire name migrates toAgentin the next minor0.2.84EffortLevelsrc/shared/effort.tsnow mirrors0.2.111,0.2.113options.envchanged to overlayprocess.env, then back to replacing itclaude.tsdeletes ambientANTHROPIC_*keys when an OAuth token exists, andbuildClaudeEnvstrips its own list, and neither deletion would survive overlay semantics0.2.1130.2.136,0.3.142TodoWritedeprecated, then headless and SDK sessions use theTasktoolsTASK_TOOLSalready name, and the reasonTask*is not the sub-agent family0.3.149options.envdroppingCLAUDE_AGENT_SDK_VERSIONwhen a custom environment is suppliedUser-Agentand telemetry were incomplete before this pin0.3.178pathToClaudeCodeExecutable0.3.179tool_use_metasidecar with display friendly names for tool calls0.3.193promptSuggestionsoption0.3.214effortLevelacceptsmaxin the TypeScript type0.3.234SDKSystemMessagegains an optionaleffortfieldWhat changed, one commit per contract
bun.lockregenerated bybun installandbun install --frozen-lockfileclean on the result.0.2.45the SDK could not type its own boundary:SDKRateLimitEventwas never declared and the assistant, user and stream-event payloads resolved toanybecause@anthropic-ai/sdkwas not in the tree. At0.3.270it is, so the local read contracts intypes.tsare 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 throughExclude, which keeps a fixture small while a real payload still satisfies the contract.ClaudeStreamMessagecovers the rest of the dialect by exclusion fromSDKMessage, so a member a future release adds joins the union on the bump and fails the twoAssertNeverclassification guards until someone decides what it means. Those guards now cover 11 top-level types and 28systemsubtypes. Two events gain a consumer:api_retrymaps to the existingretry-notificationchunk both transports already toast, so a backoff reads as a retry instead of a stalled stream, andprompt_suggestionmaps to a newprompt-suggestionchunk, 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'scontentis optional and can hold a document, a search result or a browser state, and usage cache counters can benull.2.1.270platform binary for its emitted tool table returnsTaskCreate,TaskGet,TaskList,TaskUpdate,TaskStop,TaskOutput,Agent,TodoWrite, with a normalization table mappingKillShellandKillBashtoTaskStopandBashOutput,BashOutputTool,AgentOutput,AgentOutputTooltoTaskOutput.tool-Agentshares one meta object withtool-Task, all four legacy output spellings share thetool-TaskOutputmeta, andKillShellandKillBashshare the shell-stopping one whileTaskStopkeeps its task wording, so a rename cannot drift into two entries with different wording.isSubagentToolTypein a new shared module is the single predicate grouping, dispatch and the nested-tool lookup read, andassistant-message-item.tsxno longer suppressesTaskOutputrows in either of the two places that did, which is why background output had no home in the transcript.src/shared/effort.tsbacks both pickers, with asatisfiesproof on Codex's four levels and its own label function removed in favour offormatEffortLabel. The sub-menu Codex already had is generalized toEffortSubMenuand 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.adaptiveThinkingandfeatures.promptSuggestionsare declared for all ten backends and true only where a turn can carry the value end to end. The transport sendsthinkingasadaptivewhen the Extended thinking switch is on anddisabledwhen it is off, which is the first time off has meant off, because the deprecatedmaxThinkingTokensit 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.node_moduleseven with an explicitfileslist, 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 passespathToClaudeCodeExecutablefromresources/binat the single call site in the claude router.@dnd-kitcore 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 oneDndContextand this is the only step allowed to touch the lockfile.sharp0.35.4 as an exact devDependency.scripts/generate-icon.mjs:21imports it andpackage.jsondeclared 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 nosharpat all. Verified by importing it here, which reports libvips 8.18.6..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.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.df4b974— every name the pinned CLI folds intoTaskOutputandTaskStopis registered. CodeAnt caught a real gap: the registry carried two of the six legacy spellings while its own comment named all six, soKillBash,BashOutputTool,AgentOutputandAgentOutputToolin 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.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.3306846— the effort sub-menu's props are read-only, which is what its body already did.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_OFFinsrc/shared/provider-capabilities.tsholds the "when in doubt, false" answer where the rule lives; a manifest spreads it and names only what its own backend carries, so Codex overrideseffortand 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.dc182be— one handler per chunk side effect in the transport.onDatawas 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 oneChunkContext,routeChunkcalls 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 onecloseQuietlyshared withonError,onCompleteand the abort listener. The sequence, the early returns and the set of chunks that reach the SDK are unchanged.fe09b5a— the transport takes the helpers that were extracted from it.chat-chunk-atoms.tssays 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-initstays per transport on purpose: the native runtime reads a cached snapshot and fills the gaps, the CLI reports the full set on init.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
2c8be15was 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.43ff64d— the feature flags default all-off, not four-off.TURN_CONTROLS_OFFnamed four of the thirteen flags, so every manifest still restated the other nine and the shared default covered a third of the object.FeatureFlagsis nowz.infer<typeof featureFlagsSchema>rather than a hand-named partial,ALL_FEATURES_OFFspells all thirteenfalseundersatisfies FeatureFlags, and each manifest readsfeatures: { ...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 at2c8be15was compared key by key with the rewritten tree, 130 values across 10 providers, 0 differences. −77/+47 lines across eleven files.e57175b— one hook for the Claude half of the model picker.chat-input-area.tsxandnew-chat-form.tsxeach carried a byte-identical 37-lineuseAvailableModels, an identical connection test and an identical 26-lineclaude={{ ... }}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.tsnow 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 againstAgentModelSelectorProps["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.selectedModelIdandonSelectModelstay 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.dbb9457— both transports take the SDK's own options type. Each restatedsendMessages(options: { messages: UIMessage[]; abortSignal?: AbortSignal })and then repeated the same three lines reading the last user message. Both classesimplements ChatTransport<UIMessage>, so the shape was the library's all along, and restating it narrowed it: the real options carrytrigger,chatId,messageIdandChatRequestOptions, which neither transport could see.chat-chunk-atoms.ts, which already holds the shared transport helpers, now also holdsSendMessagesOptionsderived from that contract andlastUserPrompt(messages). No directsendMessagescaller exists in the repo — the AI SDK'sChatcalls it — so widening the accepted options changes no call site.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 aslatencyClass: "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 abreak / default: / breakswitch tail intransform.tsthat 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:e57175bmoved the ternary that fed the effort rows and the||chains that resolved the custom config, the connection state and the Ollama model out ofNewChatForm, and its cognitive-complexity finding is gone, whilerenderPartat 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 andprovider-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 lint923 files no findings,tsc --noEmitandratchet:typecheck0 errors,vitest run1891 passed 1 skipped,test:node59 passed,test:contracts382 passed,ratchet:auditpassed,skills:verify50 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.mdunder round 3.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 unfilteredavailableModelsalongside the filteredprops.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 auseStateplus a sync effect that could only ever move towards a model it could find. −24/+31.e5b602a— one probe runner for ten manifests. Sonar's remaining duplication on the manifests was not the capability data. It was a sixteen-lineexecFilewrapper all ten carried byte-identically, eight asrunBinaryand two asrunLaunch, differing only in the bound: fifteen seconds in eight, thirty in openclaw and roo.providers/probe-command.tsholds 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.cacd3db— the probe's exit code stated in one place. The extraction drewtypescript:S3358onerror ? (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:execFilereports a spawn failure as a string errno and a non-zero exit as a number, so only the number is an exit code andnullmeans the binary is not there.5a98fb4— the transcript renderer's output, written down before it is restructured. 28 snapshots, one message per branch ofrenderPart: 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 isnode.jsdom30.1.1,@testing-library/react16.3.3 and its@testing-library/dom10.4.2 peer join as exact devDependencies and*.test.tsxjoins the include list; the environment staysnodeglobally 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.1900d14— the dispatcher comes out of its closure. SonarQube measuredrenderPartat 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 onePartRenderContextcarrying what only the component knows;renderMessagePartdispatches in the order it always did, which is the part that carries meaning, since a sub-agentTaskand 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 identicalAgentEditToolin two branches, so one function serves both table entries, and the closure's eighteen-entry deps list becomes auseMemoaround the context. The evidence is the absence of a diff —git diffreports no change to the.snapfile and vitest wrote 0 and obsoleted 0 — which is the only reason this is a refactor rather than a rewrite.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 insiderenderPartfor 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.
r4086282209). The effect is byte-identical at this PR's base, early return included. Neither transport consultshiddenModels— both are untouched here and both resolveMODEL_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:modelsis 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 ofmodels[0]. What41688f4did 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.min-h-[32px]→min-h-8, re-posted on lines this PR does not touch (repliesr4086282510,r4086282751). tailwindcss is^3.4.17,tailwind.config.jsonlyextends, and nothing sets a rootfont-size, somin-h-8is2remis32pxand the swap changes no computed style.min-h-[32px]appears 16 times undersrc/,min-h-8appears 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 carrypy-[5px]andw-[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.ProviderCapabilitytype mandates because that is what makes ten manifests one table. A shared spread would hand a new providerlatencyClass: "cloud"andusageSurface: "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.tsandnative-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_metadisplay names, which the registry hand-writes, and per-modelsupportsEffortdiscovery, 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 withcacd3dband97f4327.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 lint925 files no findings,ratchet:typecheck0 errors against a 0 baseline,vitest run104 files 1921 passed 1 skipped,test:node59 passed,test:contracts382 passed,ratchet:auditpassed,skills:verify50 of 50.What the fourth round changed
A fourth reviewer —
kilo-code-bot— opened 21 inline threads against head5ea0b64with aCHANGES_REQUESTEDreview, 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:50c6b2c— the prompt-suggestions Switch is named.aria-label="Prompt Suggestions"plus a role/name test (getByRole("switch", { name })).29a11b1— effort is per sub-chat.subChatClaudeEffortAtomFamily+lastSelectedkeeps the old storage key;innot??so an explicit null stays this chat's answer; the composer passessubChatIdinto the picker; both transports read the family. Five tests including migration.79294c9— the Native transport sends effort. Contract verified first (setReasoningEffort→set_reasoning_effortin the pinned runtime-client); router validatesz.enum(EFFORT_LEVELS)and applies best-effort afterset_model.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; probenullcomment narrowed; roadmap §16 ratifies every lockfile addition.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/clearbuiltin — the SDK event is unreachable from any owned path); per-model capability matrix (effort.tsfiles the follow-up; the SDK clamps; the "hidden control" premise is false — efforts are never hidden);TaskOutputinREAD_ONLY_TOOLS(sibling lanearena/01a0bb80-mauscodeownsclassifier.ts, stillBashOutput-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 in1fe7cf9, 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
openNativeTurnSessioninput —model?: string | undefinedand three siblings stating the option twice — all cleared in1e415ee, 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:
nestedMapsEqualwalked every map part througharePartsEqual, which advances the module-leveltoolStateCache, 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 in5683525:nestingFingerprintOfsnapshots the message-level map once per render into one immutable string (the same fields the tool-state snapshot records, no cache writes), andareTaskToolPropsEqualcompares it by===, so every row answers from the same value. Nine tests in a newagent-tool-utils.test.tspin it, including the regression itself — the same before/after pair rejects twice in a row, where the old comparator's second call returnedtruebecause 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 anunknownstate that could fold two objects into one[object Object]); both cleared in172fbb2, 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 mutatinginput/outputin 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.6e90855replaces that tracking withpartIOJsons— every part's input and output, stringified, short-circuiting toundefinedfor 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 fromone.tstotwo.ts, proven red with the fix stashed. Reply on the thread; all 28 Kilo roots answered.What is deliberately not in this PR
MultiEditis not registered, because the criterion naming it is stale. It appears in the2.1.270binary only in permission, deny-rule, display-label and legacy-alias tables, for exampleEdit:"Editing",MultiEdit:"Editing"andtoolName==="MultiEdit"?"Edit", and never in the emitted tool set. The SDK's tool types agree, declaringAgentInputandAgentOutputwith noTaskInput. 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
variantfield onToolMetawas removed rather than extended. Nothing in the repo read it and the structural registry type inisolated-message-group.tsxasks only for icon and title, so givingTaskOutputacollapsiblevalue would have described behaviour this codebase does not have.rate_limit_eventandpermission_deniedare 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.
NewChatFormcame under the threshold whene57175bmoved its picker wiring into the shared hook, andrenderPartwas split in round 3 against a harness written first, so new-code issues are 0 with 0 minutes of debt.NewChatFormitself 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_TOOLSinsrc/shared/permissions/classifier.tsstill listsBashOutput, which2.1.270no longer emits, soTaskOutputfalls through tounclassified-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.
bun run buildat a 4 GB heapbun run package:linux, and the artifact size with and without thebuild.filesexclusionbun run claude:downloadandcodex:downloadintegrity runs@dnd-kittool-Agentandtool-TaskOutputrenderingGates
bun install --frozen-lockfile21st-desktop) fails in this sandbox on network accessbun x biome check .npm run typechecknpm run ratchet:typechecknpm run testnpm run test:nodenpm run test:contractsnpm run lintorigin/mainhas no merge base in this clone, so the gate fell back to the whole tree instead of the changed filesnpm run ratchet:auditnpm run skills:verifybun run build(ubuntu, macOS, windows)package:linuxandpackage:mac, unsignedbun run claude:download,codex:downloadresources/binand both jobs are greenvitest 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 fileDecisions taken with the human during this step
@anthropic-ai/sdkwas accepted and recorded rather than pinned: 14 MB on disk, nothing insrc/imports it, andtypes.tsreaches its block types through the SDK's own message types by indexed access. Section 8 of the step file forbids annpm overridesblock to force a pin.MultiEditwas dropped as stale, with the evidence recorded in.dumpand posted on the issue.renderPartcomplexity finding was taken rather than deferred, on the human's choice between deferring it and splitting it with tests first:jsdom,@testing-library/reactand@testing-library/domjoin 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.min-htoken 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:
Bug Fixes:
Enhancements:
Build:
Deployment:
Documentation:
Tests:
CodeAnt-AI Description
Upgrade the agent runtimes and add clearer, safer controls for Claude turns
What Changed
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:
This lets you have a chat with CodeAnt AI about your pull request, making it easier to understand and improve your code.
Example
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:
This helps CodeAnt AI learn and adapt to your team's coding style and standards.
Example
Retrigger review
Ask CodeAnt AI to review the PR again, by typing:
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.