From b3ec3ddee3b5ea0b3e982e42c8d09f33ec932c75 Mon Sep 17 00:00:00 2001 From: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Mon, 10 Aug 2026 13:40:27 -0400 Subject: [PATCH 1/9] fix(chat): enforce subagent activity laws Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- LAWS/CHAT.md | 5 + .../__tests__/acpNotificationHandler.test.ts | 97 +++++++++++++++++++ .../chat/acp/acpNotificationHandler.ts | 28 +++--- .../lib/__tests__/subagentToolCalls.test.ts | 58 +++++++++-- src/features/chat/lib/subagentToolCalls.ts | 63 +++++++----- src/features/chat/ui/AgentWorkPanel.tsx | 3 +- src/features/chat/ui/ToolCallAdapter.tsx | 22 +++-- src/features/chat/ui/ToolChainCards.tsx | 9 +- .../ui/__tests__/ToolCallAdapter.test.tsx | 77 +++++++++++++++ src/shared/i18n/locales/en/chat.json | 25 ++--- src/shared/i18n/locales/es/chat.json | 25 ++--- src/shared/types/messages.ts | 4 +- 12 files changed, 338 insertions(+), 78 deletions(-) diff --git a/LAWS/CHAT.md b/LAWS/CHAT.md index 9216c0628..8efd458dc 100644 --- a/LAWS/CHAT.md +++ b/LAWS/CHAT.md @@ -30,3 +30,8 @@ - A message that is not first in the queue MUST NOT steer the session. - A steering result MUST affect only the message that produced it. + +## Subagent activity + +- Subagent activity MUST attribute the subagent when its identity is known. +- Subagent activity MUST describe the delegated task in plain language. diff --git a/src/features/chat/acp/__tests__/acpNotificationHandler.test.ts b/src/features/chat/acp/__tests__/acpNotificationHandler.test.ts index 8399b23a2..a6774d89f 100644 --- a/src/features/chat/acp/__tests__/acpNotificationHandler.test.ts +++ b/src/features/chat/acp/__tests__/acpNotificationHandler.test.ts @@ -1380,6 +1380,103 @@ describe("acpNotificationHandler", () => { warnSpy.mockRestore(); }); + it.each([ + { mode: "live", lateIdentity: false }, + { mode: "live", lateIdentity: true }, + { mode: "replay", lateIdentity: false }, + { mode: "replay", lateIdentity: true }, + ] as const)("retains async delegate identity and task in $mode when load identity is late=$lateIdentity", async ({ + mode, + lateIdentity, + }) => { + const sessionId = "acp-session"; + if (mode === "live") { + registerPreparedSession(sessionId, "goose", "/Users/test"); + setActiveMessageId(sessionId, "assistant-1"); + } else { + markSessionReplayLoading(sessionId); + } + + const replayMeta = + mode === "replay" + ? { messageId: "assistant-1", created: 1_700_000_120 } + : {}; + const toolMeta = (toolName: string) => ({ + goose: { + ...replayMeta, + toolCall: { toolName }, + }, + }); + + await handleSessionNotification({ + sessionId, + update: { + sessionUpdate: "tool_call", + toolCallId: "delegate-1", + title: "delegate", + rawInput: { + source: "Rivet", + instructions: "Count markdown files", + async: true, + }, + _meta: toolMeta("delegate"), + }, + } as never); + await handleSessionNotification({ + sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: "delegate-1", + status: "completed", + content: [ + { + type: "content", + content: { + type: "text", + text: "Task 20260807_119 started in background", + }, + }, + ], + _meta: toolMeta("delegate"), + }, + } as never); + + await handleSessionNotification({ + sessionId, + update: { + sessionUpdate: "tool_call", + toolCallId: "load-1", + title: "load", + rawInput: { source: "20260807_119" }, + ...(!lateIdentity ? { _meta: toolMeta("load") } : {}), + }, + } as never); + if (lateIdentity) { + await handleSessionNotification({ + sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: "load-1", + _meta: toolMeta("load"), + }, + } as never); + } + + const messages = + mode === "live" + ? useChatStore.getState().messagesBySession[sessionId] + : getReplayBuffer(sessionId); + const load = messages + ?.flatMap((message) => message.content) + .find((block) => block.type === "toolRequest" && block.id === "load-1"); + expect(load).toMatchObject({ + type: "toolRequest", + toolName: "load", + subagentAgentName: "Rivet", + subagentTaskLabel: "Count markdown files", + }); + }); + it("preserves ACP tool kind and locations on tool requests", async () => { registerPreparedSession("acp-session", "goose", "/Users/test"); setActiveMessageId("acp-session", "assistant-1"); diff --git a/src/features/chat/acp/acpNotificationHandler.ts b/src/features/chat/acp/acpNotificationHandler.ts index fd22c4db1..de606fda4 100644 --- a/src/features/chat/acp/acpNotificationHandler.ts +++ b/src/features/chat/acp/acpNotificationHandler.ts @@ -56,7 +56,7 @@ import { getToolCallIdentity, getToolChainSummary, } from "@/shared/api/acpToolCallIdentity"; -import { resolveSubagentLabel } from "@/features/chat/lib/subagentToolCalls"; +import { resolveSubagentContext } from "@/features/chat/lib/subagentToolCalls"; import { applyChatSessionConfigOptionsSnapshot } from "./sessionConfigSnapshotAdapter"; import { perfLog } from "@/shared/lib/perfLog"; import { @@ -431,7 +431,7 @@ function handleReplay(sessionId: string, update: SessionUpdate): void { getReplayAssistantMessageMetadata(sessionId, update), ); const replayArguments = rawInputToArguments(update.rawInput); - const replaySubagentLabel = resolveSubagentLabel( + const replaySubagentContext = resolveSubagentContext( identity.toolName, replayArguments, getReplayBuffer(sessionId) ?? [], @@ -446,7 +446,7 @@ function handleReplay(sessionId: string, update: SessionUpdate): void { ...toolCallUpdatePatch(update), startedAt: created ?? Date.now(), ...(chainSummary ? { chainSummary } : {}), - ...(replaySubagentLabel ? { subagentLabel: replaySubagentLabel } : {}), + ...(replaySubagentContext ?? {}), }); break; } @@ -494,13 +494,17 @@ function handleReplay(sessionId: string, update: SessionUpdate): void { // The wire tool name can arrive after the initial tool_call // (identity patched in by a later update); resolve the subagent // label now that we know what the tool is. - if (identity.toolName && tc.subagentLabel === undefined) { - const lateLabel = resolveSubagentLabel( + if ( + identity.toolName && + (tc.subagentAgentName === undefined || + tc.subagentTaskLabel === undefined) + ) { + const lateContext = resolveSubagentContext( tc.toolName, tc.arguments, getReplayBuffer(sessionId) ?? [], ); - if (lateLabel) tc.subagentLabel = lateLabel; + if (lateContext) Object.assign(tc, lateContext); } } } @@ -628,7 +632,7 @@ function handleLive(sessionId: string, update: SessionUpdate): void { const chainSummary = getToolChainSummary(update); const liveArguments = rawInputToArguments(update.rawInput); - const liveSubagentLabel = resolveSubagentLabel( + const liveSubagentContext = resolveSubagentContext( identity.toolName, liveArguments, useChatStore.getState().messagesBySession[sessionId] ?? [], @@ -643,7 +647,7 @@ function handleLive(sessionId: string, update: SessionUpdate): void { ...toolCallUpdatePatch(update), startedAt: Date.now(), ...(chainSummary ? { chainSummary } : {}), - ...(liveSubagentLabel ? { subagentLabel: liveSubagentLabel } : {}), + ...(liveSubagentContext ?? {}), }; store.setStreamingMessageId(sessionId, messageId); store.appendToStreamingMessage(sessionId, toolRequest); @@ -674,8 +678,8 @@ function handleLive(sessionId: string, update: SessionUpdate): void { // The wire tool name can arrive after the initial tool_call // (identity patched in by a later update); resolve the subagent // label now that we know what the tool is. - const lateSubagentLabel = identity.toolName - ? resolveSubagentLabel( + const lateSubagentContext = identity.toolName + ? resolveSubagentContext( identity.toolName, findLiveToolRequest(sessionId, messageId, update.toolCallId) ?.arguments ?? {}, @@ -692,9 +696,7 @@ function handleLive(sessionId: string, update: SessionUpdate): void { ...identity, ...patch, ...(chainSummary ? { chainSummary } : {}), - ...(lateSubagentLabel && c.subagentLabel === undefined - ? { subagentLabel: lateSubagentLabel } - : {}), + ...(lateSubagentContext ?? {}), } : c, ), diff --git a/src/features/chat/lib/__tests__/subagentToolCalls.test.ts b/src/features/chat/lib/__tests__/subagentToolCalls.test.ts index ed86f19ff..3ccf79a6d 100644 --- a/src/features/chat/lib/__tests__/subagentToolCalls.test.ts +++ b/src/features/chat/lib/__tests__/subagentToolCalls.test.ts @@ -1,8 +1,9 @@ import { describe, expect, it } from "vitest"; import { getSubagentToolCallInfo, + resolveDelegateContextForTask, resolveDelegateSourceForTask, - resolveSubagentLabel, + resolveSubagentContext, shortTaskId, } from "@/features/chat/lib/subagentToolCalls"; import type { MessageContent } from "@/shared/types/messages"; @@ -47,7 +48,11 @@ describe("getSubagentToolCallInfo", () => { toolName: "delegate", arguments: { source: "code-reviewer", async: true }, }), - ).toEqual({ activity: "delegating", agentName: "code-reviewer" }); + ).toEqual({ + activity: "delegating", + agentName: "code-reviewer", + sourceDefinesTask: true, + }); }); it("uses instructions as the label and truncates long labels", () => { @@ -143,13 +148,32 @@ describe("getSubagentToolCallInfo", () => { }); }); + it("uses the full prompt when description is absent", () => { + expect( + getSubagentToolCallInfo({ + toolName: "Agent", + arguments: { + subagent_type: "code-reviewer", + prompt: "Review the authentication boundary", + }, + }), + ).toEqual({ + activity: "delegating", + agentName: "code-reviewer", + label: "Review the authentication boundary", + }); + }); + it("classifies a named agent without a description", () => { expect( getSubagentToolCallInfo({ toolName: "Agent", arguments: { subagent_type: "code-reviewer" }, }), - ).toEqual({ activity: "delegating", agentName: "code-reviewer" }); + ).toEqual({ + activity: "delegating", + agentName: "code-reviewer", + }); }); }); @@ -256,6 +280,26 @@ describe("getSubagentToolCallInfo", () => { ).toBeUndefined(); }); + it("retains both identity and task for async follow-ups", () => { + const messages = transcript([ + [ + delegateRequest("call-1", { + source: "Rivet", + instructions: "Count markdown files", + async: true, + }), + delegateResponse( + "call-1", + 'Task 20260807_119 started in background: "Count markdown files"', + ), + ], + ]); + expect(resolveDelegateContextForTask(messages, "20260807_119")).toEqual({ + subagentAgentName: "Rivet", + subagentTaskLabel: "Count markdown files", + }); + }); + it("returns undefined for ad-hoc delegates (no source)", () => { const messages = transcript([ [ @@ -276,15 +320,15 @@ describe("getSubagentToolCallInfo", () => { }); }); - describe("resolveSubagentLabel", () => { + describe("resolveSubagentContext", () => { it("only resolves for load calls with a task-id source", () => { expect( - resolveSubagentLabel("load", { source: "deploy" }, []), + resolveSubagentContext("load", { source: "deploy" }, []), ).toBeUndefined(); expect( - resolveSubagentLabel("delegate", { source: "Rivet" }, []), + resolveSubagentContext("delegate", { source: "Rivet" }, []), ).toBeUndefined(); - expect(resolveSubagentLabel(undefined, {}, [])).toBeUndefined(); + expect(resolveSubagentContext(undefined, {}, [])).toBeUndefined(); }); }); diff --git a/src/features/chat/lib/subagentToolCalls.ts b/src/features/chat/lib/subagentToolCalls.ts index da3295bbb..c65eedfd6 100644 --- a/src/features/chat/lib/subagentToolCalls.ts +++ b/src/features/chat/lib/subagentToolCalls.ts @@ -29,6 +29,8 @@ export interface SubagentToolCallInfo { label?: string; /** Named delegate source (custom agent/recipe), when the spawn had one. */ agentName?: string; + /** Source-only Goose delegates run the configured task owned by the source. */ + sourceDefinesTask?: boolean; /** Goose background-task id (e.g. `20260807_72`) for await/peek/cancel. */ taskId?: string; } @@ -70,38 +72,36 @@ function toolResponseMentionsTask( return false; } +/** Context recovered from the delegate that spawned an async Goose task. */ +export interface ResolvedSubagentContext { + subagentAgentName?: string; + subagentTaskLabel?: string; +} + /** - * For a `load ` tool call, resolve the named delegate source that - * spawned the task (if any) from the session transcript. Returns undefined - * for anything that isn't a task-id load or when the delegate was ad-hoc. + * For a `load ` tool call, recover the known subagent identity and + * task description from the paired delegate in the session transcript. */ -export function resolveSubagentLabel( +export function resolveSubagentContext( toolName: string | undefined, args: Record, messages: ReadonlyArray<{ content: MessageContent[] }>, -): string | undefined { +): ResolvedSubagentContext | undefined { if (toolName !== "load") return undefined; const source = stringArg(args, "source")?.trim(); if (!source || !GOOSE_TASK_ID_PATTERN.test(source)) return undefined; - return resolveDelegateSourceForTask(messages, source); + return resolveDelegateContextForTask(messages, source); } /** - * Resolve which named delegate source (custom agent, recipe) spawned a - * background task, by scanning the session transcript for the `delegate` - * call whose result announced the task id. Purely derived — no side state. - * - * Scans newest-to-oldest: the spawning delegate is almost always recent - * (tasks are typically collected shortly after launch), and if the same - * task id ever appears twice, the most recent delegate wins. + * Resolve the delegate request whose response announced a background task id. + * Both identity and task are retained: follow-up activity must not discard + * facts that were already present in the transcript. */ -export function resolveDelegateSourceForTask( +export function resolveDelegateContextForTask( messages: ReadonlyArray<{ content: MessageContent[] }>, taskId: string, -): string | undefined { - // A delegate's response follows its request chronologically, so a reverse - // scan sees the response first: remember matching response ids, then - // resolve when the paired delegate request appears. +): ResolvedSubagentContext | undefined { const matchingResponseIds = new Set(); for (let m = messages.length - 1; m >= 0; m -= 1) { const content = messages[m].content; @@ -117,16 +117,27 @@ export function resolveDelegateSourceForTask( block.toolName === "delegate" && matchingResponseIds.has(block.id) ) { - const source = block.arguments.source; - return typeof source === "string" && source.trim().length > 0 - ? source.trim() - : undefined; + const agentName = stringArg(block.arguments, "source")?.trim(); + const taskLabel = stringArg(block.arguments, "instructions"); + const context: ResolvedSubagentContext = { + ...(agentName ? { subagentAgentName: agentName } : {}), + ...(taskLabel ? { subagentTaskLabel: truncateLabel(taskLabel) } : {}), + }; + return Object.keys(context).length > 0 ? context : undefined; } } } return undefined; } +/** Compatibility accessor for callers that only need the delegate identity. */ +export function resolveDelegateSourceForTask( + messages: ReadonlyArray<{ content: MessageContent[] }>, + taskId: string, +): string | undefined { + return resolveDelegateContextForTask(messages, taskId)?.subagentAgentName; +} + const MAX_LABEL_LENGTH = 60; function truncateLabel(value: string): string { @@ -158,10 +169,16 @@ export function getSubagentToolCallInfo(input: { if (toolName === "delegate") { const agentName = stringArg(args, "source"); const label = stringArg(args, "instructions"); + // A named Goose source owns a configured task, so source-only delegates + // still have a coherent task boundary. An anonymous/descriptionless + // delegate does not: keep it out of subagent rendering rather than + // inventing a task description. + if (!agentName && !label) return undefined; return { activity: "delegating", ...(agentName ? { agentName: agentName.trim() } : {}), ...(label ? { label: truncateLabel(label) } : {}), + ...(!label && agentName ? { sourceDefinesTask: true } : {}), }; } @@ -184,7 +201,7 @@ export function getSubagentToolCallInfo(input: { // configured agent; description is the task. if (toolName === "Task" || toolName === "Agent") { const agentName = stringArg(args, "subagent_type"); - const label = stringArg(args, "description"); + const label = stringArg(args, "description") ?? stringArg(args, "prompt"); return { activity: "delegating", ...(agentName && agentName !== "general-purpose" diff --git a/src/features/chat/ui/AgentWorkPanel.tsx b/src/features/chat/ui/AgentWorkPanel.tsx index 1cc278fd3..ff8cc9e81 100644 --- a/src/features/chat/ui/AgentWorkPanel.tsx +++ b/src/features/chat/ui/AgentWorkPanel.tsx @@ -320,7 +320,8 @@ function AgentWorkItemRow({ ; status: ToolCallStatus; locations?: ToolCallLocation[]; @@ -315,6 +317,7 @@ function subagentTitle( t: (key: string, options?: Record) => string, info: NonNullable>, resolvedAgentName?: string, + resolvedTaskLabel?: string, ): string { // Explicit key map keeps the i18n usage statically checkable. const keys = { @@ -349,14 +352,20 @@ function subagentTitle( // delegate). It replaces the word "subagent"; the task description is // kept alongside it: "Delegating to Rivet · Count markdown files…". const agentName = info.agentName ?? resolvedAgentName; - if (agentName && info.label) { - return t(agentLabeled, { name: agentName, label: info.label }); + const taskLabel = info.label ?? resolvedTaskLabel; + if (agentName && info.sourceDefinesTask) { + return t("tools.subagent.delegatingAgentConfiguredTask", { + name: agentName, + }); + } + if (agentName && taskLabel) { + return t(agentLabeled, { name: agentName, label: taskLabel }); } if (agentName) return t(agent, { name: agentName }); if (info.taskId) { return t(labeled, { label: shortTaskId(info.taskId) }); } - return info.label ? t(labeled, { label: info.label }) : t(plain); + return taskLabel ? t(labeled, { label: taskLabel }) : t(plain); } function sentenceCaseToolTitle(name: string): string { @@ -385,7 +394,8 @@ export function ToolCallAdapter({ className, name, toolName, - subagentLabel, + subagentAgentName, + subagentTaskLabel, arguments: args, status, locations, @@ -418,7 +428,7 @@ export function ToolCallAdapter({ [toolName, args], ); const displayName = subagentInfo - ? subagentTitle(t, subagentInfo, subagentLabel) + ? subagentTitle(t, subagentInfo, subagentAgentName, subagentTaskLabel) : sentenceCaseToolTitle(name); const pathRow = summaryRows.find((row) => row.kind === "path"); diff --git a/src/features/chat/ui/ToolChainCards.tsx b/src/features/chat/ui/ToolChainCards.tsx index 77ceb7b69..325e1f05b 100644 --- a/src/features/chat/ui/ToolChainCards.tsx +++ b/src/features/chat/ui/ToolChainCards.tsx @@ -397,7 +397,8 @@ export function ToolChainCards({ ) => { const name = getToolItemName(item); const toolName = getToolItemToolName(item); - const subagentLabel = item.request?.subagentLabel; + const subagentAgentName = item.request?.subagentAgentName; + const subagentTaskLabel = item.request?.subagentTaskLabel; const status = getToolItemStatus(item); const { request, response } = item; const isOpen = !options.forceClose && expandedKeys.has(item.key); @@ -430,7 +431,8 @@ export function ToolChainCards({ { }); }); +describe("ToolCallAdapter — subagent laws", () => { + it("attributes a known agent and describes an explicit task", () => { + renderAdapter({ + name: "delegate", + toolName: "delegate", + arguments: { + source: "Rivet", + instructions: "Count markdown files", + }, + }); + + expect( + screen.getByRole("button", { + name: /Delegating to Rivet · Count markdown files/i, + }), + ).toBeInTheDocument(); + }); + + it("describes a valid source-only delegation", () => { + renderAdapter({ + name: "delegate", + toolName: "delegate", + arguments: { source: "Rivet" }, + }); + + expect( + screen.getByRole("button", { + name: /Asking Rivet to run its configured task/i, + }), + ).toBeInTheDocument(); + }); + + it.each([ + { + toolName: "delegate", + arguments: {}, + title: "Delegating an unspecified task to a subagent", + }, + { + toolName: "Agent", + arguments: { subagent_type: "code-reviewer" }, + title: "Delegating an unspecified task to code-reviewer", + }, + { + toolName: "spawn_agent", + arguments: {}, + title: "Delegating an unspecified task to a subagent", + }, + ])("explicitly describes malformed $toolName activity as an unspecified task", ({ + toolName, + arguments: args, + title, + }) => { + renderAdapter({ name: toolName, toolName, arguments: args }); + + expect( + screen.getByRole("button", { name: new RegExp(title, "i") }), + ).toBeInTheDocument(); + }); + + it("retains recovered identity and task on async follow-ups", () => { + renderAdapter({ + name: "load", + toolName: "load", + subagentAgentName: "Rivet", + subagentTaskLabel: "Count markdown files", + arguments: { source: "20260807_72" }, + }); + + expect( + screen.getByRole("button", { + name: /Waiting on Rivet · Count markdown files/i, + }), + ).toBeInTheDocument(); + }); +}); + describe("ToolCallAdapter — expanded body", () => { it("renders the tool name and status badge in the header", () => { renderAdapter(); diff --git a/src/shared/i18n/locales/en/chat.json b/src/shared/i18n/locales/en/chat.json index 44b560f03..79191a79a 100644 --- a/src/shared/i18n/locales/en/chat.json +++ b/src/shared/i18n/locales/en/chat.json @@ -568,22 +568,23 @@ "structuredOutput": "Structured output", "structuredOutputLines": "{{count}} lines", "subagent": { - "delegating": "Delegating to a subagent", + "delegating": "Delegating an unspecified task to a subagent", "delegatingLabeled": "Delegating to a subagent · {{label}}", - "delegatingAgent": "Delegating to {{name}}", + "delegatingAgent": "Delegating an unspecified task to {{name}}", "delegatingAgentLabeled": "Delegating to {{name}} · {{label}}", - "waiting": "Waiting on a subagent", - "waitingLabeled": "Waiting on subagent #{{label}}", - "waitingAgent": "Waiting on {{name}}", + "waiting": "Waiting on a subagent’s unspecified delegated task", + "waitingLabeled": "Waiting on an unspecified delegated task · subagent #{{label}}", + "waitingAgent": "Waiting on {{name}}’s unspecified delegated task", "waitingAgentLabeled": "Waiting on {{name}} · {{label}}", - "checking": "Checking on a subagent", - "checkingLabeled": "Checking on subagent #{{label}}", - "checkingAgent": "Checking on {{name}}", + "checking": "Checking a subagent’s unspecified delegated task", + "checkingLabeled": "Checking an unspecified delegated task · subagent #{{label}}", + "checkingAgent": "Checking {{name}}’s unspecified delegated task", "checkingAgentLabeled": "Checking on {{name}} · {{label}}", - "cancelling": "Cancelling a subagent", - "cancellingLabeled": "Cancelling subagent #{{label}}", - "cancellingAgent": "Cancelling {{name}}", - "cancellingAgentLabeled": "Cancelling {{name}} · {{label}}" + "cancelling": "Cancelling a subagent’s unspecified delegated task", + "cancellingLabeled": "Cancelling an unspecified delegated task · subagent #{{label}}", + "cancellingAgent": "Cancelling {{name}}’s unspecified delegated task", + "cancellingAgentLabeled": "Cancelling {{name}} · {{label}}", + "delegatingAgentConfiguredTask": "Asking {{name}} to run its configured task" } }, "agent_work": { diff --git a/src/shared/i18n/locales/es/chat.json b/src/shared/i18n/locales/es/chat.json index 74c3e2f92..50d755f7f 100644 --- a/src/shared/i18n/locales/es/chat.json +++ b/src/shared/i18n/locales/es/chat.json @@ -563,22 +563,23 @@ "structuredOutput": "Salida estructurada", "structuredOutputLines": "{{count}} líneas", "subagent": { - "delegating": "Delegando a un subagente", + "delegating": "Delegando una tarea no especificada a un subagente", "delegatingLabeled": "Delegando a un subagente · {{label}}", - "delegatingAgent": "Delegando a {{name}}", + "delegatingAgent": "Delegando una tarea no especificada a {{name}}", "delegatingAgentLabeled": "Delegando a {{name}} · {{label}}", - "waiting": "Esperando a un subagente", - "waitingLabeled": "Esperando al subagente n.º {{label}}", - "waitingAgent": "Esperando a {{name}}", + "waiting": "Esperando la tarea delegada no especificada de un subagente", + "waitingLabeled": "Esperando una tarea delegada no especificada · subagente #{{label}}", + "waitingAgent": "Esperando la tarea delegada no especificada de {{name}}", "waitingAgentLabeled": "Esperando a {{name}} · {{label}}", - "checking": "Consultando a un subagente", - "checkingLabeled": "Consultando al subagente n.º {{label}}", - "checkingAgent": "Consultando a {{name}}", + "checking": "Comprobando la tarea delegada no especificada de un subagente", + "checkingLabeled": "Comprobando una tarea delegada no especificada · subagente #{{label}}", + "checkingAgent": "Comprobando la tarea delegada no especificada de {{name}}", "checkingAgentLabeled": "Consultando a {{name}} · {{label}}", - "cancelling": "Cancelando un subagente", - "cancellingLabeled": "Cancelando al subagente n.º {{label}}", - "cancellingAgent": "Cancelando a {{name}}", - "cancellingAgentLabeled": "Cancelando a {{name}} · {{label}}" + "cancelling": "Cancelando la tarea delegada no especificada de un subagente", + "cancellingLabeled": "Cancelando una tarea delegada no especificada · subagente #{{label}}", + "cancellingAgent": "Cancelando la tarea delegada no especificada de {{name}}", + "cancellingAgentLabeled": "Cancelando a {{name}} · {{label}}", + "delegatingAgentConfiguredTask": "Pidiendo a {{name}} que ejecute su tarea configurada" } }, "agent_work": { diff --git a/src/shared/types/messages.ts b/src/shared/types/messages.ts index 8f3c45ca7..e443e463a 100644 --- a/src/shared/types/messages.ts +++ b/src/shared/types/messages.ts @@ -126,7 +126,9 @@ export interface ToolRequestContent { * named source (custom agent/recipe) of the delegate that spawned the task, * resolved from the delegate's result in this session's transcript. */ - subagentLabel?: string; + subagentAgentName?: string; + /** Plain-language task recovered from the spawning delegate. */ + subagentTaskLabel?: string; } export interface ToolResponseContent { From 4631e9f3cb788eb6e9c7da84bdf8f3ec9fa0c337 Mon Sep 17 00:00:00 2001 From: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Mon, 10 Aug 2026 13:59:50 -0400 Subject: [PATCH 2/9] fix(chat): preserve lawful async subagent context Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- .../__tests__/acpNotificationHandler.test.ts | 21 ++++-- .../lib/__tests__/subagentToolCalls.test.ts | 40 +++++++---- src/features/chat/lib/subagentToolCalls.ts | 16 +++-- src/features/chat/ui/AgentWorkPanel.tsx | 1 + src/features/chat/ui/ToolCallAdapter.tsx | 52 ++++++++++---- src/features/chat/ui/ToolChainCards.tsx | 3 + .../ui/__tests__/ToolCallAdapter.test.tsx | 68 +++++++++++++++---- src/shared/i18n/locales/en/chat.json | 13 +++- src/shared/i18n/locales/es/chat.json | 13 +++- src/shared/types/messages.ts | 2 + 10 files changed, 174 insertions(+), 55 deletions(-) diff --git a/src/features/chat/acp/__tests__/acpNotificationHandler.test.ts b/src/features/chat/acp/__tests__/acpNotificationHandler.test.ts index a6774d89f..a10bde123 100644 --- a/src/features/chat/acp/__tests__/acpNotificationHandler.test.ts +++ b/src/features/chat/acp/__tests__/acpNotificationHandler.test.ts @@ -1381,13 +1381,18 @@ describe("acpNotificationHandler", () => { }); it.each([ - { mode: "live", lateIdentity: false }, - { mode: "live", lateIdentity: true }, - { mode: "replay", lateIdentity: false }, - { mode: "replay", lateIdentity: true }, - ] as const)("retains async delegate identity and task in $mode when load identity is late=$lateIdentity", async ({ + { mode: "live", lateIdentity: false, configuredTask: false }, + { mode: "live", lateIdentity: true, configuredTask: false }, + { mode: "replay", lateIdentity: false, configuredTask: false }, + { mode: "replay", lateIdentity: true, configuredTask: false }, + { mode: "live", lateIdentity: false, configuredTask: true }, + { mode: "live", lateIdentity: true, configuredTask: true }, + { mode: "replay", lateIdentity: false, configuredTask: true }, + { mode: "replay", lateIdentity: true, configuredTask: true }, + ] as const)("retains async delegate identity and task in $mode when load identity is late=$lateIdentity and configured=$configuredTask", async ({ mode, lateIdentity, + configuredTask, }) => { const sessionId = "acp-session"; if (mode === "live") { @@ -1416,7 +1421,7 @@ describe("acpNotificationHandler", () => { title: "delegate", rawInput: { source: "Rivet", - instructions: "Count markdown files", + ...(!configuredTask ? { instructions: "Count markdown files" } : {}), async: true, }, _meta: toolMeta("delegate"), @@ -1473,7 +1478,9 @@ describe("acpNotificationHandler", () => { type: "toolRequest", toolName: "load", subagentAgentName: "Rivet", - subagentTaskLabel: "Count markdown files", + ...(configuredTask + ? { subagentTaskIsConfigured: true } + : { subagentTaskLabel: "Count markdown files" }), }); }); diff --git a/src/features/chat/lib/__tests__/subagentToolCalls.test.ts b/src/features/chat/lib/__tests__/subagentToolCalls.test.ts index 3ccf79a6d..ed1b06b7b 100644 --- a/src/features/chat/lib/__tests__/subagentToolCalls.test.ts +++ b/src/features/chat/lib/__tests__/subagentToolCalls.test.ts @@ -67,10 +67,10 @@ describe("getSubagentToolCallInfo", () => { expect(info?.label?.endsWith("…")).toBe(true); }); - it("classifies delegate without any label", () => { + it("does not classify a delegate with no task boundary", () => { expect( getSubagentToolCallInfo({ toolName: "delegate", arguments: {} }), - ).toEqual({ activity: "delegating" }); + ).toBeUndefined(); }); }); @@ -148,7 +148,7 @@ describe("getSubagentToolCallInfo", () => { }); }); - it("uses the full prompt when description is absent", () => { + it("does not classify an agent when description is absent", () => { expect( getSubagentToolCallInfo({ toolName: "Agent", @@ -157,23 +157,16 @@ describe("getSubagentToolCallInfo", () => { prompt: "Review the authentication boundary", }, }), - ).toEqual({ - activity: "delegating", - agentName: "code-reviewer", - label: "Review the authentication boundary", - }); + ).toBeUndefined(); }); - it("classifies a named agent without a description", () => { + it("does not classify a named agent without a task description", () => { expect( getSubagentToolCallInfo({ toolName: "Agent", arguments: { subagent_type: "code-reviewer" }, }), - ).toEqual({ - activity: "delegating", - agentName: "code-reviewer", - }); + ).toBeUndefined(); }); }); @@ -300,6 +293,19 @@ describe("getSubagentToolCallInfo", () => { }); }); + it("retains a named source's configured task for async follow-ups", () => { + const messages = transcript([ + [ + delegateRequest("call-1", { source: "Rivet", async: true }), + delegateResponse("call-1", "Task 20260807_120 started in background"), + ], + ]); + expect(resolveDelegateContextForTask(messages, "20260807_120")).toEqual({ + subagentAgentName: "Rivet", + subagentTaskIsConfigured: true, + }); + }); + it("returns undefined for ad-hoc delegates (no source)", () => { const messages = transcript([ [ @@ -354,5 +360,13 @@ describe("getSubagentToolCallInfo", () => { label: "Investigate the failing tests", }); }); + it("does not classify spawn_agent without a prompt", () => { + expect( + getSubagentToolCallInfo({ + toolName: "spawn_agent", + arguments: {}, + }), + ).toBeUndefined(); + }); }); }); diff --git a/src/features/chat/lib/subagentToolCalls.ts b/src/features/chat/lib/subagentToolCalls.ts index c65eedfd6..a2a0a3f65 100644 --- a/src/features/chat/lib/subagentToolCalls.ts +++ b/src/features/chat/lib/subagentToolCalls.ts @@ -76,6 +76,8 @@ function toolResponseMentionsTask( export interface ResolvedSubagentContext { subagentAgentName?: string; subagentTaskLabel?: string; + /** The named Goose source owns a configured task when no instructions were supplied. */ + subagentTaskIsConfigured?: boolean; } /** @@ -121,7 +123,11 @@ export function resolveDelegateContextForTask( const taskLabel = stringArg(block.arguments, "instructions"); const context: ResolvedSubagentContext = { ...(agentName ? { subagentAgentName: agentName } : {}), - ...(taskLabel ? { subagentTaskLabel: truncateLabel(taskLabel) } : {}), + ...(taskLabel + ? { subagentTaskLabel: truncateLabel(taskLabel) } + : agentName + ? { subagentTaskIsConfigured: true } + : {}), }; return Object.keys(context).length > 0 ? context : undefined; } @@ -201,22 +207,24 @@ export function getSubagentToolCallInfo(input: { // configured agent; description is the task. if (toolName === "Task" || toolName === "Agent") { const agentName = stringArg(args, "subagent_type"); - const label = stringArg(args, "description") ?? stringArg(args, "prompt"); + const label = stringArg(args, "description"); + if (!label) return undefined; return { activity: "delegating", ...(agentName && agentName !== "general-purpose" ? { agentName: agentName.trim() } : {}), - ...(label ? { label: truncateLabel(label) } : {}), + label: truncateLabel(label), }; } // Codex: spawn_agent collaboration tool. if (toolName === "spawn_agent") { const label = stringArg(args, "prompt"); + if (!label) return undefined; return { activity: "delegating", - ...(label ? { label: truncateLabel(label) } : {}), + label: truncateLabel(label), }; } diff --git a/src/features/chat/ui/AgentWorkPanel.tsx b/src/features/chat/ui/AgentWorkPanel.tsx index ff8cc9e81..50feea638 100644 --- a/src/features/chat/ui/AgentWorkPanel.tsx +++ b/src/features/chat/ui/AgentWorkPanel.tsx @@ -322,6 +322,7 @@ function AgentWorkItemRow({ toolName={item.request?.toolName} subagentAgentName={item.request?.subagentAgentName} subagentTaskLabel={item.request?.subagentTaskLabel} + subagentTaskIsConfigured={item.request?.subagentTaskIsConfigured} arguments={item.request?.arguments ?? {}} status={status} locations={item.request?.locations} diff --git a/src/features/chat/ui/ToolCallAdapter.tsx b/src/features/chat/ui/ToolCallAdapter.tsx index 637911686..4af1f9612 100644 --- a/src/features/chat/ui/ToolCallAdapter.tsx +++ b/src/features/chat/ui/ToolCallAdapter.tsx @@ -22,10 +22,7 @@ import { } from "@/features/chat/lib/toolCallPresentation"; import type { ToolCallLocation, ToolCallStatus } from "@/shared/types/messages"; import { useArtifactActionsContext } from "@/features/chat/hooks/ArtifactPolicyContext"; -import { - getSubagentToolCallInfo, - shortTaskId, -} from "@/features/chat/lib/subagentToolCalls"; +import { getSubagentToolCallInfo } from "@/features/chat/lib/subagentToolCalls"; interface ToolCallAdapterProps { className?: string; @@ -36,6 +33,8 @@ interface ToolCallAdapterProps { subagentAgentName?: string; /** Plain-language task recovered from the spawning delegate. */ subagentTaskLabel?: string; + /** Whether the named source owns a configured task with no inline label. */ + subagentTaskIsConfigured?: boolean; arguments: Record; status: ToolCallStatus; locations?: ToolCallLocation[]; @@ -318,7 +317,8 @@ function subagentTitle( info: NonNullable>, resolvedAgentName?: string, resolvedTaskLabel?: string, -): string { + resolvedTaskIsConfigured?: boolean, +): string | undefined { // Explicit key map keeps the i18n usage statically checkable. const keys = { delegating: [ @@ -347,25 +347,39 @@ function subagentTitle( ], } as const; const [plain, labeled, agent, agentLabeled] = keys[info.activity]; + const configuredTaskKeys = { + delegating: "tools.subagent.delegatingAgentConfiguredTask", + waiting: "tools.subagent.waitingAgentConfiguredTask", + checking: "tools.subagent.checkingAgentConfiguredTask", + cancelling: "tools.subagent.cancellingAgentConfiguredTask", + } as const; + const taskLabeledKeys = { + delegating: "tools.subagent.delegatingLabeled", + waiting: "tools.subagent.waitingTaskLabeled", + checking: "tools.subagent.checkingTaskLabeled", + cancelling: "tools.subagent.cancellingTaskLabeled", + } as const; // Agent name comes from the call arguments (delegate source) or is // resolved from the transcript (load of a task spawned by a named // delegate). It replaces the word "subagent"; the task description is // kept alongside it: "Delegating to Rivet · Count markdown files…". const agentName = info.agentName ?? resolvedAgentName; const taskLabel = info.label ?? resolvedTaskLabel; - if (agentName && info.sourceDefinesTask) { - return t("tools.subagent.delegatingAgentConfiguredTask", { - name: agentName, - }); + if (agentName && (info.sourceDefinesTask || resolvedTaskIsConfigured)) { + return t(configuredTaskKeys[info.activity], { name: agentName }); } if (agentName && taskLabel) { return t(agentLabeled, { name: agentName, label: taskLabel }); } if (agentName) return t(agent, { name: agentName }); - if (info.taskId) { - return t(labeled, { label: shortTaskId(info.taskId) }); + if (taskLabel) { + return info.taskId + ? t(taskLabeledKeys[info.activity], { label: taskLabel }) + : t(labeled, { label: taskLabel }); } - return taskLabel ? t(labeled, { label: taskLabel }) : t(plain); + // A task id is implementation identity, not a plain-language task. Keep + // unresolved follow-ups out of the specialized subagent presentation. + return info.taskId ? undefined : t(plain); } function sentenceCaseToolTitle(name: string): string { @@ -396,6 +410,7 @@ export function ToolCallAdapter({ toolName, subagentAgentName, subagentTaskLabel, + subagentTaskIsConfigured, arguments: args, status, locations, @@ -427,9 +442,16 @@ export function ToolCallAdapter({ () => getSubagentToolCallInfo({ toolName, arguments: args }), [toolName, args], ); - const displayName = subagentInfo - ? subagentTitle(t, subagentInfo, subagentAgentName, subagentTaskLabel) - : sentenceCaseToolTitle(name); + const displayName = + (subagentInfo + ? subagentTitle( + t, + subagentInfo, + subagentAgentName, + subagentTaskLabel, + subagentTaskIsConfigured, + ) + : undefined) ?? sentenceCaseToolTitle(name); const pathRow = summaryRows.find((row) => row.kind === "path"); const headerFileLabel = pathRow?.value; diff --git a/src/features/chat/ui/ToolChainCards.tsx b/src/features/chat/ui/ToolChainCards.tsx index 325e1f05b..cbeb6b12a 100644 --- a/src/features/chat/ui/ToolChainCards.tsx +++ b/src/features/chat/ui/ToolChainCards.tsx @@ -399,6 +399,7 @@ export function ToolChainCards({ const toolName = getToolItemToolName(item); const subagentAgentName = item.request?.subagentAgentName; const subagentTaskLabel = item.request?.subagentTaskLabel; + const subagentTaskIsConfigured = item.request?.subagentTaskIsConfigured; const status = getToolItemStatus(item); const { request, response } = item; const isOpen = !options.forceClose && expandedKeys.has(item.key); @@ -433,6 +434,7 @@ export function ToolChainCards({ toolName={toolName} subagentAgentName={subagentAgentName} subagentTaskLabel={subagentTaskLabel} + subagentTaskIsConfigured={subagentTaskIsConfigured} arguments={request?.arguments ?? {}} status={status} locations={request?.locations} @@ -466,6 +468,7 @@ export function ToolChainCards({ toolName={toolName} subagentAgentName={subagentAgentName} subagentTaskLabel={subagentTaskLabel} + subagentTaskIsConfigured={subagentTaskIsConfigured} arguments={request?.arguments ?? {}} status={status} locations={request?.locations} diff --git a/src/features/chat/ui/__tests__/ToolCallAdapter.test.tsx b/src/features/chat/ui/__tests__/ToolCallAdapter.test.tsx index 9d0c6840f..c230bc9e0 100644 --- a/src/features/chat/ui/__tests__/ToolCallAdapter.test.tsx +++ b/src/features/chat/ui/__tests__/ToolCallAdapter.test.tsx @@ -3,6 +3,8 @@ import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ArtifactLinkCandidate } from "@/features/chat/hooks/ArtifactPolicyContext"; import type { ToolCallLocation } from "@/shared/types/messages"; +import enChat from "@/shared/i18n/locales/en/chat.json"; +import esChat from "@/shared/i18n/locales/es/chat.json"; import { ToolCallAdapter } from "../ToolCallAdapter"; const mockResolveMarkdownHref = @@ -12,6 +14,24 @@ const mockOpenResolvedPath = vi.fn<(path: string) => Promise>(); const mockOpenInApp = vi.fn<(path: string, filename?: string) => Promise>(); +const subagentLocaleKeys = [ + "delegatingAgentConfiguredTask", + "waitingAgentConfiguredTask", + "checkingAgentConfiguredTask", + "cancellingAgentConfiguredTask", +] as const; + +describe("ToolCallAdapter — subagent locale parity", () => { + it("keeps every subagent law string in English and Spanish", () => { + const en = enChat.tools.subagent; + const es = esChat.tools.subagent; + for (const key of subagentLocaleKeys) { + expect(en[key], `English key ${key}`).toBeTruthy(); + expect(es[key], `Spanish key ${key}`).toBeTruthy(); + } + }); +}); + vi.mock("@/features/chat/hooks/ArtifactPolicyContext", () => ({ useArtifactActionsContext: () => ({ resolveMarkdownHref: mockResolveMarkdownHref, @@ -160,30 +180,34 @@ describe("ToolCallAdapter — subagent laws", () => { }); it.each([ - { - toolName: "delegate", - arguments: {}, - title: "Delegating an unspecified task to a subagent", - }, + { toolName: "delegate", arguments: {} }, { toolName: "Agent", arguments: { subagent_type: "code-reviewer" }, - title: "Delegating an unspecified task to code-reviewer", }, - { - toolName: "spawn_agent", - arguments: {}, - title: "Delegating an unspecified task to a subagent", - }, - ])("explicitly describes malformed $toolName activity as an unspecified task", ({ + { toolName: "spawn_agent", arguments: {} }, + ])("falls back to the ordinary tool title for malformed $toolName activity", ({ toolName, arguments: args, - title, }) => { renderAdapter({ name: toolName, toolName, arguments: args }); expect( - screen.getByRole("button", { name: new RegExp(title, "i") }), + screen.getByRole("button", { name: new RegExp(`^${toolName}$`, "i") }), + ).toBeInTheDocument(); + }); + + it("falls back to the ordinary tool title for unresolved async loads", () => { + renderAdapter({ + name: "Loading source 20260807_72", + toolName: "load", + arguments: { source: "20260807_72" }, + }); + + expect( + screen.getByRole("button", { + name: /Loading source 20260807_72/i, + }), ).toBeInTheDocument(); }); @@ -202,6 +226,22 @@ describe("ToolCallAdapter — subagent laws", () => { }), ).toBeInTheDocument(); }); + + it("retains a recovered configured task on async follow-ups", () => { + renderAdapter({ + name: "load", + toolName: "load", + subagentAgentName: "Rivet", + subagentTaskIsConfigured: true, + arguments: { source: "20260807_72", peek: true }, + }); + + expect( + screen.getByRole("button", { + name: /Checking Rivet’s configured task/i, + }), + ).toBeInTheDocument(); + }); }); describe("ToolCallAdapter — expanded body", () => { diff --git a/src/shared/i18n/locales/en/chat.json b/src/shared/i18n/locales/en/chat.json index 79191a79a..14a3b0cd5 100644 --- a/src/shared/i18n/locales/en/chat.json +++ b/src/shared/i18n/locales/en/chat.json @@ -584,7 +584,18 @@ "cancellingLabeled": "Cancelling an unspecified delegated task · subagent #{{label}}", "cancellingAgent": "Cancelling {{name}}’s unspecified delegated task", "cancellingAgentLabeled": "Cancelling {{name}} · {{label}}", - "delegatingAgentConfiguredTask": "Asking {{name}} to run its configured task" + "delegatingAgentConfiguredTask": "Asking {{name}} to run its configured task", + "delegatingTaskUnavailable": "Delegating a task (description unavailable)", + "delegatingAgentTaskUnavailable": "Delegating to {{name}} (task description unavailable)", + "waitingAgentConfiguredTask": "Waiting on {{name}}’s configured task", + "waitingTaskUnavailable": "Waiting on a delegated task (description unavailable)", + "waitingAgentTaskUnavailable": "Waiting on {{name}} (task description unavailable)", + "checkingAgentConfiguredTask": "Checking {{name}}’s configured task", + "checkingTaskUnavailable": "Checking a delegated task (description unavailable)", + "checkingAgentTaskUnavailable": "Checking on {{name}} (task description unavailable)", + "cancellingAgentConfiguredTask": "Cancelling {{name}}’s configured task", + "cancellingTaskUnavailable": "Cancelling a delegated task (description unavailable)", + "cancellingAgentTaskUnavailable": "Cancelling {{name}} (task description unavailable)" } }, "agent_work": { diff --git a/src/shared/i18n/locales/es/chat.json b/src/shared/i18n/locales/es/chat.json index 50d755f7f..47d563cf8 100644 --- a/src/shared/i18n/locales/es/chat.json +++ b/src/shared/i18n/locales/es/chat.json @@ -579,7 +579,18 @@ "cancellingLabeled": "Cancelando una tarea delegada no especificada · subagente #{{label}}", "cancellingAgent": "Cancelando la tarea delegada no especificada de {{name}}", "cancellingAgentLabeled": "Cancelando a {{name}} · {{label}}", - "delegatingAgentConfiguredTask": "Pidiendo a {{name}} que ejecute su tarea configurada" + "delegatingAgentConfiguredTask": "Pidiendo a {{name}} que ejecute su tarea configurada", + "delegatingTaskUnavailable": "Delegando una tarea (descripción no disponible)", + "delegatingAgentTaskUnavailable": "Delegando a {{name}} (descripción de la tarea no disponible)", + "waitingAgentConfiguredTask": "Esperando la tarea configurada de {{name}}", + "waitingTaskUnavailable": "Esperando una tarea delegada (descripción no disponible)", + "waitingAgentTaskUnavailable": "Esperando a {{name}} (descripción de la tarea no disponible)", + "checkingAgentConfiguredTask": "Comprobando la tarea configurada de {{name}}", + "checkingTaskUnavailable": "Comprobando una tarea delegada (descripción no disponible)", + "checkingAgentTaskUnavailable": "Comprobando a {{name}} (descripción de la tarea no disponible)", + "cancellingAgentConfiguredTask": "Cancelando la tarea configurada de {{name}}", + "cancellingTaskUnavailable": "Cancelando una tarea delegada (descripción no disponible)", + "cancellingAgentTaskUnavailable": "Cancelando a {{name}} (descripción de la tarea no disponible)" } }, "agent_work": { diff --git a/src/shared/types/messages.ts b/src/shared/types/messages.ts index e443e463a..68b25e773 100644 --- a/src/shared/types/messages.ts +++ b/src/shared/types/messages.ts @@ -129,6 +129,8 @@ export interface ToolRequestContent { subagentAgentName?: string; /** Plain-language task recovered from the spawning delegate. */ subagentTaskLabel?: string; + /** The named Goose source owns a configured task when no instructions were supplied. */ + subagentTaskIsConfigured?: boolean; } export interface ToolResponseContent { From de5ebab4c8ac3a3799dafe7d7101e17c14c73a0e Mon Sep 17 00:00:00 2001 From: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Mon, 10 Aug 2026 14:12:33 -0400 Subject: [PATCH 3/9] fix(chat): localize task-only async activity Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- src/features/chat/ui/ToolCallAdapter.tsx | 12 +++++- .../ui/__tests__/ToolCallAdapter.test.tsx | 38 ++++++++++++++++--- src/shared/i18n/locales/en/chat.json | 3 ++ src/shared/i18n/locales/es/chat.json | 3 ++ 4 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/features/chat/ui/ToolCallAdapter.tsx b/src/features/chat/ui/ToolCallAdapter.tsx index 4af1f9612..53a7666ed 100644 --- a/src/features/chat/ui/ToolCallAdapter.tsx +++ b/src/features/chat/ui/ToolCallAdapter.tsx @@ -442,8 +442,18 @@ export function ToolCallAdapter({ () => getSubagentToolCallInfo({ toolName, arguments: args }), [toolName, args], ); + // A task-id load is only specialized once its delegated task context has + // been recovered. Until then, retain the ordinary provider title rather + // than showing a task-id or an invented description. + const hasResolvedSubagentContext = Boolean( + subagentInfo && + (!subagentInfo.taskId || + subagentAgentName || + subagentTaskLabel || + subagentTaskIsConfigured), + ); const displayName = - (subagentInfo + (subagentInfo && hasResolvedSubagentContext ? subagentTitle( t, subagentInfo, diff --git a/src/features/chat/ui/__tests__/ToolCallAdapter.test.tsx b/src/features/chat/ui/__tests__/ToolCallAdapter.test.tsx index c230bc9e0..80380062a 100644 --- a/src/features/chat/ui/__tests__/ToolCallAdapter.test.tsx +++ b/src/features/chat/ui/__tests__/ToolCallAdapter.test.tsx @@ -14,12 +14,9 @@ const mockOpenResolvedPath = vi.fn<(path: string) => Promise>(); const mockOpenInApp = vi.fn<(path: string, filename?: string) => Promise>(); -const subagentLocaleKeys = [ - "delegatingAgentConfiguredTask", - "waitingAgentConfiguredTask", - "checkingAgentConfiguredTask", - "cancellingAgentConfiguredTask", -] as const; +const subagentLocaleKeys = Object.keys(enChat.tools.subagent) as Array< + keyof typeof enChat.tools.subagent +>; describe("ToolCallAdapter — subagent locale parity", () => { it("keeps every subagent law string in English and Spanish", () => { @@ -227,6 +224,35 @@ describe("ToolCallAdapter — subagent laws", () => { ).toBeInTheDocument(); }); + it.each([ + { + arguments: { source: "20260807_72" }, + title: "Waiting on a delegated task · Count markdown files", + }, + { + arguments: { source: "20260807_72", peek: true }, + title: "Checking on a delegated task · Count markdown files", + }, + { + arguments: { source: "20260807_72", cancel: true }, + title: "Cancelling a delegated task · Count markdown files", + }, + ])("describes task-only async $title activity", ({ + arguments: args, + title, + }) => { + renderAdapter({ + name: "load", + toolName: "load", + subagentTaskLabel: "Count markdown files", + arguments: args, + }); + + expect( + screen.getByRole("button", { name: new RegExp(title, "i") }), + ).toBeInTheDocument(); + }); + it("retains a recovered configured task on async follow-ups", () => { renderAdapter({ name: "load", diff --git a/src/shared/i18n/locales/en/chat.json b/src/shared/i18n/locales/en/chat.json index 14a3b0cd5..de2c3ec41 100644 --- a/src/shared/i18n/locales/en/chat.json +++ b/src/shared/i18n/locales/en/chat.json @@ -588,12 +588,15 @@ "delegatingTaskUnavailable": "Delegating a task (description unavailable)", "delegatingAgentTaskUnavailable": "Delegating to {{name}} (task description unavailable)", "waitingAgentConfiguredTask": "Waiting on {{name}}’s configured task", + "waitingTaskLabeled": "Waiting on a delegated task · {{label}}", "waitingTaskUnavailable": "Waiting on a delegated task (description unavailable)", "waitingAgentTaskUnavailable": "Waiting on {{name}} (task description unavailable)", "checkingAgentConfiguredTask": "Checking {{name}}’s configured task", + "checkingTaskLabeled": "Checking on a delegated task · {{label}}", "checkingTaskUnavailable": "Checking a delegated task (description unavailable)", "checkingAgentTaskUnavailable": "Checking on {{name}} (task description unavailable)", "cancellingAgentConfiguredTask": "Cancelling {{name}}’s configured task", + "cancellingTaskLabeled": "Cancelling a delegated task · {{label}}", "cancellingTaskUnavailable": "Cancelling a delegated task (description unavailable)", "cancellingAgentTaskUnavailable": "Cancelling {{name}} (task description unavailable)" } diff --git a/src/shared/i18n/locales/es/chat.json b/src/shared/i18n/locales/es/chat.json index 47d563cf8..03806331c 100644 --- a/src/shared/i18n/locales/es/chat.json +++ b/src/shared/i18n/locales/es/chat.json @@ -583,12 +583,15 @@ "delegatingTaskUnavailable": "Delegando una tarea (descripción no disponible)", "delegatingAgentTaskUnavailable": "Delegando a {{name}} (descripción de la tarea no disponible)", "waitingAgentConfiguredTask": "Esperando la tarea configurada de {{name}}", + "waitingTaskLabeled": "Esperando una tarea delegada · {{label}}", "waitingTaskUnavailable": "Esperando una tarea delegada (descripción no disponible)", "waitingAgentTaskUnavailable": "Esperando a {{name}} (descripción de la tarea no disponible)", "checkingAgentConfiguredTask": "Comprobando la tarea configurada de {{name}}", + "checkingTaskLabeled": "Comprobando una tarea delegada · {{label}}", "checkingTaskUnavailable": "Comprobando una tarea delegada (descripción no disponible)", "checkingAgentTaskUnavailable": "Comprobando a {{name}} (descripción de la tarea no disponible)", "cancellingAgentConfiguredTask": "Cancelando la tarea configurada de {{name}}", + "cancellingTaskLabeled": "Cancelando una tarea delegada · {{label}}", "cancellingTaskUnavailable": "Cancelando una tarea delegada (descripción no disponible)", "cancellingAgentTaskUnavailable": "Cancelando a {{name}} (descripción de la tarea no disponible)" } From 540b5bc08c65684a9e65cbdad56a8b8eb99545d3 Mon Sep 17 00:00:00 2001 From: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Mon, 10 Aug 2026 14:31:48 -0400 Subject: [PATCH 4/9] fix(chat): preserve known subagent provenance Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- LAWS/CHAT.md | 2 +- .../lib/__tests__/subagentToolCalls.test.ts | 20 ++++--- src/features/chat/lib/subagentToolCalls.ts | 15 ++---- src/features/chat/ui/ToolCallAdapter.tsx | 37 +++++-------- .../ui/__tests__/ToolCallAdapter.test.tsx | 53 ++++++++++++++++--- src/shared/i18n/locales/en/chat.json | 32 +++++------ src/shared/i18n/locales/es/chat.json | 32 +++++------ 7 files changed, 100 insertions(+), 91 deletions(-) diff --git a/LAWS/CHAT.md b/LAWS/CHAT.md index 8efd458dc..e9cc48154 100644 --- a/LAWS/CHAT.md +++ b/LAWS/CHAT.md @@ -34,4 +34,4 @@ ## Subagent activity - Subagent activity MUST attribute the subagent when its identity is known. -- Subagent activity MUST describe the delegated task in plain language. +- Subagent activity MUST describe the delegated task when it is known. diff --git a/src/features/chat/lib/__tests__/subagentToolCalls.test.ts b/src/features/chat/lib/__tests__/subagentToolCalls.test.ts index ed1b06b7b..b0e535257 100644 --- a/src/features/chat/lib/__tests__/subagentToolCalls.test.ts +++ b/src/features/chat/lib/__tests__/subagentToolCalls.test.ts @@ -67,10 +67,10 @@ describe("getSubagentToolCallInfo", () => { expect(info?.label?.endsWith("…")).toBe(true); }); - it("does not classify a delegate with no task boundary", () => { + it("classifies a delegate even when its task is unknown", () => { expect( getSubagentToolCallInfo({ toolName: "delegate", arguments: {} }), - ).toBeUndefined(); + ).toEqual({ activity: "delegating" }); }); }); @@ -148,7 +148,7 @@ describe("getSubagentToolCallInfo", () => { }); }); - it("does not classify an agent when description is absent", () => { + it("uses prompt as the known task when description is absent", () => { expect( getSubagentToolCallInfo({ toolName: "Agent", @@ -157,16 +157,20 @@ describe("getSubagentToolCallInfo", () => { prompt: "Review the authentication boundary", }, }), - ).toBeUndefined(); + ).toEqual({ + activity: "delegating", + agentName: "code-reviewer", + label: "Review the authentication boundary", + }); }); - it("does not classify a named agent without a task description", () => { + it("retains known identity when the task is unknown", () => { expect( getSubagentToolCallInfo({ toolName: "Agent", arguments: { subagent_type: "code-reviewer" }, }), - ).toBeUndefined(); + ).toEqual({ activity: "delegating", agentName: "code-reviewer" }); }); }); @@ -360,13 +364,13 @@ describe("getSubagentToolCallInfo", () => { label: "Investigate the failing tests", }); }); - it("does not classify spawn_agent without a prompt", () => { + it("classifies spawn_agent when its task is unknown", () => { expect( getSubagentToolCallInfo({ toolName: "spawn_agent", arguments: {}, }), - ).toBeUndefined(); + ).toEqual({ activity: "delegating" }); }); }); }); diff --git a/src/features/chat/lib/subagentToolCalls.ts b/src/features/chat/lib/subagentToolCalls.ts index a2a0a3f65..076681b9a 100644 --- a/src/features/chat/lib/subagentToolCalls.ts +++ b/src/features/chat/lib/subagentToolCalls.ts @@ -175,11 +175,8 @@ export function getSubagentToolCallInfo(input: { if (toolName === "delegate") { const agentName = stringArg(args, "source"); const label = stringArg(args, "instructions"); - // A named Goose source owns a configured task, so source-only delegates - // still have a coherent task boundary. An anonymous/descriptionless - // delegate does not: keep it out of subagent rendering rather than - // inventing a task description. - if (!agentName && !label) return undefined; + // A named Goose source owns a configured task even when no inline + // instructions are present. Unknown task facts remain absent. return { activity: "delegating", ...(agentName ? { agentName: agentName.trim() } : {}), @@ -207,24 +204,22 @@ export function getSubagentToolCallInfo(input: { // configured agent; description is the task. if (toolName === "Task" || toolName === "Agent") { const agentName = stringArg(args, "subagent_type"); - const label = stringArg(args, "description"); - if (!label) return undefined; + const label = stringArg(args, "description") ?? stringArg(args, "prompt"); return { activity: "delegating", ...(agentName && agentName !== "general-purpose" ? { agentName: agentName.trim() } : {}), - label: truncateLabel(label), + ...(label ? { label: truncateLabel(label) } : {}), }; } // Codex: spawn_agent collaboration tool. if (toolName === "spawn_agent") { const label = stringArg(args, "prompt"); - if (!label) return undefined; return { activity: "delegating", - label: truncateLabel(label), + ...(label ? { label: truncateLabel(label) } : {}), }; } diff --git a/src/features/chat/ui/ToolCallAdapter.tsx b/src/features/chat/ui/ToolCallAdapter.tsx index 53a7666ed..a267a8b8a 100644 --- a/src/features/chat/ui/ToolCallAdapter.tsx +++ b/src/features/chat/ui/ToolCallAdapter.tsx @@ -318,7 +318,7 @@ function subagentTitle( resolvedAgentName?: string, resolvedTaskLabel?: string, resolvedTaskIsConfigured?: boolean, -): string | undefined { +): string { // Explicit key map keeps the i18n usage statically checkable. const keys = { delegating: [ @@ -377,9 +377,9 @@ function subagentTitle( ? t(taskLabeledKeys[info.activity], { label: taskLabel }) : t(labeled, { label: taskLabel }); } - // A task id is implementation identity, not a plain-language task. Keep - // unresolved follow-ups out of the specialized subagent presentation. - return info.taskId ? undefined : t(plain); + // A task id is correlation identity, not a task description. When no + // delegate context can be recovered, show only the known activity fact. + return t(plain); } function sentenceCaseToolTitle(name: string): string { @@ -442,26 +442,15 @@ export function ToolCallAdapter({ () => getSubagentToolCallInfo({ toolName, arguments: args }), [toolName, args], ); - // A task-id load is only specialized once its delegated task context has - // been recovered. Until then, retain the ordinary provider title rather - // than showing a task-id or an invented description. - const hasResolvedSubagentContext = Boolean( - subagentInfo && - (!subagentInfo.taskId || - subagentAgentName || - subagentTaskLabel || - subagentTaskIsConfigured), - ); - const displayName = - (subagentInfo && hasResolvedSubagentContext - ? subagentTitle( - t, - subagentInfo, - subagentAgentName, - subagentTaskLabel, - subagentTaskIsConfigured, - ) - : undefined) ?? sentenceCaseToolTitle(name); + const displayName = subagentInfo + ? subagentTitle( + t, + subagentInfo, + subagentAgentName, + subagentTaskLabel, + subagentTaskIsConfigured, + ) + : sentenceCaseToolTitle(name); const pathRow = summaryRows.find((row) => row.kind === "path"); const headerFileLabel = pathRow?.value; diff --git a/src/features/chat/ui/__tests__/ToolCallAdapter.test.tsx b/src/features/chat/ui/__tests__/ToolCallAdapter.test.tsx index 80380062a..2dab3675a 100644 --- a/src/features/chat/ui/__tests__/ToolCallAdapter.test.tsx +++ b/src/features/chat/ui/__tests__/ToolCallAdapter.test.tsx @@ -177,24 +177,34 @@ describe("ToolCallAdapter — subagent laws", () => { }); it.each([ - { toolName: "delegate", arguments: {} }, + { + toolName: "delegate", + arguments: {}, + title: "Delegating to a subagent", + }, { toolName: "Agent", arguments: { subagent_type: "code-reviewer" }, + title: "Delegating to code-reviewer", }, - { toolName: "spawn_agent", arguments: {} }, - ])("falls back to the ordinary tool title for malformed $toolName activity", ({ + { + toolName: "spawn_agent", + arguments: {}, + title: "Delegating to a subagent", + }, + ])("does not fabricate an unknown task for $toolName activity", ({ toolName, arguments: args, + title, }) => { renderAdapter({ name: toolName, toolName, arguments: args }); expect( - screen.getByRole("button", { name: new RegExp(`^${toolName}$`, "i") }), + screen.getByRole("button", { name: new RegExp(`^${title}$`, "i") }), ).toBeInTheDocument(); }); - it("falls back to the ordinary tool title for unresolved async loads", () => { + it("does not expose a task id as an unknown task description", () => { renderAdapter({ name: "Loading source 20260807_72", toolName: "load", @@ -203,9 +213,10 @@ describe("ToolCallAdapter — subagent laws", () => { expect( screen.getByRole("button", { - name: /Loading source 20260807_72/i, + name: /^Waiting on a subagent$/i, }), ).toBeInTheDocument(); + expect(screen.queryByText(/20260807_72/)).not.toBeInTheDocument(); }); it("retains recovered identity and task on async follow-ups", () => { @@ -253,6 +264,33 @@ describe("ToolCallAdapter — subagent laws", () => { ).toBeInTheDocument(); }); + it.each([ + { + arguments: { source: "20260807_72" }, + title: "Waiting on Rivet", + }, + { + arguments: { source: "20260807_72", peek: true }, + title: "Checking on Rivet", + }, + { + arguments: { source: "20260807_72", cancel: true }, + title: "Cancelling Rivet", + }, + ])("attributes agent-only async $title activity without inventing a task", ({ + arguments: args, + title, + }) => { + renderAdapter({ + name: "load", + toolName: "load", + subagentAgentName: "Rivet", + arguments: args, + }); + + expect( + screen.getByRole("button", { name: new RegExp(`^${title}$`, "i") }), + ).toBeInTheDocument(); it("retains a recovered configured task on async follow-ups", () => { renderAdapter({ name: "load", @@ -268,9 +306,8 @@ describe("ToolCallAdapter — subagent laws", () => { }), ).toBeInTheDocument(); }); -}); -describe("ToolCallAdapter — expanded body", () => { + it("renders the tool name and status badge in the header", () => { renderAdapter(); const header = screen.getByRole("button", { name: /Write_file/i }); diff --git a/src/shared/i18n/locales/en/chat.json b/src/shared/i18n/locales/en/chat.json index de2c3ec41..26e7ff50d 100644 --- a/src/shared/i18n/locales/en/chat.json +++ b/src/shared/i18n/locales/en/chat.json @@ -568,37 +568,29 @@ "structuredOutput": "Structured output", "structuredOutputLines": "{{count}} lines", "subagent": { - "delegating": "Delegating an unspecified task to a subagent", + "delegating": "Delegating to a subagent", "delegatingLabeled": "Delegating to a subagent · {{label}}", - "delegatingAgent": "Delegating an unspecified task to {{name}}", + "delegatingAgent": "Delegating to {{name}}", "delegatingAgentLabeled": "Delegating to {{name}} · {{label}}", - "waiting": "Waiting on a subagent’s unspecified delegated task", - "waitingLabeled": "Waiting on an unspecified delegated task · subagent #{{label}}", - "waitingAgent": "Waiting on {{name}}’s unspecified delegated task", + "waiting": "Waiting on a subagent", + "waitingLabeled": "Waiting on subagent #{{label}}", + "waitingAgent": "Waiting on {{name}}", "waitingAgentLabeled": "Waiting on {{name}} · {{label}}", - "checking": "Checking a subagent’s unspecified delegated task", - "checkingLabeled": "Checking an unspecified delegated task · subagent #{{label}}", - "checkingAgent": "Checking {{name}}’s unspecified delegated task", + "checking": "Checking on a subagent", + "checkingLabeled": "Checking on subagent #{{label}}", + "checkingAgent": "Checking on {{name}}", "checkingAgentLabeled": "Checking on {{name}} · {{label}}", - "cancelling": "Cancelling a subagent’s unspecified delegated task", - "cancellingLabeled": "Cancelling an unspecified delegated task · subagent #{{label}}", - "cancellingAgent": "Cancelling {{name}}’s unspecified delegated task", + "cancelling": "Cancelling a subagent", + "cancellingLabeled": "Cancelling subagent #{{label}}", + "cancellingAgent": "Cancelling {{name}}", "cancellingAgentLabeled": "Cancelling {{name}} · {{label}}", "delegatingAgentConfiguredTask": "Asking {{name}} to run its configured task", - "delegatingTaskUnavailable": "Delegating a task (description unavailable)", - "delegatingAgentTaskUnavailable": "Delegating to {{name}} (task description unavailable)", "waitingAgentConfiguredTask": "Waiting on {{name}}’s configured task", "waitingTaskLabeled": "Waiting on a delegated task · {{label}}", - "waitingTaskUnavailable": "Waiting on a delegated task (description unavailable)", - "waitingAgentTaskUnavailable": "Waiting on {{name}} (task description unavailable)", "checkingAgentConfiguredTask": "Checking {{name}}’s configured task", "checkingTaskLabeled": "Checking on a delegated task · {{label}}", - "checkingTaskUnavailable": "Checking a delegated task (description unavailable)", - "checkingAgentTaskUnavailable": "Checking on {{name}} (task description unavailable)", "cancellingAgentConfiguredTask": "Cancelling {{name}}’s configured task", - "cancellingTaskLabeled": "Cancelling a delegated task · {{label}}", - "cancellingTaskUnavailable": "Cancelling a delegated task (description unavailable)", - "cancellingAgentTaskUnavailable": "Cancelling {{name}} (task description unavailable)" + "cancellingTaskLabeled": "Cancelling a delegated task · {{label}}" } }, "agent_work": { diff --git a/src/shared/i18n/locales/es/chat.json b/src/shared/i18n/locales/es/chat.json index 03806331c..4b760a2fd 100644 --- a/src/shared/i18n/locales/es/chat.json +++ b/src/shared/i18n/locales/es/chat.json @@ -563,37 +563,29 @@ "structuredOutput": "Salida estructurada", "structuredOutputLines": "{{count}} líneas", "subagent": { - "delegating": "Delegando una tarea no especificada a un subagente", + "delegating": "Delegando a un subagente", "delegatingLabeled": "Delegando a un subagente · {{label}}", - "delegatingAgent": "Delegando una tarea no especificada a {{name}}", + "delegatingAgent": "Delegando a {{name}}", "delegatingAgentLabeled": "Delegando a {{name}} · {{label}}", - "waiting": "Esperando la tarea delegada no especificada de un subagente", - "waitingLabeled": "Esperando una tarea delegada no especificada · subagente #{{label}}", - "waitingAgent": "Esperando la tarea delegada no especificada de {{name}}", + "waiting": "Esperando a un subagente", + "waitingLabeled": "Esperando al subagente n.º {{label}}", + "waitingAgent": "Esperando a {{name}}", "waitingAgentLabeled": "Esperando a {{name}} · {{label}}", - "checking": "Comprobando la tarea delegada no especificada de un subagente", - "checkingLabeled": "Comprobando una tarea delegada no especificada · subagente #{{label}}", - "checkingAgent": "Comprobando la tarea delegada no especificada de {{name}}", + "checking": "Consultando a un subagente", + "checkingLabeled": "Consultando al subagente n.º {{label}}", + "checkingAgent": "Consultando a {{name}}", "checkingAgentLabeled": "Consultando a {{name}} · {{label}}", - "cancelling": "Cancelando la tarea delegada no especificada de un subagente", - "cancellingLabeled": "Cancelando una tarea delegada no especificada · subagente #{{label}}", - "cancellingAgent": "Cancelando la tarea delegada no especificada de {{name}}", + "cancelling": "Cancelando un subagente", + "cancellingLabeled": "Cancelando al subagente n.º {{label}}", + "cancellingAgent": "Cancelando a {{name}}", "cancellingAgentLabeled": "Cancelando a {{name}} · {{label}}", "delegatingAgentConfiguredTask": "Pidiendo a {{name}} que ejecute su tarea configurada", - "delegatingTaskUnavailable": "Delegando una tarea (descripción no disponible)", - "delegatingAgentTaskUnavailable": "Delegando a {{name}} (descripción de la tarea no disponible)", "waitingAgentConfiguredTask": "Esperando la tarea configurada de {{name}}", "waitingTaskLabeled": "Esperando una tarea delegada · {{label}}", - "waitingTaskUnavailable": "Esperando una tarea delegada (descripción no disponible)", - "waitingAgentTaskUnavailable": "Esperando a {{name}} (descripción de la tarea no disponible)", "checkingAgentConfiguredTask": "Comprobando la tarea configurada de {{name}}", "checkingTaskLabeled": "Comprobando una tarea delegada · {{label}}", - "checkingTaskUnavailable": "Comprobando una tarea delegada (descripción no disponible)", - "checkingAgentTaskUnavailable": "Comprobando a {{name}} (descripción de la tarea no disponible)", "cancellingAgentConfiguredTask": "Cancelando la tarea configurada de {{name}}", - "cancellingTaskLabeled": "Cancelando una tarea delegada · {{label}}", - "cancellingTaskUnavailable": "Cancelando una tarea delegada (descripción no disponible)", - "cancellingAgentTaskUnavailable": "Cancelando a {{name}} (descripción de la tarea no disponible)" + "cancellingTaskLabeled": "Cancelando una tarea delegada · {{label}}" } }, "agent_work": { From 604f2ccfba05884a9643e530858279ca5cabbaaa Mon Sep 17 00:00:00 2001 From: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Mon, 10 Aug 2026 14:34:40 -0400 Subject: [PATCH 5/9] test(chat): close subagent renderer suite Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- src/features/chat/ui/__tests__/ToolCallAdapter.test.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/features/chat/ui/__tests__/ToolCallAdapter.test.tsx b/src/features/chat/ui/__tests__/ToolCallAdapter.test.tsx index 2dab3675a..cc54f5dac 100644 --- a/src/features/chat/ui/__tests__/ToolCallAdapter.test.tsx +++ b/src/features/chat/ui/__tests__/ToolCallAdapter.test.tsx @@ -291,6 +291,8 @@ describe("ToolCallAdapter — subagent laws", () => { expect( screen.getByRole("button", { name: new RegExp(`^${title}$`, "i") }), ).toBeInTheDocument(); + }); + it("retains a recovered configured task on async follow-ups", () => { renderAdapter({ name: "load", @@ -306,8 +308,9 @@ describe("ToolCallAdapter — subagent laws", () => { }), ).toBeInTheDocument(); }); +}); - +describe("ToolCallAdapter — expanded body", () => { it("renders the tool name and status badge in the header", () => { renderAdapter(); const header = screen.getByRole("button", { name: /Write_file/i }); From 245778fcf43ee10eaa2d9203d267e2eb299c511e Mon Sep 17 00:00:00 2001 From: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Thu, 13 Aug 2026 10:07:24 -0400 Subject: [PATCH 6/9] fix(chat): invalidate rows for subagent provenance Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- .../transcript/projection/messageRevisions.ts | 6 ++++ .../transcriptProjectionCache.test.ts | 32 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/src/features/chat/transcript/projection/messageRevisions.ts b/src/features/chat/transcript/projection/messageRevisions.ts index 553f3c54c..46593b442 100644 --- a/src/features/chat/transcript/projection/messageRevisions.ts +++ b/src/features/chat/transcript/projection/messageRevisions.ts @@ -275,6 +275,9 @@ function toolRequestRenderRevision(content: ToolRequestContent): string { content.extensionName ?? "", content.status, content.toolKind ?? "", + content.subagentAgentName ?? "", + content.subagentTaskLabel ?? "", + String(content.subagentTaskIsConfigured ?? false), stableValueRevision(content.arguments), stableValueRevision(content.locations ?? []), String(content.startedAt ?? ""), @@ -292,6 +295,9 @@ function toolRequestHeightRevision(content: ToolRequestContent): string { content.extensionName ?? "", content.status, content.toolKind ?? "", + content.subagentAgentName ?? "", + content.subagentTaskLabel ?? "", + String(content.subagentTaskIsConfigured ?? false), stableValueRevision(content.arguments), stableValueRevision(content.locations ?? []), stableValueRevision(content.chainSummary ?? null), diff --git a/src/features/chat/transcript/projection/transcriptProjectionCache.test.ts b/src/features/chat/transcript/projection/transcriptProjectionCache.test.ts index dc95fb291..8001e1c2e 100644 --- a/src/features/chat/transcript/projection/transcriptProjectionCache.test.ts +++ b/src/features/chat/transcript/projection/transcriptProjectionCache.test.ts @@ -1460,6 +1460,38 @@ describe("transcript projection cache", () => { expect(second.heightRevision).not.toBe(first.heightRevision); }); + it.each([ + ["agent identity", { subagentAgentName: "Rivet" }], + ["task description", { subagentTaskLabel: "Count markdown files" }], + ["configured task", { subagentTaskIsConfigured: true }], + ] satisfies Array< + [string, Partial] + >)("invalidates tool rows for provenance-only %s updates", (_label, provenance) => { + const originalRequest: ToolRequestContent = { + type: "toolRequest", + id: "tool-1", + name: "load", + arguments: { task_id: "20260807_72" }, + status: "pending", + }; + const original = messageWithContent( + "assistant-1", + "assistant", + [originalRequest], + utc(2026, 6, 4, 10), + ); + const updated = { + ...original, + content: [{ ...originalRequest, ...provenance }], + }; + + const before = buildMessageRevisions(original); + const after = buildMessageRevisions(updated); + + expect(after.renderRevision).not.toBe(before.renderRevision); + expect(after.heightRevision).not.toBe(before.heightRevision); + }); + it("classifies active tool rows as estimate-only keepalive candidates", () => { const cache = createTranscriptProjectionCache(); const toolRequest: ToolRequestContent = { From f5ed095e88649fa5aef6a4f69fb8c096e6f4b3d6 Mon Sep 17 00:00:00 2001 From: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Thu, 13 Aug 2026 11:34:23 -0400 Subject: [PATCH 7/9] fix(chat): map Codex subagent provenance Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- .../lib/__tests__/subagentToolCalls.test.ts | 36 +++++++++++++++++-- src/features/chat/lib/subagentToolCalls.ts | 8 +++-- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/features/chat/lib/__tests__/subagentToolCalls.test.ts b/src/features/chat/lib/__tests__/subagentToolCalls.test.ts index b0e535257..0873cbf55 100644 --- a/src/features/chat/lib/__tests__/subagentToolCalls.test.ts +++ b/src/features/chat/lib/__tests__/subagentToolCalls.test.ts @@ -353,7 +353,23 @@ describe("getSubagentToolCallInfo", () => { }); describe("codex spawn_agent", () => { - it("classifies spawn_agent with a prompt label", () => { + it("preserves Codex agent identity and delegated task", () => { + expect( + getSubagentToolCallInfo({ + toolName: "spawn_agent", + arguments: { + task_name: "Rivet", + message: "Investigate the failing tests", + }, + }), + ).toEqual({ + activity: "delegating", + agentName: "Rivet", + label: "Investigate the failing tests", + }); + }); + + it("falls back to the legacy prompt label", () => { expect( getSubagentToolCallInfo({ toolName: "spawn_agent", @@ -364,7 +380,23 @@ describe("getSubagentToolCallInfo", () => { label: "Investigate the failing tests", }); }); - it("classifies spawn_agent when its task is unknown", () => { + + it("prefers the Codex message when both task fields are present", () => { + expect( + getSubagentToolCallInfo({ + toolName: "spawn_agent", + arguments: { + message: "Use the collaboration task", + prompt: "Legacy fallback", + }, + }), + ).toEqual({ + activity: "delegating", + label: "Use the collaboration task", + }); + }); + + it("classifies spawn_agent when its provenance is unknown", () => { expect( getSubagentToolCallInfo({ toolName: "spawn_agent", diff --git a/src/features/chat/lib/subagentToolCalls.ts b/src/features/chat/lib/subagentToolCalls.ts index 076681b9a..d257f26ee 100644 --- a/src/features/chat/lib/subagentToolCalls.ts +++ b/src/features/chat/lib/subagentToolCalls.ts @@ -214,11 +214,15 @@ export function getSubagentToolCallInfo(input: { }; } - // Codex: spawn_agent collaboration tool. + // Codex: spawn_agent collaboration tool. `task_name` identifies the spawned + // agent in the collaboration protocol, while `message` is its delegated + // task. Keep `prompt` as a compatibility fallback for older adapters. if (toolName === "spawn_agent") { - const label = stringArg(args, "prompt"); + const agentName = stringArg(args, "task_name"); + const label = stringArg(args, "message") ?? stringArg(args, "prompt"); return { activity: "delegating", + ...(agentName ? { agentName: agentName.trim() } : {}), ...(label ? { label: truncateLabel(label) } : {}), }; } From 4e77de3d1c462ba20b17d0f59cb223169ad5d0cb Mon Sep 17 00:00:00 2001 From: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Thu, 13 Aug 2026 12:21:01 -0400 Subject: [PATCH 8/9] fix(chat): preserve Codex subagent lifecycle context Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- .../__tests__/acpNotificationHandler.test.ts | 87 ++++++++ .../chat/acp/acpNotificationHandler.ts | 53 +++-- .../lib/__tests__/subagentToolCalls.test.ts | 202 ++++++++++-------- src/features/chat/lib/subagentToolCalls.ts | 103 +++++++-- .../ui/__tests__/ToolCallAdapter.test.tsx | 17 ++ 5 files changed, 339 insertions(+), 123 deletions(-) diff --git a/src/features/chat/acp/__tests__/acpNotificationHandler.test.ts b/src/features/chat/acp/__tests__/acpNotificationHandler.test.ts index a10bde123..f4865167f 100644 --- a/src/features/chat/acp/__tests__/acpNotificationHandler.test.ts +++ b/src/features/chat/acp/__tests__/acpNotificationHandler.test.ts @@ -1484,6 +1484,93 @@ describe("acpNotificationHandler", () => { }); }); + it("retains codex-acp wire provenance on the rendered tool request", async () => { + registerPreparedSession("acp-session", "codex", "/Users/test"); + setActiveMessageId("acp-session", "assistant-1"); + + await handleSessionNotification({ + sessionId: "acp-session", + update: { + sessionUpdate: "tool_call", + toolCallId: "followup-1", + title: "Sending follow-up", + rawInput: { + prompt: "Re-check the cache boundary", + senderThreadId: "root", + receiverThreadIds: ["/root/reviewer"], + agentsStates: {}, + model: "gpt-5", + reasoningEffort: "medium", + status: "running", + }, + _meta: { codex: { collaboration: { tool: "followup_task" } } }, + }, + } as never); + + const [message] = useChatStore.getState().messagesBySession["acp-session"]; + expect(message.content[0]).toMatchObject({ + type: "toolRequest", + id: "followup-1", + toolName: "followup_task", + arguments: { + prompt: "Re-check the cache boundary", + receiverThreadIds: ["/root/reviewer"], + }, + subagentAgentName: "/root/reviewer", + subagentTaskLabel: "Re-check the cache boundary", + }); + }); + + it.each([ + "live", + "replay", + ] as const)("retains codex-acp provenance when identity arrives late in %s", async (mode) => { + const sessionId = "acp-session"; + if (mode === "live") { + registerPreparedSession(sessionId, "codex", "/Users/test"); + setActiveMessageId(sessionId, "assistant-1"); + } else { + markSessionReplayLoading(sessionId); + } + + await handleSessionNotification({ + sessionId, + update: { + sessionUpdate: "tool_call", + toolCallId: "followup-1", + title: "Sending follow-up", + rawInput: { + prompt: "Re-check the cache boundary", + receiverThreadIds: ["/root/reviewer"], + }, + }, + } as never); + await handleSessionNotification({ + sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: "followup-1", + _meta: { codex: { collaboration: { tool: "followup_task" } } }, + }, + } as never); + + const messages = + mode === "live" + ? useChatStore.getState().messagesBySession[sessionId] + : getReplayBuffer(sessionId); + const request = messages + ?.flatMap((message) => message.content) + .find( + (block) => block.type === "toolRequest" && block.id === "followup-1", + ); + expect(request).toMatchObject({ + type: "toolRequest", + toolName: "followup_task", + subagentAgentName: "/root/reviewer", + subagentTaskLabel: "Re-check the cache boundary", + }); + }); + it("preserves ACP tool kind and locations on tool requests", async () => { registerPreparedSession("acp-session", "goose", "/Users/test"); setActiveMessageId("acp-session", "assistant-1"); diff --git a/src/features/chat/acp/acpNotificationHandler.ts b/src/features/chat/acp/acpNotificationHandler.ts index de606fda4..8474e1619 100644 --- a/src/features/chat/acp/acpNotificationHandler.ts +++ b/src/features/chat/acp/acpNotificationHandler.ts @@ -56,7 +56,10 @@ import { getToolCallIdentity, getToolChainSummary, } from "@/shared/api/acpToolCallIdentity"; -import { resolveSubagentContext } from "@/features/chat/lib/subagentToolCalls"; +import { + getSubagentToolCallContext, + resolveSubagentContext, +} from "@/features/chat/lib/subagentToolCalls"; import { applyChatSessionConfigOptionsSnapshot } from "./sessionConfigSnapshotAdapter"; import { perfLog } from "@/shared/lib/perfLog"; import { @@ -431,11 +434,13 @@ function handleReplay(sessionId: string, update: SessionUpdate): void { getReplayAssistantMessageMetadata(sessionId, update), ); const replayArguments = rawInputToArguments(update.rawInput); - const replaySubagentContext = resolveSubagentContext( - identity.toolName, - replayArguments, - getReplayBuffer(sessionId) ?? [], - ); + const replaySubagentContext = + getSubagentToolCallContext(identity.toolName, replayArguments) ?? + resolveSubagentContext( + identity.toolName, + replayArguments, + getReplayBuffer(sessionId) ?? [], + ); msg.content.push({ type: "toolRequest", id: update.toolCallId, @@ -499,11 +504,13 @@ function handleReplay(sessionId: string, update: SessionUpdate): void { (tc.subagentAgentName === undefined || tc.subagentTaskLabel === undefined) ) { - const lateContext = resolveSubagentContext( - tc.toolName, - tc.arguments, - getReplayBuffer(sessionId) ?? [], - ); + const lateContext = + getSubagentToolCallContext(tc.toolName, tc.arguments) ?? + resolveSubagentContext( + tc.toolName, + tc.arguments, + getReplayBuffer(sessionId) ?? [], + ); if (lateContext) Object.assign(tc, lateContext); } } @@ -632,11 +639,13 @@ function handleLive(sessionId: string, update: SessionUpdate): void { const chainSummary = getToolChainSummary(update); const liveArguments = rawInputToArguments(update.rawInput); - const liveSubagentContext = resolveSubagentContext( - identity.toolName, - liveArguments, - useChatStore.getState().messagesBySession[sessionId] ?? [], - ); + const liveSubagentContext = + getSubagentToolCallContext(identity.toolName, liveArguments) ?? + resolveSubagentContext( + identity.toolName, + liveArguments, + useChatStore.getState().messagesBySession[sessionId] ?? [], + ); const toolRequest: ToolRequestContent = { type: "toolRequest", id: update.toolCallId, @@ -678,13 +687,17 @@ function handleLive(sessionId: string, update: SessionUpdate): void { // The wire tool name can arrive after the initial tool_call // (identity patched in by a later update); resolve the subagent // label now that we know what the tool is. + const storedArguments = identity.toolName + ? (findLiveToolRequest(sessionId, messageId, update.toolCallId) + ?.arguments ?? {}) + : {}; const lateSubagentContext = identity.toolName - ? resolveSubagentContext( + ? (getSubagentToolCallContext(identity.toolName, storedArguments) ?? + resolveSubagentContext( identity.toolName, - findLiveToolRequest(sessionId, messageId, update.toolCallId) - ?.arguments ?? {}, + storedArguments, useChatStore.getState().messagesBySession[sessionId] ?? [], - ) + )) : undefined; store.updateMessage(sessionId, messageId, (msg) => ({ ...msg, diff --git a/src/features/chat/lib/__tests__/subagentToolCalls.test.ts b/src/features/chat/lib/__tests__/subagentToolCalls.test.ts index 0873cbf55..921f96678 100644 --- a/src/features/chat/lib/__tests__/subagentToolCalls.test.ts +++ b/src/features/chat/lib/__tests__/subagentToolCalls.test.ts @@ -2,9 +2,7 @@ import { describe, expect, it } from "vitest"; import { getSubagentToolCallInfo, resolveDelegateContextForTask, - resolveDelegateSourceForTask, resolveSubagentContext, - shortTaskId, } from "@/features/chat/lib/subagentToolCalls"; import type { MessageContent } from "@/shared/types/messages"; @@ -174,7 +172,7 @@ describe("getSubagentToolCallInfo", () => { }); }); - describe("resolveDelegateSourceForTask", () => { + describe("resolveDelegateContextForTask", () => { const transcript = ( blocks: MessageContent[][], ): Array<{ content: MessageContent[] }> => @@ -205,65 +203,40 @@ describe("getSubagentToolCallInfo", () => { isError: false, }); - it("resolves the source of the delegate that announced the task id", () => { + it("retains both identity and task for async follow-ups", () => { const messages = transcript([ [ - delegateRequest("call-1", { source: "Rivet", async: true }), + delegateRequest("call-1", { + source: "Rivet", + instructions: "Count markdown files", + async: true, + }), delegateResponse( "call-1", - 'Task 20260807_119 started in background: "count files"', - ), - ], - [ - delegateRequest("call-2", { source: "Vogue", async: true }), - delegateResponse( - "call-2", - 'Task 20260807_120 started in background: "read readme"', + 'Task 20260807_119 started in background: "Count markdown files"', ), ], ]); - expect(resolveDelegateSourceForTask(messages, "20260807_119")).toBe( - "Rivet", - ); - expect(resolveDelegateSourceForTask(messages, "20260807_120")).toBe( - "Vogue", - ); - }); - - it("finds the task id in structured content", () => { - const messages = transcript([ - [ - delegateRequest("call-1", { source: "Trace", async: true }), - delegateResponse("call-1", "started", { - subagent_session_id: "20260807_119", - }), - ], - ]); - expect(resolveDelegateSourceForTask(messages, "20260807_119")).toBe( - "Trace", - ); + expect(resolveDelegateContextForTask(messages, "20260807_119")).toEqual({ + subagentAgentName: "Rivet", + subagentTaskLabel: "Count markdown files", + }); }); - it("does not match a task id that is a prefix of another (7 vs 72)", () => { + it("retains a named source's configured task for async follow-ups", () => { const messages = transcript([ [ delegateRequest("call-1", { source: "Rivet", async: true }), - delegateResponse( - "call-1", - 'Task 20260807_72 started in background: "count files"', - ), + delegateResponse("call-1", "Task 20260807_120 started in background"), ], ]); - // 20260807_7 is a prefix of 20260807_72; it must NOT resolve to Rivet. - expect( - resolveDelegateSourceForTask(messages, "20260807_7"), - ).toBeUndefined(); - expect(resolveDelegateSourceForTask(messages, "20260807_72")).toBe( - "Rivet", - ); + expect(resolveDelegateContextForTask(messages, "20260807_120")).toEqual({ + subagentAgentName: "Rivet", + subagentTaskIsConfigured: true, + }); }); - it("does not prefix-match inside structured content", () => { + it("finds the exact task id in structured content", () => { const messages = transcript([ [ delegateRequest("call-1", { source: "Trace", async: true }), @@ -272,45 +245,35 @@ describe("getSubagentToolCallInfo", () => { }), ], ]); + expect(resolveDelegateContextForTask(messages, "20260807_119")).toEqual({ + subagentAgentName: "Trace", + subagentTaskIsConfigured: true, + }); expect( - resolveDelegateSourceForTask(messages, "20260807_11"), + resolveDelegateContextForTask(messages, "20260807_11"), ).toBeUndefined(); }); - it("retains both identity and task for async follow-ups", () => { + it("does not match a task id that prefixes another", () => { const messages = transcript([ [ - delegateRequest("call-1", { - source: "Rivet", - instructions: "Count markdown files", - async: true, - }), + delegateRequest("call-1", { source: "Rivet", async: true }), delegateResponse( "call-1", - 'Task 20260807_119 started in background: "Count markdown files"', + 'Task 20260807_72 started in background: "count files"', ), ], ]); - expect(resolveDelegateContextForTask(messages, "20260807_119")).toEqual({ - subagentAgentName: "Rivet", - subagentTaskLabel: "Count markdown files", - }); - }); - - it("retains a named source's configured task for async follow-ups", () => { - const messages = transcript([ - [ - delegateRequest("call-1", { source: "Rivet", async: true }), - delegateResponse("call-1", "Task 20260807_120 started in background"), - ], - ]); - expect(resolveDelegateContextForTask(messages, "20260807_120")).toEqual({ + expect( + resolveDelegateContextForTask(messages, "20260807_7"), + ).toBeUndefined(); + expect(resolveDelegateContextForTask(messages, "20260807_72")).toEqual({ subagentAgentName: "Rivet", subagentTaskIsConfigured: true, }); }); - it("returns undefined for ad-hoc delegates (no source)", () => { + it("retains task-only provenance for ad-hoc delegates", () => { const messages = transcript([ [ delegateRequest("call-1", { instructions: "do a thing" }), @@ -320,13 +283,13 @@ describe("getSubagentToolCallInfo", () => { ), ], ]); - expect( - resolveDelegateSourceForTask(messages, "20260807_119"), - ).toBeUndefined(); + expect(resolveDelegateContextForTask(messages, "20260807_119")).toEqual({ + subagentTaskLabel: "do a thing", + }); }); it("returns undefined when no delegate mentions the task id", () => { - expect(resolveDelegateSourceForTask([], "20260807_119")).toBeUndefined(); + expect(resolveDelegateContextForTask([], "20260807_119")).toBeUndefined(); }); }); @@ -342,17 +305,7 @@ describe("getSubagentToolCallInfo", () => { }); }); - describe("shortTaskId", () => { - it("drops the date prefix", () => { - expect(shortTaskId("20260807_72")).toBe("72"); - }); - - it("passes unexpected values through unchanged", () => { - expect(shortTaskId("no-separator")).toBe("no-separator"); - }); - }); - - describe("codex spawn_agent", () => { + describe("codex collaboration", () => { it("preserves Codex agent identity and delegated task", () => { expect( getSubagentToolCallInfo({ @@ -404,5 +357,86 @@ describe("getSubagentToolCallInfo", () => { }), ).toEqual({ activity: "delegating" }); }); + + it.each([ + ["send_input", "agent-42", "Review the patch"], + ["send_message", "/root/reviewer", "Review the patch"], + ["followup_task", "/root/reviewer", "Review the patch"], + ])("preserves target and task for %s", (toolName, target, message) => { + expect( + getSubagentToolCallInfo({ + toolName, + arguments: { target, message }, + }), + ).toEqual({ + activity: "delegating", + agentName: target, + label: message, + }); + }); + + it.each([ + ["resume_agent", { id: "agent-42" }, "agent-42"], + ["close_agent", { target: "agent-42" }, "agent-42"], + ["interrupt_agent", { target: "/root/reviewer" }, "/root/reviewer"], + ])("attributes %s to its target", (toolName, args, agentName) => { + expect(getSubagentToolCallInfo({ toolName, arguments: args })).toEqual({ + activity: toolName === "resume_agent" ? "delegating" : "cancelling", + agentName, + }); + }); + + it.each([ + ["spawn_agent", "Rivet", "Investigate the failing tests", "delegating"], + ["send_input", "agent-42", "Review the patch", "delegating"], + ["send_message", "/root/reviewer", "Review the patch", "delegating"], + ["followup_task", "/root/reviewer", "Review the patch", "delegating"], + ["resume_agent", "agent-42", undefined, "delegating"], + ["wait_agent", "agent-42", undefined, "waiting"], + ["close_agent", "agent-42", undefined, "cancelling"], + ["interrupt_agent", "/root/reviewer", undefined, "cancelling"], + ])("preserves codex-acp wire provenance for %s", (toolName, receiver, prompt, activity) => { + expect( + getSubagentToolCallInfo({ + toolName, + arguments: { + prompt, + senderThreadId: "root", + receiverThreadIds: [receiver], + agentsStates: {}, + model: "gpt-5", + reasoningEffort: "medium", + status: "running", + }, + }), + ).toEqual({ + activity, + agentName: receiver, + ...(prompt ? { label: prompt } : {}), + }); + }); + + it("attributes a legacy wait with one target", () => { + expect( + getSubagentToolCallInfo({ + toolName: "wait_agent", + arguments: { targets: ["agent-42"] }, + }), + ).toEqual({ activity: "waiting", agentName: "agent-42" }); + }); + + it.each([ + ["wait_agent", {}], + ["wait_agent", { targets: ["agent-1", "agent-2"] }], + ["wait_agent", { targets: ["agent-1", 42] }], + ["wait_agent", { targets: ["agent-1", " "] }], + ["wait_agent", { targets: [42] }], + ["wait_agent", { targets: [" "] }], + ["followup_task", { target: 42, message: [] }], + ])("does not fabricate provenance for malformed or ambiguous %s", (toolName, args) => { + expect(getSubagentToolCallInfo({ toolName, arguments: args })).toEqual({ + activity: toolName === "wait_agent" ? "waiting" : "delegating", + }); + }); }); }); diff --git a/src/features/chat/lib/subagentToolCalls.ts b/src/features/chat/lib/subagentToolCalls.ts index d257f26ee..f40b2b901 100644 --- a/src/features/chat/lib/subagentToolCalls.ts +++ b/src/features/chat/lib/subagentToolCalls.ts @@ -8,7 +8,8 @@ * `source` (await/peek/cancel a background subagent). `load` with a named * source (recipe/skill) is NOT a subagent run. * - Claude Code: `Task` / `Agent` tool (via `_meta.claudeCode.toolName`). - * - Codex: `spawn_agent` collaboration tool (via `_meta.codex.collaboration`). + * - Codex: collaboration lifecycle tools such as `spawn_agent`, + * `followup_task`, and `wait_agent` (via `_meta.codex.collaboration`). * * Tool names arrive on `ToolRequestContent.toolName` (extracted from `_meta` * at the ACP edge by `getToolCallIdentity`). Titles are server-authored and @@ -38,12 +39,6 @@ export interface SubagentToolCallInfo { /** Goose background-task ids look like `20260807_72`. */ const GOOSE_TASK_ID_PATTERN = /^\d{8}_\w+$/; -/** `20260807_72` → `72`; anything unexpected passes through unchanged. */ -export function shortTaskId(taskId: string): string { - const separator = taskId.indexOf("_"); - return separator > 0 ? taskId.slice(separator + 1) : taskId; -} - function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } @@ -136,14 +131,6 @@ export function resolveDelegateContextForTask( return undefined; } -/** Compatibility accessor for callers that only need the delegate identity. */ -export function resolveDelegateSourceForTask( - messages: ReadonlyArray<{ content: MessageContent[] }>, - taskId: string, -): string | undefined { - return resolveDelegateContextForTask(messages, taskId)?.subagentAgentName; -} - const MAX_LABEL_LENGTH = 60; function truncateLabel(value: string): string { @@ -162,6 +149,32 @@ function stringArg( : undefined; } +function soleStringArrayArg( + args: Record, + key: string, +): string | undefined { + const value = args[key]; + if (!Array.isArray(value) || value.length !== 1) return undefined; + const [item] = value; + return typeof item === "string" && item.trim().length > 0 + ? item.trim() + : undefined; +} + +export function getSubagentToolCallContext( + toolName: string | undefined, + args: Record, +): ResolvedSubagentContext | undefined { + const info = getSubagentToolCallInfo({ toolName, arguments: args }); + if (!info) return undefined; + const context: ResolvedSubagentContext = { + ...(info.agentName ? { subagentAgentName: info.agentName } : {}), + ...(info.label ? { subagentTaskLabel: info.label } : {}), + ...(info.sourceDefinesTask ? { subagentTaskIsConfigured: true } : {}), + }; + return Object.keys(context).length > 0 ? context : undefined; +} + export function getSubagentToolCallInfo(input: { toolName?: string; arguments: Record; @@ -214,11 +227,32 @@ export function getSubagentToolCallInfo(input: { }; } - // Codex: spawn_agent collaboration tool. `task_name` identifies the spawned - // agent in the collaboration protocol, while `message` is its delegated - // task. Keep `prompt` as a compatibility fallback for older adapters. + // Codex: spawn_agent collaboration tool. Direct adapters expose `task_name` + // and `message`; codex-acp exposes the same facts as a strict singleton + // `receiverThreadIds` and `prompt`. Keep both wire shapes compatible. if (toolName === "spawn_agent") { - const agentName = stringArg(args, "task_name"); + const agentName = + stringArg(args, "task_name") ?? + soleStringArrayArg(args, "receiverThreadIds"); + const label = stringArg(args, "message") ?? stringArg(args, "prompt"); + return { + activity: "delegating", + ...(agentName ? { agentName: agentName.trim() } : {}), + ...(label ? { label: truncateLabel(label) } : {}), + }; + } + + // Codex collaboration lifecycle calls expose provenance directly in their + // arguments. Targets are canonical subagent task names (or legacy ids), and + // message payloads are newly delegated work. + if ( + toolName === "send_input" || + toolName === "send_message" || + toolName === "followup_task" + ) { + const agentName = + stringArg(args, "target") ?? + soleStringArrayArg(args, "receiverThreadIds"); const label = stringArg(args, "message") ?? stringArg(args, "prompt"); return { activity: "delegating", @@ -227,5 +261,36 @@ export function getSubagentToolCallInfo(input: { }; } + if (toolName === "resume_agent") { + const agentName = + stringArg(args, "target") ?? + stringArg(args, "id") ?? + soleStringArrayArg(args, "receiverThreadIds"); + return { + activity: "delegating", + ...(agentName ? { agentName: agentName.trim() } : {}), + }; + } + + if (toolName === "wait_agent") { + const agentName = + soleStringArrayArg(args, "targets") ?? + soleStringArrayArg(args, "receiverThreadIds"); + return { + activity: "waiting", + ...(agentName ? { agentName } : {}), + }; + } + + if (toolName === "close_agent" || toolName === "interrupt_agent") { + const agentName = + stringArg(args, "target") ?? + soleStringArrayArg(args, "receiverThreadIds"); + return { + activity: "cancelling", + ...(agentName ? { agentName: agentName.trim() } : {}), + }; + } + return undefined; } diff --git a/src/features/chat/ui/__tests__/ToolCallAdapter.test.tsx b/src/features/chat/ui/__tests__/ToolCallAdapter.test.tsx index cc54f5dac..e88d859df 100644 --- a/src/features/chat/ui/__tests__/ToolCallAdapter.test.tsx +++ b/src/features/chat/ui/__tests__/ToolCallAdapter.test.tsx @@ -204,6 +204,23 @@ describe("ToolCallAdapter — subagent laws", () => { ).toBeInTheDocument(); }); + it("renders lawful Codex follow-up provenance", () => { + renderAdapter({ + name: "followup_task", + toolName: "followup_task", + arguments: { + target: "/root/reviewer", + message: "Re-check the cache boundary", + }, + }); + + expect( + screen.getByRole("button", { + name: /Delegating to \/root\/reviewer · Re-check the cache boundary/i, + }), + ).toBeInTheDocument(); + }); + it("does not expose a task id as an unknown task description", () => { renderAdapter({ name: "Loading source 20260807_72", From f2649740d1f509eedec2629bbc158257d3f0eb8d Mon Sep 17 00:00:00 2001 From: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Thu, 13 Aug 2026 12:43:10 -0400 Subject: [PATCH 9/9] fix(chat): describe Codex collaboration activity truthfully Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- .../lib/__tests__/subagentToolCalls.test.ts | 34 +++++--- src/features/chat/lib/subagentToolCalls.ts | 77 +++++++++++++------ src/features/chat/ui/ToolCallAdapter.tsx | 20 +++++ .../ui/__tests__/ToolCallAdapter.test.tsx | 28 +++++++ src/shared/i18n/locales/en/chat.json | 14 +++- src/shared/i18n/locales/es/chat.json | 14 +++- 6 files changed, 154 insertions(+), 33 deletions(-) diff --git a/src/features/chat/lib/__tests__/subagentToolCalls.test.ts b/src/features/chat/lib/__tests__/subagentToolCalls.test.ts index 921f96678..fce0d16d6 100644 --- a/src/features/chat/lib/__tests__/subagentToolCalls.test.ts +++ b/src/features/chat/lib/__tests__/subagentToolCalls.test.ts @@ -359,17 +359,17 @@ describe("getSubagentToolCallInfo", () => { }); it.each([ - ["send_input", "agent-42", "Review the patch"], - ["send_message", "/root/reviewer", "Review the patch"], - ["followup_task", "/root/reviewer", "Review the patch"], - ])("preserves target and task for %s", (toolName, target, message) => { + ["send_input", "agent-42", "Review the patch", "delegating"], + ["send_message", "/root/reviewer", "Review the patch", "messaging"], + ["followup_task", "/root/reviewer", "Review the patch", "delegating"], + ])("preserves target and task for %s", (toolName, target, message, activity) => { expect( getSubagentToolCallInfo({ toolName, arguments: { target, message }, }), ).toEqual({ - activity: "delegating", + activity, agentName: target, label: message, }); @@ -381,7 +381,12 @@ describe("getSubagentToolCallInfo", () => { ["interrupt_agent", { target: "/root/reviewer" }, "/root/reviewer"], ])("attributes %s to its target", (toolName, args, agentName) => { expect(getSubagentToolCallInfo({ toolName, arguments: args })).toEqual({ - activity: toolName === "resume_agent" ? "delegating" : "cancelling", + activity: + toolName === "resume_agent" + ? "delegating" + : toolName === "interrupt_agent" + ? "interrupting" + : "cancelling", agentName, }); }); @@ -389,12 +394,12 @@ describe("getSubagentToolCallInfo", () => { it.each([ ["spawn_agent", "Rivet", "Investigate the failing tests", "delegating"], ["send_input", "agent-42", "Review the patch", "delegating"], - ["send_message", "/root/reviewer", "Review the patch", "delegating"], + ["send_message", "/root/reviewer", "Review the patch", "messaging"], ["followup_task", "/root/reviewer", "Review the patch", "delegating"], ["resume_agent", "agent-42", undefined, "delegating"], ["wait_agent", "agent-42", undefined, "waiting"], ["close_agent", "agent-42", undefined, "cancelling"], - ["interrupt_agent", "/root/reviewer", undefined, "cancelling"], + ["interrupt_agent", "/root/reviewer", undefined, "interrupting"], ])("preserves codex-acp wire provenance for %s", (toolName, receiver, prompt, activity) => { expect( getSubagentToolCallInfo({ @@ -425,9 +430,20 @@ describe("getSubagentToolCallInfo", () => { ).toEqual({ activity: "waiting", agentName: "agent-42" }); }); + it("preserves every known target for multi-agent waits", () => { + expect( + getSubagentToolCallInfo({ + toolName: "wait_agent", + arguments: { targets: ["agent-1", "agent-2"] }, + }), + ).toEqual({ + activity: "waiting", + agentNames: ["agent-1", "agent-2"], + }); + }); + it.each([ ["wait_agent", {}], - ["wait_agent", { targets: ["agent-1", "agent-2"] }], ["wait_agent", { targets: ["agent-1", 42] }], ["wait_agent", { targets: ["agent-1", " "] }], ["wait_agent", { targets: [42] }], diff --git a/src/features/chat/lib/subagentToolCalls.ts b/src/features/chat/lib/subagentToolCalls.ts index f40b2b901..9c590557e 100644 --- a/src/features/chat/lib/subagentToolCalls.ts +++ b/src/features/chat/lib/subagentToolCalls.ts @@ -20,16 +20,20 @@ import type { MessageContent } from "@/shared/types/messages"; export type SubagentActivity = | "delegating" + | "messaging" | "waiting" | "checking" - | "cancelling"; + | "cancelling" + | "interrupting"; export interface SubagentToolCallInfo { activity: SubagentActivity; - /** Short human label: the task description or prompt, when provided. */ + /** Short human label: the task description or message, when provided. */ label?: string; - /** Named delegate source (custom agent/recipe), when the spawn had one. */ + /** Named delegate source or collaboration target, when exactly one is known. */ agentName?: string; + /** Collaboration targets when an operation truthfully applies to several. */ + agentNames?: string[]; /** Source-only Goose delegates run the configured task owned by the source. */ sourceDefinesTask?: boolean; /** Goose background-task id (e.g. `20260807_72`) for await/peek/cancel. */ @@ -149,16 +153,26 @@ function stringArg( : undefined; } +function stringArrayArg( + args: Record, + key: string, +): string[] | undefined { + const value = args[key]; + if (!Array.isArray(value) || value.length === 0) return undefined; + const items: string[] = []; + for (const item of value) { + if (typeof item !== "string" || item.trim().length === 0) return undefined; + items.push(item.trim()); + } + return items; +} + function soleStringArrayArg( args: Record, key: string, ): string | undefined { - const value = args[key]; - if (!Array.isArray(value) || value.length !== 1) return undefined; - const [item] = value; - return typeof item === "string" && item.trim().length > 0 - ? item.trim() - : undefined; + const value = stringArrayArg(args, key); + return value?.length === 1 ? value[0] : undefined; } export function getSubagentToolCallContext( @@ -242,14 +256,22 @@ export function getSubagentToolCallInfo(input: { }; } - // Codex collaboration lifecycle calls expose provenance directly in their - // arguments. Targets are canonical subagent task names (or legacy ids), and - // message payloads are newly delegated work. - if ( - toolName === "send_input" || - toolName === "send_message" || - toolName === "followup_task" - ) { + // Codex message delivery does not start a turn. Keep it distinct from + // follow-up delegation while retaining the recipient and message text. + if (toolName === "send_message") { + const agentName = + stringArg(args, "target") ?? + soleStringArrayArg(args, "receiverThreadIds"); + const label = stringArg(args, "message") ?? stringArg(args, "prompt"); + return { + activity: "messaging", + ...(agentName ? { agentName: agentName.trim() } : {}), + ...(label ? { label: truncateLabel(label) } : {}), + }; + } + + // Codex input and follow-up calls start work on the target agent. + if (toolName === "send_input" || toolName === "followup_task") { const agentName = stringArg(args, "target") ?? soleStringArrayArg(args, "receiverThreadIds"); @@ -273,16 +295,17 @@ export function getSubagentToolCallInfo(input: { } if (toolName === "wait_agent") { - const agentName = - soleStringArrayArg(args, "targets") ?? - soleStringArrayArg(args, "receiverThreadIds"); + const agentNames = + stringArrayArg(args, "targets") ?? + stringArrayArg(args, "receiverThreadIds"); return { activity: "waiting", - ...(agentName ? { agentName } : {}), + ...(agentNames?.length === 1 ? { agentName: agentNames[0] } : {}), + ...(agentNames && agentNames.length > 1 ? { agentNames } : {}), }; } - if (toolName === "close_agent" || toolName === "interrupt_agent") { + if (toolName === "close_agent") { const agentName = stringArg(args, "target") ?? soleStringArrayArg(args, "receiverThreadIds"); @@ -292,5 +315,15 @@ export function getSubagentToolCallInfo(input: { }; } + if (toolName === "interrupt_agent") { + const agentName = + stringArg(args, "target") ?? + soleStringArrayArg(args, "receiverThreadIds"); + return { + activity: "interrupting", + ...(agentName ? { agentName: agentName.trim() } : {}), + }; + } + return undefined; } diff --git a/src/features/chat/ui/ToolCallAdapter.tsx b/src/features/chat/ui/ToolCallAdapter.tsx index a267a8b8a..500bea55b 100644 --- a/src/features/chat/ui/ToolCallAdapter.tsx +++ b/src/features/chat/ui/ToolCallAdapter.tsx @@ -327,6 +327,12 @@ function subagentTitle( "tools.subagent.delegatingAgent", "tools.subagent.delegatingAgentLabeled", ], + messaging: [ + "tools.subagent.messaging", + "tools.subagent.messagingLabeled", + "tools.subagent.messagingAgent", + "tools.subagent.messagingAgentLabeled", + ], waiting: [ "tools.subagent.waiting", "tools.subagent.waitingLabeled", @@ -345,26 +351,40 @@ function subagentTitle( "tools.subagent.cancellingAgent", "tools.subagent.cancellingAgentLabeled", ], + interrupting: [ + "tools.subagent.interrupting", + "tools.subagent.interruptingLabeled", + "tools.subagent.interruptingAgent", + "tools.subagent.interruptingAgentLabeled", + ], } as const; const [plain, labeled, agent, agentLabeled] = keys[info.activity]; const configuredTaskKeys = { delegating: "tools.subagent.delegatingAgentConfiguredTask", + messaging: "tools.subagent.messagingAgentConfiguredTask", waiting: "tools.subagent.waitingAgentConfiguredTask", checking: "tools.subagent.checkingAgentConfiguredTask", cancelling: "tools.subagent.cancellingAgentConfiguredTask", + interrupting: "tools.subagent.interruptingAgentConfiguredTask", } as const; const taskLabeledKeys = { delegating: "tools.subagent.delegatingLabeled", + messaging: "tools.subagent.messagingLabeled", waiting: "tools.subagent.waitingTaskLabeled", checking: "tools.subagent.checkingTaskLabeled", cancelling: "tools.subagent.cancellingTaskLabeled", + interrupting: "tools.subagent.interruptingTaskLabeled", } as const; // Agent name comes from the call arguments (delegate source) or is // resolved from the transcript (load of a task spawned by a named // delegate). It replaces the word "subagent"; the task description is // kept alongside it: "Delegating to Rivet · Count markdown files…". const agentName = info.agentName ?? resolvedAgentName; + const agentNames = info.agentNames; const taskLabel = info.label ?? resolvedTaskLabel; + if (agentNames) { + return t("tools.subagent.waitingAgents", { names: agentNames.join(", ") }); + } if (agentName && (info.sourceDefinesTask || resolvedTaskIsConfigured)) { return t(configuredTaskKeys[info.activity], { name: agentName }); } diff --git a/src/features/chat/ui/__tests__/ToolCallAdapter.test.tsx b/src/features/chat/ui/__tests__/ToolCallAdapter.test.tsx index e88d859df..6d724f5e1 100644 --- a/src/features/chat/ui/__tests__/ToolCallAdapter.test.tsx +++ b/src/features/chat/ui/__tests__/ToolCallAdapter.test.tsx @@ -221,6 +221,34 @@ describe("ToolCallAdapter — subagent laws", () => { ).toBeInTheDocument(); }); + it.each([ + { + toolName: "send_message", + arguments: { target: "/root/reviewer", message: "Review the patch" }, + title: "Sending a message to /root/reviewer · Review the patch", + }, + { + toolName: "interrupt_agent", + arguments: { target: "/root/reviewer" }, + title: "Interrupting /root/reviewer’s current turn", + }, + { + toolName: "wait_agent", + arguments: { targets: ["agent-1", "agent-2"] }, + title: "Waiting on agent-1, agent-2", + }, + ])("renders truthful Codex $toolName activity", ({ + toolName, + arguments: args, + title, + }) => { + renderAdapter({ name: toolName, toolName, arguments: args }); + + expect( + screen.getByRole("button", { name: new RegExp(`^${title}$`, "i") }), + ).toBeInTheDocument(); + }); + it("does not expose a task id as an unknown task description", () => { renderAdapter({ name: "Loading source 20260807_72", diff --git a/src/shared/i18n/locales/en/chat.json b/src/shared/i18n/locales/en/chat.json index 26e7ff50d..9d3041648 100644 --- a/src/shared/i18n/locales/en/chat.json +++ b/src/shared/i18n/locales/en/chat.json @@ -572,7 +572,12 @@ "delegatingLabeled": "Delegating to a subagent · {{label}}", "delegatingAgent": "Delegating to {{name}}", "delegatingAgentLabeled": "Delegating to {{name}} · {{label}}", + "messaging": "Sending a message to a subagent", + "messagingLabeled": "Sending a message to a subagent · {{label}}", + "messagingAgent": "Sending a message to {{name}}", + "messagingAgentLabeled": "Sending a message to {{name}} · {{label}}", "waiting": "Waiting on a subagent", + "waitingAgents": "Waiting on {{names}}", "waitingLabeled": "Waiting on subagent #{{label}}", "waitingAgent": "Waiting on {{name}}", "waitingAgentLabeled": "Waiting on {{name}} · {{label}}", @@ -584,13 +589,20 @@ "cancellingLabeled": "Cancelling subagent #{{label}}", "cancellingAgent": "Cancelling {{name}}", "cancellingAgentLabeled": "Cancelling {{name}} · {{label}}", + "interrupting": "Interrupting a subagent’s current turn", + "interruptingLabeled": "Interrupting a subagent’s current turn · {{label}}", + "interruptingAgent": "Interrupting {{name}}’s current turn", + "interruptingAgentLabeled": "Interrupting {{name}}’s current turn · {{label}}", "delegatingAgentConfiguredTask": "Asking {{name}} to run its configured task", + "messagingAgentConfiguredTask": "Sending a message to {{name}} about its configured task", "waitingAgentConfiguredTask": "Waiting on {{name}}’s configured task", "waitingTaskLabeled": "Waiting on a delegated task · {{label}}", "checkingAgentConfiguredTask": "Checking {{name}}’s configured task", "checkingTaskLabeled": "Checking on a delegated task · {{label}}", "cancellingAgentConfiguredTask": "Cancelling {{name}}’s configured task", - "cancellingTaskLabeled": "Cancelling a delegated task · {{label}}" + "cancellingTaskLabeled": "Cancelling a delegated task · {{label}}", + "interruptingAgentConfiguredTask": "Interrupting {{name}}’s configured task", + "interruptingTaskLabeled": "Interrupting the current delegated turn · {{label}}" } }, "agent_work": { diff --git a/src/shared/i18n/locales/es/chat.json b/src/shared/i18n/locales/es/chat.json index 4b760a2fd..daa71bb18 100644 --- a/src/shared/i18n/locales/es/chat.json +++ b/src/shared/i18n/locales/es/chat.json @@ -567,7 +567,12 @@ "delegatingLabeled": "Delegando a un subagente · {{label}}", "delegatingAgent": "Delegando a {{name}}", "delegatingAgentLabeled": "Delegando a {{name}} · {{label}}", + "messaging": "Enviando un mensaje a un subagente", + "messagingLabeled": "Enviando un mensaje a un subagente · {{label}}", + "messagingAgent": "Enviando un mensaje a {{name}}", + "messagingAgentLabeled": "Enviando un mensaje a {{name}} · {{label}}", "waiting": "Esperando a un subagente", + "waitingAgents": "Esperando a {{names}}", "waitingLabeled": "Esperando al subagente n.º {{label}}", "waitingAgent": "Esperando a {{name}}", "waitingAgentLabeled": "Esperando a {{name}} · {{label}}", @@ -579,13 +584,20 @@ "cancellingLabeled": "Cancelando al subagente n.º {{label}}", "cancellingAgent": "Cancelando a {{name}}", "cancellingAgentLabeled": "Cancelando a {{name}} · {{label}}", + "interrupting": "Interrumpiendo el turno actual de un subagente", + "interruptingLabeled": "Interrumpiendo el turno actual de un subagente · {{label}}", + "interruptingAgent": "Interrumpiendo el turno actual de {{name}}", + "interruptingAgentLabeled": "Interrumpiendo el turno actual de {{name}} · {{label}}", "delegatingAgentConfiguredTask": "Pidiendo a {{name}} que ejecute su tarea configurada", + "messagingAgentConfiguredTask": "Enviando un mensaje a {{name}} sobre su tarea configurada", "waitingAgentConfiguredTask": "Esperando la tarea configurada de {{name}}", "waitingTaskLabeled": "Esperando una tarea delegada · {{label}}", "checkingAgentConfiguredTask": "Comprobando la tarea configurada de {{name}}", "checkingTaskLabeled": "Comprobando una tarea delegada · {{label}}", "cancellingAgentConfiguredTask": "Cancelando la tarea configurada de {{name}}", - "cancellingTaskLabeled": "Cancelando una tarea delegada · {{label}}" + "cancellingTaskLabeled": "Cancelando una tarea delegada · {{label}}", + "interruptingAgentConfiguredTask": "Interrumpiendo la tarea configurada de {{name}}", + "interruptingTaskLabeled": "Interrumpiendo el turno delegado actual · {{label}}" } }, "agent_work": {