diff --git a/packages/web/src/__tests__/SubagentCard.test.tsx b/packages/web/src/__tests__/SubagentCard.test.tsx new file mode 100644 index 0000000..2abb033 --- /dev/null +++ b/packages/web/src/__tests__/SubagentCard.test.tsx @@ -0,0 +1,101 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import { SubagentCard } from "../components/StepCards"; +import type { TrajectoryStep } from "../types"; + +function nativeStep( + status = "CORTEX_STEP_STATUS_DONE", +): TrajectoryStep { + return { + type: "CORTEX_STEP_TYPE_INVOKE_SUBAGENT", + status, + invokeSubagent: { + subagents: [ + { + role: "Integration Reviewer", + typeName: "general-purpose", + initialPrompt: "Review the integration", + }, + { + role: "Security Reviewer", + typeName: "research", + initialPrompt: "Review security boundaries", + }, + ], + }, + }; +} + +describe("SubagentCard", () => { + it("renders every native subagent and expands every prompt", async () => { + render(); + + expect(screen.getByText("Integration Reviewer")).toBeInTheDocument(); + expect(screen.getByText("Security Reviewer")).toBeInTheDocument(); + expect(screen.queryByText("Review the integration")).not.toBeInTheDocument(); + + await userEvent.click( + screen.getByRole("button", { name: /2 Subagents Invoked/i }), + ); + + expect(screen.getByText("Review the integration")).toBeInTheDocument(); + expect(screen.getByText("Review security boundaries")).toBeInTheDocument(); + }); + + it.each([ + ["CORTEX_STEP_STATUS_PENDING", "Pending", "cmd-wait"], + ["CORTEX_STEP_STATUS_ERROR", "Failed", "cmd-fail"], + ["CORTEX_STEP_STATUS_CANCELED", "Canceled", "cmd-fail"], + ["CORTEX_STEP_STATUS_INTERRUPTED", "Interrupted", "cmd-fail"], + ["CORTEX_STEP_STATUS_DONE", "Done", "cmd-ok"], + ])("renders %s with the correct state", (status, label, className) => { + const { container } = render(); + + expect(screen.getByText(label)).toBeInTheDocument(); + expect(container.querySelector(".subagent-card")).toHaveClass(className); + }); + + it("renders tool-specific send_message content", async () => { + const step: TrajectoryStep = { + type: "CORTEX_STEP_TYPE_TOOL_CALL", + metadata: { + toolCall: { + name: "send_message", + argumentsJson: JSON.stringify({ + Recipient: "conversation-123", + Message: "Please inspect the auth flow", + }), + }, + }, + }; + render(); + + expect(screen.getByText("Message to conversation-123")).toBeInTheDocument(); + expect(screen.getByText("conversation-123")).toBeInTheDocument(); + await userEvent.click( + screen.getByRole("button", { name: /Message to conversation-123/i }), + ); + expect(screen.getByText("Please inspect the auth flow")).toBeInTheDocument(); + }); + + it("renders untrusted labels as text rather than HTML", () => { + const step: TrajectoryStep = { + type: "CORTEX_STEP_TYPE_INVOKE_SUBAGENT", + invokeSubagent: { + subagents: [ + { + role: '', + initialPrompt: "safe text", + }, + ], + }, + }; + const { container } = render(); + + expect( + screen.getByText(''), + ).toBeInTheDocument(); + expect(container.querySelector("img")).toBeNull(); + }); +}); diff --git a/packages/web/src/__tests__/stepsToMessages.test.ts b/packages/web/src/__tests__/stepsToMessages.test.ts index 404018d..8115d4c 100644 --- a/packages/web/src/__tests__/stepsToMessages.test.ts +++ b/packages/web/src/__tests__/stepsToMessages.test.ts @@ -455,5 +455,188 @@ describe("stepsToMessages", () => { expect(msgs).toHaveLength(1); expect(msgs[0].type).toBe("CORTEX_STEP_TYPE_SUBAGENT"); expect(msgs[0].step).toBe(step); + expect(msgs[0].subagent).toMatchObject({ + kind: "invoke", + title: "Subagent Invoked", + action: "Invoking research subagent", + items: [ + { + role: "Config Auditor", + typeName: "research", + details: [ + { label: "Instructions", text: "Audit all config files" }, + ], + }, + ], + }); + }); + + it("correlates a payloadless native marker with captured planner arguments", () => { + const marker: TrajectoryStep = { + type: "CORTEX_STEP_TYPE_INVOKE_SUBAGENT", + status: "CORTEX_STEP_STATUS_DONE", + }; + const steps: TrajectoryStep[] = [ + { + type: "CORTEX_STEP_TYPE_PLANNER_RESPONSE", + plannerResponse: { + toolCalls: [ + { + name: "invoke_subagent", + argumentsJson: JSON.stringify({ + Subagents: [ + { + Role: "Integration Reviewer", + TypeName: "general-purpose", + Prompt: "Review API integration", + }, + { + Role: "Security Reviewer", + TypeName: "research", + Prompt: "Review trust boundaries", + }, + ], + }), + }, + ], + }, + }, + marker, + ]; + + const msgs = stepsToMessages(steps); + + expect(msgs).toHaveLength(1); + expect(msgs[0].step).toBe(marker); + expect(msgs[0].subagent?.title).toBe("2 Subagents Invoked"); + expect(msgs[0].subagent?.items).toHaveLength(2); + expect(msgs[0].subagent?.items.map((item) => item.role)).toEqual([ + "Integration Reviewer", + "Security Reviewer", + ]); + }); + + it("does not correlate a stale tool call across planner turns", () => { + const steps: TrajectoryStep[] = [ + { + type: "CORTEX_STEP_TYPE_PLANNER_RESPONSE", + plannerResponse: { + toolCalls: [ + { + name: "invoke_subagent", + argumentsJson: JSON.stringify({ + Subagents: [{ Role: "Stale Reviewer" }], + }), + }, + ], + }, + }, + { + type: "CORTEX_STEP_TYPE_PLANNER_RESPONSE", + plannerResponse: {}, + }, + { + type: "CORTEX_STEP_TYPE_INVOKE_SUBAGENT", + }, + ]; + + const [msg] = stepsToMessages(steps); + + expect(msg.subagent?.items[0].role).toBe("Subagent"); + }); + + it("uses the current native invokeSubagent payload and metadata", () => { + const step: TrajectoryStep = { + type: "CORTEX_STEP_TYPE_INVOKE_SUBAGENT", + status: "CORTEX_STEP_STATUS_RUNNING", + metadata: { + toolSummary: "Review team", + toolAction: "Running two reviews", + }, + invokeSubagent: { + subagents: [ + { + role: "Bug Hunter", + typeName: "general-purpose", + initialPrompt: "Find bugs", + modelTier: "MODEL_TIER_PRO", + }, + { + role: "Security Auditor", + typeName: "research", + initialPrompt: "Find vulnerabilities", + }, + ], + }, + }; + + const [msg] = stepsToMessages([step]); + + expect(msg.subagent).toMatchObject({ + kind: "invoke", + title: "Review team", + action: "Running two reviews", + }); + expect(msg.subagent?.items).toHaveLength(2); + expect(msg.subagent?.items[0]).toMatchObject({ + role: "Bug Hunter", + typeName: "general-purpose", + model: "MODEL_TIER_PRO", + }); + }); + + it.each([ + { + name: "define_subagent", + args: { + name: "security-reviewer", + description: "Reviews trust boundaries", + system_prompt: "Inspect untrusted input", + }, + kind: "define", + title: "Define security-reviewer", + role: "security-reviewer", + }, + { + name: "send_message", + args: { Recipient: "conversation-123", Message: "Check the parser" }, + kind: "message", + title: "Message to conversation-123", + role: "conversation-123", + }, + { + name: "manage_subagents", + args: { Action: "kill", ConversationIds: ["one", "two"] }, + kind: "manage", + title: "Stop Subagents", + role: "kill", + }, + ])("normalizes $name instead of labeling it as an invocation", (fixture) => { + const step: TrajectoryStep = { + type: "CORTEX_STEP_TYPE_TOOL_CALL", + metadata: { + toolCall: { + name: fixture.name, + argumentsJson: JSON.stringify(fixture.args), + }, + }, + }; + + const [msg] = stepsToMessages([step]); + + expect(msg.subagent).toMatchObject({ + kind: fixture.kind, + title: fixture.title, + items: [{ role: fixture.role }], + }); + }); + + it("does not treat inherited object properties as subagent tool names", () => { + const step: TrajectoryStep = { + type: "CORTEX_STEP_TYPE_TOOL_CALL", + metadata: { toolCall: { name: "toString" } }, + }; + + expect(stepsToMessages([step])).toEqual([]); }); }); diff --git a/packages/web/src/components/ChatPanel.tsx b/packages/web/src/components/ChatPanel.tsx index 4a3f792..8751eae 100644 --- a/packages/web/src/components/ChatPanel.tsx +++ b/packages/web/src/components/ChatPanel.tsx @@ -317,7 +317,7 @@ function SystemMessage({ if (msg.type === "CORTEX_STEP_TYPE_SUBAGENT") { return (
- +
); } diff --git a/packages/web/src/components/StepCards.tsx b/packages/web/src/components/StepCards.tsx index 0387cd9..33fdc61 100644 --- a/packages/web/src/components/StepCards.tsx +++ b/packages/web/src/components/StepCards.tsx @@ -14,9 +14,11 @@ import type { AskQuestionEntry, AskQuestionOption, AskQuestionRequest, - TrajectoryStep, FilePermissionRequest, + SubagentDisplayData, + TrajectoryStep, } from "../types"; +import { subagentDataFromStep } from "../utils/subagents"; /** Extract file basename from a URI or path */ function basename(uriOrPath: string): string { @@ -596,63 +598,125 @@ export function CodeActionCard({ step }: CodeActionCardProps) { // ── Subagent Card ── -export function SubagentCard({ step }: { step: TrajectoryStep }) { - const [expanded, setExpanded] = useState(false); - const toolCall = step.metadata?.toolCall; - const toolName = toolCall?.name ?? "invoke_subagent"; +const ACTIVE_SUBAGENT_STATUSES = new Set([ + "CORTEX_STEP_STATUS_GENERATING", + "CORTEX_STEP_STATUS_QUEUED", + "CORTEX_STEP_STATUS_PENDING", + "CORTEX_STEP_STATUS_RUNNING", + "CORTEX_STEP_STATUS_WAITING", +]); + +const FAILED_SUBAGENT_STATUSES = new Set([ + "CORTEX_STEP_STATUS_INVALID", + "CORTEX_STEP_STATUS_CANCELED", + "CORTEX_STEP_STATUS_ERROR", + "CORTEX_STEP_STATUS_INTERRUPTED", +]); + +function subagentStatus(status?: string): { + label?: string; + className: string; +} { + if (!status) return { className: "" }; + if (status === "CORTEX_STEP_STATUS_DONE") { + return { label: "Done", className: "cmd-ok" }; + } + if (FAILED_SUBAGENT_STATUSES.has(status)) { + const labels: Record = { + CORTEX_STEP_STATUS_INVALID: "Invalid", + CORTEX_STEP_STATUS_CANCELED: "Canceled", + CORTEX_STEP_STATUS_ERROR: "Failed", + CORTEX_STEP_STATUS_INTERRUPTED: "Interrupted", + }; + return { label: labels[status], className: "cmd-fail" }; + } + if (ACTIVE_SUBAGENT_STATUSES.has(status)) { + const labels: Record = { + CORTEX_STEP_STATUS_GENERATING: "Generating", + CORTEX_STEP_STATUS_QUEUED: "Queued", + CORTEX_STEP_STATUS_PENDING: "Pending", + CORTEX_STEP_STATUS_RUNNING: "Running", + CORTEX_STEP_STATUS_WAITING: "Waiting", + }; + return { label: labels[status], className: "cmd-wait" }; + } + return { className: "" }; +} - let subagents: Array<{ Role?: string; TypeName?: string; Prompt?: string; Model?: string }> = []; - let toolAction = (step as any).toolAction ?? ""; - let toolSummary = (step as any).toolSummary ?? ""; +interface SubagentCardProps { + step: TrajectoryStep; + data?: SubagentDisplayData; +} - if (toolCall?.argumentsJson) { - try { - const parsed = JSON.parse(toolCall.argumentsJson); - if (Array.isArray(parsed.Subagents)) { - subagents = parsed.Subagents; - } - if (parsed.toolAction) toolAction = parsed.toolAction; - if (parsed.toolSummary) toolSummary = parsed.toolSummary; - } catch { - // ignore - } - } +export function SubagentCard({ step, data }: SubagentCardProps) { + const [expanded, setExpanded] = useState(false); + const display = data ?? subagentDataFromStep(step); + if (!display) return null; - const primaryRole = - subagents[0]?.Role || - toolSummary || - (toolName === "define_subagent" ? "Subagent Defined" : "Subagent Invoked"); - const primaryTypeName = subagents[0]?.TypeName || "subagent"; - const promptText = subagents[0]?.Prompt || ""; - const isRunning = - step.status === "CORTEX_STEP_STATUS_RUNNING" || - step.status === "CORTEX_STEP_STATUS_WAITING"; + const hasDetails = display.items.some((item) => item.details.length > 0); + const status = subagentStatus(step.status); return ( -
+
- {expanded && promptText && ( -
-
Instructions:
-
{promptText}
-
- )} +
+ {display.items.map((item, itemIndex) => ( +
+
+ {item.role} + {item.typeName} + {item.model && ( + {item.model} + )} +
+ {expanded && item.details.length > 0 && ( +
+ {item.details.map((itemDetail, detailIndex) => ( +
+
+ {itemDetail.label}: +
+
+                      {itemDetail.text}
+                    
+
+ ))} +
+ )} +
+ ))} +
); } diff --git a/packages/web/src/styles/step-cards.css b/packages/web/src/styles/step-cards.css index 5b6a5a9..b60e04a 100644 --- a/packages/web/src/styles/step-cards.css +++ b/packages/web/src/styles/step-cards.css @@ -694,7 +694,7 @@ .subagent-card .subagent-role { font-weight: 600; color: var(--text-primary); - margin-right: 6px; + min-width: 0; } .subagent-type-badge { @@ -707,18 +707,60 @@ background: var(--bg-hover); color: var(--accent); border: 1px solid var(--border-subtle); - margin-right: 8px; + white-space: nowrap; } -.step-card-subagent-prompt { +.subagent-status { + margin-left: auto; + color: var(--text-muted); + font-size: 11px; + white-space: nowrap; +} + +.subagent-list { border-top: 1px solid var(--border-subtle); - padding: 10px 12px; - font-size: 12px; background: var(--bg-surface); border-bottom-left-radius: var(--radius-md); border-bottom-right-radius: var(--radius-md); } +.subagent-entry { + padding: 8px 14px; +} + +.subagent-entry + .subagent-entry { + border-top: 1px solid var(--border-subtle); +} + +.subagent-entry-header { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.subagent-entry-role { + color: var(--text-secondary); + font-size: 12px; + font-weight: 500; + overflow-wrap: anywhere; +} + +.subagent-model { + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 10px; + overflow-wrap: anywhere; +} + +.subagent-details { + margin-top: 8px; +} + +.subagent-detail + .subagent-detail { + margin-top: 10px; +} + .subagent-prompt-label { font-weight: 600; color: var(--text-muted); diff --git a/packages/web/src/transforms/stepsToMessages.ts b/packages/web/src/transforms/stepsToMessages.ts index 7242953..7ba5d70 100644 --- a/packages/web/src/transforms/stepsToMessages.ts +++ b/packages/web/src/transforms/stepsToMessages.ts @@ -1,5 +1,9 @@ -import type { ChatMessage, TrajectoryStep } from "../types"; +import type { ChatMessage, ToolCallData, TrajectoryStep } from "../types"; import { getAskQuestionRequest, getFilePermissionRequest } from "../utils/stepCards"; +import { + isSubagentToolName, + subagentDataFromStep, +} from "../utils/subagents"; function textFromItems(items?: { text?: string }[]): string { if (!items) return ""; @@ -12,11 +16,25 @@ function textFromItems(items?: { text?: string }[]): string { /** Extract displayable messages from raw trajectory steps */ export function stepsToMessages(steps: TrajectoryStep[]): ChatMessage[] { const messages: ChatMessage[] = []; + const pendingInvokeToolCalls: ToolCallData[] = []; for (let i = 0; i < steps.length; i++) { const step = steps[i]; const type = step.type; + // Older AG trajectories put invoke_subagent arguments on the planner + // response, followed by a payloadless native invocation marker. + if (type === "CORTEX_STEP_TYPE_PLANNER_RESPONSE") { + // Do not let an incomplete/truncated older invocation become the + // fallback for a later planner turn. + pendingInvokeToolCalls.length = 0; + for (const toolCall of step.plannerResponse?.toolCalls ?? []) { + if (toolCall.name === "invoke_subagent") { + pendingInvokeToolCalls.push(toolCall); + } + } + } + // File permission request: emit as a dedicated message type const fpr = getFilePermissionRequest(step); if (fpr) { @@ -46,22 +64,27 @@ export function stepsToMessages(steps: TrajectoryStep[]): ChatMessage[] { continue; } - const toolName = step.metadata?.toolCall?.name ?? ""; - const isSubagent = - toolName === "invoke_subagent" || - toolName === "define_subagent" || - toolName === "manage_subagents" || - toolName === "send_message" || + const toolName = step.metadata?.toolCall?.name; + const isNativeSubagent = type === "CORTEX_STEP_TYPE_INVOKE_SUBAGENT" || type === "CORTEX_STEP_TYPE_SUBAGENT"; + const isSubagent = isSubagentToolName(toolName) || isNativeSubagent; if (isSubagent) { + const fallbackToolCall = isNativeSubagent + ? pendingInvokeToolCalls.shift() + : undefined; + if (!isNativeSubagent && toolName === "invoke_subagent") { + pendingInvokeToolCalls.shift(); + } + const subagent = subagentDataFromStep(step, fallbackToolCall); messages.push({ role: "system", content: "", stepIndex: i, type: "CORTEX_STEP_TYPE_SUBAGENT", step, + subagent, }); continue; } diff --git a/packages/web/src/types/index.ts b/packages/web/src/types/index.ts index 7b0beff..815f5bb 100644 --- a/packages/web/src/types/index.ts +++ b/packages/web/src/types/index.ts @@ -124,6 +124,7 @@ export interface TrajectoryStep { metadata?: StepMetadata; userInput?: { items: StepItem[]; media?: unknown[] }; plannerResponse?: PlannerResponseData; + invokeSubagent?: InvokeSubagentData; runCommand?: RunCommandData; codeAction?: CodeActionData; commandStatus?: CommandStatusData; @@ -146,23 +147,54 @@ export interface PlannerResponseData { modifiedResponse?: string; thinking?: string; thinkingDuration?: string; + toolCalls?: ToolCallData[]; +} + +export interface ToolCallData { + id?: string; + name?: string; + argumentsJson?: string; } export interface StepMetadata { createdAt?: string; completedAt?: string; source?: string; - toolCall?: { - id?: string; - name?: string; - argumentsJson?: string; - }; + executionId?: string; + toolCall?: ToolCallData; + toolSummary?: string; + toolAction?: string; sourceTrajectoryStepInfo?: { trajectoryId?: string; stepIndex?: number; }; } +export interface NativeSubagentSpec { + typeName?: string; + role?: string; + initialPrompt?: string; + model?: string; + modelTier?: string; +} + +export interface SubagentResult { + conversationId?: string; + logAbsoluteUri?: string; + workspaceUris?: string[]; +} + +/** Native payload of CORTEX_STEP_TYPE_INVOKE_SUBAGENT. */ +export interface InvokeSubagentData { + subagents?: NativeSubagentSpec[]; + taskMode?: boolean; + results?: SubagentResult[]; + /** Legacy single-subagent fields retained by the AG protocol. */ + subagentName?: string; + prompt?: string; + conversationId?: string; +} + export interface RunCommandData { command?: string; commandLine?: string; @@ -257,6 +289,29 @@ export interface StepItem { text?: string; } +export type SubagentToolKind = "invoke" | "define" | "manage" | "message"; + +export interface SubagentDisplayDetail { + label: string; + text: string; +} + +export interface SubagentDisplayItem { + role: string; + typeName: string; + model?: string; + details: SubagentDisplayDetail[]; +} + +/** Sanitized, tool-independent data consumed by SubagentCard. */ +export interface SubagentDisplayData { + toolName: string; + kind: SubagentToolKind; + title: string; + action?: string; + items: SubagentDisplayItem[]; +} + /** Normalized message for display */ export interface ChatMessage { role: "user" | "assistant" | "system"; @@ -265,6 +320,8 @@ export interface ChatMessage { type: string; /** Original step data for rich rendering */ step?: TrajectoryStep; + /** Normalized subagent data for rich rendering */ + subagent?: SubagentDisplayData; /** Media attachments (images/video) */ media?: unknown[]; /** Extended thinking / chain-of-thought content */ diff --git a/packages/web/src/utils/subagents.ts b/packages/web/src/utils/subagents.ts new file mode 100644 index 0000000..f788028 --- /dev/null +++ b/packages/web/src/utils/subagents.ts @@ -0,0 +1,239 @@ +import type { + SubagentDisplayData, + SubagentDisplayDetail, + SubagentDisplayItem, + SubagentToolKind, + ToolCallData, + TrajectoryStep, +} from "../types"; + +const SUBAGENT_TOOL_KINDS: Record = { + invoke_subagent: "invoke", + define_subagent: "define", + manage_subagents: "manage", + send_message: "message", +}; + +type JsonRecord = Record; + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseArguments(argumentsJson?: string): JsonRecord | undefined { + if (!argumentsJson) return undefined; + try { + const parsed: unknown = JSON.parse(argumentsJson); + return isRecord(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} + +function stringField( + value: JsonRecord | undefined, + ...names: string[] +): string | undefined { + if (!value) return undefined; + for (const name of names) { + const field = value[name]; + if (typeof field === "string" && field.trim()) return field.trim(); + } + return undefined; +} + +function stringArrayField( + value: JsonRecord | undefined, + ...names: string[] +): string[] { + if (!value) return []; + for (const name of names) { + const field = value[name]; + if (!Array.isArray(field)) continue; + return field.filter( + (entry): entry is string => typeof entry === "string" && !!entry.trim(), + ); + } + return []; +} + +function detail(label: string, text?: string): SubagentDisplayDetail[] { + return text ? [{ label, text }] : []; +} + +function parsedSubagents(args?: JsonRecord): JsonRecord[] { + const value = args?.Subagents ?? args?.subagents; + return Array.isArray(value) ? value.filter(isRecord) : []; +} + +function invokeItems( + step: TrajectoryStep, + args?: JsonRecord, +): SubagentDisplayItem[] { + const nativeItems = step.invokeSubagent?.subagents ?? []; + if (nativeItems.length > 0) { + return nativeItems.map((subagent) => ({ + role: subagent.role?.trim() || "Subagent", + typeName: subagent.typeName?.trim() || "subagent", + model: subagent.model?.trim() || subagent.modelTier?.trim() || undefined, + details: detail("Instructions", subagent.initialPrompt?.trim()), + })); + } + + const items = parsedSubagents(args).map((subagent) => { + const model = stringField( + subagent, + "Model", + "model", + "ModelTier", + "modelTier", + ); + return { + role: stringField(subagent, "Role", "role") ?? "Subagent", + typeName: + stringField(subagent, "TypeName", "typeName", "Name", "name") ?? + "subagent", + model, + details: detail( + "Instructions", + stringField( + subagent, + "Prompt", + "prompt", + "InitialPrompt", + "initialPrompt", + ), + ), + }; + }); + if (items.length > 0) return items; + + const role = + step.invokeSubagent?.subagentName?.trim() || + stringField(args, "SubagentName", "subagentName", "Role", "role"); + const prompt = + step.invokeSubagent?.prompt?.trim() || + stringField(args, "Prompt", "prompt"); + + return [ + { + role: role || "Subagent", + typeName: "subagent", + details: detail("Instructions", prompt), + }, + ]; +} + +function defineItems(args?: JsonRecord): SubagentDisplayItem[] { + const name = stringField(args, "name", "Name") ?? "Subagent definition"; + const description = stringField(args, "description", "Description"); + const systemPrompt = stringField(args, "system_prompt", "systemPrompt"); + const details: SubagentDisplayDetail[] = []; + if (description) details.push({ label: "Description", text: description }); + if (systemPrompt) details.push({ label: "System prompt", text: systemPrompt }); + return [{ role: name, typeName: "definition", details }]; +} + +function messageItems(args?: JsonRecord): SubagentDisplayItem[] { + const recipient = + stringField(args, "Recipient", "recipient") ?? "Subagent"; + const message = stringField(args, "Message", "message"); + return [ + { + role: recipient, + typeName: "message", + details: detail("Message", message), + }, + ]; +} + +function manageItems(args?: JsonRecord): SubagentDisplayItem[] { + const action = stringField(args, "Action", "action") ?? "Manage"; + const ids = stringArrayField( + args, + "ConversationIds", + "conversationIds", + "conversation_ids", + ); + return [ + { + role: action.replaceAll("_", " "), + typeName: "manage", + details: detail("Conversation IDs", ids.join("\n")), + }, + ]; +} + +function defaultTitle( + kind: SubagentToolKind, + items: SubagentDisplayItem[], + args?: JsonRecord, +): string { + switch (kind) { + case "invoke": + return items.length === 1 + ? "Subagent Invoked" + : `${items.length} Subagents Invoked`; + case "define": + return `Define ${items[0]?.role ?? "Subagent"}`; + case "message": + return `Message to ${items[0]?.role ?? "Subagent"}`; + case "manage": { + const action = stringField(args, "Action", "action")?.toLowerCase(); + if (action === "list") return "List Subagents"; + if (action === "kill_all") return "Stop All Subagents"; + if (action === "kill") return "Stop Subagents"; + return "Manage Subagents"; + } + } +} + +export function isSubagentToolName(name?: string): name is string { + return ( + !!name && Object.prototype.hasOwnProperty.call(SUBAGENT_TOOL_KINDS, name) + ); +} + +/** + * Converts both current native AG subagent steps and older tool-call-shaped + * steps into a small display model. The fallback call covers older captures + * where the native marker has no payload and arguments only exist on the + * preceding planner response. + */ +export function subagentDataFromStep( + step: TrajectoryStep, + fallbackToolCall?: ToolCallData, +): SubagentDisplayData | undefined { + const nativeInvoke = + step.type === "CORTEX_STEP_TYPE_INVOKE_SUBAGENT" || + step.type === "CORTEX_STEP_TYPE_SUBAGENT"; + const toolCall = step.metadata?.toolCall ?? fallbackToolCall; + const toolName = nativeInvoke + ? toolCall?.name || "invoke_subagent" + : toolCall?.name; + if (!isSubagentToolName(toolName)) return undefined; + + const kind = SUBAGENT_TOOL_KINDS[toolName]; + const args = parseArguments(toolCall?.argumentsJson); + const items = + kind === "invoke" + ? invokeItems(step, args) + : kind === "define" + ? defineItems(args) + : kind === "message" + ? messageItems(args) + : manageItems(args); + const legacySummary = stringField(args, "toolSummary", "tool_summary"); + const legacyAction = stringField(args, "toolAction", "tool_action"); + + return { + toolName, + kind, + title: + step.metadata?.toolSummary || + legacySummary || + defaultTitle(kind, items, args), + action: step.metadata?.toolAction || legacyAction, + items, + }; +}