diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 4060003a3..721fc073a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -46,6 +46,9 @@ ### Fixed - Fixed local title generation stopping on a stop string that appeared in the prompt instead of the generated tokens +### Fixed + +- Fixed the Subagents HUD role display and restored generated task labels by keeping spawn handles separate from UI descriptions. ## [17.3.7] - 2026-08-17 diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index ec6eb9c6b..e268045e4 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -117,7 +117,8 @@ import { BUILTIN_SLASH_COMMAND_RESERVED_NAMES, buildTuiBuiltinSlashCommands } fr import { formatDuration } from "../slash-commands/helpers/format"; import { STTController, type SttState } from "../stt"; import { discoverTitleSystemPromptFile, resolvePromptInput } from "../system-prompt"; -import { formatTaskId } from "../task/render"; +import { labelEchoesHandle } from "../task/label"; +import { agentTypeBadge, formatTaskId } from "../task/render"; import type { ConfiguredThinkingLevel } from "../thinking"; import { tinyTitleClient } from "../tiny/title-client"; import type { LspStartupServerInfo } from "../tools"; @@ -452,8 +453,8 @@ const SUBAGENT_OBSERVER_UI_COALESCE_MS = 100; /** * Build the anchored subagent HUD block: a bold accent "Subagents" header plus - * a bounded set of running-agent rows in the same `Id: description` shape the - * inline task rows use (muted task preview when no description was given). + * a bounded set of running-agent rows in the same `Id ⟨role⟩: description` shape + * the inline task rows use (muted task preview when no description was given). * Layout mirrors the Todos HUD exactly: unindented header, then * `renderTreeList` rows (dim connectors) shifted right by one space. * Only detached background spawns are listed: a sync task call blocks the @@ -476,16 +477,23 @@ export function renderSubagentHudLines(sessions: ObservableSession[], columns: n expanded: true, renderItem: session => { const displayId = formatTaskId(session.id); - let line = `${dot} ${theme.fg("accent", theme.bold(displayId))}`; + const role = session.agent ?? session.progress?.agent; + const badge = agentTypeBadge(role, theme); + let line = `${dot} ${theme.fg("accent", theme.bold(displayId))}${badge}`; const description = session.description?.trim() || session.progress?.description?.trim(); - if (description) { - const budget = Math.max(TRUNCATE_LENGTHS.SHORT, columns - visibleWidth(displayId) - 10); - line += `${theme.fg("accent", ":")} ${theme.fg("accent", truncateToWidth(replaceTabs(description), budget))}`; + const distinctDescription = + description && !labelEchoesHandle(session.id, description) ? description : undefined; + if (distinctDescription) { + const budget = Math.max( + TRUNCATE_LENGTHS.SHORT, + columns - visibleWidth(displayId) - visibleWidth(Bun.stripANSI(badge)) - 10, + ); + line += `${theme.fg("accent", ":")} ${theme.fg("accent", truncateToWidth(replaceTabs(distinctDescription), budget))}`; } else { // No spawn description: fall back to a muted task preview, same as // the inline task rows when a row has no label. const taskPreview = session.progress?.task?.trim(); - if (taskPreview) { + if (taskPreview && !labelEchoesHandle(session.id, taskPreview)) { line += ` ${theme.fg("muted", truncateToWidth(replaceTabs(taskPreview), TRUNCATE_LENGTHS.SHORT))}`; } } diff --git a/packages/coding-agent/src/task/index.ts b/packages/coding-agent/src/task/index.ts index a2fe5ca8b..7b59e6cf0 100644 --- a/packages/coding-agent/src/task/index.ts +++ b/packages/coding-agent/src/task/index.ts @@ -1424,6 +1424,8 @@ export class TaskTool implements AgentTool | undefined): string | } /** Dim `⟨agent⟩` badge for a non-default agent type; empty for the generic worker. */ -function agentTypeBadge(agent: string | undefined, theme: Theme): string { +export function agentTypeBadge(agent: string | undefined, theme: Theme): string { const trimmed = agent?.trim(); if (!trimmed || trimmed === "task") return ""; return ` ${theme.fg("dim", `${theme.format.bracketLeft}${trimmed}${theme.format.bracketRight}`)}`; diff --git a/packages/coding-agent/src/task/structured-subagent.ts b/packages/coding-agent/src/task/structured-subagent.ts index 8667fee56..072e8abda 100644 --- a/packages/coding-agent/src/task/structured-subagent.ts +++ b/packages/coding-agent/src/task/structured-subagent.ts @@ -393,7 +393,9 @@ function buildExecutorOptions( assignment: request.assignment.trim(), context: request.context?.trim() || undefined, planReference: undefined, - description: trimToUndefined(request.identity?.label), + // Task `name` is the spawn handle (id allocation). Eval `label` is a + // real UI description. Copy it only for eval so generateTaskLabel can run. + description: request.invocationKind === "eval" ? trimToUndefined(request.identity?.label) : undefined, index: request.index ?? 0, parentToolCallId: request.parentToolCallId, detached: request.detached, @@ -476,7 +478,7 @@ function buildFailureResult( agentSource: policy.agent.source, task: renderSubagentPrompt(request.assignment), assignment: request.assignment.trim(), - description: trimToUndefined(request.identity?.label), + description: request.invocationKind === "eval" ? trimToUndefined(request.identity?.label) : undefined, exitCode: 1, output: "", stderr: message, diff --git a/packages/coding-agent/test/subagent-hud-render.test.ts b/packages/coding-agent/test/subagent-hud-render.test.ts index 4f1929b30..e2086fc15 100644 --- a/packages/coding-agent/test/subagent-hud-render.test.ts +++ b/packages/coding-agent/test/subagent-hud-render.test.ts @@ -107,6 +107,57 @@ describe("subagent HUD lines", () => { expect(out).toContain("SchemaMigrator: Migrating the users table"); }); + it("shows a non-default role badge and hides descriptions that only echo the id", () => { + const withRole = render([ + makeSession({ + id: "AuthLoader", + agent: "scout", + description: "Refactor the auth flow", + }), + ]); + expect(withRole).toContain("AuthLoader"); + expect(withRole).toMatch(/AuthLoader.*scout/); + expect(withRole).toContain("Refactor the auth flow"); + + const echoed = render([ + makeSession({ + id: "AuthLoader", + agent: "scout", + description: "AuthLoader", + }), + ]); + expect(echoed).toContain("AuthLoader"); + expect(echoed).toMatch(/AuthLoader.*scout/); + expect(echoed).not.toContain("AuthLoader: AuthLoader"); + + const collision = render([ + makeSession({ + id: "AuthLoader-3", + agent: "scout", + description: "AuthLoader", + }), + ]); + expect(collision).toContain("AuthLoader-3"); + expect(collision).toMatch(/AuthLoader-3.*scout/); + expect(collision).not.toContain("AuthLoader-3: AuthLoader"); + + const mixedCase = render([ + makeSession({ + id: "AuthLoader-3", + agent: "scout", + description: "authloader", + }), + ]); + expect(mixedCase).toContain("AuthLoader-3"); + expect(mixedCase).not.toContain("AuthLoader-3: authloader"); + + const defaultWorker = render([ + makeSession({ id: "SchemaMigrator", agent: "task", description: "Migrate users" }), + ]); + expect(defaultWorker).toContain("SchemaMigrator: Migrate users"); + expect(defaultWorker).not.toMatch(/SchemaMigrator.*task/); + }); + it("only shows active subagents and clears once everything finished", () => { const finishedStates = ["completed", "failed", "aborted"] as const; const sessions: ObservableSession[] = [ diff --git a/packages/coding-agent/test/task-label.test.ts b/packages/coding-agent/test/task-label.test.ts index bbe7756a6..17f678eba 100644 --- a/packages/coding-agent/test/task-label.test.ts +++ b/packages/coding-agent/test/task-label.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; import type { Api, Model } from "@oh-my-pi/pi-ai"; import * as ai from "@oh-my-pi/pi-ai"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; -import { generateTaskLabel } from "@oh-my-pi/pi-coding-agent/task/label"; +import { generateTaskLabel, labelEchoesHandle } from "@oh-my-pi/pi-coding-agent/task/label"; function getModelOrThrow(id: string): Model { const model = getBundledModel("anthropic", id); @@ -65,4 +65,39 @@ describe("task label generation", () => { expect(requestSignal).toBe(controller.signal); expect(await label).toBeNull(); }); + + it("rejects a generated label that only echoes the spawn handle", async () => { + const model = getModelOrThrow("claude-sonnet-4-5"); + vi.spyOn(ai, "completeSimple").mockResolvedValue({ + stopReason: "stop", + content: [{ type: "text", text: "AuthLoader" }], + } as never); + + const echoed = await generateTaskLabel( + "Sleep forty seconds then reply done", + createRegistry(model), + createSettings(model), + "AuthLoader", + ); + expect(echoed).toBeNull(); + + vi.spyOn(ai, "completeSimple").mockResolvedValue({ + stopReason: "stop", + content: [{ type: "text", text: "Sleep then reply done" }], + } as never); + const labeled = await generateTaskLabel( + "Sleep forty seconds then reply done", + createRegistry(model), + createSettings(model), + "AuthLoader", + ); + expect(labeled).toBe("Sleep then reply done"); + }); + + it("treats a case-insensitive Name-N collision as an echoed handle", () => { + expect(labelEchoesHandle("AuthLoader-3", "authloader")).toBe(true); + expect(labelEchoesHandle("AuthLoader-3", "AuthLoader")).toBe(true); + expect(labelEchoesHandle("AuthLoader", "authloader")).toBe(true); + expect(labelEchoesHandle("AuthLoader-3", "Migrate users")).toBe(false); + }); }); diff --git a/packages/coding-agent/test/task/structured-subagent.test.ts b/packages/coding-agent/test/task/structured-subagent.test.ts index a24facde7..358d38a4d 100644 --- a/packages/coding-agent/test/task/structured-subagent.test.ts +++ b/packages/coding-agent/test/task/structured-subagent.test.ts @@ -186,6 +186,33 @@ describe("structured subagent primitive", () => { expect(settled.result.modelRole).toBe("reviewer"); await fs.rm(settled.artifactsDir, { recursive: true, force: true }); }); + it("does not treat a spawn handle as the HUD description", async () => { + mockDiscovery(); + const dispatched: executorModule.ExecutorOptions[] = []; + vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { + dispatched.push(options); + return result(); + }); + + const handleOnly = await runStructuredSubagent( + request({ identity: { id: "AuthLoader", label: "AuthLoader" }, retainArtifacts: true }), + ); + expect(dispatched[0]?.description).toBeUndefined(); + expect(dispatched[0]?.id).toBe("AuthLoader"); + await fs.rm(handleOnly.artifactsDir, { recursive: true, force: true }); + + dispatched.length = 0; + const evalLabeled = await runStructuredSubagent( + request({ + invocationKind: "eval", + identity: { label: "Refactor the auth flow" }, + retainArtifacts: true, + }), + ); + expect(dispatched[0]?.description).toBe("Refactor the auth flow"); + await fs.rm(evalLabeled.artifactsDir, { recursive: true, force: true }); + }); + it("derives modelRole from the raw selector source in request, override, definition order", async () => { const customAgent = { ...AGENT, model: ["@definition"] }; mockDiscovery(customAgent);