Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
195 changes: 195 additions & 0 deletions src/browser/features/Tools/TaskToolCall.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import { GlobalWindow } from "happy-dom";

import { TooltipProvider } from "@/browser/components/Tooltip/Tooltip";

import type { DisplayedMessage } from "@/common/types/message";
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
import { computeTaskReportLinking } from "@/browser/utils/messages/taskReportLinking";

let workspaceContextMock: {
workspaceMetadata: Map<string, FrontendWorkspaceMetadata>;
Expand Down Expand Up @@ -69,6 +71,23 @@ function createWorkspaceMetadata(
const taskAwaitArgs = { task_ids: ["task-1"], timeout_secs: 70 };
const TaskAwaitToolCall = getToolComponent("task_await", taskAwaitArgs);

function createToolMessage(overrides: {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit [CRF-33] createToolMessage is the second local factory for a tool DisplayedMessage, and it hardcodes every id. (Robin, Bisky)

transcriptRenderProjection.test.ts:12 has tool(), which builds the same DisplayedMessage & { type: "tool" } with defaulted id, historyId, toolCallId, status, isPartial, and historySequence. It is a fuller version: it also defaults toolName and args and threads streamSequence, timestamp, and nestedCalls. Both tests now feed the same render-time projection helpers.

Fine today because every test passes a single-message array; a future test with two spawns gets duplicate ids and silently ambiguous linking, which is exactly the fixture shape CRF-8 needs. One exported fixture next to the DisplayedMessage consumers would serve both, and a third copy is the likely next event.

🤖

toolName: string;
args: unknown;
result?: unknown;
}): DisplayedMessage {
return {
type: "tool",
id: "tool-msg-1",
historyId: "hist-1",
toolCallId: "call-1",
status: "completed",
isPartial: false,
historySequence: 1,
...overrides,
};
}

function renderTaskAwaitToolCall(props: Record<string, unknown> = {}) {
return render(
<TooltipProvider>
Expand Down Expand Up @@ -363,6 +382,182 @@ describe("TaskAwaitToolCall", () => {
expect(view.getByText("Task service unavailable")).toBeDefined();
});

test("shows bash kind and spawn model_intent for a single completed bash task", () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit [CRF-22] No viewport-pinned story covers the new collapsed detail, so the 375px claim rests on a session nobody can rerun. (Melody, Chopper, Mafu-san)

MessageRenderer.stories.tsx:38-42 pins viewports: ["phone", "laptop"] and line 116 asserts summary.scrollWidth <= summary.clientWidth for the task_await summary row. Every poll in that fixture is running, so singleTaskDetail is never set and the longest string this PR can put in that row never reaches the guard.

The overflow risk itself was checked and is structurally absent (the detail renders inside min-w-0 flex-1 truncate under a flex items-center header, so it clips by construction). What is missing is durable evidence: adding one completed single-task poll with a long intent to that existing fixture extends the existing assertion to the new content at no extra cost. TaskNarrowLongModelId (TaskToolCall.stories.tsx:146) is the in-repo precedent for the forced-width variant.

🤖

const bashSpawn = createToolMessage({
toolName: "bash",
args: {
script: "./scripts/wait_pr_ready.sh 27330",
display_name: "PR ready watcher",
model_intent: "watching PR 27330 until it is ready",
timeout_secs: 3600,
run_in_background: true,
},
result: {
success: true,
output: "Started",
exitCode: 0,
wall_duration_ms: 10,
taskId: "bash:pr-ready-watcher-a1b2",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit [CRF-30] The fixture taskId is a shape the backend never produces, which is what hides CRF-8 from anyone reading these tests. (Nami)

bash:pr-ready-watcher-a1b2 implies a slug with a random suffix. The real value is bash: plus the sanitized display_name verbatim, so bash:PR ready watcher (backgroundProcessManager.ts:741, bashDisplayName.ts:52).

The test still exercises the right path; the invented suffix just makes the id look unique when it is not.

🤖

backgroundProcessId: "pr-ready-watcher-a1b2",
},
});

const view = renderTaskAwaitToolCall({
status: "completed",
args: { task_ids: ["bash:pr-ready-watcher-a1b2"] },
result: {
results: [
{
status: "completed",
taskId: "bash:pr-ready-watcher-a1b2",
title: "PR ready watcher",
reportMarkdown: "exit 0",
},
],
},
taskReportLinking: computeTaskReportLinking([bashSpawn]),
});

expect(view.getByText("1 task completed")).toBeDefined();
expect(view.getByText(/bash · Watching PR 27330 until it is ready/)).toBeDefined();
});

test("falls back to the task title when the spawn intent merely restates the command", () => {
const bashSpawn = createToolMessage({
toolName: "bash",
args: {
script: "git status",
display_name: "Repo State",
model_intent: "git status",
timeout_secs: 30,
run_in_background: true,
},
result: {
success: true,
output: "Started",
exitCode: 0,
wall_duration_ms: 10,
taskId: "bash:repo-state-a1b2",
backgroundProcessId: "repo-state-a1b2",
},
});

const view = renderTaskAwaitToolCall({
status: "completed",
args: { task_ids: ["bash:repo-state-a1b2"] },
result: {
results: [
{
status: "completed",
taskId: "bash:repo-state-a1b2",
title: "Repo State",
reportMarkdown: "exit 0",
},
],
},
taskReportLinking: computeTaskReportLinking([bashSpawn]),
});

expect(view.getByText(/bash · Repo State/)).toBeDefined();
expect(view.queryByText(/bash · Git status/)).toBeNull();
});

test("falls back to the completed task title when no spawn intent is linked", () => {
const view = renderTaskAwaitToolCall({
status: "completed",
args: { task_ids: ["bash:pr-ready-watcher-a1b2"] },
result: {
results: [
{
status: "completed",
taskId: "bash:pr-ready-watcher-a1b2",
title: "PR ready watcher",
reportMarkdown: "exit 0",
},
],
},
});

expect(view.getByText(/bash · PR ready watcher/)).toBeDefined();
});

test("shows agent type and title for a single completed sub-agent task", () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 [CRF-16] The sub-agent test asserts a string that both candidate title sources produce, so it cannot fail for the precedence it appears to cover. (Mafu-san)

args.title and the completed result's title are both "Pagination exploration", so expect(view.getByText(/explore · Pagination exploration/)) passes no matter which source description reads. Proved by mutation: I swapped the two fallback arms at TaskToolCall.tsx:1361-1362 to prefer spawnTitleByTaskId over firstResult.title and re-ran the file: 18 pass, 0 fail.

The explore · half is real coverage of spawnAgentTypeByTaskId; the title half is decoration. Nothing in this PR pins the precedence, which is the behavior the Codex thread flagged, so that disagreement currently cannot be settled by the suite. Give the spawn a title that differs from the report title and assert which one wins: the test then either defends the current choice or fails and answers the reviewer.

🤖

const taskSpawn = createToolMessage({
toolName: "task",
args: {
agentId: "explore",
prompt: "Find pagination helpers.",
title: "Pagination exploration",
run_in_background: true,
},
result: { status: "queued", taskId: "task-1" },
});

const view = renderTaskAwaitToolCall({
status: "completed",
result: {
results: [
{
status: "completed",
taskId: "task-1",
title: "Pagination exploration",
reportMarkdown: "Report",
},
],
},
taskReportLinking: computeTaskReportLinking([taskSpawn]),
});

expect(view.getByText(/explore · Pagination exploration/)).toBeDefined();
});

test("prefers the spawn title over the sub-agent's own report title", () => {
const taskSpawn = createToolMessage({
toolName: "task",
args: {
agentId: "explore",
prompt: "Find pagination helpers.",
title: "Pagination exploration",
run_in_background: true,
},
result: { status: "queued", taskId: "task-1" },
});

const view = renderTaskAwaitToolCall({
status: "completed",
result: {
results: [
{
status: "completed",
taskId: "task-1",
title: "Pagination Helpers Investigation Complete",
reportMarkdown: "Report",
},
],
},
taskReportLinking: computeTaskReportLinking([taskSpawn]),
});

expect(view.getByText(/explore · Pagination exploration/)).toBeDefined();
expect(view.queryByText(/Investigation Complete/)).toBeNull();
});

test("keeps multi-task completion summaries count-only", () => {
const view = renderTaskAwaitToolCall({
status: "completed",
args: { task_ids: ["task-1", "task-2"] },
result: {
results: [

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

🤖

{ status: "completed", taskId: "task-1", title: "First task", reportMarkdown: "a" },
{ status: "completed", taskId: "task-2", title: "Second task", reportMarkdown: "b" },
],
},
});

expect(view.getByText("2 tasks completed")).toBeDefined();
expect(view.queryByText(/First task/)).toBeNull();
});

test("uses valid legacy agentType for task_await rows when agentId is invalid", () => {
workspaceContextMock = {
workspaceMetadata: new Map([
Expand Down
33 changes: 33 additions & 0 deletions src/browser/features/Tools/TaskToolCall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import type {
} from "@/common/types/tools";
import type { TaskReportLinking } from "@/browser/utils/messages/taskReportLinking";
import { formatGitPatchArtifactSummary } from "./taskPatchSummary";
import { sanitizeDisplayableModelIntent } from "./bashCollapsedSummary";
import {
formatTaskGroupCreationLabel,
formatTaskGroupHeader,
Expand Down Expand Up @@ -460,6 +461,10 @@ function isWorkspaceTurnTaskHandleId(taskId: string): boolean {
return /^wst_[a-z0-9][a-z0-9_-]*$/.test(taskId);
}

function isWorkflowRunTaskHandleId(taskId: string): boolean {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 [CRF-3] isWorkflowRunTaskHandleId re-derives the wfr_ prefix that src/node/services/tools/taskId.ts already owns as a single source of truth. (Netero)

taskId.ts:6-17 declares WORKFLOW_RUN_TASK_ID_PREFIX with a comment stating it exists "so there is a single source of truth instead of duplicated literals", and exports isWorkflowRunTaskId with the same startsWith semantics. The new browser copy cannot import it (that module imports node:assert/strict), so this PR adds a second in-browser literal next to the existing fromBashTaskId copy.

The "single source of truth" claim in taskId.ts is now false for two of its three helpers, and a prefix change silently leaves the collapsed row misclassifying workflow awaits as sub-agent tasks. Move the pure predicates and prefix constants into src/common/utils/tools/taskId.ts, leave the assert-using toBashTaskId in node, and import from both sides.

🤖

return taskId.startsWith("wfr_");
}

function fromBashTaskId(taskId: string): string | null {
const prefix = "bash:";
if (!taskId.startsWith(prefix)) {
Expand Down Expand Up @@ -1334,6 +1339,33 @@ export const TaskAwaitToolCall: React.FC<TaskAwaitToolCallProps> = ({
const targetCount = totalCount > 0 ? totalCount : taskIds?.length;
const formatTasks = (count: number) => `${count} ${count === 1 ? "task" : "tasks"}`;

// "1 task completed" alone says nothing about what finished; for single-task awaits,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit [CRF-23] The second clause of the new comment narrates the code below it. (Gon P2, adjusted to Nit)

for single-task awaits, surface the task's kind plus its spawn intent/title in the collapsed row is exactly what results.length === 1, kind, description, and the join(" · ") already say. Only the first clause carries information the code cannot: the reason a detail line exists at all.

// "1 task completed" alone says nothing about what finished.

Severity adjusted down from Gon's P2: the review vocabulary reserves Nit for convention violations where the code works, and nothing depends on the second clause. Gon's wider point stands and is worth acting on as a set: three of this diff's comment sites need edits (CRF-4, CRF-24, and this one). Leorio's counterpoint, recorded because it is the more useful half: the first clause is exactly what a comment is for, quoting the string it replaces and naming who was hurt by it. More of that.

🤖

// surface the task's kind plus its spawn intent/title in the collapsed row.
const firstResult = results[0];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 [CRF-18] results[0] is dereferenced through a length guard the type system does not check, and the detail is computed for states that never read it. (Meruem P3, Nami Nit, Mafuuu Nit, Kite Nit, Zoro Nit)

const firstResult = results[0] types as non-optional because tsconfig.json sets strict without noUncheckedIndexedAccess, so firstResult.status is only safe because results.length === 1 && short-circuits ahead of it. That safety is positional: move the deref, or relax the guard to >= 1, and it becomes a runtime TypeError on an empty results with nothing flagging it.

The block also runs on the error, interrupted, executing and waiting renders, whose branches discard singleTaskDetail, and it sits 55 lines above its only consumer behind a let. Both problems go away by computing it inside the completedCount > 0 branch at line 1400:

const only = results.length === 1 ? results[0] : undefined;
if (only?.status === "completed") { ... }

The optional chain then expresses the guard instead of depending on statement order. Nami's alternative is worth weighing: a sibling pure module (buildSingleTaskAwaitDetail({ result, linking })) matches the established shape here (taskPatchSummary.ts, bashCollapsedSummary.ts) and would make CRF-2's untested branches cheap to cover without a render.

🤖

let singleTaskDetail: string | undefined;
if (results.length === 1 && firstResult.status === "completed") {
const completedTaskId = firstResult.taskId;
const bashSpawn = taskReportLinking?.bashSpawnByTaskId.get(completedTaskId);
const kind = fromBashTaskId(completedTaskId)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 [CRF-17] This is the fifth in-file spelling of "what kind is this task", and the new one already disagrees with the one 30 lines above. (Mafu-san P3, Zoro P3, Knov Nit, Chopper Nit)

Existing sites: line 1312 (agentType: isWorkspaceTurn ? "workspace" : undefined), 1317-1319 (isWorkspaceTurn ? "workspace" : resolvePersistedAgentId(...)), 1709 (task.handleKind === "workspace_turn" ? "workspace" : task.agentType), plus the args-side variant at 973 (args.kind === "workspace" ? ... : (args.agentId ?? args.subagent_type ?? "unknown")). The new branch adds a fifth and is the only one that also knows about bash: and wfr_.

They already disagree for the same task: the waiting path derives kind from the handle id and labels bash tasks with proc.displayName, while the completed path also accepts handleKind === "workspace_turn" and labels bash tasks with model_intent. One task can be described two ways in the same transcript depending on whether the row is waiting or completed. Reuse is not free (the waiting path needs live metadata that may be gone after completion), so the honest fix is one local resolveTaskDisplayKind({ taskId, handleKind, agentType }) next to isWorkspaceTurnTaskHandleId, called from all of them. That also makes the workflow and workspace legs unit-testable in one place (CRF-2). Knov adds: prefer the producer-declared handleKind over the ^wst_ regex, since the regex is an assumption about id formatting.

🤖

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit [CRF-25] kind is a third name for a concept this file already calls agentType, and it collides with handleKind. (Gon)

The same displayed label is agentType on TaskRowProps (1312, 1317, 1709), and handleKind is a result field whose values are workspace_turn, not workspace. A reader hitting kind two lines above firstResult.handleKind === "workspace_turn" has to check which vocabulary is in play.

taskKindLabel says what it is: a display string, not a discriminant. Naming this well matters more if you take CRF-17 and give the derivation one home.

🤖

? "bash"
: isWorkflowRunTaskHandleId(completedTaskId)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 [CRF-2] Two of the four kind branches (workflow, workspace) ship with no test. (Netero)

grep -n "wfr_\|wst_\|handleKind" src/browser/features/Tools/TaskToolCall.test.tsx returns only a TaskToolCall spawn-card test and TaskTerminateToolCall. So isWorkflowRunTaskHandleId has no test at all, and firstResult.handleKind === "workspace_turn" is untested in this component.

Both branches work: temporary tests rendered 1 task completed · workflow · Review pipeline for taskId: "wfr_abc" and 1 task completed · workspace · Turn title for handleKind: "workspace_turn". The gap is regression cover, and the fragile part is the ordering of that four-way ternary (the workspace check must stay after the prefix checks), which nothing pins. Bisky adds the cheaper route: there is still no taskReportLinking.test.ts, and a table test over computeTaskReportLinking([...]) asserting the three maps costs a few lines per case instead of a full render.

🤖

? "workflow"
: isWorkspaceTurnTaskHandleId(completedTaskId) ||
firstResult.handleKind === "workspace_turn"
? "workspace"
: taskReportLinking?.spawnAgentTypeByTaskId.get(completedTaskId);
// Spawn-side intent first (bash model_intent, task spawn title); the result's own
// title (report heading, bash display_name) is only a fallback.
const description =

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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)

TaskAwaitResult resolves a completed row's title as result.title ?? spawnTitle ?? workspaceTitle, where workspaceTitle comes from getTaskToolWorkspaceTitle(findWorkspaceForTaskTarget(workspaceMetadata, taskId, resultWorkspaceId)) (lines 1505-1514). The new collapsed chain is bashIntent ?? result.title ?? spawnTitle and drops the metadata leg.

Three reviewers rendered it independently:

  • completed task-1, no result.title, no linking, metadata titled "Pagination exploration": collapsed 1 task completed, expanded Pagination exploration.
  • completed wst_abc with workspaceId: "ws-9", metadata titled "Shipping the pagination fixes": collapsed 1 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.

🤖

(bashSpawn
? sanitizeDisplayableModelIntent(bashSpawn.modelIntent, bashSpawn.script)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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, key bashCollapsedSummaryMode, default intent-command) is telling Mux they want the raw command, not model prose. This row calls sanitizeDisplayableModelIntent directly and never consults useBashCollapsedSummaryMode(), so they get prose. BashTaskSpawnInfo.script is 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(taskReportLinking?.spawnTitleByTaskId.get(completedTaskId)) ??
trimToNonEmptyString(firstResult.title);
const detail = [kind, description].filter((part): part is string => part != null).join(" · ");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 · inside description, so an intent such as Reviewing docs · 0 failed · approved renders as 1 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.

🤖

singleTaskDetail = detail.length > 0 ? detail : undefined;
}

let summaryTitle: string;
let summaryDetail: string | undefined;
let summaryTone: "active" | "danger" | "interrupted" | "success" | "waiting";
Expand Down Expand Up @@ -1369,6 +1401,7 @@ export const TaskAwaitToolCall: React.FC<TaskAwaitToolCallProps> = ({
summaryTone = "waiting";
} else if (completedCount > 0) {
summaryTitle = `${formatTasks(completedCount)} completed`;
summaryDetail = singleTaskDetail;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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_await reports a finished background process as status: "completed" regardless of exit code (src/node/services/tools/task_await.ts:523-536 returns status: "completed" plus exitCode), so completedCount > 0 and summaryTone = "success". Before this PR the row said 1 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.

🤖

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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)

computeTaskAwaitPollGroupInfos groups every run of 2+ adjacent task_await tool messages (transcriptRenderProjection.ts:181-200), and ChatPane.tsx:1592-1594 returns null for non-head members and renders the member row only when expanded, so while collapsed no individual task_await summary 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 sees Checked 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.

🤖

summaryTone = "success";
} else {
summaryTitle = "Checked task status";
Expand Down
17 changes: 12 additions & 5 deletions src/browser/features/Tools/bashCollapsedSummary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,7 @@ export function buildBashCollapsedSummary(
return { kind: "command", command };
}

const intent = sanitizeModelIntent(options.args.model_intent, command);
const displayIntent =
intent && normalizeForComparison(intent) !== normalizeForComparison(command)
? intent
: undefined;
const displayIntent = sanitizeDisplayableModelIntent(options.args.model_intent, command);
if (mode === "intent") {
return {
kind: "intent",
Expand Down Expand Up @@ -91,6 +87,17 @@ export function sanitizeModelIntent(rawIntent: unknown, command: string): string
return capitalize(intent);
}

/** Sanitized intent, or undefined when it merely restates the command. */

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit [CRF-28] "or undefined when it merely restates the command" names one of the two ways this returns undefined. (Leorio)

It also returns undefined when rawIntent is not a string, is blank, or sanitizes down to nothing (sanitizeModelIntent, lines 61-87). As written the comment reads as "a present intent yields a string unless it echoes the command," which is false, and it matters because both callers branch on undefined and pick a different fallback.

/** Sanitized intent, or undefined when there is no usable intent or it merely restates the command. */

🤖

export function sanitizeDisplayableModelIntent(
rawIntent: unknown,
command: string
): string | undefined {
const intent = sanitizeModelIntent(rawIntent, command);
return intent && normalizeForComparison(intent) !== normalizeForComparison(command)
? intent
: undefined;
}

function getIntentOnlyFallback(args: BashToolArgs, command: string): string {
const displayName = typeof args.display_name === "string" ? args.display_name.trim() : "";
if (displayName && normalizeForComparison(displayName) !== normalizeForComparison(command)) {
Expand Down
Loading
Loading