fix(task): stop using the spawn handle as the HUD description

The first HUD commit hid Name: Name. The cause was earlier: task
name was copied into identity.label, which became progress.description
and skipped generateTaskLabel. Keep the handle for id allocation, but
only treat eval label as a real UI description so the tiny-model
summary can run.
This commit is contained in:
ata
2026-08-17 17:37:25 +10:00
parent 94417ec99b
commit 8a83fb0e5d
7 changed files with 81 additions and 12 deletions
+1
View File
@@ -24,6 +24,7 @@
### Fixed
- Fixed the anchored Subagents HUD omitting the spawned agent role and repeating the spawn id as `Name: Name` when the generated label echoed the handle.
- Stopped the task tool from using the spawn handle as the HUD description, which blocked tiny-model label generation and produced `Name: Name`.
## [17.3.5] - 2026-08-16
@@ -117,6 +117,7 @@ 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 { labelEchoesHandle } from "../task/label";
import { agentTypeBadge, formatTaskId } from "../task/render";
import type { ConfiguredThinkingLevel } from "../thinking";
import { tinyTitleClient } from "../tiny/title-client";
@@ -450,13 +451,6 @@ const MODEL_CYCLE_TRACK_CLEAR_MS = 4000;
const SUBAGENT_HUD_VISIBLE_LIMIT = 8;
const SUBAGENT_OBSERVER_UI_COALESCE_MS = 100;
/** True when `label` is the spawn handle, including collision suffixes (`Name-2`). */
function hudLabelEchoesId(id: string, label: string): boolean {
if (label.localeCompare(id, undefined, { sensitivity: "accent" }) === 0) return true;
const suffix = id.startsWith(`${label}-`) ? id.slice(label.length + 1) : "";
return /^\d+$/.test(suffix);
}
/**
* Build the anchored subagent HUD block: a bold accent "Subagents" header plus
* a bounded set of running-agent rows in the same `Id ⟨role⟩: description` shape
@@ -487,7 +481,7 @@ export function renderSubagentHudLines(sessions: ObservableSession[], columns: n
const badge = agentTypeBadge(role, theme);
let line = `${dot} ${theme.fg("accent", theme.bold(displayId))}${badge}`;
const description = session.description?.trim() || session.progress?.description?.trim();
const distinctDescription = description && !hudLabelEchoesId(session.id, description) ? description : undefined;
const distinctDescription = description && !labelEchoesHandle(session.id, description) ? description : undefined;
if (distinctDescription) {
const budget = Math.max(
TRUNCATE_LENGTHS.SHORT,
@@ -498,7 +492,7 @@ export function renderSubagentHudLines(sessions: ObservableSession[], columns: n
// 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 && !hudLabelEchoesId(session.id, taskPreview)) {
if (taskPreview && !labelEchoesHandle(session.id, taskPreview)) {
line += ` ${theme.fg("muted", truncateToWidth(replaceTabs(taskPreview), TRUNCATE_LENGTHS.SHORT))}`;
}
}
+2
View File
@@ -1424,6 +1424,8 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
...(Object.hasOwn(params, "outputSchema") ? { outputSchema: params.outputSchema } : {}),
...(Object.hasOwn(params, "schemaMode") ? { schemaMode: params.schemaMode } : {}),
...(params.effort !== undefined ? { effort: params.effort } : {}),
// `name` is the spawn handle: keep it for id allocation when this
// path did not pre-reserve one. Do not treat it as a HUD description.
identity: { id: preAllocatedId, label: params.name },
index: spawnIndex,
parentToolCallId: toolCallId,
+11 -1
View File
@@ -9,6 +9,14 @@ import { generateSessionTitle } from "../utils/title-generator";
const TASK_LABEL_SYSTEM_PROMPT = prompt.render(taskLabelSystemPrompt);
/** True when a generated label is just the spawn handle, including `Name-2`. */
export function labelEchoesHandle(handle: string | undefined, label: string): boolean {
if (!handle) return false;
if (label.localeCompare(handle, undefined, { sensitivity: "accent" }) === 0) return true;
const suffix = handle.startsWith(`${label}-`) ? handle.slice(label.length + 1) : "";
return /^\d+$/.test(suffix);
}
/** Compresses a delegated assignment into a one-sentence UI label via the tiny title model — fired by the executor spawn path because the task wire schema no longer carries a `description`; null on empty input or failure. */
export async function generateTaskLabel(
assignment: string,
@@ -20,7 +28,7 @@ export async function generateTaskLabel(
const text = assignment.trim();
if (!text) return null;
try {
return await generateSessionTitle(
const label = await generateSessionTitle(
text,
registry,
settings,
@@ -30,6 +38,8 @@ export async function generateTaskLabel(
TASK_LABEL_SYSTEM_PROMPT,
signal,
);
if (!label || labelEchoesHandle(sessionId, label)) return null;
return label;
} catch (err) {
logger.debug("task-label: generation failed", {
sessionId,
@@ -393,7 +393,10 @@ 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 +479,8 @@ 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,
@@ -65,4 +65,34 @@ 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: "<title>AuthLoader</title>" }],
} 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: "<title>Sleep then reply done</title>" }],
} as never);
const labeled = await generateTaskLabel(
"Sleep forty seconds then reply done",
createRegistry(model),
createSettings(model),
"AuthLoader",
);
expect(labeled).toBe("Sleep then reply done");
});
});
@@ -186,6 +186,34 @@ 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);