diff --git a/packages/coding-agent/src/prompts/tools/job.md b/packages/coding-agent/src/prompts/tools/job.md index 6c5bcef07..58593cbcb 100644 --- a/packages/coding-agent/src/prompts/tools/job.md +++ b/packages/coding-agent/src/prompts/tools/job.md @@ -8,7 +8,7 @@ Background job results are delivered automatically when complete. Reach for this Use to inspect what's running. ## `poll: [id, …]` -Block until the specified jobs finish or the wait window elapses. +Block until the specified jobs finish or the wait window elapses. Omit `poll` (with no `list`/`cancel`) to wait on ALL running jobs — NEVER enumerate ids you don't need to filter. - Use when you are genuinely blocked on a result and have no other work to do. - Returns the current snapshot when the timer elapses; running jobs remain running. - Completed jobs include their final output in the returned snapshot. diff --git a/packages/coding-agent/src/task/render.ts b/packages/coding-agent/src/task/render.ts index 57b08b789..e7adddf70 100644 --- a/packages/coding-agent/src/task/render.ts +++ b/packages/coding-agent/src/task/render.ts @@ -15,6 +15,7 @@ import { getMarkdownTheme, type Theme } from "../modes/theme/theme"; import { formatBadge, formatDuration, + formatExpandHint, formatMoreItems, formatStatusIcon, replaceTabs, @@ -542,6 +543,12 @@ function renderTaskCallLines(args: Partial | undefined, theme: Theme return lines; } +/** + * Agent rows shown per collapsed task list; the rest fold into a single + * `… N more agents` summary line (expand uncaps). + */ +const COLLAPSED_AGENT_LIMIT = 4; + /** * Render the per-item list (`id` + ui `description`) for a batch call's * streaming preview. The args stream in token by token, so the array grows @@ -552,7 +559,7 @@ function renderTaskItemLines(tasks: TaskItem[] | undefined, theme: Theme): strin if (!Array.isArray(tasks) || tasks.length === 0) return []; const bullet = theme.fg("dim", "•"); - const cap = Math.min(tasks.length, 12); + const cap = Math.min(tasks.length, COLLAPSED_AGENT_LIMIT); const lines: string[] = []; for (let i = 0; i < cap; i++) { const task = tasks[i] as Partial | undefined; @@ -1170,6 +1177,53 @@ function orderResultsForDisplay(results: readonly SingleResult[]): SingleResult[ return [...results].sort((a, b) => a.durationMs - b.durationMs || a.index - b.index); } +/** + * Summary line for progress rows folded away by the collapsed cap: per-status + * counts plus the expand hint, e.g. `… 21 more agents (18 pending · 3 done)`. + */ +function formatHiddenProgressLine(hidden: readonly AgentProgress[], theme: Theme): string { + const counts: Record = { + pending: 0, + running: 0, + completed: 0, + failed: 0, + aborted: 0, + }; + for (const p of hidden) counts[p.status]++; + const parts: string[] = []; + if (counts.completed > 0) parts.push(theme.fg("dim", `${counts.completed} done`)); + if (counts.running > 0) parts.push(theme.fg("dim", `${counts.running} running`)); + if (counts.pending > 0) parts.push(theme.fg("dim", `${counts.pending} pending`)); + if (counts.failed > 0) parts.push(theme.fg("error", `${counts.failed} failed`)); + if (counts.aborted > 0) parts.push(theme.fg("error", `${counts.aborted} aborted`)); + const breakdown = + parts.length > 0 + ? `${theme.fg("dim", " (")}${parts.join(theme.fg("dim", theme.sep.dot))}${theme.fg("dim", ")")}` + : ""; + const hint = formatExpandHint(theme, false, true); + return `${theme.fg("dim", formatMoreItems(hidden.length, "agent"))}${breakdown}${hint ? ` ${hint}` : ""}`; +} + +/** + * Pick the agent rows that stay visible when a finalized batch is collapsed: + * problem rows (aborted/failed/merge-failed) claim slots first so they are + * never folded away, then fastest finishers fill the remainder. The pick is + * filtered out of the display order, so visible rows keep the expanded layout. + */ +function selectCollapsedResults(ordered: readonly SingleResult[]): readonly SingleResult[] { + if (ordered.length <= COLLAPSED_AGENT_LIMIT) return ordered; + const picked = new Set(); + for (const result of ordered) { + if (picked.size >= COLLAPSED_AGENT_LIMIT) break; + if (result.aborted || result.exitCode !== 0 || result.error) picked.add(result); + } + for (const result of ordered) { + if (picked.size >= COLLAPSED_AGENT_LIMIT) break; + picked.add(result); + } + return ordered.filter(result => picked.has(result)); +} + /** * Render the tool result. */ @@ -1248,13 +1302,30 @@ export function renderResult( const shouldRenderProgress = Boolean(details.progress && details.progress.length > 0) && (isPartial || details.results.length === 0); if (shouldRenderProgress && details.progress) { - orderProgressForDisplay(details.progress).forEach(progress => { + const ordered = orderProgressForDisplay(details.progress); + // Collapsed view keeps the live edge: finished rows sort to the top of + // the display order, so folding from the top keeps running/pending + // agents (and their current-tool lines) visible while one summary line + // stands in for everything above it. + const visible = expanded ? ordered : ordered.slice(Math.max(0, ordered.length - COLLAPSED_AGENT_LIMIT)); + if (visible.length < ordered.length) { + lines.push(formatHiddenProgressLine(ordered.slice(0, ordered.length - visible.length), theme)); + } + for (const progress of visible) { lines.push(...renderAgentProgress(progress, "", " ", expanded, theme, spinnerFrame, frozen)); - }); + } } else if (details.results && details.results.length > 0) { - orderResultsForDisplay(details.results).forEach(res => { + const ordered = orderResultsForDisplay(details.results); + const visible = expanded ? ordered : selectCollapsedResults(ordered); + for (const res of visible) { lines.push(...renderAgentResult(res, "", " ", expanded, theme)); - }); + } + if (visible.length < ordered.length) { + const hint = formatExpandHint(theme, false, true); + lines.push( + `${theme.fg("dim", formatMoreItems(ordered.length - visible.length, "agent"))}${hint ? ` ${hint}` : ""}`, + ); + } const abortedCount = details.results.filter(r => r.aborted).length; const mergeFailedCount = details.results.filter(r => !r.aborted && r.exitCode === 0 && r.error).length; diff --git a/packages/coding-agent/src/tools/job.ts b/packages/coding-agent/src/tools/job.ts index 276bd8621..5c9057c68 100644 --- a/packages/coding-agent/src/tools/job.ts +++ b/packages/coding-agent/src/tools/job.ts @@ -24,7 +24,7 @@ import { import { ToolError } from "./tool-errors"; const jobSchema = z.object({ - poll: z.array(z.string()).optional().describe("job ids to wait for"), + poll: z.array(z.string()).optional().describe("job ids to wait for; omit to wait on all running jobs"), cancel: z.array(z.string()).optional().describe("job ids to cancel"), list: z.boolean().optional().describe("snapshot all jobs"), }); diff --git a/packages/coding-agent/test/task/task-progress-render.test.ts b/packages/coding-agent/test/task/task-progress-render.test.ts index 0dfdb6187..fc2e199a7 100644 --- a/packages/coding-agent/test/task/task-progress-render.test.ts +++ b/packages/coding-agent/test/task/task-progress-render.test.ts @@ -270,6 +270,92 @@ describe("task progress rendering", () => { expect(positions.every(p => p >= 0)).toBe(true); expect(positions).toEqual([...positions].sort((a, b) => a - b)); }); + + it("folds collapsed progress lists to the live edge with a status summary", async () => { + const theme = (await getThemeByName("dark"))!; + const details: TaskToolDetails = { + projectAgentsDir: null, + results: [], + totalDurationMs: 0, + progress: [ + runningProgress({ index: 0, id: "DoneOne", status: "completed", durationMs: 1000 }), + runningProgress({ index: 1, id: "DoneTwo", status: "completed", durationMs: 2000 }), + runningProgress({ index: 2, id: "DoneThree", status: "completed", durationMs: 3000 }), + runningProgress({ index: 3, id: "LiveOne", status: "running" }), + runningProgress({ index: 4, id: "LiveTwo", status: "running" }), + runningProgress({ index: 5, id: "LiveThree", status: "pending" }), + runningProgress({ index: 6, id: "LiveFour", status: "pending" }), + ], + }; + const result = { content: [{ type: "text", text: "" }], details }; + + const collapsed = Bun.stripANSI( + taskToolRenderer + .renderResult(result, { expanded: false, isPartial: true, spinnerFrame: 0 }, theme) + .render(120) + .join("\n"), + ); + // Finished rows fold into the summary; the live edge stays visible. + for (const id of ["LiveOne", "LiveTwo", "LiveThree", "LiveFour"]) { + expect(collapsed).toContain(id); + } + for (const id of ["DoneOne", "DoneTwo", "DoneThree"]) { + expect(collapsed).not.toContain(id); + } + expect(collapsed).toContain("… 3 more agents (3 done)"); + // The summary line sits above the visible rows (live edge at the bottom). + expect(collapsed.indexOf("more agents")).toBeLessThan(collapsed.indexOf("LiveOne")); + + const expanded = Bun.stripANSI( + taskToolRenderer + .renderResult(result, { expanded: true, isPartial: true, spinnerFrame: 0 }, theme) + .render(120) + .join("\n"), + ); + for (const id of ["DoneOne", "DoneTwo", "DoneThree", "LiveOne", "LiveFour"]) { + expect(expanded).toContain(id); + } + expect(expanded).not.toContain("more agents"); + }); + + it("keeps problem rows visible when the collapsed result list folds", async () => { + const theme = (await getThemeByName("dark"))!; + const details: TaskToolDetails = { + projectAgentsDir: null, + results: [ + finishedResult({ index: 0, id: "FastOne", durationMs: 1000 }), + finishedResult({ index: 1, id: "FastTwo", durationMs: 2000 }), + finishedResult({ index: 2, id: "FastThree", durationMs: 3000 }), + finishedResult({ index: 3, id: "SlowOne", durationMs: 8000 }), + finishedResult({ index: 4, id: "SlowTwo", durationMs: 9000 }), + finishedResult({ index: 5, id: "SlowFailed", exitCode: 1, error: "boom", durationMs: 10000 }), + ], + totalDurationMs: 10000, + }; + + const collapsed = Bun.stripANSI( + taskToolRenderer + .renderResult( + { content: [{ type: "text", text: "" }], details }, + { expanded: false, isPartial: false }, + theme, + ) + .render(120) + .join("\n"), + ); + // The failed agent claims a slot even though it finished last; the + // slowest successes fold away instead. + expect(collapsed).toContain("SlowFailed"); + for (const id of ["FastOne", "FastTwo", "FastThree"]) { + expect(collapsed).toContain(id); + } + expect(collapsed).not.toContain("SlowOne"); + expect(collapsed).not.toContain("SlowTwo"); + expect(collapsed).toContain("… 2 more agents"); + // The run summary footer still counts the full batch. + expect(collapsed).toContain("5 succeeded"); + expect(collapsed).toContain("1 failed"); + }); }); describe("task result detail-less state", () => {