diff --git a/LAWS/CHAT.md b/LAWS/CHAT.md index 9216c0628..e9cc48154 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 when it is known. diff --git a/src/features/chat/acp/__tests__/acpNotificationHandler.test.ts b/src/features/chat/acp/__tests__/acpNotificationHandler.test.ts index 8399b23a2..f4865167f 100644 --- a/src/features/chat/acp/__tests__/acpNotificationHandler.test.ts +++ b/src/features/chat/acp/__tests__/acpNotificationHandler.test.ts @@ -1380,6 +1380,197 @@ describe("acpNotificationHandler", () => { warnSpy.mockRestore(); }); + it.each([ + { 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") { + 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", + ...(!configuredTask ? { 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", + ...(configuredTask + ? { subagentTaskIsConfigured: true } + : { subagentTaskLabel: "Count markdown files" }), + }); + }); + + 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 fd22c4db1..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 { resolveSubagentLabel } 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 replaySubagentLabel = resolveSubagentLabel( - 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, @@ -446,7 +451,7 @@ function handleReplay(sessionId: string, update: SessionUpdate): void { ...toolCallUpdatePatch(update), startedAt: created ?? Date.now(), ...(chainSummary ? { chainSummary } : {}), - ...(replaySubagentLabel ? { subagentLabel: replaySubagentLabel } : {}), + ...(replaySubagentContext ?? {}), }); break; } @@ -494,13 +499,19 @@ 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( - tc.toolName, - tc.arguments, - getReplayBuffer(sessionId) ?? [], - ); - if (lateLabel) tc.subagentLabel = lateLabel; + if ( + identity.toolName && + (tc.subagentAgentName === undefined || + tc.subagentTaskLabel === undefined) + ) { + const lateContext = + getSubagentToolCallContext(tc.toolName, tc.arguments) ?? + resolveSubagentContext( + tc.toolName, + tc.arguments, + getReplayBuffer(sessionId) ?? [], + ); + if (lateContext) Object.assign(tc, lateContext); } } } @@ -628,11 +639,13 @@ function handleLive(sessionId: string, update: SessionUpdate): void { const chainSummary = getToolChainSummary(update); const liveArguments = rawInputToArguments(update.rawInput); - const liveSubagentLabel = resolveSubagentLabel( - 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, @@ -643,7 +656,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,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 lateSubagentLabel = identity.toolName - ? resolveSubagentLabel( + const storedArguments = identity.toolName + ? (findLiveToolRequest(sessionId, messageId, update.toolCallId) + ?.arguments ?? {}) + : {}; + const lateSubagentContext = identity.toolName + ? (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, @@ -692,9 +709,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..fce0d16d6 100644 --- a/src/features/chat/lib/__tests__/subagentToolCalls.test.ts +++ b/src/features/chat/lib/__tests__/subagentToolCalls.test.ts @@ -1,9 +1,8 @@ import { describe, expect, it } from "vitest"; import { getSubagentToolCallInfo, - resolveDelegateSourceForTask, - resolveSubagentLabel, - shortTaskId, + resolveDelegateContextForTask, + resolveSubagentContext, } from "@/features/chat/lib/subagentToolCalls"; import type { MessageContent } from "@/shared/types/messages"; @@ -47,7 +46,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", () => { @@ -62,7 +65,7 @@ describe("getSubagentToolCallInfo", () => { expect(info?.label?.endsWith("…")).toBe(true); }); - it("classifies delegate without any label", () => { + it("classifies a delegate even when its task is unknown", () => { expect( getSubagentToolCallInfo({ toolName: "delegate", arguments: {} }), ).toEqual({ activity: "delegating" }); @@ -143,7 +146,23 @@ describe("getSubagentToolCallInfo", () => { }); }); - it("classifies a named agent without a description", () => { + it("uses prompt as the known task 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("retains known identity when the task is unknown", () => { expect( getSubagentToolCallInfo({ toolName: "Agent", @@ -153,7 +172,7 @@ describe("getSubagentToolCallInfo", () => { }); }); - describe("resolveDelegateSourceForTask", () => { + describe("resolveDelegateContextForTask", () => { const transcript = ( blocks: MessageContent[][], ): Array<{ content: MessageContent[] }> => @@ -184,32 +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"', + 'Task 20260807_119 started in background: "Count markdown 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-2", { source: "Vogue", async: true }), - delegateResponse( - "call-2", - 'Task 20260807_120 started in background: "read readme"', - ), + delegateRequest("call-1", { source: "Rivet", async: true }), + delegateResponse("call-1", "Task 20260807_120 started in background"), ], ]); - expect(resolveDelegateSourceForTask(messages, "20260807_119")).toBe( - "Rivet", - ); - expect(resolveDelegateSourceForTask(messages, "20260807_120")).toBe( - "Vogue", - ); + expect(resolveDelegateContextForTask(messages, "20260807_120")).toEqual({ + subagentAgentName: "Rivet", + subagentTaskIsConfigured: true, + }); }); - it("finds the task id in structured content", () => { + it("finds the exact task id in structured content", () => { const messages = transcript([ [ delegateRequest("call-1", { source: "Trace", async: true }), @@ -218,12 +245,16 @@ describe("getSubagentToolCallInfo", () => { }), ], ]); - expect(resolveDelegateSourceForTask(messages, "20260807_119")).toBe( - "Trace", - ); + expect(resolveDelegateContextForTask(messages, "20260807_119")).toEqual({ + subagentAgentName: "Trace", + subagentTaskIsConfigured: true, + }); + expect( + resolveDelegateContextForTask(messages, "20260807_11"), + ).toBeUndefined(); }); - it("does not match a task id that is a prefix of another (7 vs 72)", () => { + it("does not match a task id that prefixes another", () => { const messages = transcript([ [ delegateRequest("call-1", { source: "Rivet", async: true }), @@ -233,30 +264,16 @@ describe("getSubagentToolCallInfo", () => { ), ], ]); - // 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", - ); - }); - - it("does not prefix-match inside 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_11"), + 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" }), @@ -266,40 +283,46 @@ 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(); }); }); - 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(); }); }); - 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 collaboration", () => { + 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", + }); }); - }); - describe("codex spawn_agent", () => { - it("classifies spawn_agent with a prompt label", () => { + it("falls back to the legacy prompt label", () => { expect( getSubagentToolCallInfo({ toolName: "spawn_agent", @@ -310,5 +333,126 @@ describe("getSubagentToolCallInfo", () => { label: "Investigate the failing tests", }); }); + + 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", + arguments: {}, + }), + ).toEqual({ activity: "delegating" }); + }); + + it.each([ + ["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, + 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" + : toolName === "interrupt_agent" + ? "interrupting" + : "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", "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, "interrupting"], + ])("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("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", 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 da3295bbb..9c590557e 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 @@ -19,16 +20,22 @@ 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. */ taskId?: string; } @@ -36,12 +43,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, "\\$&"); } @@ -70,38 +71,38 @@ function toolResponseMentionsTask( return false; } +/** Context recovered from the delegate that spawned an async Goose task. */ +export interface ResolvedSubagentContext { + subagentAgentName?: string; + subagentTaskLabel?: string; + /** The named Goose source owns a configured task when no instructions were supplied. */ + subagentTaskIsConfigured?: boolean; +} + /** - * 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,10 +118,17 @@ 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) } + : agentName + ? { subagentTaskIsConfigured: true } + : {}), + }; + return Object.keys(context).length > 0 ? context : undefined; } } } @@ -145,6 +153,42 @@ 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 = stringArrayArg(args, key); + return value?.length === 1 ? value[0] : 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; @@ -158,10 +202,13 @@ 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 even when no inline + // instructions are present. Unknown task facts remain absent. return { activity: "delegating", ...(agentName ? { agentName: agentName.trim() } : {}), ...(label ? { label: truncateLabel(label) } : {}), + ...(!label && agentName ? { sourceDefinesTask: true } : {}), }; } @@ -184,7 +231,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" @@ -194,14 +241,89 @@ export function getSubagentToolCallInfo(input: { }; } - // Codex: spawn_agent collaboration tool. + // 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 label = stringArg(args, "prompt"); + 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 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"); + const label = stringArg(args, "message") ?? stringArg(args, "prompt"); return { activity: "delegating", + ...(agentName ? { agentName: agentName.trim() } : {}), ...(label ? { label: truncateLabel(label) } : {}), }; } + 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 agentNames = + stringArrayArg(args, "targets") ?? + stringArrayArg(args, "receiverThreadIds"); + return { + activity: "waiting", + ...(agentNames?.length === 1 ? { agentName: agentNames[0] } : {}), + ...(agentNames && agentNames.length > 1 ? { agentNames } : {}), + }; + } + + if (toolName === "close_agent") { + const agentName = + stringArg(args, "target") ?? + soleStringArrayArg(args, "receiverThreadIds"); + return { + activity: "cancelling", + ...(agentName ? { agentName: agentName.trim() } : {}), + }; + } + + 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/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 = { diff --git a/src/features/chat/ui/AgentWorkPanel.tsx b/src/features/chat/ui/AgentWorkPanel.tsx index 1cc278fd3..50feea638 100644 --- a/src/features/chat/ui/AgentWorkPanel.tsx +++ b/src/features/chat/ui/AgentWorkPanel.tsx @@ -320,7 +320,9 @@ function AgentWorkItemRow({ ; status: ToolCallStatus; locations?: ToolCallLocation[]; @@ -315,6 +316,8 @@ function subagentTitle( t: (key: string, options?: Record) => string, info: NonNullable>, resolvedAgentName?: string, + resolvedTaskLabel?: string, + resolvedTaskIsConfigured?: boolean, ): string { // Explicit key map keeps the i18n usage statically checkable. const keys = { @@ -324,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", @@ -342,21 +351,55 @@ 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; - if (agentName && info.label) { - return t(agentLabeled, { name: agentName, label: info.label }); + 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 }); + } + 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 info.label ? t(labeled, { label: info.label }) : 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 { @@ -385,7 +428,9 @@ export function ToolCallAdapter({ className, name, toolName, - subagentLabel, + subagentAgentName, + subagentTaskLabel, + subagentTaskIsConfigured, arguments: args, status, locations, @@ -418,7 +463,13 @@ export function ToolCallAdapter({ [toolName, args], ); const displayName = subagentInfo - ? subagentTitle(t, subagentInfo, subagentLabel) + ? subagentTitle( + t, + subagentInfo, + subagentAgentName, + subagentTaskLabel, + subagentTaskIsConfigured, + ) : 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..cbeb6b12a 100644 --- a/src/features/chat/ui/ToolChainCards.tsx +++ b/src/features/chat/ui/ToolChainCards.tsx @@ -397,7 +397,9 @@ 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 subagentTaskIsConfigured = item.request?.subagentTaskIsConfigured; const status = getToolItemStatus(item); const { request, response } = item; const isOpen = !options.forceClose && expandedKeys.has(item.key); @@ -430,7 +432,9 @@ export function ToolChainCards({ Promise>(); const mockOpenInApp = vi.fn<(path: string, filename?: string) => Promise>(); +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", () => { + 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, @@ -127,6 +144,217 @@ describe("ToolCallAdapter — ArtifactActions", () => { }); }); +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 to a subagent", + }, + { + toolName: "Agent", + arguments: { subagent_type: "code-reviewer" }, + title: "Delegating to code-reviewer", + }, + { + 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(`^${title}$`, "i") }), + ).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.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", + toolName: "load", + arguments: { source: "20260807_72" }, + }); + + expect( + screen.getByRole("button", { + name: /^Waiting on a subagent$/i, + }), + ).toBeInTheDocument(); + expect(screen.queryByText(/20260807_72/)).not.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(); + }); + + 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.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", + 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", () => { 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..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}}", @@ -583,7 +588,21 @@ "cancelling": "Cancelling a subagent", "cancellingLabeled": "Cancelling subagent #{{label}}", "cancellingAgent": "Cancelling {{name}}", - "cancellingAgentLabeled": "Cancelling {{name}} · {{label}}" + "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}}", + "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 74c3e2f92..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}}", @@ -578,7 +583,21 @@ "cancelling": "Cancelando un subagente", "cancellingLabeled": "Cancelando al subagente n.º {{label}}", "cancellingAgent": "Cancelando a {{name}}", - "cancellingAgentLabeled": "Cancelando a {{name}} · {{label}}" + "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}}", + "interruptingAgentConfiguredTask": "Interrumpiendo la tarea configurada de {{name}}", + "interruptingTaskLabeled": "Interrumpiendo el turno delegado actual · {{label}}" } }, "agent_work": { diff --git a/src/shared/types/messages.ts b/src/shared/types/messages.ts index 8f3c45ca7..68b25e773 100644 --- a/src/shared/types/messages.ts +++ b/src/shared/types/messages.ts @@ -126,7 +126,11 @@ 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; + /** The named Goose source owns a configured task when no instructions were supplied. */ + subagentTaskIsConfigured?: boolean; } export interface ToolResponseContent {