diff --git a/CHANGELOG.md b/CHANGELOG.md
index f8bd925..3b8b3cc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
## [Unreleased]
+## [0.15.0] - 2026-08-03
+
+### Added
+
+- Antigravity subagent activity is now shown as rich status cards with agent
+ roles, types, prompts, tool actions, and execution states. (#122)
+
+### Fixed
+
+- Subagent cards now support both current native `invokeSubagent` payloads and
+ older captures where invocation arguments are stored on the preceding planner
+ response. (#124)
+- Multiple invoked subagents are all displayed, and `define_subagent`,
+ `send_message`, and `manage_subagents` are rendered with tool-specific
+ details. (#124)
+- Pending, running, completed, canceled, interrupted, invalid, and failed
+ subagent states are now visually distinguishable. (#124)
+
+### Security
+
+- Updated `brace-expansion` to a version containing upstream regular-expression
+ denial-of-service fixes. (#118)
+
## [0.14.0] - 2026-07-26
### Added
diff --git a/README.md b/README.md
index 1f6b700..314679d 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
[](https://github.com/L1M80/porta/actions/workflows/ci.yml)
[](LICENSE)
-
+
Remote web interface for [Antigravity](https://antigravity.google/) Agent Manager.
Access your local Antigravity sessions from your phone, tablet, or any remote browser through a lightweight LSP bridge.
diff --git a/package.json b/package.json
index 3088802..b9c3a6c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "porta",
- "version": "0.14.0",
+ "version": "0.15.0",
"private": true,
"scripts": {
"dev": "node scripts/dev.mjs",
diff --git a/packages/web/package-lock.json b/packages/web/package-lock.json
index f72bf79..c8da340 100644
--- a/packages/web/package-lock.json
+++ b/packages/web/package-lock.json
@@ -3759,16 +3759,16 @@
}
},
"node_modules/brace-expansion": {
- "version": "5.0.7",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
- "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
+ "version": "5.0.8",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
+ "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
- "node": "18 || 20 || >=22"
+ "node": "20 || >=22"
}
},
"node_modules/browserslist": {
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 92dd75f..8115d4c 100644
--- a/packages/web/src/__tests__/stepsToMessages.test.ts
+++ b/packages/web/src/__tests__/stepsToMessages.test.ts
@@ -428,4 +428,215 @@ describe("stepsToMessages", () => {
expect(msgs[3].role).toBe("assistant");
expect(msgs).toHaveLength(4);
});
+
+ // ── Subagent steps ──
+
+ it("converts invoke_subagent tool call to subagent system message", () => {
+ const step: TrajectoryStep = {
+ type: "CORTEX_STEP_TYPE_TOOL_CALL",
+ metadata: {
+ toolCall: {
+ name: "invoke_subagent",
+ argumentsJson: JSON.stringify({
+ Subagents: [
+ {
+ Role: "Config Auditor",
+ TypeName: "research",
+ Prompt: "Audit all config files",
+ },
+ ],
+ toolAction: "Invoking research subagent",
+ }),
+ },
+ },
+ };
+
+ const msgs = stepsToMessages([step]);
+ 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 eb0842b..8751eae 100644
--- a/packages/web/src/components/ChatPanel.tsx
+++ b/packages/web/src/components/ChatPanel.tsx
@@ -24,6 +24,7 @@ import {
CommandCard,
CodeActionCard,
FilePermissionCard,
+ SubagentCard,
} from "./StepCards";
import { getAskQuestionRequest, getFilePermissionRequest } from "../utils/stepCards";
import {
@@ -313,6 +314,13 @@ function SystemMessage({
);
}
+ if (msg.type === "CORTEX_STEP_TYPE_SUBAGENT") {
+ return (
+
+
+
+ );
+ }
}
return (
diff --git a/packages/web/src/components/Icons.tsx b/packages/web/src/components/Icons.tsx
index ae0b5d7..8e55698 100644
--- a/packages/web/src/components/Icons.tsx
+++ b/packages/web/src/components/Icons.tsx
@@ -247,3 +247,11 @@ export const IconGear = ({ size = 16, className }: IconProps) =>
export const IconChevronLeft = ({ size = 16, className }: IconProps) =>
d(size, className, "m15 18-6-6 6-6");
+
+export const IconUsers = ({ size = 16, className }: IconProps) =>
+ m(size, className, [
+ "M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2",
+ "M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z",
+ "M22 21v-2a4 4 0 0 0-3-3.87",
+ "M16 3.13a4 4 0 0 1 0 7.75",
+ ]);
diff --git a/packages/web/src/components/StepCards.tsx b/packages/web/src/components/StepCards.tsx
index ff1ccb0..33fdc61 100644
--- a/packages/web/src/components/StepCards.tsx
+++ b/packages/web/src/components/StepCards.tsx
@@ -8,14 +8,17 @@ import {
IconFileText,
IconLock,
IconMessageCircle,
+ IconUsers,
} from "./Icons";
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 {
@@ -592,3 +595,128 @@ export function CodeActionCard({ step }: CodeActionCardProps) {
);
}
+
+// ── Subagent Card ──
+
+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: "" };
+}
+
+interface SubagentCardProps {
+ step: TrajectoryStep;
+ data?: SubagentDisplayData;
+}
+
+export function SubagentCard({ step, data }: SubagentCardProps) {
+ const [expanded, setExpanded] = useState(false);
+ const display = data ?? subagentDataFromStep(step);
+ if (!display) return null;
+
+ const hasDetails = display.items.some((item) => item.details.length > 0);
+ const status = subagentStatus(step.status);
+
+ return (
+
+
+
+ {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 3430973..b60e04a 100644
--- a/packages/web/src/styles/step-cards.css
+++ b/packages/web/src/styles/step-cards.css
@@ -688,3 +688,93 @@
border-color: var(--accent-hover);
box-shadow: 0 2px 12px rgba(99, 102, 241, 0.3);
}
+
+/* ── Subagent Card ── */
+
+.subagent-card .subagent-role {
+ font-weight: 600;
+ color: var(--text-primary);
+ min-width: 0;
+}
+
+.subagent-type-badge {
+ font-size: 10px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ padding: 2px 6px;
+ border-radius: 4px;
+ background: var(--bg-hover);
+ color: var(--accent);
+ border: 1px solid var(--border-subtle);
+ white-space: nowrap;
+}
+
+.subagent-status {
+ margin-left: auto;
+ color: var(--text-muted);
+ font-size: 11px;
+ white-space: nowrap;
+}
+
+.subagent-list {
+ border-top: 1px solid var(--border-subtle);
+ 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);
+ margin-bottom: 4px;
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+}
+
+.subagent-prompt-text {
+ white-space: pre-wrap;
+ word-break: break-word;
+ font-family: var(--font-mono);
+ color: var(--text-secondary);
+ margin: 0;
+ line-height: 1.4;
+}
diff --git a/packages/web/src/transforms/stepsToMessages.ts b/packages/web/src/transforms/stepsToMessages.ts
index b25482d..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,6 +64,31 @@ export function stepsToMessages(steps: TrajectoryStep[]): ChatMessage[] {
continue;
}
+ 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;
+ }
+
if (type === "CORTEX_STEP_TYPE_USER_INPUT" && step.userInput?.items) {
const text = textFromItems(step.userInput.items);
const media = step.userInput.media;
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,
+ };
+}