Merge PR #8797: fix(tui): show subagent role and generate real HUD labels (@atacolak)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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))}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -9,6 +9,17 @@ 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 separator = handle.lastIndexOf("-");
|
||||
if (separator <= 0) return false;
|
||||
const prefix = handle.slice(0, separator);
|
||||
const suffix = handle.slice(separator + 1);
|
||||
return /^\d+$/.test(suffix) && prefix.localeCompare(label, undefined, { sensitivity: "accent" }) === 0;
|
||||
}
|
||||
|
||||
/** 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 +31,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 +41,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,
|
||||
|
||||
@@ -710,7 +710,7 @@ function formatAgentHeaderLabel(args: Partial<TaskParams> | 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}`)}`;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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<Api> {
|
||||
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: "<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");
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user