🤖 feat: show task kind and spawn intent in single-task task_await summary - #3793
Conversation
Review findings: reuse the intent-vs-command comparison from buildBashCollapsedSummary via a shared sanitizeDisplayableModelIntent helper, and drop the unused BashTaskSpawnInfo.displayName field (bash task_await results already carry display_name as their title).
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: aaaa78529e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
/coder-agents-review |
|
|
Chat: Review posted | View chat Review history
deep-review v0.9.0 | Round 1 | Last posted: Round 1, 32 findings (6 P2, 12 P3, 10 Nit, 4 Note), COMMENT. Review Finding inventoryFinding inventory: coder/mux PR #3793Findings
Drop/downgrade gate, keep-arguments written before each decision:
Severity escalations applied during cross-check:
Contested and acknowledgedNone. Round logRound 1Netero first pass: 3 P3, 1 Nit, 2 Notes. No P0-P2, panel proceeded. Panel of 21 trigger-matched reviewers plus Kurapika as wildcard. 6 P2, 13 P3, 9 Nit, 5 Note after cross-check; 3 dropped. Reviewed against d8e92cb..aaaa785. About deep-reviewCRF = Coder Review Finding (P0-P4, Nit, Note)
|
Codex review: the collapsed row should describe the task's spawn intent; the report heading is only a fallback.
|
@codex review |
|
Codex Review: Didn't find any major issues. 👍 Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
The module reads every field out of persisted tool args/results typed as `unknown`, and #3789/#3793 grew that from four to seven copies of the same "is a string, is not blank, use the trimmed form" check. Collapse those seven into a single coerceNonBlankString helper. The two task_await result reads are deliberately left alone: they validate a field is non-blank but then store the raw, untrimmed value, so routing them through the helper would change what gets persisted.
There was a problem hiding this comment.
The extraction is the best part of this diff: sanitizeDisplayableModelIntent removes the inline copy rather than adding a fifth intent sanitizer, so the "this intent just restates the command" rule now has one implementation shared by the bash row and the await row. Three of the five new tests drive the real computeTaskReportLinking over hand-built DisplayedMessage inputs instead of stubbing a TaskReportLinking, which is why the wiring is genuinely exercised; four of the five are red on base. The kind enumeration was checked against every set it reads from and is complete, the taskId-implies-background-spawn invariant holds against BashToolResultSchema, and the detail renders inside a min-w-0 flex-1 truncate span so long intents clip by construction. make typecheck is clean on both projects and the touched suites pass.
Round 1, 22 reviewers plus a first pass. 6 P2, 13 P3, 9 Nit, 5 Note.
The P2s cluster into four things the collapsed row now gets wrong or fails to show. Two are wrong claims: a background bash that exited nonzero renders as a green 1 task completed · bash · <intent> (CRF-7), and a reused bash: taskId lets a newer spawn's intent label an older row (CRF-8, reproduced deterministically through the compaction path). Two are the feature not arriving: adjacent task_await polls collapse into Checked task status N times and no per-row summary renders at all (CRF-10, verified in transcriptRenderProjection.ts and ChatPane.tsx), and the description chain omits the workspace-title rung the expanded row uses, so title-less results fall back to bare 1 task completed (CRF-11). CRF-9 is the guard you added being bypassed by the fallback directly beneath it. CRF-16 is the sub-agent test: it asserts a string that both candidate title sources produce, mutation-proved, which is also the precedence question the Codex thread raised.
One correction to reviewer evidence, since it changes CRF-9's reachability: display_name is required in the bash schema (toolDefinitions.ts:1537, z.string() with no nullish), so the "model omitted display_name" path three reviewers described is not reachable through a schema-valid call. The reachable path is a display_name that restates the command, and sanitizeBashDisplayName still rewrites / | : > < to _ in authored names, so the mangled-pseudo-command outcome stands.
Two things need a human decision rather than an agent one. The PR's scope note ("the Waiting for N tasks state could get the same suffix ... if desired") has no ticket, which makes it a drop, not a deferral: CRF-14 and CRF-15 are the waiting and interrupted rows still carrying the exact problem the Background section opens with. Either extend them or file the ticket. Separately, CI reports Test / Unit failing (run 92014224880). Neither the first pass nor the orchestrator could attribute it to this diff: the touched suite is 18/18, and the full local run is 10281 pass with 15 failures that are all sandbox git-wrapper artifacts in diffParser.test.ts and friends. Someone with CI log access needs to confirm that before merge; we are not claiming it is unrelated, only that we could not reproduce it (CRF-6).
From Luffy, who liked this one: "OI, THIS IS THE GOOD KIND OF CHANGE! 'One task completed' was a row that told you nothing and made you click."
src/browser/features/Tools/TaskToolCall.tsx:1436
P3 [CRF-13] The new detail is excluded from the row's accessible name, so the information this PR adds is sighted-only. (Mafu-san P3, Leorio P3, Knov P3)
aria-label={${summaryTitle}. Show task wait details}names the row with the count alone. A screen reader user hears "1 task completed. Show task wait details" and still has to expand the row, which is the exact cost the PR sets out to remove.
The gap is class-wide, not new: the sharpest pre-existing instance is the failure case, where the label says "Task wait failed" while the entire diagnosis (callError) lives in summaryDetail. This PR adds a third summaryDetail producer, so the new text joins the error text on the wrong side of that divide. One line covers all of them:
aria-label={`${[summaryTitle, summaryDetail].filter(Boolean).join(" · ")}. Show task wait details`}Unverified: which string an actual screen reader speaks, since ToolHeader renders a bare div with no role. Either way the code holds two descriptions of one row and only one is complete. Note the existing test at TaskToolCall.test.tsx:553 queries by that label, so the change is observable.
🤖
src/browser/features/Tools/TaskToolCall.tsx:1393
P3 [CRF-14] The row now names what finished but still says nothing about what you are waiting on, which is the state the user actually sits in. (Leorio P3, Nami P3, Luffy P3)
While the await is in flight the row says
Waiting for 1 task, and while partially doneStill waiting for 1 task · 1 completed. Same disease, and worse, because unlike the failure path this state is not force-expanded, and it is the state the row spends its entire lifetime in before the answer arrives. Someone watching an await hang for four minutes gets a spinner, an elapsed counter, and the word "task".
The data is already computed in this render: for status === "executing" with a single task_ids entry, awaitedRows[0].title is resolved from backgroundProcesses (proc.displayName ?? proc.id) or from workspace metadata, and agentType from resolvePersistedAgentId, at lines 1291-1336. It is computed, then thrown away for the collapsed row.
The PR says this state "could get the same suffix via the same helper if desired." A scope note with no ticket is a drop, not a deferral, and no agent gets to accept it: either extend the single-task path to the waiting and still-waiting branches, or file the ticket and link it here.
🤖
src/browser/features/Tools/TaskToolCall.tsx:1381
P3 [CRF-15] A single interrupted task is the one collapsed terminal state with no identity and no auto-expand, and it is left untouched. (Pariston P3, Mafu-san P3)
forceExpanded(line 1413) opens the row forcallError,status === "failed", andfailedCount > 0.isInterruptedTaskAwaitResultresults are deliberately excluded fromfailedCount, so the interrupted branch stays collapsed showing1 task interruptedand nothing else. Verified: a single{status: "interrupted", taskId: "task-1"}with a linkedtaskspawn renders1 task interruptedwith no detail, even though the title and the agent type are sitting intaskReportLinking.
Failed rows are fine because they force-expand. Interrupted rows do not, so this is the collapsed state where a user asks "which one got interrupted?" and gets no answer. singleTaskDetail already computes kind independently of status; widening the guard to firstResult.status === "completed" || isInterruptedTaskAwaitResult(firstResult) is a two-line change. The scope note names only the waiting state, which suggests this branch was not considered rather than excluded.
🤖
🤖 This review was automatically generated with Coder Agents.
| summaryTone = "waiting"; | ||
| } else if (completedCount > 0) { | ||
| summaryTitle = `${formatTasks(completedCount)} completed`; | ||
| summaryDetail = singleTaskDetail; |
There was a problem hiding this comment.
P2 [CRF-7] A background bash that exited nonzero or was killed renders as 1 task completed · bash · <intent> with a green check, asserting the work succeeded. (Mafuuu P2, Chopper P2, Meruem P2)
task_awaitreports a finished background process asstatus: "completed"regardless of exit code (src/node/services/tools/task_await.ts:523-536returnsstatus: "completed"plusexitCode), socompletedCount > 0andsummaryTone = "success". Before this PR the row said1 task completed, which claims nothing about the goal. Now it claims the goal was reached.
Verified by render, twice independently: a completed result { taskId: "bash:deploy-a1b2", title: "Deploy", exitCode: 1 } with intent "deploying the service to staging" produces 1 task completed · bash · Deploying the service to staging with the green CircleCheck. The expanded row already prints exit {exitCode} at line 1592, so collapsed and expanded now disagree about whether the work succeeded, and the collapsed one is what gets read. The field that contradicts the claim is on the object this code already reads.
When firstResult.exitCode is a nonzero number, either append exit N and drop the success tone, or suppress the intent.
🤖
| const taskId = getBashSpawnTaskId(msg.result); | ||
| const spawnInfo = taskId ? getBashSpawnInfoFromArgs(msg.args) : null; | ||
| if (taskId && spawnInfo) { | ||
| bashSpawnByTaskId.set(taskId, spawnInfo); |
There was a problem hiding this comment.
P2 [CRF-8] bash: taskIds are only unique among live processes, so this last-wins index attaches a newer spawn's model_intent to an older completed await row. (Komugi P2, Mafuuu P2, Nami P2, Knov P3, Razor P3)
The key is
bash:<processId>andprocessIdis fully deterministic fromdisplay_name(resolveBashDisplayName, no entropy). Uniqueness comes only fromgenerateUniqueProcessId(backgroundProcessManager.ts:692), which appends" (1)"only while the base ID is still present in the in-memorythis.processesmap. That map is emptied for the workspace on compaction (agentSession.ts:3054) and on session dispose (agentSession.ts:625), and it is never rehydrated (backgroundProcessManager.ts:228-230). The transcript is not dropped at those points.
Forced, not inferred: two spawns with taskId: "bash:Running tests" (different scripts, different intents) plus the earlier task's own task_await result render the later intent on the earlier row, passing on the first run, so the misattribution is deterministic once the IDs collide. Razor proved the index half at unit level (bashSpawnByTaskId.size === 1, holding the second spawn).
The row's own result carries the right answer and loses to a cross-message guess, on exactly the question this feature exists to answer. The cheapest correct fix is to fail closed: on a duplicate key, delete the entry (and remember the id) so the lookup misses and the row falls back to its own firstResult.title, which task_await fills per result. Resolving the nearest spawn preceding the await by historySequence is the stronger version.
🤖
| (bashSpawn | ||
| ? sanitizeDisplayableModelIntent(bashSpawn.modelIntent, bashSpawn.script) | ||
| : undefined) ?? | ||
| trimToNonEmptyString(firstResult.title) ?? |
There was a problem hiding this comment.
P2 [CRF-9] The title fallback has no command-echo guard, so the row prints the command that sanitizeDisplayableModelIntent just rejected. (Hisoka P2, Bisky P3, Pariston P3, Luffy P3)
sanitizeDisplayableModelIntentexists to stop the row from restating the command. When it returnsundefined, the very next fallback isfirstResult.title, and for a bash task that title isproc.displayName ?? proc.id(task_await.ts:527), which isresolveBashDisplayName(script, display_name). So the guard is bypassed by the path that trips it.
Verified by render: with script, display_name and model_intent all ./scripts/wait_pr_ready.sh 27330, the collapsed row reads bash · ./scripts/wait_pr_ready.sh 27330. And because sanitizeBashDisplayName rewrites < > : " / \ | ? * to _, an echoing display name renders as a mangled pseudo-command: bun run make lint 2>&1 | tail -20 comes out as bun run make lint 2_&1 _ tail -20. It looks like a command and is not one.
One correction to the reviewer evidence: display_name is required in the bash schema (toolDefinitions.ts:1537), so the "model omitted display_name" variant is not reachable through a schema-valid call. The echo variant is, and bashCollapsedSummary.ts:99 already knows models do this: getIntentOnlyFallback compares display_name against the command before using it. Run the title fallback through the same normalizeForComparison check and let the detail be bash alone when it matches.
Test 2 dodged this by giving its fixture a friendly display_name ("Repo State"): it proved the intent guard, then landed on a title that happened to be clean.
🤖
| summaryTone = "waiting"; | ||
| } else if (completedCount > 0) { | ||
| summaryTitle = `${formatTasks(completedCount)} completed`; | ||
| summaryDetail = singleTaskDetail; |
There was a problem hiding this comment.
P2 [CRF-10] A run of two or more adjacent task_await rows renders a different summary producer that has no detail at all, so the enrichment never appears in that shape. (Melody P2, Kite P3)
computeTaskAwaitPollGroupInfosgroups every run of 2+ adjacenttask_awaittool messages (transcriptRenderProjection.ts:181-200), andChatPane.tsx:1592-1594returnsnullfor non-head members and renders the member row only whenexpanded, so while collapsed no individualtask_awaitsummary renders, including the head's. The completed poll is normally the last element of such a run, so in exactly the case the PR targets the user seesChecked task status 3 times.
Orchestrator verified the mechanism: for a cleanly settled group defaultExpanded = needsAttention is false, summarizeOperationalBundle returns Checked task status N times with details: "", and the head branch at ChatPane.tsx:1584-1611 renders only OperationalBundleMessage. Frequency is not measured; the mechanism is. Your own comment at line 1409 says task_await "commonly appears several times during one turn".
Either give the task_await branch of summarizeOperationalBundle the same single-completed-task suffix (extract the derivation so one helper owns the string), or exclude the completed poll from the group. If neither, the PR description should stop promising the collapsed transcript row shows what finished, because for bundled polls it does not.
🤖
| firstResult.handleKind === "workspace_turn" | ||
| ? "workspace" | ||
| : taskReportLinking?.spawnAgentTypeByTaskId.get(completedTaskId); | ||
| const description = |
There was a problem hiding this comment.
P2 [CRF-11] The collapsed row builds a weaker title chain than the expanded row, so the exact case this PR targets can still read 1 task completed. (Pariston P2, Zoro P3, Chopper P3, Robin P3, Meruem P3, Melody P3, Kite P3)
TaskAwaitResultresolves a completed row's title asresult.title ?? spawnTitle ?? workspaceTitle, whereworkspaceTitlecomes fromgetTaskToolWorkspaceTitle(findWorkspaceForTaskTarget(workspaceMetadata, taskId, resultWorkspaceId))(lines 1505-1514). The new collapsed chain isbashIntent ?? result.title ?? spawnTitleand drops the metadata leg.
Three reviewers rendered it independently:
- completed
task-1, noresult.title, no linking, metadata titled "Pagination exploration": collapsed1 task completed, expandedPagination exploration. - completed
wst_abcwithworkspaceId: "ws-9", metadata titled "Shipping the pagination fixes": collapsed1 task completed · workspace, expanded shows the title.
title is optional on the workspace-turn result because WorkspaceTurnTaskHandleRecord.title is optional (taskHandleStore.ts:45), and both spawnTitleByTaskId and spawnAgentTypeByTaskId are populated from the same task message, so when it is out of the window the row loses kind and description together. Knov measured that window: computeTaskReportLinking runs on deferredMessages, capped at MAX_DISPLAYED_MESSAGES = 64, and tool is not in ALWAYS_KEEP_MESSAGE_TYPES.
workspaceMetadata is already in scope at line 1285. The fix worth making is one resolveAwaitResultTitle(result, linking, workspaceMetadata) consumed by both the collapsed detail and fallbackTitle at 1514, so a new rung can only be added in one place.
🤖
| : taskReportLinking?.spawnAgentTypeByTaskId.get(completedTaskId); | ||
| const description = | ||
| (bashSpawn | ||
| ? sanitizeDisplayableModelIntent(bashSpawn.modelIntent, bashSpawn.script) |
There was a problem hiding this comment.
Note [CRF-29] The bash detail ignores the user's "Collapsed bash summaries" setting. (Nami)
A user who set that select to
command(GeneralSection.tsx:589, keybashCollapsedSummaryMode, defaultintent-command) is telling Mux they want the raw command, not model prose. This row callssanitizeDisplayableModelIntentdirectly and never consultsuseBashCollapsedSummaryMode(), so they get prose.BashTaskSpawnInfo.scriptis already captured, so honoring the mode is a lookup plus a branch.
Held at Note deliberately: the setting's own copy scopes itself to "collapsed bash tools", and a task_await row is not one. A decision, not necessarily a change.
🤖
| : undefined) ?? | ||
| trimToNonEmptyString(firstResult.title) ?? | ||
| trimToNonEmptyString(taskReportLinking?.spawnTitleByTaskId.get(completedTaskId)); | ||
| const detail = [kind, description].filter((part): part is string => part != null).join(" · "); |
There was a problem hiding this comment.
Note [CRF-31] Model-controlled text is spliced into a ·-delimited status string, so an intent can fabricate row structure. (Kurapika)
detail = [kind, description].join(" · ")and the render at line 1470 prepends another·. Nothing rejects·insidedescription, so an intent such asReviewing docs · 0 failed · approvedrenders as1 task completed · bash · Reviewing docs · 0 failed · approved, claiming a status the await never reported.
No security finding: the sink is a React text child inside a truncating span, no dangerouslySetInnerHTML, no markdown, no URL construction. The blast radius is one muted line the user can expand, and the class already exists wherever bash intents render, so it is not this PR's invention. Recorded because this is the only place in the diff where model text is spliced into a delimiter-structured status string.
🤖
| (bashSpawn | ||
| ? sanitizeDisplayableModelIntent(bashSpawn.modelIntent, bashSpawn.script) | ||
| : undefined) ?? | ||
| trimToNonEmptyString(firstResult.title) ?? |
There was a problem hiding this comment.
Note [CRF-32] Capitalization of the detail slot depends on which fallback wins. (Leorio)
The intent path runs through
capitalizeinsidesanitizeModelIntent, so it rendersbash · Watching PR 27330 until it is ready. The two title fallbacks render raw, so a task titledrun integration testsrendersbash · run integration testsin the same slot.getIntentOnlyFallbackcapitalizes itsdisplay_namefallback for exactly this reason, but task titles render raw everywhere else in this file, so both conventions have a claim.
Cosmetic either way. If you want the slot self-consistent, wrap the fallbacks in capitalize.
🤖
| status: "completed", | ||
| args: { task_ids: ["task-1", "task-2"] }, | ||
| result: { | ||
| results: [ |
There was a problem hiding this comment.
Note [CRF-5] keeps multi-task completion summaries count-only is green on base code. (Netero, Bisky)
Verified by red-green: with the three production files reverted to base, the file ran 14 pass / 4 fail. The four failures are the four other new tests; this one passed.
It is a boundary guard for the deliberate "multi stays count-only" decision, not a proof of this diff, so it carries no red-green value today. Worth knowing when reading the "5 new tests ... red-green verified" claim in the PR body: four were verified red, one cannot be, and a fifth (CRF-16) cannot fail for the behavior it names.
🤖
| @@ -87,18 +146,35 @@ export function computeTaskReportLinking(messages: DisplayedMessage[]): TaskRepo | |||
| // First pass: record which taskIds have a visible `task` tool call (and capture spawn titles). | |||
There was a problem hiding this comment.
Nit [CRF-4] The first-pass comment still describes a task-only loop. (Netero, Gon)
// First pass: record which taskIds have a visible \task` tool call (and capture spawn titles).sits directly above a loop that now also branches onmsg.toolName === "bash"` and captures spawn agent types.
The comment was accurate before this diff. Extend it to name the bash-spawn indexing.
🤖
…aries (#3796) ## Summary Multi-task `task_await` completion rows previously said only `N tasks completed`, hiding what actually finished. The collapsed row now lists each completed task's kind and spawn intent/title: a single detail stays inline in the header (unchanged from #3793), while multiple details render as stacked truncated lines below it. ## Background #3793 added the `kind · intent/title` detail for single-task awaits but deliberately kept multi-task summaries count-only. In practice that forces expanding every multi-task await row to see which background work landed. A comma-joined inline list was considered first but truncates quickly and reads poorly because `,` and `·` separators compete, so multiple details stack as one line per task. ## Implementation - The single-task detail computation now runs for every completed result, reusing the same kind derivation (`bash:`/`wfr_`/`wst_` handles or spawn `agentId`) and `sanitizeDisplayableModelIntent` fallback chain per task. - With exactly one detail it remains the inline header suffix; with more, a stacked list renders below the header, gated to collapsed + success state since the expanded view already shows per-task result rows. - Tasks with no resolvable kind/intent/title are skipped rather than rendering empty lines. ## Validation - Storybook screenshots (built Storybook, dark theme) comparing inline comma-joined vs stacked layouts for 1/2/3-task completions; stacked layout verified to align with header text and truncate per line. --- _Generated with `mux` • Model: `anthropic:claude-fable-5` • Thinking: `xhigh`_ <!-- mux-attribution: model=anthropic:claude-fable-5 thinking=xhigh -->
The module reads every field out of persisted tool args/results typed as `unknown`, and #3789/#3793 grew that from four to seven copies of the same "is a string, is not blank, use the trimmed form" check. Collapse those seven into a single coerceNonBlankString helper. The two task_await result reads are deliberately left alone: they validate a field is non-blank but then store the raw, untrimmed value, so routing them through the helper would change what gets persisted.
The module reads every field out of persisted tool args/results typed as `unknown`, and #3789/#3793 grew that from four to seven copies of the same "is a string, is not blank, use the trimmed form" check. Collapse those seven into a single coerceNonBlankString helper. The two task_await result reads are deliberately left alone: they validate a field is non-blank but then store the raw, untrimmed value, so routing them through the helper would change what gets persisted.
The module reads every field out of persisted tool args/results typed as `unknown`, and #3789/#3793 grew that from four to seven copies of the same "is a string, is not blank, use the trimmed form" check. Collapse those seven into a single coerceNonBlankString helper. The two task_await result reads are deliberately left alone: they validate a field is non-blank but then store the raw, untrimmed value, so routing them through the helper would change what gets persisted.
The module reads every field out of persisted tool args/results typed as `unknown`, and #3789/#3793 grew that from four to seven copies of the same "is a string, is not blank, use the trimmed form" check. Collapse those seven into a single coerceNonBlankString helper. The two task_await result reads are deliberately left alone: they validate a field is non-blank but then store the raw, untrimmed value, so routing them through the helper would change what gets persisted.
The module reads every field out of persisted tool args/results typed as `unknown`, and #3789/#3793 grew that from four to seven copies of the same "is a string, is not blank, use the trimmed form" check. Collapse those seven into a single coerceNonBlankString helper. The two task_await result reads are deliberately left alone: they validate a field is non-blank but then store the raw, untrimmed value, so routing them through the helper would change what gets persisted.
The module reads every field out of persisted tool args/results typed as `unknown`, and #3789/#3793 grew that from four to seven copies of the same "is a string, is not blank, use the trimmed form" check. Collapse those seven into a single coerceNonBlankString helper. The two task_await result reads are deliberately left alone: they validate a field is non-blank but then store the raw, untrimmed value, so routing them through the helper would change what gets persisted.
The module reads every field out of persisted tool args/results typed as `unknown`, and #3789/#3793 grew that from four to seven copies of the same "is a string, is not blank, use the trimmed form" check. Collapse those seven into a single coerceNonBlankString helper. The two task_await result reads are deliberately left alone: they validate a field is non-blank but then store the raw, untrimmed value, so routing them through the helper would change what gets persisted.
Summary
For a single-task
task_await, the collapsed transcript row now shows what actually finished: the task kind (bash,workflow,workspace, or the sub-agent type) plus the spawn intent or title, e.g.1 task completed · bash · Resolving pagination feedback and requesting another review.Background
The collapsed row previously said only
1 task completed, which conveys nothing about what finished, forcing the user to expand every await row to see which background work landed.Implementation
bash:→ bash,wfr_→ workflow,wst_→ workspace) or from the spawningtaskcall'sagentId/subagent_type.task_awaitresults don't carrymodel_intent, so the existing render-timecomputeTaskReportLinkingpass (which already links spawn titles) now also indexes backgroundbashspawns by taskId and captures theirmodel_intent+ script.sanitizeDisplayableModelIntenthelper (extracted frombuildBashCollapsedSummary) so an intent that merely restates the command is rejected, falling back to the result title (bashdisplay_nameor agent report title).Scope note: only the completed state is enriched; the
Waiting for N tasksstate could get the same suffix via the same helper if desired.Validation
TaskToolCall.test.tsx(bash intent, command-echo intent fallback, title fallback, agent type, multi-task stays count-only), red-green verified against the summary wiring and the echo-intent fallback.Generated with
mux• Model:anthropic:claude-fable-5• Thinking:xhigh