From efada2fe18c0c1bc5b5ee943bb47af79ff4b3551 Mon Sep 17 00:00:00 2001 From: Ross <144740362+ross0x01@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:55:27 -0500 Subject: [PATCH 01/27] Add independent validation subagents --- .../cancel/__tests__/route.test.ts | 79 ++ .../subagents/[subagentId]/cancel/route.ts | 46 ++ .../token/__tests__/route.test.ts | 69 ++ app/api/subagents/[subagentId]/token/route.ts | 48 ++ app/components/ComputerSidebar.tsx | 15 +- app/components/MessagePartHandler.tsx | 12 + app/components/SubagentsSidebar.tsx | 534 ++++++++++++ .../__tests__/SubagentsSidebar.test.tsx | 129 +++ app/components/tools/SubagentToolHandler.tsx | 100 +++ .../__tests__/SubagentToolHandler.test.tsx | 54 ++ app/hooks/useSubagentRealtime.ts | 104 +++ .../components/SharedMessagePartHandler.tsx | 37 + convex/__tests__/subagents.test.ts | 248 ++++++ convex/__tests__/vulnerabilityReports.test.ts | 203 +++++ convex/_generated/api.d.ts | 4 + convex/chats.ts | 45 + convex/schema.ts | 148 ++++ convex/subagents.ts | 775 ++++++++++++++++++ convex/userDeletion.ts | 47 +- convex/vulnerabilityReports.ts | 233 ++++++ lib/__tests__/system-prompt.test.ts | 29 + lib/ai/subagents/__tests__/contracts.test.ts | 77 ++ .../__tests__/runtime-contracts.test.ts | 55 ++ lib/ai/subagents/contracts.ts | 215 +++++ lib/ai/subagents/fingerprint.ts | 39 + lib/ai/subagents/profiles.ts | 74 ++ lib/ai/subagents/sandbox-identity.ts | 20 + lib/ai/tools/delegate-task.ts | 284 +++++++ lib/ai/tools/index.ts | 19 + lib/ai/tools/interact-terminal-session.ts | 7 +- lib/ai/tools/run-terminal-cmd.ts | 11 +- lib/ai/tools/vulnerability-report.ts | 24 + lib/analytics/sandbox-resource-pressure.ts | 2 + lib/analytics/subagents.ts | 51 ++ .../__tests__/agent-long-contracts.test.ts | 2 +- lib/api/agent-trigger-route.ts | 6 + lib/db/subagents.ts | 222 +++++ lib/posthog/__tests__/server.test.ts | 22 +- lib/posthog/server.ts | 13 + lib/posthog/subagent-feature.ts | 14 + lib/system-prompt.ts | 12 + lib/utils/__tests__/sidebar-utils.test.ts | 21 + lib/utils/sidebar-utils.ts | 13 + trigger/agent-long.ts | 48 ++ trigger/subagent.ts | 607 ++++++++++++++ types/agent.ts | 2 + types/chat.ts | 15 +- 47 files changed, 4817 insertions(+), 17 deletions(-) create mode 100644 app/api/subagents/[subagentId]/cancel/__tests__/route.test.ts create mode 100644 app/api/subagents/[subagentId]/cancel/route.ts create mode 100644 app/api/subagents/[subagentId]/token/__tests__/route.test.ts create mode 100644 app/api/subagents/[subagentId]/token/route.ts create mode 100644 app/components/SubagentsSidebar.tsx create mode 100644 app/components/__tests__/SubagentsSidebar.test.tsx create mode 100644 app/components/tools/SubagentToolHandler.tsx create mode 100644 app/components/tools/__tests__/SubagentToolHandler.test.tsx create mode 100644 app/hooks/useSubagentRealtime.ts create mode 100644 convex/__tests__/subagents.test.ts create mode 100644 convex/__tests__/vulnerabilityReports.test.ts create mode 100644 convex/subagents.ts create mode 100644 convex/vulnerabilityReports.ts create mode 100644 lib/ai/subagents/__tests__/contracts.test.ts create mode 100644 lib/ai/subagents/__tests__/runtime-contracts.test.ts create mode 100644 lib/ai/subagents/contracts.ts create mode 100644 lib/ai/subagents/fingerprint.ts create mode 100644 lib/ai/subagents/profiles.ts create mode 100644 lib/ai/subagents/sandbox-identity.ts create mode 100644 lib/ai/tools/delegate-task.ts create mode 100644 lib/ai/tools/vulnerability-report.ts create mode 100644 lib/analytics/subagents.ts create mode 100644 lib/db/subagents.ts create mode 100644 lib/posthog/subagent-feature.ts create mode 100644 trigger/subagent.ts diff --git a/app/api/subagents/[subagentId]/cancel/__tests__/route.test.ts b/app/api/subagents/[subagentId]/cancel/__tests__/route.test.ts new file mode 100644 index 000000000..ceaa602d1 --- /dev/null +++ b/app/api/subagents/[subagentId]/cancel/__tests__/route.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; + +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: { status?: number }) => ({ + status: init?.status ?? 200, + json: async () => body, + }), + }, +})); + +const getUserID = jest.fn(); +jest.mock("@/lib/auth/get-user-id", () => ({ + getUserID: (...args: unknown[]) => getUserID(...args), +})); + +const cancelAgentTriggerRun = jest.fn(); +jest.mock("@/lib/api/agent-approval-session", () => ({ + cancelAgentTriggerRun: (...args: unknown[]) => cancelAgentTriggerRun(...args), +})); + +const getOwnedSubagent = jest.fn(); +const cancelSubagentForUser = jest.fn(); +jest.mock("@/lib/db/subagents", () => ({ + getOwnedSubagent: (...args: unknown[]) => getOwnedSubagent(...args), + cancelSubagentForUser: (...args: unknown[]) => cancelSubagentForUser(...args), +})); + +const { POST } = require("../route") as typeof import("../route"); + +describe("subagent cancel route", () => { + beforeEach(() => { + jest.clearAllMocks(); + getUserID.mockResolvedValue("user-1"); + getOwnedSubagent.mockResolvedValue({ + status: "running", + trigger_run_id: "child-run-1", + }); + cancelAgentTriggerRun.mockResolvedValue(true); + cancelSubagentForUser.mockResolvedValue(true); + }); + + it("cancels exactly the authenticated user's active child run", async () => { + const response = await POST({} as any, { + params: Promise.resolve({ subagentId: "sa_1" }), + }); + + expect(response.status).toBe(200); + expect(getOwnedSubagent).toHaveBeenCalledWith("sa_1", "user-1"); + expect(cancelAgentTriggerRun).toHaveBeenCalledWith("child-run-1"); + expect(cancelSubagentForUser).toHaveBeenCalledWith({ + subagentId: "sa_1", + userId: "user-1", + triggerRunId: "child-run-1", + reason: "user_canceled_child", + }); + }); + + it("does not reveal or cancel another user's child", async () => { + getOwnedSubagent.mockRejectedValue(new Error("not owned")); + const response = await POST({} as any, { + params: Promise.resolve({ subagentId: "sa_other" }), + }); + + expect(response.status).toBe(404); + expect(cancelAgentTriggerRun).not.toHaveBeenCalled(); + expect(cancelSubagentForUser).not.toHaveBeenCalled(); + }); + + it("waits for a queued child to receive a Trigger run id", async () => { + getOwnedSubagent.mockResolvedValue({ status: "queued" }); + const response = await POST({} as any, { + params: Promise.resolve({ subagentId: "sa_queued" }), + }); + + expect(response.status).toBe(409); + expect(cancelAgentTriggerRun).not.toHaveBeenCalled(); + }); +}); diff --git a/app/api/subagents/[subagentId]/cancel/route.ts b/app/api/subagents/[subagentId]/cancel/route.ts new file mode 100644 index 000000000..bc6f9a39a --- /dev/null +++ b/app/api/subagents/[subagentId]/cancel/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { getUserID } from "@/lib/auth/get-user-id"; +import { cancelAgentTriggerRun } from "@/lib/api/agent-approval-session"; +import { cancelSubagentForUser, getOwnedSubagent } from "@/lib/db/subagents"; +import { SUBAGENT_ACTIVE_STATUSES } from "@/lib/ai/subagents/contracts"; + +export const maxDuration = 30; + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ subagentId: string }> }, +) { + try { + const { subagentId } = await params; + if (!subagentId) { + return NextResponse.json( + { error: "Subagent ID required" }, + { status: 400 }, + ); + } + const userId = await getUserID(req); + const child = await getOwnedSubagent(subagentId, userId); + if (!SUBAGENT_ACTIVE_STATUSES.has(child.status)) { + return NextResponse.json({ canceled: false, status: child.status }); + } + if (!child.trigger_run_id) { + return NextResponse.json( + { canceled: false, status: child.status }, + { status: 409 }, + ); + } + const canceled = await cancelAgentTriggerRun(child.trigger_run_id); + if (canceled) { + await cancelSubagentForUser({ + subagentId, + userId, + triggerRunId: child.trigger_run_id, + reason: "user_canceled_child", + }); + } + return NextResponse.json({ canceled, status: child.status }); + } catch { + return NextResponse.json({ error: "Subagent not found" }, { status: 404 }); + } +} diff --git a/app/api/subagents/[subagentId]/token/__tests__/route.test.ts b/app/api/subagents/[subagentId]/token/__tests__/route.test.ts new file mode 100644 index 000000000..3ce0dcd6e --- /dev/null +++ b/app/api/subagents/[subagentId]/token/__tests__/route.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; + +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: { status?: number }) => ({ + status: init?.status ?? 200, + json: async () => body, + }), + }, +})); + +const createPublicToken = jest.fn(); +jest.mock("@trigger.dev/sdk", () => ({ + auth: { + createPublicToken: (...args: unknown[]) => createPublicToken(...args), + }, +})); + +const getUserID = jest.fn(); +jest.mock("@/lib/auth/get-user-id", () => ({ + getUserID: (...args: unknown[]) => getUserID(...args), +})); + +const getOwnedSubagent = jest.fn(); +jest.mock("@/lib/db/subagents", () => ({ + getOwnedSubagent: (...args: unknown[]) => getOwnedSubagent(...args), +})); + +const { POST } = require("../route") as typeof import("../route"); + +describe("subagent realtime token route", () => { + beforeEach(() => { + jest.clearAllMocks(); + getUserID.mockResolvedValue("user-1"); + getOwnedSubagent.mockResolvedValue({ trigger_run_id: "child-run-1" }); + createPublicToken.mockResolvedValue("scoped-token"); + }); + + it("mints a short-lived read token for exactly the owned child run", async () => { + const response = await POST({} as any, { + params: Promise.resolve({ subagentId: "sa_1" }), + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual( + expect.objectContaining({ + accessToken: "scoped-token", + runId: "child-run-1", + streamId: "ui", + expiresInSeconds: 600, + }), + ); + expect(getOwnedSubagent).toHaveBeenCalledWith("sa_1", "user-1"); + expect(createPublicToken).toHaveBeenCalledWith({ + scopes: { read: { runs: ["child-run-1"] } }, + expirationTime: "10m", + }); + }); + + it("does not reveal a child that the authenticated user does not own", async () => { + getOwnedSubagent.mockRejectedValue(new Error("not owned")); + const response = await POST({} as any, { + params: Promise.resolve({ subagentId: "sa_other" }), + }); + + expect(response.status).toBe(404); + expect(createPublicToken).not.toHaveBeenCalled(); + }); +}); diff --git a/app/api/subagents/[subagentId]/token/route.ts b/app/api/subagents/[subagentId]/token/route.ts new file mode 100644 index 000000000..242372b49 --- /dev/null +++ b/app/api/subagents/[subagentId]/token/route.ts @@ -0,0 +1,48 @@ +import { auth } from "@trigger.dev/sdk"; +import { NextRequest, NextResponse } from "next/server"; + +import { getUserID } from "@/lib/auth/get-user-id"; +import { getOwnedSubagent } from "@/lib/db/subagents"; +import { AGENT_UI_STREAM_ID } from "@/trigger/stream-ids"; + +export const maxDuration = 30; +export const dynamic = "force-dynamic"; + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ subagentId: string }> }, +) { + try { + const { subagentId } = await params; + if (!subagentId) { + return NextResponse.json( + { error: "Subagent ID required" }, + { status: 400 }, + ); + } + const userId = await getUserID(req); + const child = await getOwnedSubagent(subagentId, userId); + if (!child.trigger_run_id) { + return NextResponse.json( + { error: "Child run has not started" }, + { status: 409 }, + ); + } + + const accessToken = await auth.createPublicToken({ + scopes: { read: { runs: [child.trigger_run_id] } }, + expirationTime: "10m", + }); + return NextResponse.json( + { + accessToken, + runId: child.trigger_run_id, + streamId: AGENT_UI_STREAM_ID, + expiresInSeconds: 600, + }, + { headers: { "Cache-Control": "no-store" } }, + ); + } catch { + return NextResponse.json({ error: "Subagent not found" }, { status: 404 }); + } +} diff --git a/app/components/ComputerSidebar.tsx b/app/components/ComputerSidebar.tsx index 6b14bd27c..1642c5622 100644 --- a/app/components/ComputerSidebar.tsx +++ b/app/components/ComputerSidebar.tsx @@ -32,6 +32,7 @@ import { isSidebarWebSearch, isSidebarNotes, isSidebarSharedFiles, + isSidebarSubagents, type SidebarContent, type ChatStatus, type NoteCategory, @@ -51,6 +52,11 @@ import { getDisplayTarget, } from "./computer-sidebar-utils"; +const SubagentsSidebar = dynamic( + () => import("./SubagentsSidebar").then((module) => module.SubagentsSidebar), + { ssr: false }, +); + interface ComputerSidebarProps { sidebarOpen: boolean; sidebarContent: SidebarContent | null; @@ -750,8 +756,7 @@ export const ComputerSidebarBase: React.FC = ({ key={file.fileId || `file-${index}`} part={{ fileId: file.fileId as - | Id<"files"> - | undefined, + Id<"files"> | undefined, s3Key: file.s3Key, name: file.name, filename: file.name, @@ -1030,6 +1035,12 @@ export const ComputerSidebar: React.FC<{ const { sidebarOpen, sidebarContent, closeSidebar, openSidebar } = useGlobalState(); + if (sidebarOpen && sidebarContent && isSidebarSubagents(sidebarContent)) { + return ( + + ); + } + return ( ; + case "tool-delegate_task": + return ( + + ); + + case "tool-vulnerability_report": + return ; + case "tool-create_note": return ( diff --git a/app/components/SubagentsSidebar.tsx b/app/components/SubagentsSidebar.tsx new file mode 100644 index 000000000..024fdfec4 --- /dev/null +++ b/app/components/SubagentsSidebar.tsx @@ -0,0 +1,534 @@ +"use client"; + +import { memo, useEffect, useMemo, useRef, useState } from "react"; +import { useQuery } from "convex/react"; +import { + ArrowLeft, + Ban, + CheckCircle2, + ChevronRight, + CircleAlert, + LoaderCircle, + Minimize2, + RefreshCw, + ShieldCheck, + Users, +} from "lucide-react"; +import type { UIMessage } from "ai"; + +import { api } from "@/convex/_generated/api"; +import type { SidebarSubagents } from "@/types/chat"; +import { MessagePartHandler } from "./MessagePartHandler"; +import { useSubagentRealtime } from "@/app/hooks/useSubagentRealtime"; +import { captureAuthenticatedEvent } from "@/lib/analytics/client"; + +type ChildStatus = + | "queued" + | "running" + | "finalizing" + | "completed" + | "failed" + | "canceled" + | "timed_out"; + +type ChildSummary = { + subagent_id: string; + parent_trigger_run_id: string; + parent_tool_call_id: string; + trigger_run_id?: string; + status: ChildStatus; + candidate: { title: string; affected_asset: string }; + summary?: string; + verdict?: "confirmed" | "rejected" | "inconclusive"; + confidence?: "low" | "medium" | "high"; + failure_code?: string; + failure_reason?: string; + cancel_reason?: string; + report_id?: string; + cost_dollars?: number; + step_count?: number; + created_at: number; + started_at?: number; + completed_at?: number; +}; + +const ACTIVE_STATUSES = new Set([ + "queued", + "running", + "finalizing", +]); + +const isActive = (status: ChildStatus) => ACTIVE_STATUSES.has(status); + +const formatElapsed = (start: number, end: number): string => { + const seconds = Math.max(0, Math.floor((end - start) / 1_000)); + const minutes = Math.floor(seconds / 60); + const remainder = seconds % 60; + return minutes > 0 ? `${minutes}m ${remainder}s` : `${remainder}s`; +}; + +const statusLabel = (child: ChildSummary): string => { + if (child.status === "completed") { + return child.verdict === "confirmed" + ? "Confirmed" + : child.verdict === "rejected" + ? "Rejected" + : "Inconclusive"; + } + if (child.status === "timed_out") return "Timed out"; + return child.status.charAt(0).toUpperCase() + child.status.slice(1); +}; + +const StatusIcon = ({ child }: { child: ChildSummary }) => { + if (isActive(child.status)) { + return ; + } + if (child.status === "completed" && child.verdict === "confirmed") { + return ; + } + if (child.status === "completed") { + return ( + + ); + } + if (child.status === "canceled") { + return ; + } + return ; +}; + +const ChildRow = ({ + child, + now, + onOpen, +}: { + child: ChildSummary; + now: number; + onOpen: () => void; +}) => ( + +); + +const Transcript = memo(function Transcript({ + child, +}: { + child: ChildSummary; +}) { + const persisted = useQuery(api.subagents.getMessagesOwned, { + subagentId: child.subagent_id, + }); + const active = isActive(child.status); + const hasPersistedAssistant = persisted?.some( + (message) => message.role === "assistant", + ); + const { + message: liveMessage, + state, + retry, + } = useSubagentRealtime({ + subagentId: child.subagent_id, + enabled: + !!child.trigger_run_id && + (active || (persisted !== undefined && !hasPersistedAssistant)), + }); + + const messages = useMemo(() => { + const saved = (persisted ?? []).map((message): UIMessage => ({ + id: `${child.subagent_id}-${message.sequence}`, + role: message.role, + parts: message.parts as UIMessage["parts"], + })); + return liveMessage && !hasPersistedAssistant + ? [...saved, liveMessage] + : saved; + }, [child.subagent_id, hasPersistedAssistant, liveMessage, persisted]); + + if (persisted === undefined) { + return ( +
+ Loading transcript… +
+ ); + } + + return ( +
+ {messages.length === 0 && state !== "error" && ( +
+ {(active || state === "connecting") && ( + + )} + {state === "connecting" + ? "Connecting to activity…" + : active + ? "Waiting for activity…" + : "No transcript activity was persisted."} +
+ )} + {state === "error" && active && ( +
+

Live activity disconnected.

+ +
+ )} + {state === "error" && !active && messages.length === 0 && ( +
+ Transcript activity is unavailable. The final status above is still + authoritative. +
+ )} +
+ {messages.map((message) => ( +
+
+ {message.role === "assistant" ? "Validator" : "Validation brief"} +
+
+ {message.parts.map((part, partIndex) => ( + + ))} +
+
+ ))} +
+
+ ); +}); + +export const SubagentsSidebar = ({ + content, + closeSidebar, +}: { + content: SidebarSubagents; + closeSidebar: () => void; +}) => { + const runs = useQuery(api.subagents.listForParentMessage, { + parentMessageId: content.parentMessageId, + }) as ChildSummary[] | undefined; + const [selectedId, setSelectedId] = useState( + content.selectedSubagentId ?? null, + ); + const [now, setNow] = useState(Date.now()); + const [canceling, setCanceling] = useState(false); + const [cancelError, setCancelError] = useState(null); + const openedChildren = useRef(new Set()); + const selectedForCleanup = useRef(null); + const selectedOpenedAt = useRef<{ id: string; at: number } | null>(null); + + const selected = + runs?.find((child) => child.subagent_id === selectedId) ?? null; + selectedForCleanup.current = selected; + const active = runs?.filter((child) => isActive(child.status)) ?? []; + const done = runs?.filter((child) => !isActive(child.status)) ?? []; + + useEffect(() => { + captureAuthenticatedEvent("subagent_sidebar_opened", { + parent_message_id: content.parentMessageId, + profile: "security_validation", + view: "list", + }); + return () => { + const child = selectedForCleanup.current; + if (child && isActive(child.status)) { + const openedAt = selectedOpenedAt.current; + captureAuthenticatedEvent("subagent_abandoned", { + subagent_id: child.subagent_id, + parent_trigger_run_id: child.parent_trigger_run_id, + profile: "security_validation", + status: child.status, + open_duration_ms: + Date.now() - + (openedAt?.id === child.subagent_id + ? openedAt.at + : child.created_at), + }); + } + }; + }, [content.parentMessageId]); + + useEffect(() => { + if (!selected) return; + selectedOpenedAt.current = { id: selected.subagent_id, at: Date.now() }; + if (openedChildren.current.has(selected.subagent_id)) return; + openedChildren.current.add(selected.subagent_id); + captureAuthenticatedEvent("subagent_opened", { + subagent_id: selected.subagent_id, + parent_trigger_run_id: selected.parent_trigger_run_id, + profile: "security_validation", + status: selected.status, + open_latency_ms: Date.now() - selected.created_at, + }); + }, [selected]); + + useEffect(() => { + const handleEscape = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + if (selectedId) { + setSelectedId(null); + } else { + closeSidebar(); + } + }; + window.addEventListener("keydown", handleEscape); + return () => window.removeEventListener("keydown", handleEscape); + }, [closeSidebar, selectedId]); + + useEffect(() => { + if (!runs?.some((child) => isActive(child.status))) return; + const interval = window.setInterval(() => setNow(Date.now()), 1_000); + return () => window.clearInterval(interval); + }, [runs]); + + const cancelSelected = async () => { + if (!selected || !isActive(selected.status)) return; + setCanceling(true); + setCancelError(null); + try { + const response = await fetch( + `/api/subagents/${encodeURIComponent(selected.subagent_id)}/cancel`, + { method: "POST" }, + ); + if (!response.ok) throw new Error("Cancel failed"); + } catch { + setCancelError("Could not cancel this validation. Try again."); + } finally { + setCanceling(false); + } + }; + + return ( + + ); +}; diff --git a/app/components/__tests__/SubagentsSidebar.test.tsx b/app/components/__tests__/SubagentsSidebar.test.tsx new file mode 100644 index 000000000..976f1acea --- /dev/null +++ b/app/components/__tests__/SubagentsSidebar.test.tsx @@ -0,0 +1,129 @@ +import "@testing-library/jest-dom"; +import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; + +const mockUseQuery = jest.fn(); +jest.mock("convex/react", () => ({ + useQuery: (...args: unknown[]) => mockUseQuery(...args), +})); + +jest.mock("@/convex/_generated/api", () => ({ + api: { + subagents: { + listForParentMessage: "listForParentMessage", + getMessagesOwned: "getMessagesOwned", + }, + }, +})); + +jest.mock("@/app/hooks/useSubagentRealtime", () => ({ + useSubagentRealtime: () => ({ + message: null, + state: "connecting", + retry: jest.fn(), + }), +})); + +const captureAuthenticatedEvent = jest.fn(); +jest.mock("@/lib/analytics/client", () => ({ + captureAuthenticatedEvent: (...args: unknown[]) => + captureAuthenticatedEvent(...args), +})); + +jest.mock("../MessagePartHandler", () => ({ + MessagePartHandler: ({ part }: { part: { text?: string } }) => ( +
{part.text}
+ ), +})); + +const { SubagentsSidebar } = + require("../SubagentsSidebar") as typeof import("../SubagentsSidebar"); + +const activeChild = { + subagent_id: "sa_active", + parent_trigger_run_id: "parent-run", + parent_tool_call_id: "tool-1", + trigger_run_id: "child-run", + status: "running", + candidate: { + title: "Active candidate", + affected_asset: "https://example.test/profile", + }, + created_at: Date.now() - 5_000, + started_at: Date.now() - 4_000, +}; + +const doneChild = { + ...activeChild, + subagent_id: "sa_done", + trigger_run_id: "child-run-done", + status: "completed", + verdict: "rejected", + summary: "The supplied proof did not reproduce.", + candidate: { + title: "Rejected candidate", + affected_asset: "https://example.test/search", + }, + completed_at: Date.now() - 1_000, +}; + +describe("SubagentsSidebar", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseQuery.mockImplementation((_query, args) => { + if ("parentMessageId" in args) return [activeChild, doneChild]; + return [ + { + sequence: 0, + role: "user", + parts: [{ type: "text", text: "Validate this candidate" }], + created_at: Date.now(), + updated_at: Date.now(), + }, + ]; + }); + }); + + it("groups active and done children, then opens a live child detail", () => { + const closeSidebar = jest.fn(); + render( + , + ); + + expect(screen.getByRole("heading", { name: "Active" })).toBeVisible(); + expect(screen.getByRole("heading", { name: "Done" })).toBeVisible(); + expect(screen.getByText("Active candidate")).toBeVisible(); + expect(screen.getByText("Rejected candidate")).toBeVisible(); + + fireEvent.click( + screen.getByRole("button", { + name: /Open Active candidate, Running/i, + }), + ); + + expect( + screen.getByRole("heading", { name: "Active candidate" }), + ).toBeVisible(); + expect(screen.getByText("Validate this candidate")).toBeVisible(); + expect(screen.getByRole("button", { name: "Cancel" })).toBeVisible(); + expect(captureAuthenticatedEvent).toHaveBeenCalledWith( + "subagent_opened", + expect.objectContaining({ subagent_id: "sa_active" }), + ); + + fireEvent.keyDown(window, { key: "Escape" }); + expect(screen.getByRole("heading", { name: "Subagents" })).toBeVisible(); + expect(closeSidebar).not.toHaveBeenCalled(); + + fireEvent.keyDown(window, { key: "Escape" }); + expect(closeSidebar).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/components/tools/SubagentToolHandler.tsx b/app/components/tools/SubagentToolHandler.tsx new file mode 100644 index 000000000..84f0bd909 --- /dev/null +++ b/app/components/tools/SubagentToolHandler.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { memo, useMemo } from "react"; +import { ShieldCheck, Users } from "lucide-react"; +import type { UIMessage } from "@ai-sdk/react"; + +import ToolBlock from "@/components/ui/tool-block"; +import type { ChatStatus, SidebarSubagents } from "@/types/chat"; +import { isSidebarSubagents } from "@/types/chat"; +import { useToolSidebar } from "@/app/hooks/useToolSidebar"; + +type DelegateOutput = { + subagent_id?: string; + status?: string; + verdict?: "confirmed" | "rejected" | "inconclusive" | null; + summary?: string; +}; + +export const SubagentToolHandler = memo(function SubagentToolHandler({ + message, + part, + status, +}: { + message: UIMessage; + part: any; + status: ChatStatus; +}) { + const { toolCallId, state, input, output, errorText } = part; + const title = input?.profile_input?.candidate?.title ?? "candidate"; + const sidebarContent = useMemo( + () => ({ + kind: "subagents", + parentMessageId: message.id, + toolCallId, + }), + [message.id, toolCallId], + ); + const { handleOpenInSidebar, handleKeyDown } = useToolSidebar({ + toolCallId, + content: sidebarContent, + typeGuard: isSidebarSubagents, + }); + + const result = output as DelegateOutput | undefined; + const action = + result?.status && result.status !== "completed" + ? "Validation failed" + : result?.verdict === "confirmed" + ? "Confirmed independently" + : result?.verdict === "rejected" + ? "Rejected independently" + : result?.verdict === "inconclusive" + ? "Validation inconclusive" + : state === "input-streaming" + ? "Preparing independent validation" + : status === "streaming" + ? "Validating independently" + : errorText + ? "Validation failed" + : "Independent validation"; + + return ( + } + action={action} + target={title} + isShimmer={ + state === "input-streaming" || + (state === "input-available" && status === "streaming") + } + isClickable={state !== "input-streaming"} + onClick={handleOpenInSidebar} + onKeyDown={handleKeyDown} + /> + ); +}); + +export const VulnerabilityReportToolHandler = memo( + function VulnerabilityReportToolHandler({ part }: { part: any }) { + const output = part.output as + { success?: boolean; reportId?: string; reason?: string } | undefined; + return ( + } + action={ + output?.success + ? "Saved validated report" + : part.state === "input-streaming" || + part.state === "input-available" + ? "Promoting validated report" + : "Report promotion blocked" + } + target={output?.reportId ?? part.input?.title} + isShimmer={ + part.state === "input-streaming" || part.state === "input-available" + } + /> + ); + }, +); diff --git a/app/components/tools/__tests__/SubagentToolHandler.test.tsx b/app/components/tools/__tests__/SubagentToolHandler.test.tsx new file mode 100644 index 000000000..caa3d60fc --- /dev/null +++ b/app/components/tools/__tests__/SubagentToolHandler.test.tsx @@ -0,0 +1,54 @@ +import "@testing-library/jest-dom"; +import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; + +const openSidebar = jest.fn(); +jest.mock("@/app/contexts/GlobalState", () => ({ + useGlobalState: () => ({ + openSidebar, + closeSidebar: jest.fn(), + sidebarOpen: false, + sidebarContent: null, + updateSidebarContent: jest.fn(), + }), +})); + +const { SubagentToolHandler } = + require("../SubagentToolHandler") as typeof import("../SubagentToolHandler"); + +describe("SubagentToolHandler", () => { + beforeEach(() => jest.clearAllMocks()); + + it("opens the parent run's Subagents sidebar from the waiting tool block", () => { + render( + , + ); + + fireEvent.click( + screen.getByRole("button", { name: "Open Stored XSS in sidebar" }), + ); + expect(openSidebar).toHaveBeenCalledWith({ + kind: "subagents", + parentMessageId: "parent-run", + toolCallId: "tool-delegate-1", + }); + }); +}); diff --git a/app/hooks/useSubagentRealtime.ts b/app/hooks/useSubagentRealtime.ts new file mode 100644 index 000000000..3c2db47e6 --- /dev/null +++ b/app/hooks/useSubagentRealtime.ts @@ -0,0 +1,104 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { readUIMessageStream, type UIMessage, type UIMessageChunk } from "ai"; + +import { readTriggerRunStream } from "@/lib/chat/trigger-browser-realtime"; + +type TokenResponse = { + accessToken: string; + runId: string; + streamId: string; +}; + +type RealtimeState = "idle" | "connecting" | "live" | "complete" | "error"; + +const requestToken = async ( + subagentId: string, + signal: AbortSignal, +): Promise => { + const response = await fetch( + `/api/subagents/${encodeURIComponent(subagentId)}/token`, + { method: "POST", signal, cache: "no-store" }, + ); + if (!response.ok) throw new Error("Unable to authorize child stream"); + return (await response.json()) as TokenResponse; +}; + +const toReadableStream = ( + iterable: AsyncGenerator, +): ReadableStream => + new ReadableStream({ + async pull(controller) { + const next = await iterable.next(); + if (next.done) { + controller.close(); + return; + } + controller.enqueue(next.value as UIMessageChunk); + }, + async cancel() { + await iterable.return(undefined); + }, + }); + +export const useSubagentRealtime = ({ + subagentId, + enabled, +}: { + subagentId?: string; + enabled: boolean; +}) => { + const [message, setMessage] = useState(null); + const [state, setState] = useState("idle"); + const [retryKey, setRetryKey] = useState(0); + const retry = useCallback(() => setRetryKey((value) => value + 1), []); + + useEffect(() => { + setMessage(null); + if (!subagentId || !enabled) { + setState("idle"); + return; + } + + const abort = new AbortController(); + let mounted = true; + setState("connecting"); + + void (async () => { + try { + const token = await requestToken(subagentId, abort.signal); + const chunks = readTriggerRunStream( + token.runId, + token.streamId, + { + accessToken: token.accessToken, + refreshAccessToken: async () => + (await requestToken(subagentId, abort.signal)).accessToken, + signal: abort.signal, + timeoutInSeconds: 120, + }, + ); + const messages = readUIMessageStream({ + stream: toReadableStream(chunks), + terminateOnError: true, + }); + for await (const nextMessage of messages) { + if (!mounted) return; + setState("live"); + setMessage(nextMessage); + } + if (mounted) setState("complete"); + } catch { + if (mounted && !abort.signal.aborted) setState("error"); + } + })(); + + return () => { + mounted = false; + abort.abort(); + }; + }, [enabled, retryKey, subagentId]); + + return { message, state, retry }; +}; diff --git a/app/share/[shareId]/components/SharedMessagePartHandler.tsx b/app/share/[shareId]/components/SharedMessagePartHandler.tsx index bb76cb9b4..82228e7e6 100644 --- a/app/share/[shareId]/components/SharedMessagePartHandler.tsx +++ b/app/share/[shareId]/components/SharedMessagePartHandler.tsx @@ -16,6 +16,8 @@ import { FileDown, ExternalLink, Globe, + ShieldCheck, + Users, } from "lucide-react"; import { getNotesIcon, @@ -149,6 +151,41 @@ export const SharedMessagePartHandler = ({ return renderGetTerminalFilesTool(part, idx); } + if (part.type === "tool-delegate_task") { + const verdict = part.output?.verdict; + return ( +