Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

[![CI](https://github.com/L1M80/porta/actions/workflows/ci.yml/badge.svg)](https://github.com/L1M80/porta/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
![Version](https://img.shields.io/badge/version-0.14.0-green)
![Version](https://img.shields.io/badge/version-0.15.0-green)

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.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "porta",
"version": "0.14.0",
"version": "0.15.0",
"private": true,
"scripts": {
"dev": "node scripts/dev.mjs",
Expand Down
8 changes: 4 additions & 4 deletions packages/web/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

101 changes: 101 additions & 0 deletions packages/web/src/__tests__/SubagentCard.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<SubagentCard step={nativeStep()} />);

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(<SubagentCard step={nativeStep(status)} />);

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(<SubagentCard step={step} />);

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: '<img src=x onerror="alert(1)">',
initialPrompt: "safe text",
},
],
},
};
const { container } = render(<SubagentCard step={step} />);

expect(
screen.getByText('<img src=x onerror="alert(1)">'),
).toBeInTheDocument();
expect(container.querySelector("img")).toBeNull();
});
});
211 changes: 211 additions & 0 deletions packages/web/src/__tests__/stepsToMessages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
});
});
Loading
Loading