feat: sync universal schema and sdk updates

This commit is contained in:
Nathan Flurry 2026-01-27 02:52:25 -08:00
parent 79bb441287
commit f5d1a6383d
56 changed files with 6800 additions and 3974 deletions

View file

@ -38,6 +38,11 @@ Universal schema guidance:
- Never use synthetic data or mocked responses in tests. - Never use synthetic data or mocked responses in tests.
- Never manually write agent types; always use generated types in `resources/agent-schemas/`. If types are broken, fix the generated types. - Never manually write agent types; always use generated types in `resources/agent-schemas/`. If types are broken, fix the generated types.
- The universal schema must provide consistent behavior across providers; avoid requiring frontend/client logic to special-case agents. - The universal schema must provide consistent behavior across providers; avoid requiring frontend/client logic to special-case agents.
- When parsing agent data, if something is unexpected or does not match the schema, bail out and surface the error rather than trying to continue with partial parsing.
- When defining the universal schema, choose the option most compatible with native agent APIs, and add synthetics to fill gaps for other agents.
- Use `docs/glossary.md` as the source of truth for universal schema terminology and keep it updated alongside schema changes.
- On parse failures, emit an `agent.unparsed` event (source=daemon, synthetic=true) and treat it as a test failure. Preserve raw payloads when `include_raw=true`.
- Track subagent support in `docs/conversion.md`. For now, normalize subagent activity into normal message/tool flow, but revisit explicit subagent modeling later.
### CLI ⇄ HTTP endpoint map (keep in sync) ### CLI ⇄ HTTP endpoint map (keep in sync)

View file

@ -12,7 +12,7 @@ description = "Universal agent API for AI coding assistants"
[workspace.dependencies] [workspace.dependencies]
# Internal crates # Internal crates
sandbox-agent-core = { path = "server/packages/sandbox-agent" } sandbox-agent = { path = "server/packages/sandbox-agent" }
sandbox-agent-error = { path = "server/packages/error" } sandbox-agent-error = { path = "server/packages/error" }
sandbox-agent-agent-management = { path = "server/packages/agent-management" } sandbox-agent-agent-management = { path = "server/packages/agent-management" }
sandbox-agent-agent-credentials = { path = "server/packages/agent-credentials" } sandbox-agent-agent-credentials = { path = "server/packages/agent-credentials" }

View file

@ -1,6 +1,6 @@
# Sandbox Agent SDK # Sandbox Agent SDK
Universal API for running Claude Code, Codex, OpenCode, and Amp inside sandboxes. Universal API for automatic coding agents in sandboxes. Supprots Claude Code, Codex, OpenCode, and Amp.
- **Any coding agent**: Universal API to interact with all agents with full feature coverage - **Any coding agent**: Universal API to interact with all agents with full feature coverage
- **Server or SDK mode**: Run as an HTTP server or with the TypeScript SDK - **Server or SDK mode**: Run as an HTTP server or with the TypeScript SDK
@ -16,14 +16,14 @@ Roadmap:
## Agent Support ## Agent Support
| Feature | [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview) | [Codex](https://github.com/openai/codex) | [OpenCode](https://github.com/opencode-ai/opencode) | [Amp](https://ampcode.com) | | Feature | [Claude Code*](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview) | [Codex](https://github.com/openai/codex) | [OpenCode](https://github.com/opencode-ai/opencode) | [Amp](https://ampcode.com) |
|---------|:-----------:|:-----:|:--------:|:---:| |---------|:-----------:|:-----:|:--------:|:---:|
| Stability | Stable | Stable | Experimental | Experimental | | Stability | Stable | Stable | Experimental | Experimental |
| Text Messages | ✓ | ✓ | ✓ | ✓ | | Text Messages | ✓ | ✓ | ✓ | ✓ |
| Tool Calls | | ✓ | ✓ | ✓ | | Tool Calls | —* | ✓ | ✓ | ✓ |
| Tool Results | | ✓ | ✓ | ✓ | | Tool Results | —* | ✓ | ✓ | ✓ |
| Questions (HITL) | | | ✓ | | | Questions (HITL) | —* | | ✓ | |
| Permissions (HITL) | | | ✓ | | | Permissions (HITL) | —* | | ✓ | |
| Images | | ✓ | ✓ | | | Images | | ✓ | ✓ | |
| File Attachments | | ✓ | ✓ | | | File Attachments | | ✓ | ✓ | |
| Session Lifecycle | | ✓ | ✓ | | | Session Lifecycle | | ✓ | ✓ | |
@ -34,13 +34,15 @@ Roadmap:
| MCP Tools | | ✓ | | | | MCP Tools | | ✓ | | |
| Streaming Deltas | | ✓ | ✓ | | | Streaming Deltas | | ✓ | ✓ | |
* Claude headless CLI does not natively support tool calls/results or HITL questions/permissions yet; these are WIP.
Want support for another agent? [Open an issue](https://github.com/anthropics/sandbox-agent/issues/new) to request it. Want support for another agent? [Open an issue](https://github.com/anthropics/sandbox-agent/issues/new) to request it.
## Architecture ## Architecture
- TODO - TODO
- Embedded (runs agents locally) - Local
- Sandboxed - Remote/Sandboxed
## Components ## Components
@ -49,6 +51,26 @@ Want support for another agent? [Open an issue](https://github.com/anthropics/sa
- Inspector: inspect.sandboxagent.dev - Inspector: inspect.sandboxagent.dev
- CLI: TODO - CLI: TODO
## Quickstart
### SDK
- Local
- Remote/Sandboxed
Docs
### Server
- Run server
- Auth
Docs
### CLI
Docs
## Project Goals ## Project Goals
This project aims to solve 3 problems with agents: This project aims to solve 3 problems with agents:
@ -98,4 +120,3 @@ TODO
- the harnesses do a lot of heavy lifting - the harnesses do a lot of heavy lifting
- the difference between opencode, claude, and codex is vast & vastly opinionated - the difference between opencode, claude, and codex is vast & vastly opinionated

View file

@ -1,12 +1,19 @@
## launch ## launch
- provide mock data for validating your rendering
- provides history with all items, then iterates thorugh all items on a stream
- this is a special type of serve function
- make sure claude.md covers everything
- re-review agent schemas and compare it to ours - re-review agent schemas and compare it to ours
- write integration guide
- add optional raw payloads to events via query parameters
- auto-serve frontend from cli - auto-serve frontend from cli
- verify embedded sdk works - verify embedded sdk works
- fix bugs in ui - fix bugs in ui
- double messages - double messages
- user-sent messages - user-sent messages
- permissions - permissions
- add an API option to stream only the next assistant item after a posted message (single-response stream)
- consider migraing our standard to match the vercel ai standard - consider migraing our standard to match the vercel ai standard
- discuss actor arch in readme + give example - discuss actor arch in readme + give example
- skillfile - skillfile
@ -18,9 +25,18 @@
- **Auto-configure MCP & Skills**: Auto-load MCP servers & skills for your agents - **Auto-configure MCP & Skills**: Auto-load MCP servers & skills for your agents
- **Process & logs manager**: Manage processes, logs, and ports for your agents to run background processes - **Process & logs manager**: Manage processes, logs, and ports for your agents to run background processes
- **Codex app-server concurrency**: Run a single shared Codex app-server with multiple threads in parallel (like OpenCode), with file-write safety - **Codex app-server concurrency**: Run a single shared Codex app-server with multiple threads in parallel (like OpenCode), with file-write safety
- persistence
## later ## later
- missing features
- file changes
- api compat
- vercel ai sdk + hono proxy
- tanstack ai
- opencode ui
- synthetic question tool
- since claude headless does not support this
- guides: - guides:
- ralph - ralph
- swarms - swarms

View file

@ -20,7 +20,7 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \ --mount=type=cache,target=/usr/local/cargo/git \
--mount=type=cache,target=/build/target \ --mount=type=cache,target=/build/target \
RUSTFLAGS="-C target-feature=+crt-static" \ RUSTFLAGS="-C target-feature=+crt-static" \
cargo build -p sandbox-agent-core --release --target x86_64-unknown-linux-musl && \ cargo build -p sandbox-agent --release --target x86_64-unknown-linux-musl && \
mkdir -p /artifacts && \ mkdir -p /artifacts && \
cp target/x86_64-unknown-linux-musl/release/sandbox-agent /artifacts/sandbox-agent-x86_64-unknown-linux-musl cp target/x86_64-unknown-linux-musl/release/sandbox-agent /artifacts/sandbox-agent-x86_64-unknown-linux-musl

View file

@ -55,7 +55,7 @@ COPY . .
RUN --mount=type=cache,target=/usr/local/cargo/registry \ RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \ --mount=type=cache,target=/usr/local/cargo/git \
--mount=type=cache,target=/build/target \ --mount=type=cache,target=/build/target \
cargo build -p sandbox-agent-core --release --target aarch64-apple-darwin && \ cargo build -p sandbox-agent --release --target aarch64-apple-darwin && \
mkdir -p /artifacts && \ mkdir -p /artifacts && \
cp target/aarch64-apple-darwin/release/sandbox-agent /artifacts/sandbox-agent-aarch64-apple-darwin cp target/aarch64-apple-darwin/release/sandbox-agent /artifacts/sandbox-agent-aarch64-apple-darwin

View file

@ -55,7 +55,7 @@ COPY . .
RUN --mount=type=cache,target=/usr/local/cargo/registry \ RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \ --mount=type=cache,target=/usr/local/cargo/git \
--mount=type=cache,target=/build/target \ --mount=type=cache,target=/build/target \
cargo build -p sandbox-agent-core --release --target x86_64-apple-darwin && \ cargo build -p sandbox-agent --release --target x86_64-apple-darwin && \
mkdir -p /artifacts && \ mkdir -p /artifacts && \
cp target/x86_64-apple-darwin/release/sandbox-agent /artifacts/sandbox-agent-x86_64-apple-darwin cp target/x86_64-apple-darwin/release/sandbox-agent /artifacts/sandbox-agent-x86_64-apple-darwin

View file

@ -42,7 +42,7 @@ COPY . .
RUN --mount=type=cache,target=/usr/local/cargo/registry \ RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \ --mount=type=cache,target=/usr/local/cargo/git \
--mount=type=cache,target=/build/target \ --mount=type=cache,target=/build/target \
cargo build -p sandbox-agent-core --release --target x86_64-pc-windows-gnu && \ cargo build -p sandbox-agent --release --target x86_64-pc-windows-gnu && \
mkdir -p /artifacts && \ mkdir -p /artifacts && \
cp target/x86_64-pc-windows-gnu/release/sandbox-agent.exe /artifacts/sandbox-agent-x86_64-pc-windows-gnu.exe cp target/x86_64-pc-windows-gnu/release/sandbox-agent.exe /artifacts/sandbox-agent-x86_64-pc-windows-gnu.exe

View file

@ -17,6 +17,10 @@ Notes:
- When a provider does not supply IDs (Claude), we synthesize item_id values and keep native_item_id null. - When a provider does not supply IDs (Claude), we synthesize item_id values and keep native_item_id null.
- native_session_id is the only provider session identifier. It is intentionally used for thread/session/run ids. - native_session_id is the only provider session identifier. It is intentionally used for thread/session/run ids.
- native_item_id preserves the agent-native item/message id when present. - native_item_id preserves the agent-native item/message id when present.
- source indicates who emitted the event: agent (native) or daemon (synthetic).
- raw is always present on events. When clients do not opt-in to raw payloads, raw is null.
- opt-in via `include_raw=true` on events endpoints (HTTP + SSE).
- If parsing fails, emit agent.unparsed (source=daemon, synthetic=true). Tests must assert zero unparsed events.
Events / Message Flow Events / Message Flow
@ -42,14 +46,14 @@ Synthetics
+------------------------------+------------------------+--------------------------+--------------------------------------------------------------+ +------------------------------+------------------------+--------------------------+--------------------------------------------------------------+
| Synthetic element | When it appears | Stored as | Notes | | Synthetic element | When it appears | Stored as | Notes |
+------------------------------+------------------------+--------------------------+--------------------------------------------------------------+ +------------------------------+------------------------+--------------------------+--------------------------------------------------------------+
| session.started | When agent emits no explicit start | session.started event | Mark origin=daemon | | session.started | When agent emits no explicit start | session.started event | Mark source=daemon |
| session.ended | When agent emits no explicit end | session.ended event | Mark origin=daemon; reason may be inferred | | session.ended | When agent emits no explicit end | session.ended event | Mark source=daemon; reason may be inferred |
| item_id (Claude) | Claude provides no item IDs | item_id | Maintain provider_item_id map when possible | | item_id (Claude) | Claude provides no item IDs | item_id | Maintain provider_item_id map when possible |
| user message (Claude) | Claude emits only assistant output | item.completed | Mark origin=daemon; preserve raw input in event metadata | | user message (Claude) | Claude emits only assistant output | item.completed | Mark source=daemon; preserve raw input in event metadata |
| question events (Claude) | Plan mode ExitPlanMode tool usage | question.requested/resolved | Synthetic mapping from tool call/result | | question events (Claude) | Plan mode ExitPlanMode tool usage | question.requested/resolved | Synthetic mapping from tool call/result |
| native_session_id (Codex) | Codex uses threadId | native_session_id | Intentionally merged threadId into native_session_id | | native_session_id (Codex) | Codex uses threadId | native_session_id | Intentionally merged threadId into native_session_id |
+------------------------------+------------------------+--------------------------+--------------------------------------------------------------+ +------------------------------+------------------------+--------------------------+--------------------------------------------------------------+
| message.delta (Claude/Amp) | No native deltas | item.delta | Synthetic delta with full message content; origin=daemon | | message.delta (Claude/Amp) | No native deltas | item.delta | Synthetic delta with full message content; source=daemon |
+------------------------------+------------------------+--------------------------+--------------------------------------------------------------+ +------------------------------+------------------------+--------------------------+--------------------------------------------------------------+
| message.delta (OpenCode) | part delta before message | item.delta | If part arrives first, create item.started stub then delta | | message.delta (OpenCode) | part delta before message | item.delta | If part arrives first, create item.started stub then delta |
+------------------------------+------------------------+--------------------------+--------------------------------------------------------------+ +------------------------------+------------------------+--------------------------+--------------------------------------------------------------+
@ -68,5 +72,9 @@ Policy:
Message normalization notes Message normalization notes
- user vs assistant: normalized via role in the universal item; provider role fields or item types determine role. - user vs assistant: normalized via role in the universal item; provider role fields or item types determine role.
- file artifacts: always represented as content parts (type=file_ref) inside message/tool_result items, not a separate item kind.
- reasoning: represented as content parts (type=reasoning) inside message items, with visibility when available.
- subagents: OpenCode subtask parts and Claude Task tool usage are currently normalized into standard message/tool flow (no dedicated subagent fields).
- OpenCode unrolling: message.updated creates/updates the parent message item; tool-related parts emit separate tool item events (item.started/ item.completed) with parent_id pointing to the message item. - OpenCode unrolling: message.updated creates/updates the parent message item; tool-related parts emit separate tool item events (item.started/ item.completed) with parent_id pointing to the message item.
- If a message.part.updated arrives before message.updated, we create a stub item.started (origin=daemon) so deltas have a parent. - If a message.part.updated arrives before message.updated, we create a stub item.started (source=daemon) so deltas have a parent.
- Tool calls/results are always emitted as separate tool items to keep behavior consistent across agents.

62
docs/glossary.md Normal file
View file

@ -0,0 +1,62 @@
# Glossary (Universal Schema)
This glossary defines the universal schema terms used across the daemon, SDK, and tests.
Session terms
- session_id: daemon-generated identifier for a universal session.
- native_session_id: provider-native thread/session/run identifier (thread_id merged here).
- session.started: event emitted at session start (native or synthetic).
- session.ended: event emitted at session end (native or synthetic); includes reason and terminated_by.
- terminated_by: who ended the session: agent or daemon.
- reason: why the session ended: completed, error, or terminated.
Event terms
- UniversalEvent: envelope that wraps all events; includes source, type, data, raw.
- event_id: unique identifier for the event.
- sequence: monotonic event sequence number within a session.
- time: RFC3339 timestamp for the event.
- source: event origin: agent (native) or daemon (synthetic).
- raw: original provider payload for native events; optional for synthetic events.
Item terms
- item_id: daemon-generated identifier for a universal item.
- native_item_id: provider-native item/message identifier when available; null otherwise.
- parent_id: item_id of the parent item (e.g., tool call/result parented to a message).
- kind: item category: message, tool_call, tool_result, system, status, unknown.
- role: actor role for message items: user, assistant, system, tool (or null).
- status: item lifecycle status: in_progress, completed, failed (or null).
Item event terms
- item.started: item creation event (may be synthetic).
- item.delta: streaming delta event (native where supported; synthetic otherwise).
- item.completed: final item event with complete content.
Content terms
- content: ordered list of parts that make up an item payload.
- content part: a typed element inside content (text, json, tool_call, tool_result, file_ref, image, status, reasoning).
- text: plain text content part.
- json: structured JSON content part.
- tool_call: tool invocation content part (name, arguments, call_id).
- tool_result: tool result content part (call_id, output).
- file_ref: file reference content part (path, action, diff).
- image: image content part (path, mime).
- status: status content part (label, detail).
- reasoning: reasoning content part (text, visibility).
- visibility: reasoning visibility: public or private.
HITL terms
- permission.requested / permission.resolved: human-in-the-loop permission flow events.
- permission_id: identifier for the permission request.
- question.requested / question.resolved: human-in-the-loop question flow events.
- question_id: identifier for the question request.
- options: question answer options.
- response: selected answer for a question.
Synthetic terms
- synthetic event: a daemon-emitted event used to fill gaps in provider-native schemas.
- source=daemon: marks synthetic events.
- synthetic delta: a single full-content delta emitted for providers without native deltas.
Provider terms
- agent: the native provider (claude, codex, opencode, amp).
- native payload: the providers original event/message object stored in raw.

File diff suppressed because it is too large Load diff

View file

@ -662,6 +662,75 @@
border-bottom-left-radius: 4px; border-bottom-left-radius: 4px;
} }
.message.system .avatar {
background: var(--border-2);
color: var(--text);
}
.message.tool .avatar {
background: rgba(100, 210, 255, 0.2);
color: var(--cyan);
}
.message.system .message-content {
background: var(--surface-2);
color: var(--muted);
border: 1px solid var(--border-2);
}
.message.tool .message-content {
background: rgba(100, 210, 255, 0.1);
color: var(--text-secondary);
border: 1px solid rgba(100, 210, 255, 0.25);
}
.message.error .message-content {
background: rgba(255, 59, 48, 0.12);
border: 1px solid rgba(255, 59, 48, 0.4);
color: var(--danger);
}
.message.error .avatar {
background: rgba(255, 59, 48, 0.2);
color: var(--danger);
}
.message-meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.4px;
color: var(--muted);
margin-bottom: 6px;
}
.message-meta .pill {
font-size: 9px;
padding: 2px 6px;
}
.part {
margin-top: 8px;
}
.part:first-child {
margin-top: 0;
}
.part-title {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.3px;
color: var(--muted);
margin-bottom: 4px;
}
.part-body {
white-space: pre-wrap;
}
.message-error { .message-error {
background: rgba(255, 59, 48, 0.1); background: rgba(255, 59, 48, 0.1);
border: 1px solid rgba(255, 59, 48, 0.3); border: 1px solid rgba(255, 59, 48, 0.3);
@ -1071,11 +1140,38 @@
letter-spacing: 0.3px; letter-spacing: 0.3px;
} }
.event-type.message { color: var(--accent); } .event-type.session,
.event-type.started { color: var(--success); } .event-type.session-started,
.event-type.error { color: var(--danger); } .event-type.session-ended {
.event-type.question { color: var(--warning); } color: var(--success);
.event-type.permission { color: var(--purple); } }
.event-type.item,
.event-type.item-started,
.event-type.item-completed {
color: var(--accent);
}
.event-type.item-delta {
color: var(--cyan);
}
.event-type.error,
.event-type.agent-unparsed {
color: var(--danger);
}
.event-type.question,
.event-type.question-requested,
.event-type.question-resolved {
color: var(--warning);
}
.event-type.permission,
.event-type.permission-requested,
.event-type.permission-resolved {
color: var(--purple);
}
.event-time { .event-time {
font-size: 10px; font-size: 10px;

View file

@ -19,13 +19,14 @@ import {
createSandboxDaemonClient, createSandboxDaemonClient,
type SandboxDaemonClient, type SandboxDaemonClient,
type AgentInfo, type AgentInfo,
type AgentCapabilities,
type AgentModeInfo, type AgentModeInfo,
type PermissionRequest, type PermissionEventData,
type QuestionRequest, type QuestionEventData,
type SessionInfo, type SessionInfo,
type UniversalEvent, type UniversalEvent,
type UniversalMessage, type UniversalItem,
type UniversalMessagePart type ContentPart
} from "sandbox-agent"; } from "sandbox-agent";
type RequestLog = { type RequestLog = {
@ -39,9 +40,38 @@ type RequestLog = {
error?: string; error?: string;
}; };
type ItemEventData = {
item: UniversalItem;
};
type ItemDeltaEventData = {
item_id: string;
native_item_id?: string | null;
delta: string;
};
type TimelineEntry = {
id: string;
kind: "item" | "meta";
time: string;
item?: UniversalItem;
deltaText?: string;
meta?: {
title: string;
detail?: string;
severity?: "info" | "error";
};
};
type DebugTab = "log" | "events" | "approvals" | "agents"; type DebugTab = "log" | "events" | "approvals" | "agents";
const defaultAgents = ["claude", "codex", "opencode", "amp"]; const defaultAgents = ["claude", "codex", "opencode", "amp"];
const emptyCapabilities: AgentCapabilities = {
planMode: false,
permissions: false,
questions: false,
toolCalls: false
};
const formatJson = (value: unknown) => { const formatJson = (value: unknown) => {
if (value === null || value === undefined) return ""; if (value === null || value === undefined) return "";
@ -55,6 +85,16 @@ const formatJson = (value: unknown) => {
const escapeSingleQuotes = (value: string) => value.replace(/'/g, `'\\''`); const escapeSingleQuotes = (value: string) => value.replace(/'/g, `'\\''`);
const formatCapabilities = (capabilities: AgentCapabilities) => {
const parts = [
`planMode ${capabilities.planMode ? "✓" : "—"}`,
`permissions ${capabilities.permissions ? "✓" : "—"}`,
`questions ${capabilities.questions ? "✓" : "—"}`,
`toolCalls ${capabilities.toolCalls ? "✓" : "—"}`
];
return parts.join(" · ");
};
const buildCurl = (method: string, url: string, body?: string, token?: string) => { const buildCurl = (method: string, url: string, body?: string, token?: string) => {
const headers: string[] = []; const headers: string[] = [];
if (token) { if (token) {
@ -69,14 +109,7 @@ const buildCurl = (method: string, url: string, body?: string, token?: string) =
.trim(); .trim();
}; };
const getEventType = (event: UniversalEvent) => { const getEventType = (event: UniversalEvent) => event.type;
if ("message" in event.data) return "message";
if ("started" in event.data) return "started";
if ("error" in event.data) return "error";
if ("questionAsked" in event.data) return "question";
if ("permissionAsked" in event.data) return "permission";
return "event";
};
const formatTime = (value: string) => { const formatTime = (value: string) => {
if (!value) return ""; if (!value) return "";
@ -85,6 +118,128 @@ const formatTime = (value: string) => {
return date.toLocaleTimeString(); return date.toLocaleTimeString();
}; };
const getEventCategory = (type: string) => type.split(".")[0] ?? type;
const getEventClass = (type: string) => type.replace(/\./g, "-");
const buildStubItem = (itemId: string, nativeItemId?: string | null): UniversalItem => {
return {
item_id: itemId,
native_item_id: nativeItemId ?? null,
parent_id: null,
kind: "message",
role: null,
content: [],
status: "in_progress"
} as UniversalItem;
};
const getMessageClass = (item: UniversalItem) => {
if (item.kind === "tool_call" || item.kind === "tool_result") return "tool";
if (item.kind === "system" || item.kind === "status") return "system";
if (item.role === "user") return "user";
if (item.role === "tool") return "tool";
if (item.role === "system") return "system";
return "assistant";
};
const getAvatarLabel = (messageClass: string) => {
if (messageClass === "user") return "U";
if (messageClass === "tool") return "T";
if (messageClass === "system") return "S";
if (messageClass === "error") return "!";
return "AI";
};
const renderContentPart = (part: ContentPart, index: number) => {
const partType = (part as { type?: string }).type ?? "unknown";
const key = `${partType}-${index}`;
switch (partType) {
case "text":
return (
<div key={key} className="part">
<div className="part-body">{(part as { text: string }).text}</div>
</div>
);
case "json":
return (
<div key={key} className="part">
<div className="part-title">json</div>
<pre className="code-block">{formatJson((part as { json: unknown }).json)}</pre>
</div>
);
case "tool_call": {
const { name, arguments: args, call_id } = part as {
name: string;
arguments: string;
call_id: string;
};
return (
<div key={key} className="part">
<div className="part-title">
tool call - {name}
{call_id ? ` - ${call_id}` : ""}
</div>
{args ? <pre className="code-block">{args}</pre> : <div className="muted">No arguments</div>}
</div>
);
}
case "tool_result": {
const { call_id, output } = part as { call_id: string; output: string };
return (
<div key={key} className="part">
<div className="part-title">tool result - {call_id}</div>
{output ? <pre className="code-block">{output}</pre> : <div className="muted">No output</div>}
</div>
);
}
case "file_ref": {
const { path, action, diff } = part as { path: string; action: string; diff?: string | null };
return (
<div key={key} className="part">
<div className="part-title">file - {action}</div>
<div className="part-body mono">{path}</div>
{diff && <pre className="code-block">{diff}</pre>}
</div>
);
}
case "reasoning": {
const { text, visibility } = part as { text: string; visibility: string };
return (
<div key={key} className="part">
<div className="part-title">reasoning - {visibility}</div>
<div className="part-body muted">{text}</div>
</div>
);
}
case "image": {
const { path, mime } = part as { path: string; mime?: string | null };
return (
<div key={key} className="part">
<div className="part-title">image {mime ? `- ${mime}` : ""}</div>
<div className="part-body mono">{path}</div>
</div>
);
}
case "status": {
const { label, detail } = part as { label: string; detail?: string | null };
return (
<div key={key} className="part">
<div className="part-title">status - {label}</div>
{detail && <div className="part-body">{detail}</div>}
</div>
);
}
default:
return (
<div key={key} className="part">
<div className="part-title">unknown</div>
<pre className="code-block">{formatJson(part)}</pre>
</div>
);
}
};
const getDefaultEndpoint = () => { const getDefaultEndpoint = () => {
if (typeof window === "undefined") return "http://127.0.0.1:2468"; if (typeof window === "undefined") return "http://127.0.0.1:2468";
const { origin, protocol } = window.location; const { origin, protocol } = window.location;
@ -381,9 +536,9 @@ export default function App() {
const appendEvents = useCallback((incoming: UniversalEvent[]) => { const appendEvents = useCallback((incoming: UniversalEvent[]) => {
if (!incoming.length) return; if (!incoming.length) return;
setEvents((prev) => [...prev, ...incoming]); setEvents((prev) => [...prev, ...incoming]);
const lastId = incoming[incoming.length - 1]?.id ?? offsetRef.current; const lastSeq = incoming[incoming.length - 1]?.sequence ?? offsetRef.current;
offsetRef.current = lastId; offsetRef.current = lastSeq;
setOffset(lastId); setOffset(lastSeq);
}, []); }, []);
const fetchEvents = useCallback(async () => { const fetchEvents = useCallback(async () => {
@ -478,35 +633,18 @@ export default function App() {
} }
}; };
const toggleQuestionOption = ( const selectQuestionOption = (requestId: string, optionLabel: string) => {
requestId: string, setQuestionSelections((prev) => ({
questionIndex: number, ...prev,
optionLabel: string, [requestId]: [[optionLabel]]
multiSelect: boolean }));
) => {
setQuestionSelections((prev) => {
const next = { ...prev };
const currentAnswers = next[requestId] ? [...next[requestId]] : [];
const selections = currentAnswers[questionIndex] ? [...currentAnswers[questionIndex]] : [];
if (multiSelect) {
if (selections.includes(optionLabel)) {
currentAnswers[questionIndex] = selections.filter((label) => label !== optionLabel);
} else {
currentAnswers[questionIndex] = [...selections, optionLabel];
}
} else {
currentAnswers[questionIndex] = [optionLabel];
}
next[requestId] = currentAnswers;
return next;
});
}; };
const answerQuestion = async (request: QuestionRequest) => { const answerQuestion = async (request: QuestionEventData) => {
const answers = questionSelections[request.id] ?? []; const answers = questionSelections[request.question_id] ?? [];
try { try {
await getClient().replyQuestion(sessionId, request.id, { answers }); await getClient().replyQuestion(sessionId, request.question_id, { answers });
setQuestionStatus((prev) => ({ ...prev, [request.id]: "replied" })); setQuestionStatus((prev) => ({ ...prev, [request.question_id]: "replied" }));
} catch (error) { } catch (error) {
setEventError(getErrorMessage(error, "Unable to reply")); setEventError(getErrorMessage(error, "Unable to reply"));
} }
@ -531,37 +669,134 @@ export default function App() {
}; };
const questionRequests = useMemo(() => { const questionRequests = useMemo(() => {
return events const latestById = new Map<string, QuestionEventData>();
.filter((event) => "questionAsked" in event.data) for (const event of events) {
.map((event) => (event.data as { questionAsked: QuestionRequest }).questionAsked) if (event.type === "question.requested" || event.type === "question.resolved") {
.filter((request) => !questionStatus[request.id]); const data = event.data as QuestionEventData;
latestById.set(data.question_id, data);
}
}
return Array.from(latestById.values()).filter(
(request) => request.status === "requested" && !questionStatus[request.question_id]
);
}, [events, questionStatus]); }, [events, questionStatus]);
const permissionRequests = useMemo(() => { const permissionRequests = useMemo(() => {
return events const latestById = new Map<string, PermissionEventData>();
.filter((event) => "permissionAsked" in event.data) for (const event of events) {
.map((event) => (event.data as { permissionAsked: PermissionRequest }).permissionAsked) if (event.type === "permission.requested" || event.type === "permission.resolved") {
.filter((request) => !permissionStatus[request.id]); const data = event.data as PermissionEventData;
latestById.set(data.permission_id, data);
}
}
return Array.from(latestById.values()).filter(
(request) => request.status === "requested" && !permissionStatus[request.permission_id]
);
}, [events, permissionStatus]); }, [events, permissionStatus]);
const transcriptMessages = useMemo(() => { const transcriptEntries = useMemo(() => {
return events const entries: TimelineEntry[] = [];
.filter((event): event is UniversalEvent & { data: { message: UniversalMessage } } => "message" in event.data) const itemMap = new Map<string, TimelineEntry>();
.map((event) => {
const msg = event.data.message; const upsertItemEntry = (item: UniversalItem, time: string) => {
const parts = ("parts" in msg ? msg.parts : []) ?? []; let entry = itemMap.get(item.item_id);
const content = parts if (!entry) {
.filter((part: UniversalMessagePart): part is UniversalMessagePart & { type: "text"; text: string } => part.type === "text" && "text" in part && typeof part.text === "string") entry = {
.map((part) => part.text) id: item.item_id,
.join("\n"); kind: "item",
return { time,
id: event.id, item,
role: "role" in msg ? msg.role : "assistant", deltaText: ""
content,
timestamp: event.timestamp
}; };
}) itemMap.set(item.item_id, entry);
.filter((msg) => msg.content); entries.push(entry);
} else {
entry.item = item;
entry.time = time;
}
return entry;
};
for (const event of events) {
switch (event.type) {
case "item.started": {
const data = event.data as ItemEventData;
upsertItemEntry(data.item, event.time);
break;
}
case "item.delta": {
const data = event.data as ItemDeltaEventData;
const stub = buildStubItem(data.item_id, data.native_item_id);
const entry = upsertItemEntry(stub, event.time);
entry.deltaText = `${entry.deltaText ?? ""}${data.delta ?? ""}`;
break;
}
case "item.completed": {
const data = event.data as ItemEventData;
const entry = upsertItemEntry(data.item, event.time);
entry.deltaText = "";
break;
}
case "error": {
const data = event.data as { message: string; code?: string | null };
entries.push({
id: event.event_id,
kind: "meta",
time: event.time,
meta: {
title: data.code ? `Error - ${data.code}` : "Error",
detail: data.message,
severity: "error"
}
});
break;
}
case "agent.unparsed": {
const data = event.data as { error: string; location: string };
entries.push({
id: event.event_id,
kind: "meta",
time: event.time,
meta: {
title: "Agent parse failure",
detail: `${data.location}: ${data.error}`,
severity: "error"
}
});
break;
}
case "session.started": {
entries.push({
id: event.event_id,
kind: "meta",
time: event.time,
meta: {
title: "Session started",
severity: "info"
}
});
break;
}
case "session.ended": {
const data = event.data as { reason: string; terminated_by: string };
entries.push({
id: event.event_id,
kind: "meta",
time: event.time,
meta: {
title: "Session ended",
detail: `${data.reason} - ${data.terminated_by}`,
severity: "info"
}
});
break;
}
default:
break;
}
}
return entries;
}, [events]); }, [events]);
useEffect(() => { useEffect(() => {
@ -592,7 +827,7 @@ export default function App() {
useEffect(() => { useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [transcriptMessages]); }, [transcriptEntries]);
// Auto-load modes when agent changes // Auto-load modes when agent changes
useEffect(() => { useEffect(() => {
@ -801,7 +1036,7 @@ export default function App() {
Create Session Create Session
</button> </button>
</div> </div>
) : transcriptMessages.length === 0 && !sessionError ? ( ) : transcriptEntries.length === 0 && !sessionError ? (
<div className="empty-state"> <div className="empty-state">
<Terminal className="empty-state-icon" /> <Terminal className="empty-state-icon" />
<div className="empty-state-title">Ready to Chat</div> <div className="empty-state-title">Ready to Chat</div>
@ -811,16 +1046,59 @@ export default function App() {
</div> </div>
) : ( ) : (
<div className="messages"> <div className="messages">
{transcriptMessages.map((msg) => ( {transcriptEntries.map((entry) => {
<div key={msg.id} className={`message ${msg.role === "user" ? "user" : "assistant"}`}> if (entry.kind === "meta") {
<div className="avatar"> const messageClass = entry.meta?.severity === "error" ? "error" : "system";
{msg.role === "user" ? "U" : "AI"} return (
<div key={entry.id} className={`message ${messageClass}`}>
<div className="avatar">{getAvatarLabel(messageClass)}</div>
<div className="message-content">
<div className="message-meta">
<span>{entry.meta?.title ?? "Status"}</span>
</div>
{entry.meta?.detail && <div className="part-body">{entry.meta.detail}</div>}
</div>
</div>
);
}
const item = entry.item;
if (!item) return null;
const hasParts = (item.content ?? []).length > 0;
const isInProgress = item.status === "in_progress";
const isFailed = item.status === "failed";
const messageClass = getMessageClass(item);
const statusLabel = item.status !== "completed" ? item.status.replace("_", " ") : "";
const kindLabel = item.kind.replace("_", " ");
return (
<div key={entry.id} className={`message ${messageClass} ${isFailed ? "error" : ""}`}>
<div className="avatar">{getAvatarLabel(isFailed ? "error" : messageClass)}</div>
<div className="message-content">
{(item.kind !== "message" || item.status !== "completed") && (
<div className="message-meta">
<span>{kindLabel}</span>
{statusLabel && (
<span className={`pill ${item.status === "failed" ? "danger" : "accent"}`}>
{statusLabel}
</span>
)}
</div>
)}
{hasParts ? (
(item.content ?? []).map(renderContentPart)
) : entry.deltaText ? (
<span>
{entry.deltaText}
{isInProgress && <span className="cursor" />}
</span>
) : (
<span className="muted">No content yet.</span>
)}
</div>
</div> </div>
<div className="message-content"> );
{msg.content} })}
</div>
</div>
))}
{sessionError && ( {sessionError && (
<div className="message-error"> <div className="message-error">
{sessionError} {sessionError}
@ -1028,13 +1306,18 @@ export default function App() {
<div className="event-list"> <div className="event-list">
{[...events].reverse().map((event) => { {[...events].reverse().map((event) => {
const type = getEventType(event); const type = getEventType(event);
const category = getEventCategory(type);
const eventClass = `${category} ${getEventClass(type)}`;
return ( return (
<div key={event.id} className="event-item"> <div key={event.event_id ?? event.sequence} className="event-item">
<div className="event-header"> <div className="event-header">
<span className={`event-type ${type}`}>{type}</span> <span className={`event-type ${eventClass}`}>{type}</span>
<span className="event-time">{formatTime(event.timestamp)}</span> <span className="event-time">{formatTime(event.time)}</span>
</div>
<div className="event-id">
Event #{event.event_id || event.sequence} - seq {event.sequence} - {event.source}
{event.synthetic ? " (synthetic)" : ""}
</div> </div>
<div className="event-id">Event #{event.id}</div>
<pre className="code-block">{formatJson(event.data)}</pre> <pre className="code-block">{formatJson(event.data)}</pre>
</div> </div>
); );
@ -1052,13 +1335,11 @@ export default function App() {
) : ( ) : (
<> <>
{questionRequests.map((request) => { {questionRequests.map((request) => {
const selections = questionSelections[request.id] ?? []; const selections = questionSelections[request.question_id] ?? [];
const answeredAll = request.questions.every((q, idx) => { const selected = selections[0] ?? [];
const answer = selections[idx] ?? []; const answered = selected.length > 0;
return answer.length > 0;
});
return ( return (
<div key={request.id} className="card"> <div key={request.question_id} className="card">
<div className="card-header"> <div className="card-header">
<span className="card-title"> <span className="card-title">
<HelpCircle className="button-icon" style={{ marginRight: 6 }} /> <HelpCircle className="button-icon" style={{ marginRight: 6 }} />
@ -1066,52 +1347,35 @@ export default function App() {
</span> </span>
<span className="pill accent">Pending</span> <span className="pill accent">Pending</span>
</div> </div>
{request.questions.map((question, qIdx) => ( <div style={{ marginTop: 12 }}>
<div key={qIdx} style={{ marginTop: 12 }}> <div style={{ fontSize: 12, marginBottom: 8 }}>{request.prompt}</div>
<div style={{ fontSize: 12, marginBottom: 8 }}> <div className="option-list">
{question.header && <strong>{question.header}: </strong>} {request.options.map((option) => {
{question.question} const isSelected = selected.includes(option);
</div> return (
<div className="option-list"> <label key={option} className="option-item">
{question.options.map((option) => { <input
const selected = selections[qIdx]?.includes(option.label) ?? false; type="radio"
return ( checked={isSelected}
<label key={option.label} className="option-item"> onChange={() => selectQuestionOption(request.question_id, option)}
<input />
type={question.multiSelect ? "checkbox" : "radio"} <span>{option}</span>
checked={selected} </label>
onChange={() => );
toggleQuestionOption( })}
request.id,
qIdx,
option.label,
Boolean(question.multiSelect)
)
}
/>
<span>
{option.label}
{option.description && (
<span className="muted"> - {option.description}</span>
)}
</span>
</label>
);
})}
</div>
</div> </div>
))} </div>
<div className="card-actions"> <div className="card-actions">
<button <button
className="button success small" className="button success small"
disabled={!answeredAll} disabled={!answered}
onClick={() => answerQuestion(request)} onClick={() => answerQuestion(request)}
> >
Reply Reply
</button> </button>
<button <button
className="button danger small" className="button danger small"
onClick={() => rejectQuestion(request.id)} onClick={() => rejectQuestion(request.question_id)}
> >
Reject Reject
</button> </button>
@ -1121,7 +1385,7 @@ export default function App() {
})} })}
{permissionRequests.map((request) => ( {permissionRequests.map((request) => (
<div key={request.id} className="card"> <div key={request.permission_id} className="card">
<div className="card-header"> <div className="card-header">
<span className="card-title"> <span className="card-title">
<Shield className="button-icon" style={{ marginRight: 6 }} /> <Shield className="button-icon" style={{ marginRight: 6 }} />
@ -1130,32 +1394,27 @@ export default function App() {
<span className="pill accent">Pending</span> <span className="pill accent">Pending</span>
</div> </div>
<div className="card-meta" style={{ marginTop: 8 }}> <div className="card-meta" style={{ marginTop: 8 }}>
{request.permission} {request.action}
</div> </div>
{request.patterns && request.patterns.length > 0 && ( {request.metadata !== null && request.metadata !== undefined && (
<div className="mono muted" style={{ fontSize: 11, marginTop: 4 }}>
{request.patterns.join(", ")}
</div>
)}
{request.metadata && (
<pre className="code-block">{formatJson(request.metadata)}</pre> <pre className="code-block">{formatJson(request.metadata)}</pre>
)} )}
<div className="card-actions"> <div className="card-actions">
<button <button
className="button success small" className="button success small"
onClick={() => replyPermission(request.id, "once")} onClick={() => replyPermission(request.permission_id, "once")}
> >
Allow Once Allow Once
</button> </button>
<button <button
className="button secondary small" className="button secondary small"
onClick={() => replyPermission(request.id, "always")} onClick={() => replyPermission(request.permission_id, "always")}
> >
Always Always
</button> </button>
<button <button
className="button danger small" className="button danger small"
onClick={() => replyPermission(request.id, "reject")} onClick={() => replyPermission(request.permission_id, "reject")}
> >
Reject Reject
</button> </button>
@ -1180,7 +1439,15 @@ export default function App() {
<div className="card-meta">No agents reported. Click refresh to check.</div> <div className="card-meta">No agents reported. Click refresh to check.</div>
)} )}
{(agents.length ? agents : defaultAgents.map((id) => ({ id, installed: false, version: undefined, path: undefined }))).map((agent) => ( {(agents.length
? agents
: defaultAgents.map((id) => ({
id,
installed: false,
version: undefined,
path: undefined,
capabilities: emptyCapabilities
}))).map((agent) => (
<div key={agent.id} className="card"> <div key={agent.id} className="card">
<div className="card-header"> <div className="card-header">
<span className="card-title">{agent.id}</span> <span className="card-title">{agent.id}</span>
@ -1192,6 +1459,9 @@ export default function App() {
{agent.version ? `v${agent.version}` : "Version unknown"} {agent.version ? `v${agent.version}` : "Version unknown"}
{agent.path && <span className="mono muted" style={{ marginLeft: 8 }}>{agent.path}</span>} {agent.path && <span className="mono muted" style={{ marginLeft: 8 }}>{agent.path}</span>}
</div> </div>
<div className="card-meta" style={{ marginTop: 8 }}>
Capabilities: {formatCapabilities(agent.capabilities ?? emptyCapabilities)}
</div>
{modesByAgent[agent.id] && modesByAgent[agent.id].length > 0 && ( {modesByAgent[agent.id] && modesByAgent[agent.id].length > 0 && (
<div className="card-meta" style={{ marginTop: 8 }}> <div className="card-meta" style={{ marginTop: 8 }}>
Modes: {modesByAgent[agent.id].map((m) => m.id).join(", ")} Modes: {modesByAgent[agent.id].map((m) => m.id).join(", ")}

611
pnpm-lock.yaml generated
View file

@ -105,7 +105,11 @@ importers:
specifier: ^4.19.0 specifier: ^4.19.0
version: 4.21.0 version: 4.21.0
sdks/cli: {} sdks/cli:
devDependencies:
vitest:
specifier: ^3.0.0
version: 3.2.4(@types/node@22.19.7)
sdks/cli/platforms/darwin-arm64: {} sdks/cli/platforms/darwin-arm64: {}
@ -123,9 +127,15 @@ importers:
openapi-typescript: openapi-typescript:
specifier: ^6.7.0 specifier: ^6.7.0
version: 6.7.6 version: 6.7.6
tsup:
specifier: ^8.0.0
version: 8.5.1(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)
typescript: typescript:
specifier: ^5.7.0 specifier: ^5.7.0
version: 5.9.3 version: 5.9.3
vitest:
specifier: ^3.0.0
version: 3.2.4(@types/node@22.19.7)
packages: packages:
@ -787,6 +797,12 @@ packages:
'@types/babel__traverse@7.28.0': '@types/babel__traverse@7.28.0':
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/estree@1.0.8': '@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@ -816,6 +832,40 @@ packages:
peerDependencies: peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
'@vitest/expect@3.2.4':
resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==}
'@vitest/mocker@3.2.4':
resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==}
peerDependencies:
msw: ^2.4.9
vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0
peerDependenciesMeta:
msw:
optional: true
vite:
optional: true
'@vitest/pretty-format@3.2.4':
resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==}
'@vitest/runner@3.2.4':
resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==}
'@vitest/snapshot@3.2.4':
resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==}
'@vitest/spy@3.2.4':
resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==}
'@vitest/utils@3.2.4':
resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==}
acorn@8.15.0:
resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==}
engines: {node: '>=0.4.0'}
hasBin: true
ansi-colors@4.1.3: ansi-colors@4.1.3:
resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==}
engines: {node: '>=6'} engines: {node: '>=6'}
@ -836,9 +886,16 @@ packages:
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
engines: {node: '>=12'} engines: {node: '>=12'}
any-promise@1.3.0:
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
argparse@2.0.1: argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
baseline-browser-mapping@2.9.18: baseline-browser-mapping@2.9.18:
resolution: {integrity: sha512-e23vBV1ZLfjb9apvfPk4rHVu2ry6RIr2Wfs+O324okSidrX7pTAnEJPCh/O5BtRlr7QtZI7ktOP3vsqr7Z5XoA==} resolution: {integrity: sha512-e23vBV1ZLfjb9apvfPk4rHVu2ry6RIr2Wfs+O324okSidrX7pTAnEJPCh/O5BtRlr7QtZI7ktOP3vsqr7Z5XoA==}
hasBin: true hasBin: true
@ -855,9 +912,27 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true hasBin: true
bundle-require@5.1.0:
resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
peerDependencies:
esbuild: '>=0.18'
cac@6.7.14:
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
engines: {node: '>=8'}
caniuse-lite@1.0.30001766: caniuse-lite@1.0.30001766:
resolution: {integrity: sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==} resolution: {integrity: sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==}
chai@5.3.3:
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
engines: {node: '>=18'}
check-error@2.1.3:
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
engines: {node: '>= 16'}
cheerio-select@2.1.0: cheerio-select@2.1.0:
resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==}
@ -865,6 +940,10 @@ packages:
resolution: {integrity: sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==} resolution: {integrity: sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==}
engines: {node: '>=20.18.1'} engines: {node: '>=20.18.1'}
chokidar@4.0.3:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'}
chownr@3.0.0: chownr@3.0.0:
resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -880,6 +959,17 @@ packages:
resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==}
engines: {node: '>=18'} engines: {node: '>=18'}
commander@4.1.1:
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
engines: {node: '>= 6'}
confbox@0.1.8:
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
consola@3.4.2:
resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==}
engines: {node: ^14.18.0 || >=16.10.0}
convert-source-map@2.0.0: convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
@ -906,6 +996,10 @@ packages:
supports-color: supports-color:
optional: true optional: true
deep-eql@5.0.2:
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
engines: {node: '>=6'}
dom-serializer@2.0.0: dom-serializer@2.0.0:
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
@ -946,6 +1040,9 @@ packages:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'} engines: {node: '>=0.12'}
es-module-lexer@1.7.0:
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
esbuild@0.21.5: esbuild@0.21.5:
resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
engines: {node: '>=12'} engines: {node: '>=12'}
@ -960,6 +1057,13 @@ packages:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'} engines: {node: '>=6'}
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
expect-type@1.3.0:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
fast-glob@3.3.3: fast-glob@3.3.3:
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
engines: {node: '>=8.6.0'} engines: {node: '>=8.6.0'}
@ -967,10 +1071,22 @@ packages:
fastq@1.20.1: fastq@1.20.1:
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
peerDependencies:
picomatch: ^3 || ^4
peerDependenciesMeta:
picomatch:
optional: true
fill-range@7.1.1: fill-range@7.1.1:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'} engines: {node: '>=8'}
fix-dts-default-cjs-exports@1.0.1:
resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==}
foreground-child@3.3.1: foreground-child@3.3.1:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'} engines: {node: '>=14'}
@ -1026,9 +1142,16 @@ packages:
resolution: {integrity: sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==} resolution: {integrity: sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==}
engines: {node: 20 || >=22} engines: {node: 20 || >=22}
joycon@3.1.1:
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
engines: {node: '>=10'}
js-tokens@4.0.0: js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
js-tokens@9.0.1:
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
js-yaml@4.1.1: js-yaml@4.1.1:
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
hasBin: true hasBin: true
@ -1043,10 +1166,24 @@ packages:
engines: {node: '>=6'} engines: {node: '>=6'}
hasBin: true hasBin: true
lilconfig@3.1.3:
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
engines: {node: '>=14'}
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
load-tsconfig@0.2.5:
resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
loose-envify@1.4.0: loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true hasBin: true
loupe@3.2.1:
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
lru-cache@11.2.4: lru-cache@11.2.4:
resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==} resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==}
engines: {node: 20 || >=22} engines: {node: 20 || >=22}
@ -1059,6 +1196,9 @@ packages:
peerDependencies: peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
merge2@1.4.1: merge2@1.4.1:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@ -1079,9 +1219,15 @@ packages:
resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==}
engines: {node: '>= 18'} engines: {node: '>= 18'}
mlly@1.8.0:
resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==}
ms@2.1.3: ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
mz@2.7.0:
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
nanoid@3.3.11: nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@ -1097,6 +1243,10 @@ packages:
nth-check@2.1.1: nth-check@2.1.1:
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
openapi-typescript@6.7.6: openapi-typescript@6.7.6:
resolution: {integrity: sha512-c/hfooPx+RBIOPM09GSxABOZhYPblDoyaGhqBkD/59vtpN21jEuWKDlM0KYTvqJVlSYjKs0tBcIdeXKChlSPtw==} resolution: {integrity: sha512-c/hfooPx+RBIOPM09GSxABOZhYPblDoyaGhqBkD/59vtpN21jEuWKDlM0KYTvqJVlSYjKs0tBcIdeXKChlSPtw==}
hasBin: true hasBin: true
@ -1121,6 +1271,13 @@ packages:
resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==} resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==}
engines: {node: 20 || >=22} engines: {node: 20 || >=22}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
pathval@2.0.1:
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
engines: {node: '>= 14.16'}
picocolors@1.1.1: picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@ -1128,6 +1285,35 @@ packages:
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
engines: {node: '>=8.6'} engines: {node: '>=8.6'}
picomatch@4.0.3:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'}
pirates@4.0.7:
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
engines: {node: '>= 6'}
pkg-types@1.3.1:
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
postcss-load-config@6.0.1:
resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==}
engines: {node: '>= 18'}
peerDependencies:
jiti: '>=1.21.0'
postcss: '>=8.0.9'
tsx: ^4.8.1
yaml: ^2.4.2
peerDependenciesMeta:
jiti:
optional: true
postcss:
optional: true
tsx:
optional: true
yaml:
optional: true
postcss@8.5.6: postcss@8.5.6:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
@ -1148,6 +1334,14 @@ packages:
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
readdirp@4.1.2:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
resolve-from@5.0.0:
resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==}
engines: {node: '>=8'}
resolve-pkg-maps@1.0.0: resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
@ -1190,6 +1384,9 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'} engines: {node: '>=8'}
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
signal-exit@4.1.0: signal-exit@4.1.0:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'} engines: {node: '>=14'}
@ -1198,6 +1395,16 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
source-map@0.7.6:
resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==}
engines: {node: '>= 12'}
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
std-env@3.10.0:
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
string-width@4.2.3: string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -1214,6 +1421,14 @@ packages:
resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==}
engines: {node: '>=12'} engines: {node: '>=12'}
strip-literal@3.1.0:
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
sucrase@3.35.1:
resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==}
engines: {node: '>=16 || 14 >=14.17'}
hasBin: true
supports-color@9.4.0: supports-color@9.4.0:
resolution: {integrity: sha512-VL+lNrEoIXww1coLPOmiEmK/0sGigko5COxI09KzHc2VJXJsQ37UaQ+8quuxjDeA7+KnLGTWRyOXSLLR2Wb4jw==} resolution: {integrity: sha512-VL+lNrEoIXww1coLPOmiEmK/0sGigko5COxI09KzHc2VJXJsQ37UaQ+8quuxjDeA7+KnLGTWRyOXSLLR2Wb4jw==}
engines: {node: '>=12'} engines: {node: '>=12'}
@ -1222,10 +1437,46 @@ packages:
resolution: {integrity: sha512-xqUeu2JAIJpXyvskvU3uvQW8PAmHrtXp2KDuMJwQqW8Sqq0CaZBAQ+dKS3RBXVhU4wC5NjAdKrmh84241gO9cA==} resolution: {integrity: sha512-xqUeu2JAIJpXyvskvU3uvQW8PAmHrtXp2KDuMJwQqW8Sqq0CaZBAQ+dKS3RBXVhU4wC5NjAdKrmh84241gO9cA==}
engines: {node: '>=18'} engines: {node: '>=18'}
thenify-all@1.6.0:
resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
engines: {node: '>=0.8'}
thenify@3.3.1:
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
tinyexec@0.3.2:
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
tinyglobby@0.2.15:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'}
tinypool@1.1.1:
resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==}
engines: {node: ^18.0.0 || >=20.0.0}
tinyrainbow@2.0.0:
resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==}
engines: {node: '>=14.0.0'}
tinyspy@4.0.4:
resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==}
engines: {node: '>=14.0.0'}
to-regex-range@5.0.1: to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'} engines: {node: '>=8.0'}
tree-kill@1.2.2:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true
ts-interface-checker@0.1.13:
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
ts-json-schema-generator@2.4.0: ts-json-schema-generator@2.4.0:
resolution: {integrity: sha512-HbmNsgs58CfdJq0gpteRTxPXG26zumezOs+SB9tgky6MpqiFgQwieCn2MW70+sxpHouZ/w9LW0V6L4ZQO4y1Ug==} resolution: {integrity: sha512-HbmNsgs58CfdJq0gpteRTxPXG26zumezOs+SB9tgky6MpqiFgQwieCn2MW70+sxpHouZ/w9LW0V6L4ZQO4y1Ug==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
@ -1234,6 +1485,25 @@ packages:
tslib@2.8.1: tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
tsup@8.5.1:
resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==}
engines: {node: '>=18'}
hasBin: true
peerDependencies:
'@microsoft/api-extractor': ^7.36.0
'@swc/core': ^1
postcss: ^8.4.12
typescript: '>=4.5.0'
peerDependenciesMeta:
'@microsoft/api-extractor':
optional: true
'@swc/core':
optional: true
postcss:
optional: true
typescript:
optional: true
tsx@4.21.0: tsx@4.21.0:
resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
@ -1278,6 +1548,9 @@ packages:
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
hasBin: true hasBin: true
ufo@1.6.3:
resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==}
undici-types@6.21.0: undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
@ -1295,6 +1568,11 @@ packages:
peerDependencies: peerDependencies:
browserslist: '>= 4.21.0' browserslist: '>= 4.21.0'
vite-node@3.2.4:
resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
hasBin: true
vite@5.4.21: vite@5.4.21:
resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==}
engines: {node: ^18.0.0 || >=20.0.0} engines: {node: ^18.0.0 || >=20.0.0}
@ -1326,6 +1604,34 @@ packages:
terser: terser:
optional: true optional: true
vitest@3.2.4:
resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@types/debug': ^4.1.12
'@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
'@vitest/browser': 3.2.4
'@vitest/ui': 3.2.4
happy-dom: '*'
jsdom: '*'
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
'@types/debug':
optional: true
'@types/node':
optional: true
'@vitest/browser':
optional: true
'@vitest/ui':
optional: true
happy-dom:
optional: true
jsdom:
optional: true
whatwg-encoding@3.1.1: whatwg-encoding@3.1.1:
resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -1340,6 +1646,11 @@ packages:
engines: {node: '>= 8'} engines: {node: '>= 8'}
hasBin: true hasBin: true
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
hasBin: true
wrap-ansi@7.0.0: wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -1842,6 +2153,13 @@ snapshots:
dependencies: dependencies:
'@babel/types': 7.28.6 '@babel/types': 7.28.6
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
assertion-error: 2.0.1
'@types/deep-eql@4.0.2': {}
'@types/estree@1.0.8': {} '@types/estree@1.0.8': {}
'@types/json-schema@7.0.15': {} '@types/json-schema@7.0.15': {}
@ -1875,6 +2193,50 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@vitest/expect@3.2.4':
dependencies:
'@types/chai': 5.2.3
'@vitest/spy': 3.2.4
'@vitest/utils': 3.2.4
chai: 5.3.3
tinyrainbow: 2.0.0
'@vitest/mocker@3.2.4(vite@5.4.21(@types/node@22.19.7))':
dependencies:
'@vitest/spy': 3.2.4
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 5.4.21(@types/node@22.19.7)
'@vitest/pretty-format@3.2.4':
dependencies:
tinyrainbow: 2.0.0
'@vitest/runner@3.2.4':
dependencies:
'@vitest/utils': 3.2.4
pathe: 2.0.3
strip-literal: 3.1.0
'@vitest/snapshot@3.2.4':
dependencies:
'@vitest/pretty-format': 3.2.4
magic-string: 0.30.21
pathe: 2.0.3
'@vitest/spy@3.2.4':
dependencies:
tinyspy: 4.0.4
'@vitest/utils@3.2.4':
dependencies:
'@vitest/pretty-format': 3.2.4
loupe: 3.2.1
tinyrainbow: 2.0.0
acorn@8.15.0: {}
ansi-colors@4.1.3: {} ansi-colors@4.1.3: {}
ansi-regex@5.0.1: {} ansi-regex@5.0.1: {}
@ -1887,8 +2249,12 @@ snapshots:
ansi-styles@6.2.3: {} ansi-styles@6.2.3: {}
any-promise@1.3.0: {}
argparse@2.0.1: {} argparse@2.0.1: {}
assertion-error@2.0.1: {}
baseline-browser-mapping@2.9.18: {} baseline-browser-mapping@2.9.18: {}
boolbase@1.0.0: {} boolbase@1.0.0: {}
@ -1905,8 +2271,25 @@ snapshots:
node-releases: 2.0.27 node-releases: 2.0.27
update-browserslist-db: 1.2.3(browserslist@4.28.1) update-browserslist-db: 1.2.3(browserslist@4.28.1)
bundle-require@5.1.0(esbuild@0.27.2):
dependencies:
esbuild: 0.27.2
load-tsconfig: 0.2.5
cac@6.7.14: {}
caniuse-lite@1.0.30001766: {} caniuse-lite@1.0.30001766: {}
chai@5.3.3:
dependencies:
assertion-error: 2.0.1
check-error: 2.1.3
deep-eql: 5.0.2
loupe: 3.2.1
pathval: 2.0.1
check-error@2.1.3: {}
cheerio-select@2.1.0: cheerio-select@2.1.0:
dependencies: dependencies:
boolbase: 1.0.0 boolbase: 1.0.0
@ -1930,6 +2313,10 @@ snapshots:
undici: 7.19.1 undici: 7.19.1
whatwg-mimetype: 4.0.0 whatwg-mimetype: 4.0.0
chokidar@4.0.3:
dependencies:
readdirp: 4.1.2
chownr@3.0.0: {} chownr@3.0.0: {}
color-convert@2.0.1: color-convert@2.0.1:
@ -1940,6 +2327,12 @@ snapshots:
commander@13.1.0: {} commander@13.1.0: {}
commander@4.1.1: {}
confbox@0.1.8: {}
consola@3.4.2: {}
convert-source-map@2.0.0: {} convert-source-map@2.0.0: {}
cross-spawn@7.0.6: cross-spawn@7.0.6:
@ -1964,6 +2357,8 @@ snapshots:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
deep-eql@5.0.2: {}
dom-serializer@2.0.0: dom-serializer@2.0.0:
dependencies: dependencies:
domelementtype: 2.3.0 domelementtype: 2.3.0
@ -2001,6 +2396,8 @@ snapshots:
entities@7.0.1: {} entities@7.0.1: {}
es-module-lexer@1.7.0: {}
esbuild@0.21.5: esbuild@0.21.5:
optionalDependencies: optionalDependencies:
'@esbuild/aix-ppc64': 0.21.5 '@esbuild/aix-ppc64': 0.21.5
@ -2058,6 +2455,12 @@ snapshots:
escalade@3.2.0: {} escalade@3.2.0: {}
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.8
expect-type@1.3.0: {}
fast-glob@3.3.3: fast-glob@3.3.3:
dependencies: dependencies:
'@nodelib/fs.stat': 2.0.5 '@nodelib/fs.stat': 2.0.5
@ -2070,10 +2473,20 @@ snapshots:
dependencies: dependencies:
reusify: 1.1.0 reusify: 1.1.0
fdir@6.5.0(picomatch@4.0.3):
optionalDependencies:
picomatch: 4.0.3
fill-range@7.1.1: fill-range@7.1.1:
dependencies: dependencies:
to-regex-range: 5.0.1 to-regex-range: 5.0.1
fix-dts-default-cjs-exports@1.0.1:
dependencies:
magic-string: 0.30.21
mlly: 1.8.0
rollup: 4.56.0
foreground-child@3.3.1: foreground-child@3.3.1:
dependencies: dependencies:
cross-spawn: 7.0.6 cross-spawn: 7.0.6
@ -2128,8 +2541,12 @@ snapshots:
dependencies: dependencies:
'@isaacs/cliui': 8.0.2 '@isaacs/cliui': 8.0.2
joycon@3.1.1: {}
js-tokens@4.0.0: {} js-tokens@4.0.0: {}
js-tokens@9.0.1: {}
js-yaml@4.1.1: js-yaml@4.1.1:
dependencies: dependencies:
argparse: 2.0.1 argparse: 2.0.1
@ -2138,10 +2555,18 @@ snapshots:
json5@2.2.3: {} json5@2.2.3: {}
lilconfig@3.1.3: {}
lines-and-columns@1.2.4: {}
load-tsconfig@0.2.5: {}
loose-envify@1.4.0: loose-envify@1.4.0:
dependencies: dependencies:
js-tokens: 4.0.0 js-tokens: 4.0.0
loupe@3.2.1: {}
lru-cache@11.2.4: {} lru-cache@11.2.4: {}
lru-cache@5.1.1: lru-cache@5.1.1:
@ -2152,6 +2577,10 @@ snapshots:
dependencies: dependencies:
react: 18.3.1 react: 18.3.1
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
merge2@1.4.1: {} merge2@1.4.1: {}
micromatch@4.0.8: micromatch@4.0.8:
@ -2169,8 +2598,21 @@ snapshots:
dependencies: dependencies:
minipass: 7.1.2 minipass: 7.1.2
mlly@1.8.0:
dependencies:
acorn: 8.15.0
pathe: 2.0.3
pkg-types: 1.3.1
ufo: 1.6.3
ms@2.1.3: {} ms@2.1.3: {}
mz@2.7.0:
dependencies:
any-promise: 1.3.0
object-assign: 4.1.1
thenify-all: 1.6.0
nanoid@3.3.11: {} nanoid@3.3.11: {}
node-releases@2.0.27: {} node-releases@2.0.27: {}
@ -2181,6 +2623,8 @@ snapshots:
dependencies: dependencies:
boolbase: 1.0.0 boolbase: 1.0.0
object-assign@4.1.1: {}
openapi-typescript@6.7.6: openapi-typescript@6.7.6:
dependencies: dependencies:
ansi-colors: 4.1.3 ansi-colors: 4.1.3
@ -2212,10 +2656,31 @@ snapshots:
lru-cache: 11.2.4 lru-cache: 11.2.4
minipass: 7.1.2 minipass: 7.1.2
pathe@2.0.3: {}
pathval@2.0.1: {}
picocolors@1.1.1: {} picocolors@1.1.1: {}
picomatch@2.3.1: {} picomatch@2.3.1: {}
picomatch@4.0.3: {}
pirates@4.0.7: {}
pkg-types@1.3.1:
dependencies:
confbox: 0.1.8
mlly: 1.8.0
pathe: 2.0.3
postcss-load-config@6.0.1(postcss@8.5.6)(tsx@4.21.0):
dependencies:
lilconfig: 3.1.3
optionalDependencies:
postcss: 8.5.6
tsx: 4.21.0
postcss@8.5.6: postcss@8.5.6:
dependencies: dependencies:
nanoid: 3.3.11 nanoid: 3.3.11
@ -2236,6 +2701,10 @@ snapshots:
dependencies: dependencies:
loose-envify: 1.4.0 loose-envify: 1.4.0
readdirp@4.1.2: {}
resolve-from@5.0.0: {}
resolve-pkg-maps@1.0.0: {} resolve-pkg-maps@1.0.0: {}
reusify@1.1.0: {} reusify@1.1.0: {}
@ -2293,10 +2762,18 @@ snapshots:
shebang-regex@3.0.0: {} shebang-regex@3.0.0: {}
siginfo@2.0.0: {}
signal-exit@4.1.0: {} signal-exit@4.1.0: {}
source-map-js@1.2.1: {} source-map-js@1.2.1: {}
source-map@0.7.6: {}
stackback@0.0.2: {}
std-env@3.10.0: {}
string-width@4.2.3: string-width@4.2.3:
dependencies: dependencies:
emoji-regex: 8.0.0 emoji-regex: 8.0.0
@ -2317,6 +2794,20 @@ snapshots:
dependencies: dependencies:
ansi-regex: 6.2.2 ansi-regex: 6.2.2
strip-literal@3.1.0:
dependencies:
js-tokens: 9.0.1
sucrase@3.35.1:
dependencies:
'@jridgewell/gen-mapping': 0.3.13
commander: 4.1.1
lines-and-columns: 1.2.4
mz: 2.7.0
pirates: 4.0.7
tinyglobby: 0.2.15
ts-interface-checker: 0.1.13
supports-color@9.4.0: {} supports-color@9.4.0: {}
tar@7.5.6: tar@7.5.6:
@ -2327,10 +2818,37 @@ snapshots:
minizlib: 3.1.0 minizlib: 3.1.0
yallist: 5.0.0 yallist: 5.0.0
thenify-all@1.6.0:
dependencies:
thenify: 3.3.1
thenify@3.3.1:
dependencies:
any-promise: 1.3.0
tinybench@2.9.0: {}
tinyexec@0.3.2: {}
tinyglobby@0.2.15:
dependencies:
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
tinypool@1.1.1: {}
tinyrainbow@2.0.0: {}
tinyspy@4.0.4: {}
to-regex-range@5.0.1: to-regex-range@5.0.1:
dependencies: dependencies:
is-number: 7.0.0 is-number: 7.0.0
tree-kill@1.2.2: {}
ts-interface-checker@0.1.13: {}
ts-json-schema-generator@2.4.0: ts-json-schema-generator@2.4.0:
dependencies: dependencies:
'@types/json-schema': 7.0.15 '@types/json-schema': 7.0.15
@ -2344,6 +2862,34 @@ snapshots:
tslib@2.8.1: {} tslib@2.8.1: {}
tsup@8.5.1(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3):
dependencies:
bundle-require: 5.1.0(esbuild@0.27.2)
cac: 6.7.14
chokidar: 4.0.3
consola: 3.4.2
debug: 4.4.3
esbuild: 0.27.2
fix-dts-default-cjs-exports: 1.0.1
joycon: 3.1.1
picocolors: 1.1.1
postcss-load-config: 6.0.1(postcss@8.5.6)(tsx@4.21.0)
resolve-from: 5.0.0
rollup: 4.56.0
source-map: 0.7.6
sucrase: 3.35.1
tinyexec: 0.3.2
tinyglobby: 0.2.15
tree-kill: 1.2.2
optionalDependencies:
postcss: 8.5.6
typescript: 5.9.3
transitivePeerDependencies:
- jiti
- supports-color
- tsx
- yaml
tsx@4.21.0: tsx@4.21.0:
dependencies: dependencies:
esbuild: 0.27.2 esbuild: 0.27.2
@ -2380,6 +2926,8 @@ snapshots:
typescript@5.9.3: {} typescript@5.9.3: {}
ufo@1.6.3: {}
undici-types@6.21.0: {} undici-types@6.21.0: {}
undici@5.29.0: undici@5.29.0:
@ -2394,6 +2942,24 @@ snapshots:
escalade: 3.2.0 escalade: 3.2.0
picocolors: 1.1.1 picocolors: 1.1.1
vite-node@3.2.4(@types/node@22.19.7):
dependencies:
cac: 6.7.14
debug: 4.4.3
es-module-lexer: 1.7.0
pathe: 2.0.3
vite: 5.4.21(@types/node@22.19.7)
transitivePeerDependencies:
- '@types/node'
- less
- lightningcss
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
vite@5.4.21(@types/node@22.19.7): vite@5.4.21(@types/node@22.19.7):
dependencies: dependencies:
esbuild: 0.21.5 esbuild: 0.21.5
@ -2403,6 +2969,44 @@ snapshots:
'@types/node': 22.19.7 '@types/node': 22.19.7
fsevents: 2.3.3 fsevents: 2.3.3
vitest@3.2.4(@types/node@22.19.7):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.4
'@vitest/mocker': 3.2.4(vite@5.4.21(@types/node@22.19.7))
'@vitest/pretty-format': 3.2.4
'@vitest/runner': 3.2.4
'@vitest/snapshot': 3.2.4
'@vitest/spy': 3.2.4
'@vitest/utils': 3.2.4
chai: 5.3.3
debug: 4.4.3
expect-type: 1.3.0
magic-string: 0.30.21
pathe: 2.0.3
picomatch: 4.0.3
std-env: 3.10.0
tinybench: 2.9.0
tinyexec: 0.3.2
tinyglobby: 0.2.15
tinypool: 1.1.1
tinyrainbow: 2.0.0
vite: 5.4.21(@types/node@22.19.7)
vite-node: 3.2.4(@types/node@22.19.7)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 22.19.7
transitivePeerDependencies:
- less
- lightningcss
- msw
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
whatwg-encoding@3.1.1: whatwg-encoding@3.1.1:
dependencies: dependencies:
iconv-lite: 0.6.3 iconv-lite: 0.6.3
@ -2413,6 +3017,11 @@ snapshots:
dependencies: dependencies:
isexe: 2.0.0 isexe: 2.0.0
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
stackback: 0.0.2
wrap-ansi@7.0.0: wrap-ansi@7.0.0:
dependencies: dependencies:
ansi-styles: 4.3.0 ansi-styles: 4.3.0

22
research.md Normal file
View file

@ -0,0 +1,22 @@
# Research: Subagents
Summary of subagent support across providers based on current agent schemas and docs.
OpenCode
- Schema includes `Part` variants with `type = "subtask"` and fields like `agent`, `prompt`, and `description`.
- These parts are emitted via `message.part.updated`, which can be used to render subagent activity.
Codex
- Schema includes `SessionSource` with a `subagent` variant (e.g., review/compact), attached to `Thread.source`.
- This indicates the origin of a thread, not active subagent status updates in the event stream.
Claude
- CLI schema does not expose subagent events.
- The Task tool supports `subagent_type`, but it is not represented as a structured event; it may be inferred only from tool usage output.
Amp
- Schema has no subagent event types.
- Permission rules include `context: "subagent"` and `delegate` actions, but no event stream for subagent status.
Current universal behavior
- Subagent activity is normalized into the standard message/tool flow; no dedicated subagent fields yet.

View file

@ -117,6 +117,13 @@ Claude CLI outputs newline-delimited JSON events:
} }
``` ```
## Limitations (Headless CLI)
- The headless CLI tool list does not include the `AskUserQuestion` tool, even though the Agent SDK documents it.
- As a result, prompting the CLI to "call AskUserQuestion" does not emit question events; it proceeds with normal tool/message flow instead.
- If we need structured question events, we can implement a wrapper around the Claude Agent SDK (instead of the CLI) and surface question events in our own transport.
- The current Python SDK repo does not expose `AskUserQuestion` types; it only supports permission prompts via the control protocol.
## Response Schema ## Response Schema
```typescript ```typescript

View file

@ -2068,7 +2068,7 @@
"type": { "type": {
"type": "string", "type": "string",
"enum": [ "enum": [
"danger-full-access" "dangerFullAccess"
], ],
"title": "DangerFullAccessSandboxPolicyType" "title": "DangerFullAccessSandboxPolicyType"
} }
@ -2085,7 +2085,7 @@
"type": { "type": {
"type": "string", "type": "string",
"enum": [ "enum": [
"read-only" "readOnly"
], ],
"title": "ReadOnlySandboxPolicyType" "title": "ReadOnlySandboxPolicyType"
} }
@ -2111,7 +2111,7 @@
"type": { "type": {
"type": "string", "type": "string",
"enum": [ "enum": [
"external-sandbox" "externalSandbox"
], ],
"title": "ExternalSandboxSandboxPolicyType" "title": "ExternalSandboxSandboxPolicyType"
} }
@ -2143,7 +2143,7 @@
"type": { "type": {
"type": "string", "type": "string",
"enum": [ "enum": [
"workspace-write" "workspaceWrite"
], ],
"title": "WorkspaceWriteSandboxPolicyType" "title": "WorkspaceWriteSandboxPolicyType"
}, },
@ -2172,7 +2172,7 @@
"type": { "type": {
"type": "string", "type": "string",
"enum": [ "enum": [
"danger-full-access" "dangerFullAccess"
], ],
"title": "DangerFullAccessSandboxPolicy2Type" "title": "DangerFullAccessSandboxPolicy2Type"
} }
@ -2189,7 +2189,7 @@
"type": { "type": {
"type": "string", "type": "string",
"enum": [ "enum": [
"read-only" "readOnly"
], ],
"title": "ReadOnlySandboxPolicy2Type" "title": "ReadOnlySandboxPolicy2Type"
} }
@ -2215,7 +2215,7 @@
"type": { "type": {
"type": "string", "type": "string",
"enum": [ "enum": [
"external-sandbox" "externalSandbox"
], ],
"title": "ExternalSandboxSandboxPolicy2Type" "title": "ExternalSandboxSandboxPolicy2Type"
} }
@ -2247,7 +2247,7 @@
"type": { "type": {
"type": "string", "type": "string",
"enum": [ "enum": [
"workspace-write" "workspaceWrite"
], ],
"title": "WorkspaceWriteSandboxPolicy2Type" "title": "WorkspaceWriteSandboxPolicy2Type"
}, },
@ -11393,9 +11393,9 @@
"SandboxMode": { "SandboxMode": {
"type": "string", "type": "string",
"enum": [ "enum": [
"read-only", "readOnly",
"workspace-write", "workspaceWrite",
"danger-full-access" "dangerFullAccess"
] ]
}, },
"ThreadStartParams": { "ThreadStartParams": {
@ -17953,4 +17953,4 @@
} }
} }
} }
} }

View file

@ -389,6 +389,12 @@ function runChecks(rootDir: string) {
console.log("==> Running TypeScript checks"); console.log("==> Running TypeScript checks");
run("pnpm", ["run", "build"], { cwd: rootDir }); run("pnpm", ["run", "build"], { cwd: rootDir });
console.log("==> Running TypeScript SDK tests");
run("pnpm", ["--filter", "sandbox-agent", "test"], { cwd: rootDir });
console.log("==> Running CLI SDK tests");
run("pnpm", ["--filter", "@sandbox-agent/cli", "test"], { cwd: rootDir });
console.log("==> Validating OpenAPI spec for Mintlify"); console.log("==> Validating OpenAPI spec for Mintlify");
run("pnpm", ["dlx", "mint", "openapi-check", "docs/openapi.json"], { cwd: rootDir }); run("pnpm", ["dlx", "mint", "openapi-check", "docs/openapi.json"], { cwd: rootDir });
} }

View file

@ -11,6 +11,12 @@
"sandbox-agent": "bin/sandbox-agent", "sandbox-agent": "bin/sandbox-agent",
"sandbox-daemon": "bin/sandbox-agent" "sandbox-daemon": "bin/sandbox-agent"
}, },
"scripts": {
"test": "vitest run"
},
"devDependencies": {
"vitest": "^3.0.0"
},
"optionalDependencies": { "optionalDependencies": {
"@sandbox-agent/cli-darwin-arm64": "0.1.0", "@sandbox-agent/cli-darwin-arm64": "0.1.0",
"@sandbox-agent/cli-darwin-x64": "0.1.0", "@sandbox-agent/cli-darwin-x64": "0.1.0",

View file

@ -0,0 +1,97 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { execFileSync, spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const LAUNCHER_PATH = resolve(__dirname, "../bin/sandbox-agent");
// Check for binary in common locations
function findBinary(): string | null {
if (process.env.SANDBOX_AGENT_BIN) {
return process.env.SANDBOX_AGENT_BIN;
}
// Check cargo build output
const cargoPaths = [
resolve(__dirname, "../../../target/debug/sandbox-agent"),
resolve(__dirname, "../../../target/release/sandbox-agent"),
];
for (const p of cargoPaths) {
if (existsSync(p)) {
return p;
}
}
return null;
}
const BINARY_PATH = findBinary();
const SKIP_INTEGRATION = !BINARY_PATH;
describe("CLI Launcher", () => {
describe("platform detection", () => {
it("defines all supported platforms", () => {
const PLATFORMS: Record<string, string> = {
"darwin-arm64": "@sandbox-agent/cli-darwin-arm64",
"darwin-x64": "@sandbox-agent/cli-darwin-x64",
"linux-x64": "@sandbox-agent/cli-linux-x64",
"win32-x64": "@sandbox-agent/cli-win32-x64",
};
// Verify platform map covers expected platforms
expect(PLATFORMS["darwin-arm64"]).toBe("@sandbox-agent/cli-darwin-arm64");
expect(PLATFORMS["darwin-x64"]).toBe("@sandbox-agent/cli-darwin-x64");
expect(PLATFORMS["linux-x64"]).toBe("@sandbox-agent/cli-linux-x64");
expect(PLATFORMS["win32-x64"]).toBe("@sandbox-agent/cli-win32-x64");
});
it("generates correct platform key format", () => {
const key = `${process.platform}-${process.arch}`;
expect(key).toMatch(/^[a-z0-9]+-[a-z0-9]+$/);
});
});
});
describe.skipIf(SKIP_INTEGRATION)("CLI Integration", () => {
it("runs --help successfully", () => {
const result = spawnSync(BINARY_PATH!, ["--help"], {
encoding: "utf8",
timeout: 10000,
});
expect(result.status).toBe(0);
expect(result.stdout).toContain("sandbox-agent");
});
it("runs --version successfully", () => {
const result = spawnSync(BINARY_PATH!, ["--version"], {
encoding: "utf8",
timeout: 10000,
});
expect(result.status).toBe(0);
expect(result.stdout).toMatch(/\d+\.\d+\.\d+/);
});
it("lists agents", () => {
const result = spawnSync(BINARY_PATH!, ["agents", "list"], {
encoding: "utf8",
timeout: 10000,
});
expect(result.status).toBe(0);
});
it("shows server help", () => {
const result = spawnSync(BINARY_PATH!, ["server", "--help"], {
encoding: "utf8",
timeout: 10000,
});
expect(result.status).toBe(0);
expect(result.stdout).toContain("server");
});
});

View file

@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["tests/**/*.test.ts"],
testTimeout: 30000,
},
});

View file

@ -23,13 +23,17 @@
"generate:openapi": "cargo check -p sandbox-agent-openapi-gen && cargo run -p sandbox-agent-openapi-gen -- --out ../../docs/openapi.json", "generate:openapi": "cargo check -p sandbox-agent-openapi-gen && cargo run -p sandbox-agent-openapi-gen -- --out ../../docs/openapi.json",
"generate:types": "openapi-typescript ../../docs/openapi.json -o src/generated/openapi.ts", "generate:types": "openapi-typescript ../../docs/openapi.json -o src/generated/openapi.ts",
"generate": "pnpm run generate:openapi && pnpm run generate:types", "generate": "pnpm run generate:openapi && pnpm run generate:types",
"build": "pnpm run generate && tsc -p tsconfig.json", "build": "pnpm run generate && tsup",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"openapi-typescript": "^6.7.0", "openapi-typescript": "^6.7.0",
"typescript": "^5.7.0" "tsup": "^8.0.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@sandbox-agent/cli": "0.1.0" "@sandbox-agent/cli": "0.1.0"

View file

@ -1,31 +1,23 @@
import type { components } from "./generated/openapi.js";
import type { import type {
SandboxDaemonSpawnHandle, SandboxDaemonSpawnHandle,
SandboxDaemonSpawnOptions, SandboxDaemonSpawnOptions,
} from "./spawn.js"; } from "./spawn.ts";
import type {
export type AgentInstallRequest = components["schemas"]["AgentInstallRequest"]; AgentInstallRequest,
export type AgentModeInfo = components["schemas"]["AgentModeInfo"]; AgentListResponse,
export type AgentModesResponse = components["schemas"]["AgentModesResponse"]; AgentModesResponse,
export type AgentInfo = components["schemas"]["AgentInfo"]; CreateSessionRequest,
export type AgentListResponse = components["schemas"]["AgentListResponse"]; CreateSessionResponse,
export type CreateSessionRequest = components["schemas"]["CreateSessionRequest"]; EventsQuery,
export type CreateSessionResponse = components["schemas"]["CreateSessionResponse"]; EventsResponse,
export type HealthResponse = components["schemas"]["HealthResponse"]; HealthResponse,
export type MessageRequest = components["schemas"]["MessageRequest"]; MessageRequest,
export type EventsQuery = components["schemas"]["EventsQuery"]; PermissionReplyRequest,
export type EventsResponse = components["schemas"]["EventsResponse"]; ProblemDetails,
export type PermissionRequest = components["schemas"]["PermissionRequest"]; QuestionReplyRequest,
export type QuestionReplyRequest = components["schemas"]["QuestionReplyRequest"]; SessionListResponse,
export type QuestionRequest = components["schemas"]["QuestionRequest"]; UniversalEvent,
export type PermissionReplyRequest = components["schemas"]["PermissionReplyRequest"]; } from "./types.ts";
export type PermissionReply = components["schemas"]["PermissionReply"];
export type ProblemDetails = components["schemas"]["ProblemDetails"];
export type SessionInfo = components["schemas"]["SessionInfo"];
export type SessionListResponse = components["schemas"]["SessionListResponse"];
export type UniversalEvent = components["schemas"]["UniversalEvent"];
export type UniversalMessage = components["schemas"]["UniversalMessage"];
export type UniversalMessagePart = components["schemas"]["UniversalMessagePart"];
const API_PREFIX = "/v1"; const API_PREFIX = "/v1";
@ -179,13 +171,14 @@ export class SandboxDaemonClient {
if (done) { if (done) {
break; break;
} }
buffer += decoder.decode(value, { stream: true }); // Normalize CRLF to LF for consistent parsing
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n");
let index = buffer.indexOf("\n\n"); let index = buffer.indexOf("\n\n");
while (index !== -1) { while (index !== -1) {
const chunk = buffer.slice(0, index); const chunk = buffer.slice(0, index);
buffer = buffer.slice(index + 2); buffer = buffer.slice(index + 2);
const dataLines = chunk const dataLines = chunk
.split(/\r?\n/) .split("\n")
.filter((line) => line.startsWith("data:")); .filter((line) => line.startsWith("data:"));
if (dataLines.length > 0) { if (dataLines.length > 0) {
const payload = dataLines const payload = dataLines

View file

@ -4,11 +4,6 @@
*/ */
/** OneOf type helpers */
type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never };
type XOR<T, U> = (T | U) extends object ? (Without<T, U> & U) | (Without<U, T> & T) : T | U;
type OneOf<T extends any[]> = T extends [infer Only] ? Only : T extends [infer A, infer B, ...infer Rest] ? OneOf<[XOR<A, B>, ...Rest]> : never;
export interface paths { export interface paths {
"/v1/agents": { "/v1/agents": {
get: operations["list_agents"]; get: operations["list_agents"];
@ -46,12 +41,21 @@ export interface paths {
"/v1/sessions/{session_id}/questions/{question_id}/reply": { "/v1/sessions/{session_id}/questions/{question_id}/reply": {
post: operations["reply_question"]; post: operations["reply_question"];
}; };
"/v1/sessions/{session_id}/terminate": {
post: operations["terminate_session"];
};
} }
export type webhooks = Record<string, never>; export type webhooks = Record<string, never>;
export interface components { export interface components {
schemas: { schemas: {
AgentCapabilities: {
permissions: boolean;
planMode: boolean;
questions: boolean;
toolCalls: boolean;
};
AgentError: { AgentError: {
agent?: string | null; agent?: string | null;
details?: unknown; details?: unknown;
@ -60,6 +64,7 @@ export interface components {
type: components["schemas"]["ErrorType"]; type: components["schemas"]["ErrorType"];
}; };
AgentInfo: { AgentInfo: {
capabilities: components["schemas"]["AgentCapabilities"];
id: string; id: string;
installed: boolean; installed: boolean;
path?: string | null; path?: string | null;
@ -79,25 +84,52 @@ export interface components {
AgentModesResponse: { AgentModesResponse: {
modes: components["schemas"]["AgentModeInfo"][]; modes: components["schemas"]["AgentModeInfo"][];
}; };
AttachmentSource: { AgentUnparsedData: {
error: string;
location: string;
raw_hash?: string | null;
};
ContentPart: {
text: string;
/** @enum {string} */
type: "text";
} | {
json: unknown;
/** @enum {string} */
type: "json";
} | {
arguments: string;
call_id: string;
name: string;
/** @enum {string} */
type: "tool_call";
} | {
call_id: string;
output: string;
/** @enum {string} */
type: "tool_result";
} | ({
action: components["schemas"]["FileAction"];
diff?: string | null;
path: string; path: string;
/** @enum {string} */ /** @enum {string} */
type: "path"; type: "file_ref";
} | { }) | {
text: string;
/** @enum {string} */ /** @enum {string} */
type: "url"; type: "reasoning";
url: string; visibility: components["schemas"]["ReasoningVisibility"];
} | ({ } | ({
data: string; mime?: string | null;
encoding?: string | null; path: string;
/** @enum {string} */ /** @enum {string} */
type: "data"; type: "image";
}) | ({
detail?: string | null;
label: string;
/** @enum {string} */
type: "status";
}); });
CrashInfo: {
details?: unknown;
kind?: string | null;
message: string;
};
CreateSessionRequest: { CreateSessionRequest: {
agent: string; agent: string;
agentMode?: string | null; agentMode?: string | null;
@ -107,13 +139,21 @@ export interface components {
variant?: string | null; variant?: string | null;
}; };
CreateSessionResponse: { CreateSessionResponse: {
agentSessionId?: string | null;
error?: components["schemas"]["AgentError"] | null; error?: components["schemas"]["AgentError"] | null;
healthy: boolean; healthy: boolean;
nativeSessionId?: string | null;
};
ErrorData: {
code?: string | null;
details?: unknown;
message: string;
}; };
/** @enum {string} */ /** @enum {string} */
ErrorType: "invalid_request" | "unsupported_agent" | "agent_not_installed" | "install_failed" | "agent_process_exited" | "token_invalid" | "permission_denied" | "session_not_found" | "session_already_exists" | "mode_not_supported" | "stream_error" | "timeout"; ErrorType: "invalid_request" | "unsupported_agent" | "agent_not_installed" | "install_failed" | "agent_process_exited" | "token_invalid" | "permission_denied" | "session_not_found" | "session_already_exists" | "mode_not_supported" | "stream_error" | "timeout";
/** @enum {string} */
EventSource: "agent" | "daemon";
EventsQuery: { EventsQuery: {
includeRaw?: boolean | null;
/** Format: int64 */ /** Format: int64 */
limit?: number | null; limit?: number | null;
/** Format: int64 */ /** Format: int64 */
@ -123,32 +163,41 @@ export interface components {
events: components["schemas"]["UniversalEvent"][]; events: components["schemas"]["UniversalEvent"][];
hasMore: boolean; hasMore: boolean;
}; };
/** @enum {string} */
FileAction: "read" | "write" | "patch";
HealthResponse: { HealthResponse: {
status: string; status: string;
}; };
ItemDeltaData: {
delta: string;
item_id: string;
native_item_id?: string | null;
};
ItemEventData: {
item: components["schemas"]["UniversalItem"];
};
/** @enum {string} */
ItemKind: "message" | "tool_call" | "tool_result" | "system" | "status" | "unknown";
/** @enum {string} */
ItemRole: "user" | "assistant" | "system" | "tool";
/** @enum {string} */
ItemStatus: "in_progress" | "completed" | "failed";
MessageRequest: { MessageRequest: {
message: string; message: string;
}; };
PermissionEventData: {
action: string;
metadata?: unknown;
permission_id: string;
status: components["schemas"]["PermissionStatus"];
};
/** @enum {string} */ /** @enum {string} */
PermissionReply: "once" | "always" | "reject"; PermissionReply: "once" | "always" | "reject";
PermissionReplyRequest: { PermissionReplyRequest: {
reply: components["schemas"]["PermissionReply"]; reply: components["schemas"]["PermissionReply"];
}; };
PermissionRequest: { /** @enum {string} */
always: string[]; PermissionStatus: "requested" | "approved" | "denied";
id: string;
metadata?: {
[key: string]: unknown;
};
patterns: string[];
permission: string;
sessionId: string;
tool?: components["schemas"]["PermissionToolRef"] | null;
};
PermissionToolRef: {
callId: string;
messageId: string;
};
ProblemDetails: { ProblemDetails: {
detail?: string | null; detail?: string | null;
instance?: string | null; instance?: string | null;
@ -158,38 +207,34 @@ export interface components {
type: string; type: string;
[key: string]: unknown; [key: string]: unknown;
}; };
QuestionInfo: { QuestionEventData: {
custom?: boolean | null; options: string[];
header?: string | null; prompt: string;
multiSelect?: boolean | null; question_id: string;
options: components["schemas"]["QuestionOption"][]; response?: string | null;
question: string; status: components["schemas"]["QuestionStatus"];
};
QuestionOption: {
description?: string | null;
label: string;
}; };
QuestionReplyRequest: { QuestionReplyRequest: {
answers: string[][]; answers: string[][];
}; };
QuestionRequest: { /** @enum {string} */
id: string; QuestionStatus: "requested" | "answered" | "rejected";
questions: components["schemas"]["QuestionInfo"][]; /** @enum {string} */
sessionId: string; ReasoningVisibility: "public" | "private";
tool?: components["schemas"]["QuestionToolRef"] | null; /** @enum {string} */
}; SessionEndReason: "completed" | "error" | "terminated";
QuestionToolRef: { SessionEndedData: {
callId: string; reason: components["schemas"]["SessionEndReason"];
messageId: string; terminated_by: components["schemas"]["TerminatedBy"];
}; };
SessionInfo: { SessionInfo: {
agent: string; agent: string;
agentMode: string; agentMode: string;
agentSessionId?: string | null;
ended: boolean; ended: boolean;
/** Format: int64 */ /** Format: int64 */
eventCount: number; eventCount: number;
model?: string | null; model?: string | null;
nativeSessionId?: string | null;
permissionMode: string; permissionMode: string;
sessionId: string; sessionId: string;
variant?: string | null; variant?: string | null;
@ -197,98 +242,35 @@ export interface components {
SessionListResponse: { SessionListResponse: {
sessions: components["schemas"]["SessionInfo"][]; sessions: components["schemas"]["SessionInfo"][];
}; };
Started: { SessionStartedData: {
details?: unknown; metadata?: unknown;
message?: string | null;
}; };
/** @enum {string} */
TerminatedBy: "agent" | "daemon";
UniversalEvent: { UniversalEvent: {
agent: string;
agentSessionId?: string | null;
data: components["schemas"]["UniversalEventData"]; data: components["schemas"]["UniversalEventData"];
event_id: string;
native_session_id?: string | null;
raw?: unknown;
/** Format: int64 */ /** Format: int64 */
id: number; sequence: number;
sessionId: string; session_id: string;
timestamp: string; source: components["schemas"]["EventSource"];
synthetic: boolean;
time: string;
type: components["schemas"]["UniversalEventType"];
}; };
UniversalEventData: { UniversalEventData: components["schemas"]["SessionStartedData"] | components["schemas"]["SessionEndedData"] | components["schemas"]["ItemEventData"] | components["schemas"]["ItemDeltaData"] | components["schemas"]["ErrorData"] | components["schemas"]["PermissionEventData"] | components["schemas"]["QuestionEventData"] | components["schemas"]["AgentUnparsedData"];
message: components["schemas"]["UniversalMessage"]; /** @enum {string} */
} | { UniversalEventType: "session.started" | "session.ended" | "item.started" | "item.delta" | "item.completed" | "error" | "permission.requested" | "permission.resolved" | "question.requested" | "question.resolved" | "agent.unparsed";
started: components["schemas"]["Started"]; UniversalItem: {
} | { content: components["schemas"]["ContentPart"][];
error: components["schemas"]["CrashInfo"]; item_id: string;
} | { kind: components["schemas"]["ItemKind"];
questionAsked: components["schemas"]["QuestionRequest"]; native_item_id?: string | null;
} | { parent_id?: string | null;
permissionAsked: components["schemas"]["PermissionRequest"]; role?: components["schemas"]["ItemRole"] | null;
} | { status: components["schemas"]["ItemStatus"];
raw: unknown;
};
UniversalMessage: OneOf<[components["schemas"]["UniversalMessageParsed"], {
error?: string | null;
raw: unknown;
}]>;
UniversalMessageParsed: {
id?: string | null;
metadata?: {
[key: string]: unknown;
};
parts: components["schemas"]["UniversalMessagePart"][];
role: string;
};
UniversalMessagePart: {
text: string;
/** @enum {string} */
type: "text";
} | ({
id?: string | null;
input: unknown;
name: string;
/** @enum {string} */
type: "tool_call";
}) | ({
id?: string | null;
is_error?: boolean | null;
name?: string | null;
output: unknown;
/** @enum {string} */
type: "tool_result";
}) | ({
arguments: unknown;
id?: string | null;
name?: string | null;
raw?: unknown;
/** @enum {string} */
type: "function_call";
}) | ({
id?: string | null;
is_error?: boolean | null;
name?: string | null;
raw?: unknown;
result: unknown;
/** @enum {string} */
type: "function_result";
}) | ({
filename?: string | null;
mime_type?: string | null;
raw?: unknown;
source: components["schemas"]["AttachmentSource"];
/** @enum {string} */
type: "file";
}) | ({
alt?: string | null;
mime_type?: string | null;
raw?: unknown;
source: components["schemas"]["AttachmentSource"];
/** @enum {string} */
type: "image";
}) | {
message: string;
/** @enum {string} */
type: "error";
} | {
raw: unknown;
/** @enum {string} */
type: "unknown";
}; };
}; };
responses: never; responses: never;
@ -418,10 +400,12 @@ export interface operations {
get_events: { get_events: {
parameters: { parameters: {
query?: { query?: {
/** @description Last seen event id (exclusive) */ /** @description Last seen event sequence (exclusive) */
offset?: number | null; offset?: number | null;
/** @description Max events to return */ /** @description Max events to return */
limit?: number | null; limit?: number | null;
/** @description Include raw provider payloads */
include_raw?: boolean | null;
}; };
path: { path: {
/** @description Session id */ /** @description Session id */
@ -444,8 +428,10 @@ export interface operations {
get_events_sse: { get_events_sse: {
parameters: { parameters: {
query?: { query?: {
/** @description Last seen event id (exclusive) */ /** @description Last seen event sequence (exclusive) */
offset?: number | null; offset?: number | null;
/** @description Include raw provider payloads */
include_raw?: boolean | null;
}; };
path: { path: {
/** @description Session id */ /** @description Session id */
@ -556,4 +542,23 @@ export interface operations {
}; };
}; };
}; };
terminate_session: {
parameters: {
path: {
/** @description Session id */
session_id: string;
};
};
responses: {
/** @description Session terminated */
204: {
content: never;
};
404: {
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
};
};
} }

View file

@ -3,32 +3,53 @@ export {
SandboxDaemonError, SandboxDaemonError,
connectSandboxDaemonClient, connectSandboxDaemonClient,
createSandboxDaemonClient, createSandboxDaemonClient,
} from "./client.js"; } from "./client.ts";
export type { export type {
SandboxDaemonClientOptions,
SandboxDaemonConnectOptions,
} from "./client.ts";
export type {
AgentCapabilities,
AgentInfo, AgentInfo,
AgentInstallRequest, AgentInstallRequest,
AgentListResponse, AgentListResponse,
AgentModeInfo, AgentModeInfo,
AgentModesResponse, AgentModesResponse,
AgentUnparsedData,
ContentPart,
CreateSessionRequest, CreateSessionRequest,
CreateSessionResponse, CreateSessionResponse,
ErrorData,
EventSource,
EventsQuery, EventsQuery,
EventsResponse, EventsResponse,
FileAction,
HealthResponse, HealthResponse,
ItemDeltaData,
ItemEventData,
ItemKind,
ItemRole,
ItemStatus,
MessageRequest, MessageRequest,
PermissionRequest, PermissionEventData,
PermissionReply, PermissionReply,
PermissionReplyRequest, PermissionReplyRequest,
PermissionStatus,
ProblemDetails, ProblemDetails,
QuestionRequest, QuestionEventData,
QuestionReplyRequest, QuestionReplyRequest,
QuestionStatus,
ReasoningVisibility,
SessionEndReason,
SessionEndedData,
SessionInfo, SessionInfo,
SessionListResponse, SessionListResponse,
SessionStartedData,
TerminatedBy,
UniversalEvent, UniversalEvent,
UniversalMessage, UniversalEventData,
UniversalMessagePart, UniversalEventType,
SandboxDaemonClientOptions, UniversalItem,
SandboxDaemonConnectOptions, } from "./types.ts";
} from "./client.js"; export type { components, paths } from "./generated/openapi.ts";
export type { components, paths } from "./generated/openapi.js"; export type { SandboxDaemonSpawnOptions, SandboxDaemonSpawnLogMode } from "./spawn.ts";
export type { SandboxDaemonSpawnOptions, SandboxDaemonSpawnLogMode } from "./spawn.js";

View file

@ -0,0 +1,45 @@
import type { components } from "./generated/openapi.ts";
type S = components["schemas"];
export type AgentCapabilities = S["AgentCapabilities"];
export type AgentInfo = S["AgentInfo"];
export type AgentInstallRequest = S["AgentInstallRequest"];
export type AgentListResponse = S["AgentListResponse"];
export type AgentModeInfo = S["AgentModeInfo"];
export type AgentModesResponse = S["AgentModesResponse"];
export type AgentUnparsedData = S["AgentUnparsedData"];
export type ContentPart = S["ContentPart"];
export type CreateSessionRequest = S["CreateSessionRequest"];
export type CreateSessionResponse = S["CreateSessionResponse"];
export type ErrorData = S["ErrorData"];
export type EventSource = S["EventSource"];
export type EventsQuery = S["EventsQuery"];
export type EventsResponse = S["EventsResponse"];
export type FileAction = S["FileAction"];
export type HealthResponse = S["HealthResponse"];
export type ItemDeltaData = S["ItemDeltaData"];
export type ItemEventData = S["ItemEventData"];
export type ItemKind = S["ItemKind"];
export type ItemRole = S["ItemRole"];
export type ItemStatus = S["ItemStatus"];
export type MessageRequest = S["MessageRequest"];
export type PermissionEventData = S["PermissionEventData"];
export type PermissionReply = S["PermissionReply"];
export type PermissionReplyRequest = S["PermissionReplyRequest"];
export type PermissionStatus = S["PermissionStatus"];
export type ProblemDetails = S["ProblemDetails"];
export type QuestionEventData = S["QuestionEventData"];
export type QuestionReplyRequest = S["QuestionReplyRequest"];
export type QuestionStatus = S["QuestionStatus"];
export type ReasoningVisibility = S["ReasoningVisibility"];
export type SessionEndReason = S["SessionEndReason"];
export type SessionEndedData = S["SessionEndedData"];
export type SessionInfo = S["SessionInfo"];
export type SessionListResponse = S["SessionListResponse"];
export type SessionStartedData = S["SessionStartedData"];
export type TerminatedBy = S["TerminatedBy"];
export type UniversalEvent = S["UniversalEvent"];
export type UniversalEventData = S["UniversalEventData"];
export type UniversalEventType = S["UniversalEventType"];
export type UniversalItem = S["UniversalItem"];

View file

@ -0,0 +1,305 @@
import { describe, it, expect, vi, type Mock } from "vitest";
import { SandboxDaemonClient, SandboxDaemonError } from "../src/client.ts";
function createMockFetch(
response: unknown,
status = 200,
headers: Record<string, string> = {}
): Mock<typeof fetch> {
return vi.fn<typeof fetch>().mockResolvedValue(
new Response(JSON.stringify(response), {
status,
headers: { "Content-Type": "application/json", ...headers },
})
);
}
function createMockFetchError(status: number, problem: unknown): Mock<typeof fetch> {
return vi.fn<typeof fetch>().mockResolvedValue(
new Response(JSON.stringify(problem), {
status,
headers: { "Content-Type": "application/problem+json" },
})
);
}
describe("SandboxDaemonClient", () => {
describe("constructor", () => {
it("creates client with baseUrl", () => {
const client = new SandboxDaemonClient({
baseUrl: "http://localhost:8080",
});
expect(client).toBeInstanceOf(SandboxDaemonClient);
});
it("strips trailing slash from baseUrl", async () => {
const mockFetch = createMockFetch({ status: "ok" });
const client = new SandboxDaemonClient({
baseUrl: "http://localhost:8080/",
fetch: mockFetch,
});
await client.getHealth();
expect(mockFetch).toHaveBeenCalledWith(
"http://localhost:8080/v1/health",
expect.any(Object)
);
});
it("throws if fetch is not available", () => {
const originalFetch = globalThis.fetch;
// @ts-expect-error - testing missing fetch
globalThis.fetch = undefined;
expect(() => {
new SandboxDaemonClient({
baseUrl: "http://localhost:8080",
});
}).toThrow("Fetch API is not available");
globalThis.fetch = originalFetch;
});
});
describe("connect", () => {
it("creates client without spawn when baseUrl provided", async () => {
const client = await SandboxDaemonClient.connect({
baseUrl: "http://localhost:8080",
spawn: false,
});
expect(client).toBeInstanceOf(SandboxDaemonClient);
});
it("throws when no baseUrl and spawn disabled", async () => {
await expect(
SandboxDaemonClient.connect({ spawn: false })
).rejects.toThrow("baseUrl is required when autospawn is disabled");
});
});
describe("getHealth", () => {
it("returns health response", async () => {
const mockFetch = createMockFetch({ status: "ok" });
const client = new SandboxDaemonClient({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});
const result = await client.getHealth();
expect(result).toEqual({ status: "ok" });
expect(mockFetch).toHaveBeenCalledWith(
"http://localhost:8080/v1/health",
expect.objectContaining({ method: "GET" })
);
});
});
describe("listAgents", () => {
it("returns agent list", async () => {
const agents = { agents: [{ id: "claude", installed: true }] };
const mockFetch = createMockFetch(agents);
const client = new SandboxDaemonClient({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});
const result = await client.listAgents();
expect(result).toEqual(agents);
});
});
describe("createSession", () => {
it("creates session with agent", async () => {
const response = { healthy: true, agentSessionId: "abc123" };
const mockFetch = createMockFetch(response);
const client = new SandboxDaemonClient({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});
const result = await client.createSession("test-session", {
agent: "claude",
});
expect(result).toEqual(response);
expect(mockFetch).toHaveBeenCalledWith(
"http://localhost:8080/v1/sessions/test-session",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ agent: "claude" }),
})
);
});
it("encodes session ID in URL", async () => {
const mockFetch = createMockFetch({ healthy: true });
const client = new SandboxDaemonClient({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});
await client.createSession("test/session", { agent: "claude" });
expect(mockFetch).toHaveBeenCalledWith(
"http://localhost:8080/v1/sessions/test%2Fsession",
expect.any(Object)
);
});
});
describe("postMessage", () => {
it("sends message to session", async () => {
const mockFetch = vi.fn().mockResolvedValue(
new Response(null, { status: 204 })
);
const client = new SandboxDaemonClient({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});
await client.postMessage("test-session", { message: "Hello" });
expect(mockFetch).toHaveBeenCalledWith(
"http://localhost:8080/v1/sessions/test-session/messages",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ message: "Hello" }),
})
);
});
});
describe("getEvents", () => {
it("returns events", async () => {
const events = { events: [], hasMore: false };
const mockFetch = createMockFetch(events);
const client = new SandboxDaemonClient({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});
const result = await client.getEvents("test-session");
expect(result).toEqual(events);
});
it("passes query parameters", async () => {
const mockFetch = createMockFetch({ events: [], hasMore: false });
const client = new SandboxDaemonClient({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});
await client.getEvents("test-session", { offset: 10, limit: 50 });
expect(mockFetch).toHaveBeenCalledWith(
"http://localhost:8080/v1/sessions/test-session/events?offset=10&limit=50",
expect.any(Object)
);
});
});
describe("authentication", () => {
it("includes authorization header when token provided", async () => {
const mockFetch = createMockFetch({ status: "ok" });
const client = new SandboxDaemonClient({
baseUrl: "http://localhost:8080",
token: "test-token",
fetch: mockFetch,
});
await client.getHealth();
expect(mockFetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: expect.any(Headers),
})
);
const call = mockFetch.mock.calls[0];
const headers = call?.[1]?.headers as Headers | undefined;
expect(headers?.get("Authorization")).toBe("Bearer test-token");
});
});
describe("error handling", () => {
it("throws SandboxDaemonError on non-ok response", async () => {
const problem = {
type: "error",
title: "Not Found",
status: 404,
detail: "Session not found",
};
const mockFetch = createMockFetchError(404, problem);
const client = new SandboxDaemonClient({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});
await expect(client.getEvents("nonexistent")).rejects.toThrow(
SandboxDaemonError
);
try {
await client.getEvents("nonexistent");
} catch (e) {
expect(e).toBeInstanceOf(SandboxDaemonError);
const error = e as SandboxDaemonError;
expect(error.status).toBe(404);
expect(error.problem?.title).toBe("Not Found");
}
});
});
describe("replyQuestion", () => {
it("sends question reply", async () => {
const mockFetch = vi.fn().mockResolvedValue(
new Response(null, { status: 204 })
);
const client = new SandboxDaemonClient({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});
await client.replyQuestion("test-session", "q1", {
answers: [["Yes"]],
});
expect(mockFetch).toHaveBeenCalledWith(
"http://localhost:8080/v1/sessions/test-session/questions/q1/reply",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ answers: [["Yes"]] }),
})
);
});
});
describe("replyPermission", () => {
it("sends permission reply", async () => {
const mockFetch = vi.fn().mockResolvedValue(
new Response(null, { status: 204 })
);
const client = new SandboxDaemonClient({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});
await client.replyPermission("test-session", "p1", {
reply: "once",
});
expect(mockFetch).toHaveBeenCalledWith(
"http://localhost:8080/v1/sessions/test-session/permissions/p1/reply",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ reply: "once" }),
})
);
});
});
});

View file

@ -0,0 +1,174 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { existsSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { type ChildProcess } from "node:child_process";
import { SandboxDaemonClient } from "../src/client.ts";
import { spawnSandboxDaemon, isNodeRuntime } from "../src/spawn.ts";
const __dirname = dirname(fileURLToPath(import.meta.url));
// Check for binary in common locations
function findBinary(): string | null {
if (process.env.SANDBOX_AGENT_BIN) {
return process.env.SANDBOX_AGENT_BIN;
}
// Check cargo build output (run from sdks/typescript/tests)
const cargoPaths = [
resolve(__dirname, "../../../target/debug/sandbox-agent"),
resolve(__dirname, "../../../target/release/sandbox-agent"),
];
for (const p of cargoPaths) {
if (existsSync(p)) {
return p;
}
}
return null;
}
const BINARY_PATH = findBinary();
const SKIP_INTEGRATION = !BINARY_PATH && !process.env.RUN_INTEGRATION_TESTS;
// Set env var if we found a binary
if (BINARY_PATH && !process.env.SANDBOX_AGENT_BIN) {
process.env.SANDBOX_AGENT_BIN = BINARY_PATH;
}
describe.skipIf(SKIP_INTEGRATION)("Integration: spawn (local mode)", () => {
it("spawns daemon and connects", async () => {
const handle = await spawnSandboxDaemon({
enabled: true,
log: "silent",
timeoutMs: 30000,
});
try {
expect(handle.baseUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
expect(handle.token).toBeTruthy();
const client = new SandboxDaemonClient({
baseUrl: handle.baseUrl,
token: handle.token,
});
const health = await client.getHealth();
expect(health.status).toBe("ok");
} finally {
await handle.dispose();
}
});
it("SandboxDaemonClient.connect spawns automatically", async () => {
const client = await SandboxDaemonClient.connect({
spawn: { log: "silent", timeoutMs: 30000 },
});
try {
const health = await client.getHealth();
expect(health.status).toBe("ok");
const agents = await client.listAgents();
expect(agents.agents).toBeDefined();
expect(Array.isArray(agents.agents)).toBe(true);
} finally {
await client.dispose();
}
});
it("lists available agents", async () => {
const client = await SandboxDaemonClient.connect({
spawn: { log: "silent", timeoutMs: 30000 },
});
try {
const agents = await client.listAgents();
expect(agents.agents).toBeDefined();
// Should have at least some agents defined
expect(agents.agents.length).toBeGreaterThan(0);
} finally {
await client.dispose();
}
});
});
describe.skipIf(SKIP_INTEGRATION)("Integration: connect (remote mode)", () => {
let daemonProcess: ChildProcess;
let baseUrl: string;
let token: string;
beforeAll(async () => {
// Start daemon manually to simulate remote server
const handle = await spawnSandboxDaemon({
enabled: true,
log: "silent",
timeoutMs: 30000,
});
daemonProcess = handle.child;
baseUrl = handle.baseUrl;
token = handle.token;
});
afterAll(async () => {
if (daemonProcess && daemonProcess.exitCode === null) {
daemonProcess.kill("SIGTERM");
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
daemonProcess.kill("SIGKILL");
resolve();
}, 5000);
daemonProcess.once("exit", () => {
clearTimeout(timeout);
resolve();
});
});
}
});
it("connects to remote server", async () => {
const client = await SandboxDaemonClient.connect({
baseUrl,
token,
spawn: false,
});
const health = await client.getHealth();
expect(health.status).toBe("ok");
});
it("creates client directly without spawn", () => {
const client = new SandboxDaemonClient({
baseUrl,
token,
});
expect(client).toBeInstanceOf(SandboxDaemonClient);
});
it("handles authentication", async () => {
const client = new SandboxDaemonClient({
baseUrl,
token,
});
const health = await client.getHealth();
expect(health.status).toBe("ok");
});
it("rejects invalid token on protected endpoints", async () => {
const client = new SandboxDaemonClient({
baseUrl,
token: "invalid-token",
});
// Health endpoint may be open, but listing agents should require auth
await expect(client.listAgents()).rejects.toThrow();
});
});
describe("Runtime detection", () => {
it("detects Node.js runtime", () => {
expect(isNodeRuntime()).toBe(true);
});
});

View file

@ -0,0 +1,208 @@
import { describe, it, expect, vi, type Mock } from "vitest";
import { SandboxDaemonClient } from "../src/client.ts";
import type { UniversalEvent } from "../src/types.ts";
function createMockResponse(chunks: string[]): Response {
let chunkIndex = 0;
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
if (chunkIndex < chunks.length) {
controller.enqueue(encoder.encode(chunks[chunkIndex]));
chunkIndex++;
} else {
controller.close();
}
},
});
return new Response(stream, {
status: 200,
headers: { "Content-Type": "text/event-stream" },
});
}
function createMockFetch(chunks: string[]): Mock<typeof fetch> {
return vi.fn<typeof fetch>().mockResolvedValue(createMockResponse(chunks));
}
function createEvent(sequence: number): UniversalEvent {
return {
event_id: `evt-${sequence}`,
sequence,
session_id: "test-session",
source: "agent",
synthetic: false,
time: new Date().toISOString(),
type: "item.started",
data: {
item_id: `item-${sequence}`,
kind: "message",
role: "assistant",
status: "in_progress",
content: [],
},
} as UniversalEvent;
}
describe("SSE Parser", () => {
it("parses single SSE event", async () => {
const event = createEvent(1);
const mockFetch = createMockFetch([`data: ${JSON.stringify(event)}\n\n`]);
const client = new SandboxDaemonClient({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});
const events: UniversalEvent[] = [];
for await (const e of client.streamEvents("test-session")) {
events.push(e);
}
expect(events).toHaveLength(1);
expect(events[0].sequence).toBe(1);
});
it("parses multiple SSE events", async () => {
const event1 = createEvent(1);
const event2 = createEvent(2);
const mockFetch = createMockFetch([
`data: ${JSON.stringify(event1)}\n\n`,
`data: ${JSON.stringify(event2)}\n\n`,
]);
const client = new SandboxDaemonClient({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});
const events: UniversalEvent[] = [];
for await (const e of client.streamEvents("test-session")) {
events.push(e);
}
expect(events).toHaveLength(2);
expect(events[0].sequence).toBe(1);
expect(events[1].sequence).toBe(2);
});
it("handles chunked SSE data", async () => {
const event = createEvent(1);
const fullMessage = `data: ${JSON.stringify(event)}\n\n`;
// Split in the middle of the message
const mockFetch = createMockFetch([
fullMessage.slice(0, 10),
fullMessage.slice(10),
]);
const client = new SandboxDaemonClient({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});
const events: UniversalEvent[] = [];
for await (const e of client.streamEvents("test-session")) {
events.push(e);
}
expect(events).toHaveLength(1);
expect(events[0].sequence).toBe(1);
});
it("handles multiple events in single chunk", async () => {
const event1 = createEvent(1);
const event2 = createEvent(2);
const mockFetch = createMockFetch([
`data: ${JSON.stringify(event1)}\n\ndata: ${JSON.stringify(event2)}\n\n`,
]);
const client = new SandboxDaemonClient({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});
const events: UniversalEvent[] = [];
for await (const e of client.streamEvents("test-session")) {
events.push(e);
}
expect(events).toHaveLength(2);
});
it("ignores non-data lines", async () => {
const event = createEvent(1);
const mockFetch = createMockFetch([
`: this is a comment\n`,
`id: 1\n`,
`data: ${JSON.stringify(event)}\n\n`,
]);
const client = new SandboxDaemonClient({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});
const events: UniversalEvent[] = [];
for await (const e of client.streamEvents("test-session")) {
events.push(e);
}
expect(events).toHaveLength(1);
});
it("handles CRLF line endings", async () => {
const event = createEvent(1);
const mockFetch = createMockFetch([
`data: ${JSON.stringify(event)}\r\n\r\n`,
]);
const client = new SandboxDaemonClient({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});
const events: UniversalEvent[] = [];
for await (const e of client.streamEvents("test-session")) {
events.push(e);
}
expect(events).toHaveLength(1);
});
it("handles empty stream", async () => {
const mockFetch = createMockFetch([]);
const client = new SandboxDaemonClient({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});
const events: UniversalEvent[] = [];
for await (const e of client.streamEvents("test-session")) {
events.push(e);
}
expect(events).toHaveLength(0);
});
it("passes query parameters", async () => {
const mockFetch = createMockFetch([]);
const client = new SandboxDaemonClient({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});
// Consume the stream
for await (const _ of client.streamEvents("test-session", { offset: 5 })) {
// empty
}
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("offset=5"),
expect.any(Object)
);
});
});

View file

@ -2,15 +2,14 @@
"compilerOptions": { "compilerOptions": {
"target": "ES2022", "target": "ES2022",
"lib": ["ES2022", "DOM"], "lib": ["ES2022", "DOM"],
"module": "NodeNext", "module": "ESNext",
"moduleResolution": "NodeNext", "moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"noEmit": true,
"esModuleInterop": true, "esModuleInterop": true,
"strict": true, "strict": true,
"skipLibCheck": true, "skipLibCheck": true,
"outDir": "dist", "resolveJsonModule": true
"rootDir": "src",
"resolveJsonModule": true,
"declaration": true
}, },
"include": ["src/**/*"], "include": ["src/**/*"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]

View file

@ -0,0 +1,9 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm"],
dts: true,
clean: true,
sourcemap: true,
});

View file

@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["tests/**/*.test.ts"],
testTimeout: 30000,
},
});

View file

@ -55,12 +55,12 @@ To keep snapshots deterministic:
Run only Claude snapshots: Run only Claude snapshots:
``` ```
SANDBOX_TEST_AGENTS=claude cargo test -p sandbox-agent-core --test http_sse_snapshots SANDBOX_TEST_AGENTS=claude cargo test -p sandbox-agent --test http_sse_snapshots
``` ```
Run all detected agents: Run all detected agents:
``` ```
cargo test -p sandbox-agent-core --test http_sse_snapshots cargo test -p sandbox-agent --test http_sse_snapshots
``` ```
## Universal Schema ## Universal Schema

View file

@ -446,7 +446,6 @@ impl AgentManager {
}], }],
model: options.model.clone(), model: options.model.clone(),
output_schema: None, output_schema: None,
personality: None,
sandbox_policy: sandbox_policy.clone(), sandbox_policy: sandbox_policy.clone(),
summary: None, summary: None,
thread_id: thread_id.clone().unwrap_or_default(), thread_id: thread_id.clone().unwrap_or_default(),

View file

@ -12,6 +12,6 @@ tracing-logfmt.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true
[build-dependencies] [build-dependencies]
sandbox-agent-core.workspace = true sandbox-agent.workspace = true
serde_json.workspace = true serde_json.workspace = true
utoipa.workspace = true utoipa.workspace = true

View file

@ -2,7 +2,7 @@ use std::fs;
use std::io::{self, Write}; use std::io::{self, Write};
use std::path::Path; use std::path::Path;
use sandbox_agent_core::router::ApiDoc; use sandbox_agent::router::ApiDoc;
use utoipa::OpenApi; use utoipa::OpenApi;
fn main() { fn main() {

View file

@ -1,5 +1,5 @@
[package] [package]
name = "sandbox-agent-core" name = "sandbox-agent"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
authors.workspace = true authors.workspace = true

View file

@ -10,13 +10,13 @@ use sandbox_agent_agent_management::credentials::{
extract_all_credentials, AuthType, CredentialExtractionOptions, ExtractedCredentials, extract_all_credentials, AuthType, CredentialExtractionOptions, ExtractedCredentials,
ProviderCredentials, ProviderCredentials,
}; };
use sandbox_agent_core::router::{ use sandbox_agent::router::{
AgentInstallRequest, AppState, AuthConfig, CreateSessionRequest, MessageRequest, AgentInstallRequest, AppState, AuthConfig, CreateSessionRequest, MessageRequest,
PermissionReply, PermissionReplyRequest, QuestionReplyRequest, PermissionReply, PermissionReplyRequest, QuestionReplyRequest,
}; };
use sandbox_agent_core::router::{AgentListResponse, AgentModesResponse, CreateSessionResponse, EventsResponse}; use sandbox_agent::router::{AgentListResponse, AgentModesResponse, CreateSessionResponse, EventsResponse};
use sandbox_agent_core::router::build_router; use sandbox_agent::router::build_router;
use sandbox_agent_core::ui; use sandbox_agent::ui;
use serde::Serialize; use serde::Serialize;
use serde_json::Value; use serde_json::Value;
use thiserror::Error; use thiserror::Error;
@ -118,6 +118,9 @@ enum SessionsCommand {
#[command(name = "send-message")] #[command(name = "send-message")]
/// Send a message to an existing session. /// Send a message to an existing session.
SendMessage(SessionMessageArgs), SendMessage(SessionMessageArgs),
#[command(name = "terminate")]
/// Terminate a session.
Terminate(SessionTerminateArgs),
#[command(name = "get-messages")] #[command(name = "get-messages")]
/// Alias for events; returns session events. /// Alias for events; returns session events.
GetMessages(SessionEventsArgs), GetMessages(SessionEventsArgs),
@ -195,6 +198,8 @@ struct SessionEventsArgs {
offset: Option<u64>, offset: Option<u64>,
#[arg(long, short = 'l')] #[arg(long, short = 'l')]
limit: Option<u64>, limit: Option<u64>,
#[arg(long)]
include_raw: bool,
#[command(flatten)] #[command(flatten)]
client: ClientArgs, client: ClientArgs,
} }
@ -204,6 +209,15 @@ struct SessionEventsSseArgs {
session_id: String, session_id: String,
#[arg(long, short = 'o')] #[arg(long, short = 'o')]
offset: Option<u64>, offset: Option<u64>,
#[arg(long)]
include_raw: bool,
#[command(flatten)]
client: ClientArgs,
}
#[derive(Args, Debug)]
struct SessionTerminateArgs {
session_id: String,
#[command(flatten)] #[command(flatten)]
client: ClientArgs, client: ClientArgs,
} }
@ -419,16 +433,41 @@ fn run_sessions(command: &SessionsCommand, cli: &Cli) -> Result<(), CliError> {
let response = ctx.post(&path, &body)?; let response = ctx.post(&path, &body)?;
print_empty_response(response) print_empty_response(response)
} }
SessionsCommand::Terminate(args) => {
let ctx = ClientContext::new(cli, &args.client)?;
let path = format!("{API_PREFIX}/sessions/{}/terminate", args.session_id);
let response = ctx.post_empty(&path)?;
print_empty_response(response)
}
SessionsCommand::GetMessages(args) | SessionsCommand::Events(args) => { SessionsCommand::GetMessages(args) | SessionsCommand::Events(args) => {
let ctx = ClientContext::new(cli, &args.client)?; let ctx = ClientContext::new(cli, &args.client)?;
let path = format!("{API_PREFIX}/sessions/{}/events", args.session_id); let path = format!("{API_PREFIX}/sessions/{}/events", args.session_id);
let response = ctx.get_with_query(&path, &[ ("offset", args.offset), ("limit", args.limit) ])?; let response = ctx.get_with_query(
&path,
&[
("offset", args.offset.map(|v| v.to_string())),
("limit", args.limit.map(|v| v.to_string())),
(
"include_raw",
if args.include_raw { Some("true".to_string()) } else { None },
),
],
)?;
print_json_response::<EventsResponse>(response) print_json_response::<EventsResponse>(response)
} }
SessionsCommand::EventsSse(args) => { SessionsCommand::EventsSse(args) => {
let ctx = ClientContext::new(cli, &args.client)?; let ctx = ClientContext::new(cli, &args.client)?;
let path = format!("{API_PREFIX}/sessions/{}/events/sse", args.session_id); let path = format!("{API_PREFIX}/sessions/{}/events/sse", args.session_id);
let response = ctx.get_with_query(&path, &[("offset", args.offset)])?; let response = ctx.get_with_query(
&path,
&[
("offset", args.offset.map(|v| v.to_string())),
(
"include_raw",
if args.include_raw { Some("true".to_string()) } else { None },
),
],
)?;
print_text_response(response) print_text_response(response)
} }
SessionsCommand::ReplyQuestion(args) => { SessionsCommand::ReplyQuestion(args) => {
@ -786,7 +825,7 @@ impl ClientContext {
fn get_with_query( fn get_with_query(
&self, &self,
path: &str, path: &str,
query: &[(&str, Option<u64>)], query: &[(&str, Option<String>)],
) -> Result<reqwest::blocking::Response, CliError> { ) -> Result<reqwest::blocking::Response, CliError> {
let mut request = self.request(Method::GET, path); let mut request = self.request(Method::GET, path);
for (key, value) in query { for (key, value) in query {

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,657 @@
use std::collections::HashMap;
use std::time::{Duration, Instant};
use axum::body::Body;
use axum::http::{Method, Request, StatusCode};
use axum::Router;
use http_body_util::BodyExt;
use serde_json::{json, Value};
use tempfile::TempDir;
use tower::util::ServiceExt;
use sandbox_agent_agent_management::agents::{AgentId, AgentManager};
use sandbox_agent_agent_management::testing::test_agents_from_env;
use sandbox_agent_agent_credentials::ExtractedCredentials;
use sandbox_agent::router::{
build_router,
AgentCapabilities,
AgentListResponse,
AuthConfig,
};
const PROMPT: &str = "Reply with exactly the single word OK.";
const TOOL_PROMPT: &str =
"Use the bash tool to run `ls` in the current directory. Do not answer without using the tool.";
const QUESTION_PROMPT: &str =
"Call the AskUserQuestion tool with exactly one yes/no question and wait for a reply. Do not answer yourself.";
/// Agent-agnostic event sequence tests.
///
/// These tests assert that the universal schema output is valid and consistent
/// across agents, and they use capability flags from /v1/agents to skip
/// unsupported flows.
struct TestApp {
app: Router,
_install_dir: TempDir,
}
impl TestApp {
fn new() -> Self {
let install_dir = tempfile::tempdir().expect("create temp install dir");
let manager = AgentManager::new(install_dir.path())
.expect("create agent manager");
let state = sandbox_agent::router::AppState::new(AuthConfig::disabled(), manager);
let app = build_router(state);
Self {
app,
_install_dir: install_dir,
}
}
}
struct EnvGuard {
saved: HashMap<String, Option<String>>,
}
impl Drop for EnvGuard {
fn drop(&mut self) {
for (key, value) in &self.saved {
match value {
Some(value) => std::env::set_var(key, value),
None => std::env::remove_var(key),
}
}
}
}
fn apply_credentials(creds: &ExtractedCredentials) -> EnvGuard {
let keys = ["ANTHROPIC_API_KEY", "CLAUDE_API_KEY", "OPENAI_API_KEY", "CODEX_API_KEY"];
let mut saved = HashMap::new();
for key in keys {
saved.insert(key.to_string(), std::env::var(key).ok());
}
match creds.anthropic.as_ref() {
Some(cred) => {
std::env::set_var("ANTHROPIC_API_KEY", &cred.api_key);
std::env::set_var("CLAUDE_API_KEY", &cred.api_key);
}
None => {
std::env::remove_var("ANTHROPIC_API_KEY");
std::env::remove_var("CLAUDE_API_KEY");
}
}
match creds.openai.as_ref() {
Some(cred) => {
std::env::set_var("OPENAI_API_KEY", &cred.api_key);
std::env::set_var("CODEX_API_KEY", &cred.api_key);
}
None => {
std::env::remove_var("OPENAI_API_KEY");
std::env::remove_var("CODEX_API_KEY");
}
}
EnvGuard { saved }
}
async fn send_json(
app: &Router,
method: Method,
path: &str,
body: Option<Value>,
) -> (StatusCode, Value) {
let request = Request::builder()
.method(method)
.uri(path)
.header("content-type", "application/json")
.body(Body::from(body.map(|value| value.to_string()).unwrap_or_default()))
.expect("request");
let response = app
.clone()
.oneshot(request)
.await
.expect("response");
let status = response.status();
let bytes = response
.into_body()
.collect()
.await
.expect("body")
.to_bytes();
let payload = if bytes.is_empty() {
Value::Null
} else {
serde_json::from_slice(&bytes).unwrap_or(Value::Null)
};
(status, payload)
}
async fn send_status(app: &Router, method: Method, path: &str, body: Option<Value>) -> StatusCode {
let (status, _) = send_json(app, method, path, body).await;
status
}
async fn install_agent(app: &Router, agent: AgentId) {
let status = send_status(
app,
Method::POST,
&format!("/v1/agents/{}/install", agent.as_str()),
Some(json!({})),
)
.await;
assert_eq!(status, StatusCode::NO_CONTENT, "install agent {}", agent.as_str());
}
async fn create_session(app: &Router, agent: AgentId, session_id: &str, permission_mode: &str) {
let status = send_status(
app,
Method::POST,
&format!("/v1/sessions/{session_id}"),
Some(json!({
"agent": agent.as_str(),
"permissionMode": permission_mode,
})),
)
.await;
assert_eq!(status, StatusCode::OK, "create session");
}
async fn create_session_with_mode(
app: &Router,
agent: AgentId,
session_id: &str,
agent_mode: &str,
permission_mode: &str,
) {
let status = send_status(
app,
Method::POST,
&format!("/v1/sessions/{session_id}"),
Some(json!({
"agent": agent.as_str(),
"agentMode": agent_mode,
"permissionMode": permission_mode,
})),
)
.await;
assert_eq!(status, StatusCode::OK, "create session");
}
fn test_permission_mode(agent: AgentId) -> &'static str {
match agent {
AgentId::Opencode => "default",
_ => "bypass",
}
}
async fn send_message(app: &Router, session_id: &str, message: &str) {
let status = send_status(
app,
Method::POST,
&format!("/v1/sessions/{session_id}/messages"),
Some(json!({ "message": message })),
)
.await;
assert_eq!(status, StatusCode::NO_CONTENT, "send message");
}
async fn poll_events_until<F>(
app: &Router,
session_id: &str,
timeout: Duration,
mut stop: F,
) -> Vec<Value>
where
F: FnMut(&[Value]) -> bool,
{
let start = Instant::now();
let mut offset = 0u64;
let mut events = Vec::new();
while start.elapsed() < timeout {
let path = format!("/v1/sessions/{session_id}/events?offset={offset}&limit=200");
let (status, payload) = send_json(app, Method::GET, &path, None).await;
assert_eq!(status, StatusCode::OK, "poll events");
let new_events = payload
.get("events")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
if !new_events.is_empty() {
if let Some(last) = new_events
.last()
.and_then(|event| event.get("sequence"))
.and_then(Value::as_u64)
{
offset = last;
}
events.extend(new_events);
if stop(&events) {
break;
}
}
tokio::time::sleep(Duration::from_millis(800)).await;
}
events
}
async fn fetch_capabilities(app: &Router) -> HashMap<String, AgentCapabilities> {
let (status, payload) = send_json(app, Method::GET, "/v1/agents", None).await;
assert_eq!(status, StatusCode::OK, "list agents");
let response: AgentListResponse = serde_json::from_value(payload).expect("agents payload");
response
.agents
.into_iter()
.map(|agent| (agent.id, agent.capabilities))
.collect()
}
fn has_event_type(events: &[Value], event_type: &str) -> bool {
events
.iter()
.any(|event| event.get("type").and_then(Value::as_str) == Some(event_type))
}
fn find_assistant_message_item(events: &[Value]) -> Option<String> {
events.iter().find_map(|event| {
if event.get("type").and_then(Value::as_str) != Some("item.completed") {
return None;
}
let item = event.get("data")?.get("item")?;
let role = item.get("role")?.as_str()?;
let kind = item.get("kind")?.as_str()?;
if role != "assistant" || kind != "message" {
return None;
}
item.get("item_id")?.as_str().map(|id| id.to_string())
})
}
fn event_sequence(event: &Value) -> Option<u64> {
event.get("sequence").and_then(Value::as_u64)
}
fn find_item_event_seq(events: &[Value], event_type: &str, item_id: &str) -> Option<u64> {
events.iter().find_map(|event| {
if event.get("type").and_then(Value::as_str) != Some(event_type) {
return None;
}
match event_type {
"item.delta" => {
let data = event.get("data")?;
let id = data.get("item_id")?.as_str()?;
if id == item_id {
event_sequence(event)
} else {
None
}
}
_ => {
let item = event.get("data")?.get("item")?;
let id = item.get("item_id")?.as_str()?;
if id == item_id {
event_sequence(event)
} else {
None
}
}
}
})
}
fn find_permission_id(events: &[Value]) -> Option<String> {
events.iter().find_map(|event| {
if event.get("type").and_then(Value::as_str) != Some("permission.requested") {
return None;
}
event
.get("data")
.and_then(|data| data.get("permission_id"))
.and_then(Value::as_str)
.map(|id| id.to_string())
})
}
fn find_question_id(events: &[Value]) -> Option<String> {
events.iter().find_map(|event| {
if event.get("type").and_then(Value::as_str) != Some("question.requested") {
return None;
}
event
.get("data")
.and_then(|data| data.get("question_id"))
.and_then(Value::as_str)
.map(|id| id.to_string())
})
}
fn find_first_answer(events: &[Value]) -> Option<Vec<Vec<String>>> {
events.iter().find_map(|event| {
if event.get("type").and_then(Value::as_str) != Some("question.requested") {
return None;
}
let options = event
.get("data")
.and_then(|data| data.get("options"))
.and_then(Value::as_array)?;
let option = options.first()?.as_str()?.to_string();
Some(vec![vec![option]])
})
}
fn find_tool_call(events: &[Value]) -> Option<String> {
events.iter().find_map(|event| {
if event.get("type").and_then(Value::as_str) != Some("item.started")
&& event.get("type").and_then(Value::as_str) != Some("item.completed")
{
return None;
}
let item = event.get("data")?.get("item")?;
let kind = item.get("kind")?.as_str()?;
if kind != "tool_call" {
return None;
}
item.get("item_id")?.as_str().map(|id| id.to_string())
})
}
fn has_tool_result(events: &[Value]) -> bool {
events.iter().any(|event| {
if event.get("type").and_then(Value::as_str) != Some("item.completed") {
return false;
}
let item = match event.get("data").and_then(|data| data.get("item")) {
Some(item) => item,
None => return false,
};
item.get("kind").and_then(Value::as_str) == Some("tool_result")
})
}
fn expect_basic_sequence(events: &[Value]) {
assert!(has_event_type(events, "session.started"), "session.started missing");
let item_id = find_assistant_message_item(events).expect("assistant message missing");
let started_seq = find_item_event_seq(events, "item.started", &item_id)
.expect("item.started missing");
// Intentionally require deltas here to validate our synthetic delta behavior.
let delta_seq = find_item_event_seq(events, "item.delta", &item_id)
.expect("item.delta missing");
let completed_seq = find_item_event_seq(events, "item.completed", &item_id)
.expect("item.completed missing");
assert!(started_seq < delta_seq, "item.started must precede delta");
assert!(delta_seq < completed_seq, "delta must precede completion");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn agent_agnostic_basic_reply() {
let configs = test_agents_from_env().expect("configure SANDBOX_TEST_AGENTS or install agents");
let app = TestApp::new();
let capabilities = fetch_capabilities(&app.app).await;
for config in &configs {
let _guard = apply_credentials(&config.credentials);
install_agent(&app.app, config.agent).await;
let session_id = format!("basic-{}", config.agent.as_str());
create_session(&app.app, config.agent, &session_id, "default").await;
send_message(&app.app, &session_id, PROMPT).await;
let events = poll_events_until(&app.app, &session_id, Duration::from_secs(120), |events| {
has_event_type(events, "error") || find_assistant_message_item(events).is_some()
})
.await;
assert!(
!events.is_empty(),
"no events collected for {}",
config.agent.as_str()
);
expect_basic_sequence(&events);
let caps = capabilities
.get(config.agent.as_str())
.expect("capabilities missing");
if caps.tool_calls {
assert!(
!events.iter().any(|event| {
event.get("type").and_then(Value::as_str) == Some("agent.unparsed")
}),
"agent.unparsed event detected"
);
}
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn agent_agnostic_tool_flow() {
let configs = test_agents_from_env().expect("configure SANDBOX_TEST_AGENTS or install agents");
let app = TestApp::new();
let capabilities = fetch_capabilities(&app.app).await;
for config in &configs {
let caps = capabilities
.get(config.agent.as_str())
.expect("capabilities missing");
if !caps.tool_calls {
continue;
}
let _guard = apply_credentials(&config.credentials);
install_agent(&app.app, config.agent).await;
let session_id = format!("tool-{}", config.agent.as_str());
create_session(&app.app, config.agent, &session_id, test_permission_mode(config.agent)).await;
send_message(&app.app, &session_id, TOOL_PROMPT).await;
let start = Instant::now();
let mut offset = 0u64;
let mut events = Vec::new();
let mut replied = false;
while start.elapsed() < Duration::from_secs(180) {
let path = format!("/v1/sessions/{session_id}/events?offset={offset}&limit=200");
let (status, payload) = send_json(&app.app, Method::GET, &path, None).await;
assert_eq!(status, StatusCode::OK, "poll events");
let new_events = payload
.get("events")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
if !new_events.is_empty() {
if let Some(last) = new_events
.last()
.and_then(|event| event.get("sequence"))
.and_then(Value::as_u64)
{
offset = last;
}
events.extend(new_events);
if !replied {
if let Some(permission_id) = find_permission_id(&events) {
let _ = send_status(
&app.app,
Method::POST,
&format!(
"/v1/sessions/{session_id}/permissions/{permission_id}/reply"
),
Some(json!({ "reply": "once" })),
)
.await;
replied = true;
}
}
if has_tool_result(&events) {
break;
}
}
tokio::time::sleep(Duration::from_millis(800)).await;
}
let tool_call = find_tool_call(&events);
let tool_result = has_tool_result(&events);
assert!(
tool_call.is_some(),
"tool_call missing for tool-capable agent {}",
config.agent.as_str()
);
if tool_call.is_some() {
assert!(
tool_result,
"tool_result missing after tool_call for {}",
config.agent.as_str()
);
}
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn agent_agnostic_permission_flow() {
let configs = test_agents_from_env().expect("configure SANDBOX_TEST_AGENTS or install agents");
let app = TestApp::new();
let capabilities = fetch_capabilities(&app.app).await;
for config in &configs {
let caps = capabilities
.get(config.agent.as_str())
.expect("capabilities missing");
if !(caps.plan_mode && caps.permissions) {
continue;
}
let _guard = apply_credentials(&config.credentials);
install_agent(&app.app, config.agent).await;
let session_id = format!("perm-{}", config.agent.as_str());
create_session(&app.app, config.agent, &session_id, "plan").await;
send_message(&app.app, &session_id, TOOL_PROMPT).await;
let events = poll_events_until(&app.app, &session_id, Duration::from_secs(120), |events| {
find_permission_id(events).is_some() || has_event_type(events, "error")
})
.await;
let permission_id = find_permission_id(&events).expect("permission.requested missing");
let status = send_status(
&app.app,
Method::POST,
&format!("/v1/sessions/{session_id}/permissions/{permission_id}/reply"),
Some(json!({ "reply": "once" })),
)
.await;
assert_eq!(status, StatusCode::NO_CONTENT, "permission reply");
let resolved = poll_events_until(&app.app, &session_id, Duration::from_secs(120), |events| {
events.iter().any(|event| {
event.get("type").and_then(Value::as_str) == Some("permission.resolved")
})
})
.await;
assert!(
resolved.iter().any(|event| {
event.get("type").and_then(Value::as_str) == Some("permission.resolved")
&& event
.get("synthetic")
.and_then(Value::as_bool)
.unwrap_or(false)
}),
"permission.resolved should be synthetic"
);
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn agent_agnostic_question_flow() {
let configs = test_agents_from_env().expect("configure SANDBOX_TEST_AGENTS or install agents");
let app = TestApp::new();
let capabilities = fetch_capabilities(&app.app).await;
for config in &configs {
let caps = capabilities
.get(config.agent.as_str())
.expect("capabilities missing");
if !caps.questions {
continue;
}
let _guard = apply_credentials(&config.credentials);
install_agent(&app.app, config.agent).await;
let session_id = format!("question-{}", config.agent.as_str());
create_session_with_mode(&app.app, config.agent, &session_id, "plan", "plan").await;
send_message(&app.app, &session_id, QUESTION_PROMPT).await;
let events = poll_events_until(&app.app, &session_id, Duration::from_secs(120), |events| {
find_question_id(events).is_some() || has_event_type(events, "error")
})
.await;
let question_id = find_question_id(&events).expect("question.requested missing");
let answers = find_first_answer(&events).unwrap_or_else(|| vec![vec![]]);
let status = send_status(
&app.app,
Method::POST,
&format!("/v1/sessions/{session_id}/questions/{question_id}/reply"),
Some(json!({ "answers": answers })),
)
.await;
assert_eq!(status, StatusCode::NO_CONTENT, "question reply");
let resolved = poll_events_until(&app.app, &session_id, Duration::from_secs(120), |events| {
events.iter().any(|event| {
event.get("type").and_then(Value::as_str) == Some("question.resolved")
})
})
.await;
assert!(
resolved.iter().any(|event| {
event.get("type").and_then(Value::as_str) == Some("question.resolved")
&& event
.get("synthetic")
.and_then(Value::as_bool)
.unwrap_or(false)
}),
"question.resolved should be synthetic"
);
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn agent_agnostic_termination() {
let configs = test_agents_from_env().expect("configure SANDBOX_TEST_AGENTS or install agents");
let app = TestApp::new();
for config in &configs {
let _guard = apply_credentials(&config.credentials);
install_agent(&app.app, config.agent).await;
let session_id = format!("terminate-{}", config.agent.as_str());
create_session(&app.app, config.agent, &session_id, "default").await;
let status = send_status(
&app.app,
Method::POST,
&format!("/v1/sessions/{session_id}/terminate"),
None,
)
.await;
assert_eq!(status, StatusCode::NO_CONTENT, "terminate session");
let events = poll_events_until(&app.app, &session_id, Duration::from_secs(30), |events| {
has_event_type(events, "session.ended")
})
.await;
assert!(has_event_type(&events, "session.ended"), "missing session.ended");
let status = send_status(
&app.app,
Method::POST,
&format!("/v1/sessions/{session_id}/messages"),
Some(json!({ "message": PROMPT })),
)
.await;
assert!(!status.is_success(), "terminated session should reject messages");
}
}

View file

@ -12,7 +12,7 @@ use tempfile::TempDir;
use sandbox_agent_agent_management::agents::{AgentId, AgentManager}; use sandbox_agent_agent_management::agents::{AgentId, AgentManager};
use sandbox_agent_agent_management::testing::{test_agents_from_env, TestAgentConfig}; use sandbox_agent_agent_management::testing::{test_agents_from_env, TestAgentConfig};
use sandbox_agent_agent_credentials::ExtractedCredentials; use sandbox_agent_agent_credentials::ExtractedCredentials;
use sandbox_agent_core::router::{build_router, AppState, AuthConfig}; use sandbox_agent::router::{build_router, AppState, AuthConfig};
use tower::util::ServiceExt; use tower::util::ServiceExt;
use tower_http::cors::CorsLayer; use tower_http::cors::CorsLayer;
@ -226,7 +226,11 @@ async fn poll_events_until(
.cloned() .cloned()
.unwrap_or_default(); .unwrap_or_default();
if !new_events.is_empty() { if !new_events.is_empty() {
if let Some(last) = new_events.last().and_then(|event| event.get("id")).and_then(Value::as_u64) { if let Some(last) = new_events
.last()
.and_then(|event| event.get("sequence"))
.and_then(Value::as_u64)
{
offset = last; offset = last;
} }
events.extend(new_events); events.extend(new_events);
@ -307,26 +311,48 @@ fn should_stop(events: &[Value]) -> bool {
fn is_assistant_message(event: &Value) -> bool { fn is_assistant_message(event: &Value) -> bool {
event event
.get("data") .get("type")
.and_then(|data| data.get("message"))
.and_then(|message| message.get("role"))
.and_then(Value::as_str) .and_then(Value::as_str)
.map(|role| role == "assistant") .map(|event_type| event_type == "item.completed")
.unwrap_or(false) .unwrap_or(false)
&& event
.get("data")
.and_then(|data| data.get("item"))
.and_then(|item| item.get("role"))
.and_then(Value::as_str)
.map(|role| role == "assistant")
.unwrap_or(false)
} }
fn is_error_event(event: &Value) -> bool { fn is_error_event(event: &Value) -> bool {
matches!(
event.get("type").and_then(Value::as_str),
Some("error") | Some("agent.unparsed")
)
}
fn is_unparsed_event(event: &Value) -> bool {
event event
.get("data") .get("type")
.and_then(|data| data.get("error")) .and_then(Value::as_str)
.is_some() .map(|value| value == "agent.unparsed")
.unwrap_or(false)
} }
fn is_permission_event(event: &Value) -> bool { fn is_permission_event(event: &Value) -> bool {
event event
.get("data") .get("type")
.and_then(|data| data.get("permissionAsked")) .and_then(Value::as_str)
.is_some() .map(|value| value == "permission.requested")
.unwrap_or(false)
}
fn is_question_event(event: &Value) -> bool {
event
.get("type")
.and_then(Value::as_str)
.map(|value| value == "question.requested")
.unwrap_or(false)
} }
fn truncate_permission_events(events: &[Value]) -> Vec<Value> { fn truncate_permission_events(events: &[Value]) -> Vec<Value> {
@ -339,7 +365,21 @@ fn truncate_permission_events(events: &[Value]) -> Vec<Value> {
events.to_vec() events.to_vec()
} }
fn truncate_question_events(events: &[Value]) -> Vec<Value> {
if let Some(idx) = events.iter().position(is_question_event) {
return events[..=idx].to_vec();
}
if let Some(idx) = events.iter().position(is_assistant_message) {
return events[..=idx].to_vec();
}
events.to_vec()
}
fn normalize_events(events: &[Value]) -> Value { fn normalize_events(events: &[Value]) -> Value {
assert!(
!events.iter().any(is_unparsed_event),
"agent.unparsed event encountered"
);
let normalized = events let normalized = events
.iter() .iter()
.enumerate() .enumerate()
@ -361,77 +401,100 @@ fn truncate_after_first_stop(events: &[Value]) -> Vec<Value> {
fn normalize_event(event: &Value, seq: usize) -> Value { fn normalize_event(event: &Value, seq: usize) -> Value {
let mut map = Map::new(); let mut map = Map::new();
map.insert("seq".to_string(), Value::Number(seq.into())); map.insert("seq".to_string(), Value::Number(seq.into()));
if let Some(agent) = event.get("agent").and_then(Value::as_str) { if let Some(event_type) = event.get("type").and_then(Value::as_str) {
map.insert("agent".to_string(), Value::String(agent.to_string())); map.insert("type".to_string(), Value::String(event_type.to_string()));
} }
if let Some(source) = event.get("source").and_then(Value::as_str) {
map.insert("source".to_string(), Value::String(source.to_string()));
}
if let Some(synthetic) = event.get("synthetic").and_then(Value::as_bool) {
map.insert("synthetic".to_string(), Value::Bool(synthetic));
}
let data = event.get("data").unwrap_or(&Value::Null); let data = event.get("data").unwrap_or(&Value::Null);
if let Some(message) = data.get("message") { match event.get("type").and_then(Value::as_str).unwrap_or("") {
map.insert("kind".to_string(), Value::String("message".to_string())); "session.started" => {
map.insert("message".to_string(), normalize_message(message)); map.insert("session".to_string(), Value::String("started".to_string()));
} else if let Some(started) = data.get("started") { if data.get("metadata").is_some() {
map.insert("kind".to_string(), Value::String("started".to_string())); map.insert("metadata".to_string(), Value::Bool(true));
map.insert("started".to_string(), normalize_started(started)); }
} else if let Some(error) = data.get("error") { }
map.insert("kind".to_string(), Value::String("error".to_string())); "session.ended" => {
map.insert("error".to_string(), normalize_error(error)); map.insert("session".to_string(), Value::String("ended".to_string()));
} else if let Some(question) = data.get("questionAsked") { map.insert("ended".to_string(), normalize_session_end(data));
map.insert("kind".to_string(), Value::String("question".to_string())); }
map.insert("question".to_string(), normalize_question(question)); "item.started" | "item.completed" => {
} else if let Some(permission) = data.get("permissionAsked") { if let Some(item) = data.get("item") {
map.insert("kind".to_string(), Value::String("permission".to_string())); map.insert("item".to_string(), normalize_item(item));
map.insert("permission".to_string(), normalize_permission(permission)); }
} else { }
map.insert("kind".to_string(), Value::String("unknown".to_string())); "item.delta" => {
let mut delta = Map::new();
if data.get("item_id").is_some() {
delta.insert("item_id".to_string(), Value::String("<redacted>".to_string()));
}
if data.get("native_item_id").is_some() {
delta.insert("native_item_id".to_string(), Value::String("<redacted>".to_string()));
}
if data.get("delta").is_some() {
delta.insert("delta".to_string(), Value::String("<redacted>".to_string()));
}
map.insert("delta".to_string(), Value::Object(delta));
}
"permission.requested" | "permission.resolved" => {
map.insert("permission".to_string(), normalize_permission(data));
}
"question.requested" | "question.resolved" => {
map.insert("question".to_string(), normalize_question(data));
}
"error" => {
map.insert("error".to_string(), normalize_error(data));
}
"agent.unparsed" => {
map.insert("unparsed".to_string(), Value::Bool(true));
}
_ => {}
} }
Value::Object(map) Value::Object(map)
} }
fn normalize_message(message: &Value) -> Value { fn normalize_item(item: &Value) -> Value {
let mut map = Map::new(); let mut map = Map::new();
if let Some(role) = message.get("role").and_then(Value::as_str) { if let Some(kind) = item.get("kind").and_then(Value::as_str) {
map.insert("kind".to_string(), Value::String(kind.to_string()));
}
if let Some(role) = item.get("role").and_then(Value::as_str) {
map.insert("role".to_string(), Value::String(role.to_string())); map.insert("role".to_string(), Value::String(role.to_string()));
} }
if let Some(parts) = message.get("parts").and_then(Value::as_array) { if let Some(status) = item.get("status").and_then(Value::as_str) {
let parts = parts.iter().map(normalize_part).collect::<Vec<_>>(); map.insert("status".to_string(), Value::String(status.to_string()));
map.insert("parts".to_string(), Value::Array(parts)); }
} else if message.get("raw").is_some() { if let Some(content) = item.get("content").and_then(Value::as_array) {
map.insert("unparsed".to_string(), Value::Bool(true)); let types = content
.iter()
.filter_map(|part| part.get("type").and_then(Value::as_str))
.map(|value| Value::String(value.to_string()))
.collect::<Vec<_>>();
map.insert("content_types".to_string(), Value::Array(types));
} }
Value::Object(map) Value::Object(map)
} }
fn normalize_part(part: &Value) -> Value { fn normalize_session_end(data: &Value) -> Value {
let mut map = Map::new(); let mut map = Map::new();
if let Some(part_type) = part.get("type").and_then(Value::as_str) { if let Some(reason) = data.get("reason").and_then(Value::as_str) {
map.insert("type".to_string(), Value::String(part_type.to_string())); map.insert("reason".to_string(), Value::String(reason.to_string()));
} }
if let Some(name) = part.get("name").and_then(Value::as_str) { if let Some(terminated_by) = data.get("terminated_by").and_then(Value::as_str) {
map.insert("name".to_string(), Value::String(name.to_string())); map.insert("terminated_by".to_string(), Value::String(terminated_by.to_string()));
}
if part.get("text").is_some() {
map.insert("text".to_string(), Value::String("<redacted>".to_string()));
}
if part.get("input").is_some() {
map.insert("input".to_string(), Value::Bool(true));
}
if part.get("output").is_some() {
map.insert("output".to_string(), Value::Bool(true));
}
Value::Object(map)
}
fn normalize_started(started: &Value) -> Value {
let mut map = Map::new();
if let Some(message) = started.get("message").and_then(Value::as_str) {
map.insert("message".to_string(), Value::String(message.to_string()));
} }
Value::Object(map) Value::Object(map)
} }
fn normalize_error(error: &Value) -> Value { fn normalize_error(error: &Value) -> Value {
let mut map = Map::new(); let mut map = Map::new();
if let Some(kind) = error.get("kind").and_then(Value::as_str) { if let Some(code) = error.get("code").and_then(Value::as_str) {
map.insert("kind".to_string(), Value::String(kind.to_string())); map.insert("code".to_string(), Value::String(code.to_string()));
} }
if let Some(message) = error.get("message").and_then(Value::as_str) { if let Some(message) = error.get("message").and_then(Value::as_str) {
map.insert("message".to_string(), Value::String(message.to_string())); map.insert("message".to_string(), Value::String(message.to_string()));
@ -441,22 +504,28 @@ fn normalize_error(error: &Value) -> Value {
fn normalize_question(question: &Value) -> Value { fn normalize_question(question: &Value) -> Value {
let mut map = Map::new(); let mut map = Map::new();
if question.get("id").is_some() { if question.get("question_id").is_some() {
map.insert("id".to_string(), Value::String("<redacted>".to_string())); map.insert("id".to_string(), Value::String("<redacted>".to_string()));
} }
if let Some(questions) = question.get("questions").and_then(Value::as_array) { if let Some(options) = question.get("options").and_then(Value::as_array) {
map.insert("count".to_string(), Value::Number(questions.len().into())); map.insert("options".to_string(), Value::Number(options.len().into()));
}
if let Some(status) = question.get("status").and_then(Value::as_str) {
map.insert("status".to_string(), Value::String(status.to_string()));
} }
Value::Object(map) Value::Object(map)
} }
fn normalize_permission(permission: &Value) -> Value { fn normalize_permission(permission: &Value) -> Value {
let mut map = Map::new(); let mut map = Map::new();
if permission.get("id").is_some() { if permission.get("permission_id").is_some() {
map.insert("id".to_string(), Value::String("<redacted>".to_string())); map.insert("id".to_string(), Value::String("<redacted>".to_string()));
} }
if let Some(value) = permission.get("permission").and_then(Value::as_str) { if let Some(value) = permission.get("action").and_then(Value::as_str) {
map.insert("permission".to_string(), Value::String(value.to_string())); map.insert("action".to_string(), Value::String(value.to_string()));
}
if let Some(status) = permission.get("status").and_then(Value::as_str) {
map.insert("status".to_string(), Value::String(status.to_string()));
} }
Value::Object(map) Value::Object(map)
} }
@ -538,8 +607,8 @@ fn normalize_create_session(value: &Value) -> Value {
if let Some(healthy) = value.get("healthy").and_then(Value::as_bool) { if let Some(healthy) = value.get("healthy").and_then(Value::as_bool) {
map.insert("healthy".to_string(), Value::Bool(healthy)); map.insert("healthy".to_string(), Value::Bool(healthy));
} }
if value.get("agentSessionId").is_some() { if value.get("nativeSessionId").is_some() {
map.insert("agentSessionId".to_string(), Value::String("<redacted>".to_string())); map.insert("nativeSessionId".to_string(), Value::String("<redacted>".to_string()));
} }
if let Some(error) = value.get("error") { if let Some(error) = value.get("error") {
map.insert("error".to_string(), error.clone()); map.insert("error".to_string(), error.clone());
@ -611,7 +680,7 @@ where
if !new_events.is_empty() { if !new_events.is_empty() {
if let Some(last) = new_events if let Some(last) = new_events
.last() .last()
.and_then(|event| event.get("id")) .and_then(|event| event.get("sequence"))
.and_then(Value::as_u64) .and_then(Value::as_u64)
{ {
offset = last; offset = last;
@ -631,9 +700,11 @@ fn find_permission_id(events: &[Value]) -> Option<String> {
.iter() .iter()
.find_map(|event| { .find_map(|event| {
event event
.get("data") .get("type")
.and_then(|data| data.get("permissionAsked")) .and_then(Value::as_str)
.and_then(|permission| permission.get("id")) .filter(|value| *value == "permission.requested")
.and_then(|_| event.get("data"))
.and_then(|data| data.get("permission_id"))
.and_then(Value::as_str) .and_then(Value::as_str)
.map(|id| id.to_string()) .map(|id| id.to_string())
}) })
@ -641,31 +712,23 @@ fn find_permission_id(events: &[Value]) -> Option<String> {
fn find_question_id_and_answers(events: &[Value]) -> Option<(String, Vec<Vec<String>>)> { fn find_question_id_and_answers(events: &[Value]) -> Option<(String, Vec<Vec<String>>)> {
let question = events.iter().find_map(|event| { let question = events.iter().find_map(|event| {
event let event_type = event.get("type").and_then(Value::as_str)?;
.get("data") if event_type != "question.requested" {
.and_then(|data| data.get("questionAsked")) return None;
.cloned() }
event.get("data").cloned()
})?; })?;
let id = question.get("id").and_then(Value::as_str)?.to_string(); let id = question.get("question_id").and_then(Value::as_str)?.to_string();
let questions = question let options = question
.get("questions") .get("options")
.and_then(Value::as_array) .and_then(Value::as_array)
.cloned() .cloned()
.unwrap_or_default(); .unwrap_or_default();
let mut answers = Vec::new(); let mut answers = Vec::new();
for question in questions { if let Some(option) = options.first().and_then(Value::as_str) {
let option = question answers.push(vec![option.to_string()]);
.get("options") } else {
.and_then(Value::as_array) answers.push(Vec::new());
.and_then(|options| options.first())
.and_then(|option| option.get("label"))
.and_then(Value::as_str)
.map(|label| label.to_string());
if let Some(label) = option {
answers.push(vec![label]);
} else {
answers.push(Vec::new());
}
} }
Some((id, answers)) Some((id, answers))
} }
@ -1039,6 +1102,7 @@ async fn approval_flow_snapshots() {
|events| find_question_id_and_answers(events).is_some() || should_stop(events), |events| find_question_id_and_answers(events).is_some() || should_stop(events),
) )
.await; .await;
let question_events = truncate_question_events(&question_events);
insta::with_settings!({ insta::with_settings!({
snapshot_suffix => snapshot_name("question_reply_events", Some(config.agent)), snapshot_suffix => snapshot_name("question_reply_events", Some(config.agent)),
}, { }, {
@ -1100,6 +1164,7 @@ async fn approval_flow_snapshots() {
|events| find_question_id_and_answers(events).is_some() || should_stop(events), |events| find_question_id_and_answers(events).is_some() || should_stop(events),
) )
.await; .await;
let reject_events = truncate_question_events(&reject_events);
insta::with_settings!({ insta::with_settings!({
snapshot_suffix => snapshot_name("question_reject_events", Some(config.agent)), snapshot_suffix => snapshot_name("question_reject_events", Some(config.agent)),
}, { }, {

View file

@ -2,8 +2,8 @@ use axum::body::Body;
use axum::http::{Request, StatusCode}; use axum::http::{Request, StatusCode};
use http_body_util::BodyExt; use http_body_util::BodyExt;
use sandbox_agent_agent_management::agents::AgentManager; use sandbox_agent_agent_management::agents::AgentManager;
use sandbox_agent_core::router::{build_router, AppState, AuthConfig}; use sandbox_agent::router::{build_router, AppState, AuthConfig};
use sandbox_agent_core::ui; use sandbox_agent::ui;
use tempfile::TempDir; use tempfile::TempDir;
use tower::util::ServiceExt; use tower::util::ServiceExt;

View file

@ -1,22 +1,45 @@
--- ---
source: server/packages/sandbox-agent/tests/http_sse_snapshots.rs source: server/packages/sandbox-agent/tests/http_sse_snapshots.rs
assertion_line: 1025
expression: normalize_events(&permission_events) expression: normalize_events(&permission_events)
--- ---
- agent: claude - metadata: true
kind: started
seq: 1 seq: 1
started: session: started
message: session.created source: daemon
- agent: claude synthetic: true
kind: started type: session.started
- metadata: true
seq: 2 seq: 2
started: session: started
message: system.init source: agent
- agent: claude synthetic: false
kind: message type: session.started
message: - item:
parts: content_types:
- text: "<redacted>" - text
type: text kind: message
role: assistant role: assistant
status: in_progress
seq: 3 seq: 3
source: daemon
synthetic: true
type: item.started
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 4
source: daemon
synthetic: true
type: item.delta
- item:
content_types:
- text
kind: message
role: assistant
status: completed
seq: 5
source: agent
synthetic: false
type: item.completed

View file

@ -1,22 +1,45 @@
--- ---
source: server/packages/sandbox-agent/tests/http_sse_snapshots.rs source: server/packages/sandbox-agent/tests/http_sse_snapshots.rs
assertion_line: 1151
expression: normalize_events(&reject_events) expression: normalize_events(&reject_events)
--- ---
- agent: claude - metadata: true
kind: started
seq: 1 seq: 1
started: session: started
message: session.created source: daemon
- agent: claude synthetic: true
kind: started type: session.started
- metadata: true
seq: 2 seq: 2
started: session: started
message: system.init source: agent
- agent: claude synthetic: false
kind: message type: session.started
message: - item:
parts: content_types:
- text: "<redacted>" - text
type: text kind: message
role: assistant role: assistant
status: in_progress
seq: 3 seq: 3
source: daemon
synthetic: true
type: item.started
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 4
source: daemon
synthetic: true
type: item.delta
- item:
content_types:
- text
kind: message
role: assistant
status: completed
seq: 5
source: agent
synthetic: false
type: item.completed

View file

@ -1,31 +1,45 @@
--- ---
source: server/packages/sandbox-agent/tests/http_sse_snapshots.rs source: server/packages/sandbox-agent/tests/http_sse_snapshots.rs
assertion_line: 1045 assertion_line: 1109
expression: normalize_events(&question_events) expression: normalize_events(&question_events)
--- ---
- agent: claude - metadata: true
kind: started
seq: 1 seq: 1
started: session: started
message: session.created source: daemon
- agent: claude synthetic: true
kind: started type: session.started
- metadata: true
seq: 2 seq: 2
started: session: started
message: system.init source: agent
- agent: claude synthetic: false
kind: message type: session.started
message: - item:
parts: content_types:
- text: "<redacted>" - text
type: text kind: message
role: assistant role: assistant
status: in_progress
seq: 3 seq: 3
- agent: claude source: daemon
kind: message synthetic: true
message: type: item.started
parts: - delta:
- text: "<redacted>" delta: "<redacted>"
type: text item_id: "<redacted>"
role: assistant native_item_id: "<redacted>"
seq: 4 seq: 4
source: daemon
synthetic: true
type: item.delta
- item:
content_types:
- text
kind: message
role: assistant
status: completed
seq: 5
source: agent
synthetic: false
type: item.completed

View file

@ -1,42 +1,87 @@
--- ---
source: server/packages/sandbox-agent/tests/http_sse_snapshots.rs source: server/packages/sandbox-agent/tests/http_sse_snapshots.rs
assertion_line: 1259
expression: snapshot expression: snapshot
--- ---
session_a: session_a:
- agent: claude - metadata: true
kind: started
seq: 1 seq: 1
started: session: started
message: session.created source: daemon
- agent: claude synthetic: true
kind: started type: session.started
- metadata: true
seq: 2 seq: 2
started: session: started
message: system.init source: agent
- agent: claude synthetic: false
kind: message type: session.started
message: - item:
parts: content_types:
- text: "<redacted>" - text
type: text kind: message
role: assistant role: assistant
status: in_progress
seq: 3 seq: 3
source: daemon
synthetic: true
type: item.started
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 4
source: daemon
synthetic: true
type: item.delta
- item:
content_types:
- text
kind: message
role: assistant
status: completed
seq: 5
source: agent
synthetic: false
type: item.completed
session_b: session_b:
- agent: claude - metadata: true
kind: started
seq: 1 seq: 1
started: session: started
message: session.created source: daemon
- agent: claude synthetic: true
kind: started type: session.started
- metadata: true
seq: 2 seq: 2
started: session: started
message: system.init source: agent
- agent: claude synthetic: false
kind: message type: session.started
message: - item:
parts: content_types:
- text: "<redacted>" - text
type: text kind: message
role: assistant role: assistant
status: in_progress
seq: 3 seq: 3
source: daemon
synthetic: true
type: item.started
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 4
source: daemon
synthetic: true
type: item.delta
- item:
content_types:
- text
kind: message
role: assistant
status: completed
seq: 5
source: agent
synthetic: false
type: item.completed

View file

@ -1,22 +1,45 @@
--- ---
source: server/packages/sandbox-agent/tests/http_sse_snapshots.rs source: server/packages/sandbox-agent/tests/http_sse_snapshots.rs
assertion_line: 742
expression: normalized expression: normalized
--- ---
- agent: claude - metadata: true
kind: started
seq: 1 seq: 1
started: session: started
message: session.created source: daemon
- agent: claude synthetic: true
kind: started type: session.started
- metadata: true
seq: 2 seq: 2
started: session: started
message: system.init source: agent
- agent: claude synthetic: false
kind: message type: session.started
message: - item:
parts: content_types:
- text: "<redacted>" - text
type: text kind: message
role: assistant role: assistant
status: in_progress
seq: 3 seq: 3
source: daemon
synthetic: true
type: item.started
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 4
source: daemon
synthetic: true
type: item.delta
- item:
content_types:
- text
kind: message
role: assistant
status: completed
seq: 5
source: agent
synthetic: false
type: item.completed

View file

@ -1,22 +1,45 @@
--- ---
source: server/packages/sandbox-agent/tests/http_sse_snapshots.rs source: server/packages/sandbox-agent/tests/http_sse_snapshots.rs
assertion_line: 775
expression: normalized expression: normalized
--- ---
- agent: claude - metadata: true
kind: started
seq: 1 seq: 1
started: session: started
message: session.created source: daemon
- agent: claude synthetic: true
kind: started type: session.started
- metadata: true
seq: 2 seq: 2
started: session: started
message: system.init source: agent
- agent: claude synthetic: false
kind: message type: session.started
message: - item:
parts: content_types:
- text: "<redacted>" - text
type: text kind: message
role: assistant role: assistant
status: in_progress
seq: 3 seq: 3
source: daemon
synthetic: true
type: item.started
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 4
source: daemon
synthetic: true
type: item.delta
- item:
content_types:
- text
kind: message
role: assistant
status: completed
seq: 5
source: agent
synthetic: false
type: item.completed

View file

@ -1,155 +1,161 @@
use crate::{ use std::sync::atomic::{AtomicU64, Ordering};
message_from_parts,
message_from_text,
text_only_from_parts,
ConversionError,
CrashInfo,
EventConversion,
UniversalEventData,
UniversalMessage,
UniversalMessageParsed,
UniversalMessagePart,
};
use crate::amp as schema;
use serde_json::{Map, Value};
pub fn event_to_universal(event: &schema::StreamJsonMessage) -> EventConversion { use serde_json::Value;
let schema::StreamJsonMessage {
content, use crate::amp as schema;
error, use crate::{
id, ContentPart,
tool_call, ErrorData,
type_, EventConversion,
} = event; ItemDeltaData,
match type_ { ItemEventData,
ItemKind,
ItemRole,
ItemStatus,
SessionEndedData,
SessionEndReason,
TerminatedBy,
UniversalEventData,
UniversalEventType,
UniversalItem,
};
static TEMP_ID: AtomicU64 = AtomicU64::new(1);
fn next_temp_id(prefix: &str) -> String {
let id = TEMP_ID.fetch_add(1, Ordering::Relaxed);
format!("{prefix}_{id}")
}
pub fn event_to_universal(event: &schema::StreamJsonMessage) -> Result<Vec<EventConversion>, String> {
let mut events = Vec::new();
match event.type_ {
schema::StreamJsonMessageType::Message => { schema::StreamJsonMessageType::Message => {
let text = content.clone().unwrap_or_default(); let text = event.content.clone().unwrap_or_default();
let mut message = message_from_text("assistant", text); let item = UniversalItem {
if let UniversalMessage::Parsed(parsed) = &mut message { item_id: next_temp_id("tmp_amp_message"),
parsed.id = id.clone(); native_item_id: event.id.clone(),
} parent_id: None,
EventConversion::new(UniversalEventData::Message { message }) kind: ItemKind::Message,
role: Some(ItemRole::Assistant),
content: vec![ContentPart::Text { text: text.clone() }],
status: ItemStatus::Completed,
};
events.extend(message_events(item, text));
} }
schema::StreamJsonMessageType::ToolCall => { schema::StreamJsonMessageType::ToolCall => {
let tool_call = tool_call.as_ref(); let tool_call = event.tool_call.clone();
let part = if let Some(tool_call) = tool_call { let (name, arguments, call_id) = if let Some(call) = tool_call {
let schema::ToolCall { arguments, id, name } = tool_call; let arguments = match call.arguments {
let input = match arguments { schema::ToolCallArguments::Variant0(text) => text,
schema::ToolCallArguments::Variant0(text) => Value::String(text.clone()), schema::ToolCallArguments::Variant1(map) => {
schema::ToolCallArguments::Variant1(map) => Value::Object(map.clone()), serde_json::to_string(&Value::Object(map)).unwrap_or_else(|_| "{}".to_string())
}
}; };
UniversalMessagePart::ToolCall { (call.name, arguments, call.id)
id: Some(id.clone()),
name: name.clone(),
input,
}
} else { } else {
UniversalMessagePart::Unknown { raw: Value::Null } ("unknown".to_string(), "{}".to_string(), next_temp_id("tmp_amp_tool"))
}; };
let mut message = message_from_parts("assistant", vec![part]); let item = UniversalItem {
if let UniversalMessage::Parsed(parsed) = &mut message { item_id: next_temp_id("tmp_amp_tool_call"),
parsed.id = id.clone(); native_item_id: Some(call_id.clone()),
} parent_id: None,
EventConversion::new(UniversalEventData::Message { message }) kind: ItemKind::ToolCall,
role: Some(ItemRole::Assistant),
content: vec![ContentPart::ToolCall {
name,
arguments,
call_id,
}],
status: ItemStatus::Completed,
};
events.extend(item_events(item));
} }
schema::StreamJsonMessageType::ToolResult => { schema::StreamJsonMessageType::ToolResult => {
let output = content let output = event.content.clone().unwrap_or_default();
let call_id = event
.id
.clone() .clone()
.map(Value::String) .unwrap_or_else(|| next_temp_id("tmp_amp_tool"));
.unwrap_or(Value::Null); let item = UniversalItem {
let part = UniversalMessagePart::ToolResult { item_id: next_temp_id("tmp_amp_tool_result"),
id: id.clone(), native_item_id: Some(call_id.clone()),
name: None, parent_id: None,
output, kind: ItemKind::ToolResult,
is_error: None, role: Some(ItemRole::Tool),
content: vec![ContentPart::ToolResult {
call_id,
output,
}],
status: ItemStatus::Completed,
}; };
let message = message_from_parts("tool", vec![part]); events.extend(item_events(item));
EventConversion::new(UniversalEventData::Message { message })
} }
schema::StreamJsonMessageType::Error => { schema::StreamJsonMessageType::Error => {
let message = error.clone().unwrap_or_else(|| "amp error".to_string()); let message = event.error.clone().unwrap_or_else(|| "amp error".to_string());
let crash = CrashInfo { events.push(EventConversion::new(
message, UniversalEventType::Error,
kind: Some("amp".to_string()), UniversalEventData::Error(ErrorData {
details: serde_json::to_value(event).ok(), message,
}; code: Some("amp".to_string()),
EventConversion::new(UniversalEventData::Error { error: crash }) details: serde_json::to_value(event).ok(),
}),
));
}
schema::StreamJsonMessageType::Done => {
events.push(
EventConversion::new(
UniversalEventType::SessionEnded,
UniversalEventData::SessionEnded(SessionEndedData {
reason: SessionEndReason::Completed,
terminated_by: TerminatedBy::Agent,
}),
)
.with_raw(serde_json::to_value(event).ok()),
);
} }
schema::StreamJsonMessageType::Done => EventConversion::new(UniversalEventData::Unknown {
raw: serde_json::to_value(event).unwrap_or(Value::Null),
}),
} }
for conversion in &mut events {
conversion.raw = serde_json::to_value(event).ok();
}
Ok(events)
} }
pub fn universal_event_to_amp(event: &UniversalEventData) -> Result<schema::StreamJsonMessage, ConversionError> { fn item_events(item: UniversalItem) -> Vec<EventConversion> {
match event { vec![EventConversion::new(
UniversalEventData::Message { message } => { UniversalEventType::ItemCompleted,
let parsed = match message { UniversalEventData::Item(ItemEventData { item }),
UniversalMessage::Parsed(parsed) => parsed, )]
UniversalMessage::Unparsed { .. } => {
return Err(ConversionError::Unsupported("unparsed message"))
}
};
let content = text_only_from_parts(&parsed.parts)?;
Ok(schema::StreamJsonMessage {
content: Some(content),
error: None,
id: parsed.id.clone(),
tool_call: None,
type_: schema::StreamJsonMessageType::Message,
})
}
_ => Err(ConversionError::Unsupported("amp event")),
}
} }
pub fn message_to_universal(message: &schema::Message) -> UniversalMessage { fn message_events(item: UniversalItem, delta: String) -> Vec<EventConversion> {
let schema::Message { let mut events = Vec::new();
role, let mut started = item.clone();
content, started.status = ItemStatus::InProgress;
tool_calls, events.push(
} = message; EventConversion::new(
let mut parts = vec![UniversalMessagePart::Text { UniversalEventType::ItemStarted,
text: content.clone(), UniversalEventData::Item(ItemEventData { item: started }),
}]; )
for call in tool_calls { .synthetic(),
let schema::ToolCall { arguments, id, name } = call; );
let input = match arguments { if !delta.is_empty() {
schema::ToolCallArguments::Variant0(text) => Value::String(text.clone()), events.push(
schema::ToolCallArguments::Variant1(map) => Value::Object(map.clone()), EventConversion::new(
}; UniversalEventType::ItemDelta,
parts.push(UniversalMessagePart::ToolCall { UniversalEventData::ItemDelta(ItemDeltaData {
id: Some(id.clone()), item_id: item.item_id.clone(),
name: name.clone(), native_item_id: item.native_item_id.clone(),
input, delta,
}); }),
)
.synthetic(),
);
} }
UniversalMessage::Parsed(UniversalMessageParsed { events.push(EventConversion::new(
role: role.to_string(), UniversalEventType::ItemCompleted,
id: None, UniversalEventData::Item(ItemEventData { item }),
metadata: Map::new(), ));
parts, events
})
}
pub fn universal_message_to_message(
message: &UniversalMessage,
) -> Result<schema::Message, ConversionError> {
let parsed = match message {
UniversalMessage::Parsed(parsed) => parsed,
UniversalMessage::Unparsed { .. } => {
return Err(ConversionError::Unsupported("unparsed message"))
}
};
let content = text_only_from_parts(&parsed.parts)?;
Ok(schema::Message {
role: match parsed.role.as_str() {
"user" => schema::MessageRole::User,
"assistant" => schema::MessageRole::Assistant,
"system" => schema::MessageRole::System,
_ => schema::MessageRole::User,
},
content,
tool_calls: vec![],
})
} }

View file

@ -1,94 +1,76 @@
use std::sync::atomic::{AtomicU64, Ordering};
use serde_json::Value;
use crate::{ use crate::{
message_from_parts, ContentPart,
message_from_text,
text_only_from_parts,
ConversionError,
EventConversion, EventConversion,
QuestionInfo, ItemEventData,
QuestionOption, ItemKind,
QuestionRequest, ItemRole,
Started, ItemStatus,
QuestionEventData,
QuestionStatus,
SessionStartedData,
UniversalEventData, UniversalEventData,
UniversalMessage, UniversalEventType,
UniversalMessageParsed, UniversalItem,
UniversalMessagePart,
}; };
use serde_json::{Map, Value};
static TEMP_ID: AtomicU64 = AtomicU64::new(1);
fn next_temp_id(prefix: &str) -> String {
let id = TEMP_ID.fetch_add(1, Ordering::Relaxed);
format!("{prefix}_{id}")
}
pub fn event_to_universal_with_session( pub fn event_to_universal_with_session(
event: &Value, event: &Value,
session_id: String, session_id: String,
) -> EventConversion { ) -> Result<Vec<EventConversion>, String> {
let event_type = event.get("type").and_then(Value::as_str).unwrap_or(""); let event_type = event.get("type").and_then(Value::as_str).unwrap_or("");
match event_type { let mut conversions = match event_type {
"system" => system_event_to_universal(event), "system" => vec![system_event_to_universal(event)],
"assistant" => assistant_event_to_universal(event), "assistant" => assistant_event_to_universal(event),
"tool_use" => tool_use_event_to_universal(event, session_id), "tool_use" => tool_use_event_to_universal(event, session_id),
"tool_result" => tool_result_event_to_universal(event), "tool_result" => tool_result_event_to_universal(event),
"result" => result_event_to_universal(event), "result" => result_event_to_universal(event),
_ => EventConversion::new(UniversalEventData::Unknown { raw: event.clone() }), _ => return Err(format!("unsupported Claude event type: {event_type}")),
}
}
pub fn universal_event_to_claude(event: &UniversalEventData) -> Result<Value, ConversionError> {
match event {
UniversalEventData::Message { message } => {
let parsed = match message {
UniversalMessage::Parsed(parsed) => parsed,
UniversalMessage::Unparsed { .. } => {
return Err(ConversionError::Unsupported("unparsed message"))
}
};
let text = text_only_from_parts(&parsed.parts)?;
Ok(Value::Object(Map::from_iter([
("type".to_string(), Value::String("assistant".to_string())),
(
"message".to_string(),
Value::Object(Map::from_iter([(
"content".to_string(),
Value::Array(vec![Value::Object(Map::from_iter([(
"type".to_string(),
Value::String("text".to_string()),
), (
"text".to_string(),
Value::String(text),
)]))]),
)])),
),
])))
}
_ => Err(ConversionError::Unsupported("claude event")),
}
}
pub fn prompt_to_universal(prompt: &str) -> UniversalMessage {
message_from_text("user", prompt.to_string())
}
pub fn universal_message_to_prompt(message: &UniversalMessage) -> Result<String, ConversionError> {
let parsed = match message {
UniversalMessage::Parsed(parsed) => parsed,
UniversalMessage::Unparsed { .. } => {
return Err(ConversionError::Unsupported("unparsed message"))
}
}; };
text_only_from_parts(&parsed.parts)
for conversion in &mut conversions {
conversion.raw = Some(event.clone());
}
Ok(conversions)
} }
fn assistant_event_to_universal(event: &Value) -> EventConversion { fn system_event_to_universal(event: &Value) -> EventConversion {
let data = SessionStartedData {
metadata: Some(event.clone()),
};
EventConversion::new(UniversalEventType::SessionStarted, UniversalEventData::SessionStarted(data))
.with_raw(Some(event.clone()))
}
fn assistant_event_to_universal(event: &Value) -> Vec<EventConversion> {
let mut conversions = Vec::new();
let content = event let content = event
.get("message") .get("message")
.and_then(|msg| msg.get("content")) .and_then(|msg| msg.get("content"))
.and_then(Value::as_array) .and_then(Value::as_array)
.cloned() .cloned()
.unwrap_or_default(); .unwrap_or_default();
let mut parts = Vec::new();
let message_id = next_temp_id("tmp_claude_message");
let mut message_parts = Vec::new();
for block in content { for block in content {
let block_type = block.get("type").and_then(Value::as_str).unwrap_or(""); let block_type = block.get("type").and_then(Value::as_str).unwrap_or("");
match block_type { match block_type {
"text" => { "text" => {
if let Some(text) = block.get("text").and_then(Value::as_str) { if let Some(text) = block.get("text").and_then(Value::as_str) {
parts.push(UniversalMessagePart::Text { message_parts.push(ContentPart::Text {
text: text.to_string(), text: text.to_string(),
}); });
} }
@ -96,39 +78,50 @@ fn assistant_event_to_universal(event: &Value) -> EventConversion {
"tool_use" => { "tool_use" => {
if let Some(name) = block.get("name").and_then(Value::as_str) { if let Some(name) = block.get("name").and_then(Value::as_str) {
let input = block.get("input").cloned().unwrap_or(Value::Null); let input = block.get("input").cloned().unwrap_or(Value::Null);
let id = block.get("id").and_then(Value::as_str).map(|s| s.to_string()); let call_id = block
parts.push(UniversalMessagePart::ToolCall { .get("id")
id, .and_then(Value::as_str)
name: name.to_string(), .map(|s| s.to_string())
input, .unwrap_or_else(|| next_temp_id("tmp_claude_tool"));
}); let arguments = serde_json::to_string(&input).unwrap_or_else(|_| "{}".to_string());
let tool_item = UniversalItem {
item_id: next_temp_id("tmp_claude_tool_item"),
native_item_id: Some(call_id.clone()),
parent_id: Some(message_id.clone()),
kind: ItemKind::ToolCall,
role: Some(ItemRole::Assistant),
content: vec![ContentPart::ToolCall {
name: name.to_string(),
arguments,
call_id,
}],
status: ItemStatus::Completed,
};
conversions.extend(item_events(tool_item, true));
} }
} }
_ => parts.push(UniversalMessagePart::Unknown { raw: block }), _ => {
message_parts.push(ContentPart::Json { json: block });
}
} }
} }
let message = UniversalMessage::Parsed(UniversalMessageParsed {
role: "assistant".to_string(),
id: None,
metadata: Map::new(),
parts,
});
EventConversion::new(UniversalEventData::Message { message })
}
fn system_event_to_universal(event: &Value) -> EventConversion { let message_item = UniversalItem {
let subtype = event item_id: message_id,
.get("subtype") native_item_id: None,
.and_then(Value::as_str) parent_id: None,
.unwrap_or("system"); kind: ItemKind::Message,
let started = Started { role: Some(ItemRole::Assistant),
message: Some(format!("system.{subtype}")), content: message_parts.clone(),
details: Some(event.clone()), status: ItemStatus::Completed,
}; };
EventConversion::new(UniversalEventData::Started { started })
conversions.extend(message_events(message_item, message_parts, true));
conversions
} }
fn tool_use_event_to_universal(event: &Value, session_id: String) -> EventConversion { fn tool_use_event_to_universal(event: &Value, session_id: String) -> Vec<EventConversion> {
let mut conversions = Vec::new();
let tool_use = event.get("tool_use"); let tool_use = event.get("tool_use");
let name = tool_use let name = tool_use
.and_then(|tool| tool.get("name")) .and_then(|tool| tool.get("name"))
@ -141,113 +134,219 @@ fn tool_use_event_to_universal(event: &Value, session_id: String) -> EventConver
let id = tool_use let id = tool_use
.and_then(|tool| tool.get("id")) .and_then(|tool| tool.get("id"))
.and_then(Value::as_str) .and_then(Value::as_str)
.map(|s| s.to_string()); .map(|s| s.to_string())
.unwrap_or_else(|| next_temp_id("tmp_claude_tool"));
if name == "AskUserQuestion" { let is_question_tool = matches!(
if let Some(question) = name,
question_from_claude_input(&input, id.clone(), session_id.clone()) "AskUserQuestion" | "ask_user_question" | "askUserQuestion" | "ask-user-question"
{ );
return EventConversion::new(UniversalEventData::QuestionAsked { let has_question_payload = input.get("questions").is_some();
question_asked: question, if is_question_tool || has_question_payload {
}); if let Some(question) = question_from_claude_input(&input, id.clone()) {
conversions.push(
EventConversion::new(
UniversalEventType::QuestionRequested,
UniversalEventData::Question(question),
)
.with_raw(Some(event.clone())),
);
} }
} }
let message = message_from_parts( let arguments = serde_json::to_string(&input).unwrap_or_else(|_| "{}".to_string());
"assistant", let tool_item = UniversalItem {
vec![UniversalMessagePart::ToolCall { item_id: next_temp_id("tmp_claude_tool_item"),
id, native_item_id: Some(id.clone()),
parent_id: None,
kind: ItemKind::ToolCall,
role: Some(ItemRole::Assistant),
content: vec![ContentPart::ToolCall {
name: name.to_string(), name: name.to_string(),
input, arguments,
call_id: id,
}], }],
); status: ItemStatus::Completed,
EventConversion::new(UniversalEventData::Message { message }) };
conversions.extend(item_events(tool_item, true));
if conversions.is_empty() {
let data = QuestionEventData {
question_id: next_temp_id("tmp_claude_question"),
prompt: "".to_string(),
options: Vec::new(),
response: None,
status: QuestionStatus::Requested,
};
conversions.push(
EventConversion::new(
UniversalEventType::QuestionRequested,
UniversalEventData::Question(data),
)
.with_raw(Some(Value::String(format!(
"unexpected question payload for session {session_id}"
)))),
);
}
conversions
} }
fn tool_result_event_to_universal(event: &Value) -> EventConversion { fn tool_result_event_to_universal(event: &Value) -> Vec<EventConversion> {
let mut conversions = Vec::new();
let tool_result = event.get("tool_result"); let tool_result = event.get("tool_result");
let output = tool_result let output = tool_result
.and_then(|tool| tool.get("content")) .and_then(|tool| tool.get("content"))
.cloned() .cloned()
.unwrap_or(Value::Null); .unwrap_or(Value::Null);
let is_error = tool_result
.and_then(|tool| tool.get("is_error"))
.and_then(Value::as_bool);
let id = tool_result let id = tool_result
.and_then(|tool| tool.get("id")) .and_then(|tool| tool.get("id"))
.and_then(Value::as_str) .and_then(Value::as_str)
.map(|s| s.to_string()); .map(|s| s.to_string())
.unwrap_or_else(|| next_temp_id("tmp_claude_tool"));
let output_text = serde_json::to_string(&output).unwrap_or_else(|_| "".to_string());
let message = message_from_parts( let tool_item = UniversalItem {
"tool", item_id: next_temp_id("tmp_claude_tool_result"),
vec![UniversalMessagePart::ToolResult { native_item_id: Some(id.clone()),
id, parent_id: None,
name: None, kind: ItemKind::ToolResult,
output, role: Some(ItemRole::Tool),
is_error, content: vec![ContentPart::ToolResult {
call_id: id,
output: output_text,
}], }],
); status: ItemStatus::Completed,
EventConversion::new(UniversalEventData::Message { message }) };
conversions.extend(item_events(tool_item, true));
conversions
} }
fn result_event_to_universal(event: &Value) -> EventConversion { fn result_event_to_universal(event: &Value) -> Vec<EventConversion> {
let result_text = event let result_text = event
.get("result") .get("result")
.and_then(Value::as_str) .and_then(Value::as_str)
.unwrap_or("") .unwrap_or("")
.to_string(); .to_string();
let session_id = event let message_item = UniversalItem {
.get("session_id") item_id: next_temp_id("tmp_claude_result"),
.and_then(Value::as_str) native_item_id: None,
.map(|s| s.to_string()); parent_id: None,
let message = message_from_text("assistant", result_text); kind: ItemKind::Message,
EventConversion::new(UniversalEventData::Message { message }).with_session(session_id) role: Some(ItemRole::Assistant),
content: vec![ContentPart::Text { text: result_text.clone() }],
status: ItemStatus::Completed,
};
message_events(message_item, vec![ContentPart::Text { text: result_text }], true)
} }
fn question_from_claude_input( fn item_events(item: UniversalItem, synthetic_start: bool) -> Vec<EventConversion> {
input: &Value, let mut events = Vec::new();
tool_id: Option<String>, if synthetic_start {
session_id: String, let mut started_item = item.clone();
) -> Option<QuestionRequest> { started_item.status = ItemStatus::InProgress;
let questions = input.get("questions").and_then(Value::as_array)?; events.push(
let mut parsed_questions = Vec::new(); EventConversion::new(
for question in questions { UniversalEventType::ItemStarted,
let question_text = question.get("question")?.as_str()?.to_string(); UniversalEventData::Item(ItemEventData { item: started_item }),
let header = question )
.get("header") .synthetic(),
.and_then(Value::as_str) );
.map(|s| s.to_string()); }
let multi_select = question events.push(EventConversion::new(
.get("multiSelect") UniversalEventType::ItemCompleted,
.and_then(Value::as_bool); UniversalEventData::Item(ItemEventData { item }),
let options = question ));
events
}
fn message_events(item: UniversalItem, parts: Vec<ContentPart>, synthetic_start: bool) -> Vec<EventConversion> {
let mut events = Vec::new();
if synthetic_start {
let mut started_item = item.clone();
started_item.status = ItemStatus::InProgress;
events.push(
EventConversion::new(
UniversalEventType::ItemStarted,
UniversalEventData::Item(ItemEventData { item: started_item }),
)
.synthetic(),
);
}
let mut delta_text = String::new();
for part in &parts {
if let ContentPart::Text { text } = part {
delta_text.push_str(text);
}
}
if !delta_text.is_empty() {
events.push(
EventConversion::new(
UniversalEventType::ItemDelta,
UniversalEventData::ItemDelta(crate::ItemDeltaData {
item_id: item.item_id.clone(),
native_item_id: item.native_item_id.clone(),
delta: delta_text,
}),
)
.synthetic(),
);
}
events.push(EventConversion::new(
UniversalEventType::ItemCompleted,
UniversalEventData::Item(ItemEventData { item }),
));
events
}
fn question_from_claude_input(input: &Value, tool_id: String) -> Option<QuestionEventData> {
if let Some(questions) = input.get("questions").and_then(Value::as_array) {
if let Some(first) = questions.first() {
let prompt = first.get("question")?.as_str()?.to_string();
let options = first
.get("options")
.and_then(Value::as_array)
.map(|opts| {
opts.iter()
.filter_map(|opt| opt.get("label").and_then(Value::as_str))
.map(|label| label.to_string())
.collect::<Vec<_>>()
})
.unwrap_or_default();
return Some(QuestionEventData {
question_id: tool_id,
prompt,
options,
response: None,
status: QuestionStatus::Requested,
});
}
}
let prompt = input
.get("question")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
if prompt.is_empty() {
return None;
}
Some(QuestionEventData {
question_id: tool_id,
prompt,
options: input
.get("options") .get("options")
.and_then(Value::as_array) .and_then(Value::as_array)
.map(|options| { .map(|opts| {
options opts.iter()
.iter() .filter_map(Value::as_str)
.filter_map(|option| { .map(|s| s.to_string())
let label = option.get("label")?.as_str()?.to_string();
let description = option
.get("description")
.and_then(Value::as_str)
.map(|s| s.to_string());
Some(QuestionOption { label, description })
})
.collect::<Vec<_>>() .collect::<Vec<_>>()
})?; })
parsed_questions.push(QuestionInfo { .unwrap_or_default(),
question: question_text, response: None,
header, status: QuestionStatus::Requested,
options,
multi_select,
custom: None,
});
}
Some(QuestionRequest {
id: tool_id.unwrap_or_else(|| "claude-question".to_string()),
session_id,
questions: parsed_questions,
tool: None,
}) })
} }

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,6 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{Map, Value}; use serde_json::Value;
use schemars::JsonSchema; use schemars::JsonSchema;
use thiserror::Error;
use utoipa::ToSchema; use utoipa::ToSchema;
pub use sandbox_agent_extracted_agent_schemas::{amp, claude, codex, opencode}; pub use sandbox_agent_extracted_agent_schemas::{amp, claude, codex, opencode};
@ -11,318 +10,282 @@ pub mod agents;
pub use agents::{amp as convert_amp, claude as convert_claude, codex as convert_codex, opencode as convert_opencode}; pub use agents::{amp as convert_amp, claude as convert_claude, codex as convert_codex, opencode as convert_opencode};
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct UniversalEvent { pub struct UniversalEvent {
pub id: u64, pub event_id: String,
pub timestamp: String, pub sequence: u64,
pub time: String,
pub session_id: String, pub session_id: String,
pub agent: String, pub native_session_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] pub synthetic: bool,
pub agent_session_id: Option<String>, pub source: EventSource,
#[serde(rename = "type")]
pub event_type: UniversalEventType,
pub data: UniversalEventData, pub data: UniversalEventData,
pub raw: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum EventSource {
Agent,
Daemon,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, ToSchema)]
pub enum UniversalEventType {
#[serde(rename = "session.started")]
SessionStarted,
#[serde(rename = "session.ended")]
SessionEnded,
#[serde(rename = "item.started")]
ItemStarted,
#[serde(rename = "item.delta")]
ItemDelta,
#[serde(rename = "item.completed")]
ItemCompleted,
#[serde(rename = "error")]
Error,
#[serde(rename = "permission.requested")]
PermissionRequested,
#[serde(rename = "permission.resolved")]
PermissionResolved,
#[serde(rename = "question.requested")]
QuestionRequested,
#[serde(rename = "question.resolved")]
QuestionResolved,
#[serde(rename = "agent.unparsed")]
AgentUnparsed,
} }
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(untagged)] #[serde(untagged)]
pub enum UniversalEventData { pub enum UniversalEventData {
Message { message: UniversalMessage }, SessionStarted(SessionStartedData),
Started { started: Started }, SessionEnded(SessionEndedData),
Error { error: CrashInfo }, Item(ItemEventData),
QuestionAsked { ItemDelta(ItemDeltaData),
#[serde(rename = "questionAsked")] Error(ErrorData),
question_asked: QuestionRequest, Permission(PermissionEventData),
}, Question(QuestionEventData),
PermissionAsked { AgentUnparsed(AgentUnparsedData),
#[serde(rename = "permissionAsked")]
permission_asked: PermissionRequest,
},
Unknown { raw: Value },
} }
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "camelCase")] pub struct SessionStartedData {
pub struct Started { pub metadata: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub details: Option<Value>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "camelCase")] pub struct SessionEndedData {
pub struct CrashInfo { pub reason: SessionEndReason,
pub terminated_by: TerminatedBy,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum SessionEndReason {
Completed,
Error,
Terminated,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum TerminatedBy {
Agent,
Daemon,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
pub struct ItemEventData {
pub item: UniversalItem,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
pub struct ItemDeltaData {
pub item_id: String,
pub native_item_id: Option<String>,
pub delta: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
pub struct ErrorData {
pub message: String, pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")] pub code: Option<String>,
pub kind: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub details: Option<Value>, pub details: Option<Value>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
pub struct UniversalMessageParsed { pub struct AgentUnparsedData {
pub role: String, pub error: String,
#[serde(default, skip_serializing_if = "Option::is_none")] pub location: String,
pub id: Option<String>, pub raw_hash: Option<String>,
#[serde(default, skip_serializing_if = "Map::is_empty")]
pub metadata: Map<String, Value>,
pub parts: Vec<UniversalMessagePart>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(untagged)] pub struct PermissionEventData {
pub enum UniversalMessage { pub permission_id: String,
Parsed(UniversalMessageParsed), pub action: String,
Unparsed { pub status: PermissionStatus,
raw: Value, pub metadata: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")] }
error: Option<String>,
}, #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum PermissionStatus {
Requested,
Approved,
Denied,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
pub struct QuestionEventData {
pub question_id: String,
pub prompt: String,
pub options: Vec<String>,
pub response: Option<String>,
pub status: QuestionStatus,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum QuestionStatus {
Requested,
Answered,
Rejected,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
pub struct UniversalItem {
pub item_id: String,
pub native_item_id: Option<String>,
pub parent_id: Option<String>,
pub kind: ItemKind,
pub role: Option<ItemRole>,
pub content: Vec<ContentPart>,
pub status: ItemStatus,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum ItemKind {
Message,
ToolCall,
ToolResult,
System,
Status,
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum ItemRole {
User,
Assistant,
System,
Tool,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum ItemStatus {
InProgress,
Completed,
Failed,
} }
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
pub enum UniversalMessagePart { pub enum ContentPart {
Text { text: String }, Text { text: String },
ToolCall { Json { json: Value },
#[serde(default, skip_serializing_if = "Option::is_none")] ToolCall { name: String, arguments: String, call_id: String },
id: Option<String>, ToolResult { call_id: String, output: String },
name: String, FileRef { path: String, action: FileAction, diff: Option<String> },
input: Value, Reasoning { text: String, visibility: ReasoningVisibility },
}, Image { path: String, mime: Option<String> },
ToolResult { Status { label: String, detail: Option<String> },
#[serde(default, skip_serializing_if = "Option::is_none")]
id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
output: Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
is_error: Option<bool>,
},
FunctionCall {
#[serde(default, skip_serializing_if = "Option::is_none")]
id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
arguments: Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
raw: Option<Value>,
},
FunctionResult {
#[serde(default, skip_serializing_if = "Option::is_none")]
id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
result: Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
is_error: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
raw: Option<Value>,
},
File {
source: AttachmentSource,
#[serde(default, skip_serializing_if = "Option::is_none")]
mime_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
filename: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
raw: Option<Value>,
},
Image {
source: AttachmentSource,
#[serde(default, skip_serializing_if = "Option::is_none")]
mime_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
alt: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
raw: Option<Value>,
},
Error { message: String },
Unknown { raw: Value },
} }
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(tag = "type", rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum AttachmentSource { pub enum FileAction {
Path { path: String }, Read,
Url { url: String }, Write,
Data { Patch,
data: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
encoding: Option<String>,
},
} }
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "snake_case")]
pub struct QuestionRequest { pub enum ReasoningVisibility {
pub id: String, Public,
pub session_id: String, Private,
pub questions: Vec<QuestionInfo>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool: Option<QuestionToolRef>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct QuestionInfo {
pub question: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub header: Option<String>,
pub options: Vec<QuestionOption>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub multi_select: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub custom: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct QuestionOption {
pub label: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct QuestionToolRef {
pub message_id: String,
pub call_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct PermissionRequest {
pub id: String,
pub session_id: String,
pub permission: String,
pub patterns: Vec<String>,
#[serde(default, skip_serializing_if = "Map::is_empty")]
pub metadata: Map<String, Value>,
pub always: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool: Option<PermissionToolRef>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct PermissionToolRef {
pub message_id: String,
pub call_id: String,
}
#[derive(Debug, Error)]
pub enum ConversionError {
#[error("unsupported conversion: {0}")]
Unsupported(&'static str),
#[error("missing field: {0}")]
MissingField(&'static str),
#[error("invalid value: {0}")]
InvalidValue(String),
#[error("serde error: {0}")]
Serde(String),
}
impl From<serde_json::Error> for ConversionError {
fn from(err: serde_json::Error) -> Self {
Self::Serde(err.to_string())
}
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct EventConversion { pub struct EventConversion {
pub event_type: UniversalEventType,
pub data: UniversalEventData, pub data: UniversalEventData,
pub agent_session_id: Option<String>, pub native_session_id: Option<String>,
pub source: EventSource,
pub synthetic: bool,
pub raw: Option<Value>,
} }
impl EventConversion { impl EventConversion {
pub fn new(data: UniversalEventData) -> Self { pub fn new(event_type: UniversalEventType, data: UniversalEventData) -> Self {
Self { Self {
event_type,
data, data,
agent_session_id: None, native_session_id: None,
source: EventSource::Agent,
synthetic: false,
raw: None,
} }
} }
pub fn with_session(mut self, session_id: Option<String>) -> Self { pub fn with_native_session(mut self, session_id: Option<String>) -> Self {
self.agent_session_id = session_id; self.native_session_id = session_id;
self
}
pub fn with_raw(mut self, raw: Option<Value>) -> Self {
self.raw = raw;
self
}
pub fn synthetic(mut self) -> Self {
self.synthetic = true;
self.source = EventSource::Daemon;
self
}
pub fn with_source(mut self, source: EventSource) -> Self {
self.source = source;
self self
} }
} }
fn message_from_text(role: &str, text: String) -> UniversalMessage { pub fn item_from_text(role: ItemRole, text: String) -> UniversalItem {
UniversalMessage::Parsed(UniversalMessageParsed { UniversalItem {
role: role.to_string(), item_id: String::new(),
id: None, native_item_id: None,
metadata: Map::new(), parent_id: None,
parts: vec![UniversalMessagePart::Text { text }], kind: ItemKind::Message,
}) role: Some(role),
} content: vec![ContentPart::Text { text }],
status: ItemStatus::Completed,
fn message_from_parts(role: &str, parts: Vec<UniversalMessagePart>) -> UniversalMessage {
UniversalMessage::Parsed(UniversalMessageParsed {
role: role.to_string(),
id: None,
metadata: Map::new(),
parts,
})
}
fn text_only_from_parts(parts: &[UniversalMessagePart]) -> Result<String, ConversionError> {
let mut text = String::new();
for part in parts {
match part {
UniversalMessagePart::Text { text: part_text } => {
if !text.is_empty() {
text.push_str("\n");
}
text.push_str(part_text);
}
UniversalMessagePart::ToolCall { .. } => {
return Err(ConversionError::Unsupported("tool call part"))
}
UniversalMessagePart::ToolResult { .. } => {
return Err(ConversionError::Unsupported("tool result part"))
}
UniversalMessagePart::FunctionCall { .. } => {
return Err(ConversionError::Unsupported("function call part"))
}
UniversalMessagePart::FunctionResult { .. } => {
return Err(ConversionError::Unsupported("function result part"))
}
UniversalMessagePart::File { .. } => {
return Err(ConversionError::Unsupported("file part"))
}
UniversalMessagePart::Image { .. } => {
return Err(ConversionError::Unsupported("image part"))
}
UniversalMessagePart::Error { .. } => {
return Err(ConversionError::Unsupported("error part"))
}
UniversalMessagePart::Unknown { .. } => {
return Err(ConversionError::Unsupported("unknown part"))
}
}
}
if text.is_empty() {
Err(ConversionError::MissingField("text part"))
} else {
Ok(text)
} }
} }
fn extract_message_from_value(value: &Value) -> Option<String> { pub fn item_from_parts(role: ItemRole, kind: ItemKind, parts: Vec<ContentPart>) -> UniversalItem {
if let Some(message) = value.get("message").and_then(Value::as_str) { UniversalItem {
return Some(message.to_string()); item_id: String::new(),
native_item_id: None,
parent_id: None,
kind,
role: Some(role),
content: parts,
status: ItemStatus::Completed,
} }
if let Some(message) = value.get("error").and_then(|v| v.get("message")).and_then(Value::as_str) {
return Some(message.to_string());
}
if let Some(message) = value.get("data").and_then(|v| v.get("message")).and_then(Value::as_str) {
return Some(message.to_string());
}
None
} }

File diff suppressed because it is too large Load diff

143
spec/universal-schema.md Normal file
View file

@ -0,0 +1,143 @@
# Universal Schema (Single Version, Breaking)
This document defines the canonical universal session + event model. It replaces prior versions; there is no v2. The design prioritizes compatibility with native agent APIs and fills gaps with explicit synthetics.
Principles
- Most-compatible-first: choose semantics that map cleanly to native APIs (Codex/OpenCode/Amp/Claude).
- Uniform behavior: clients should not special-case agents; the daemon normalizes differences.
- Synthetics fill gaps: when a provider lacks a feature (session start/end, deltas, user messages), we synthesize events with `source=daemon`.
- Raw preservation: always keep native payloads in `raw` for agent-sourced events.
- UI coverage: update the inspector/UI to the new schema and ensure UI tests cover all session features (messages, deltas, tools, permissions, questions, errors, termination).
Identifiers
- session_id: daemon-generated session identifier.
- native_session_id: provider thread/session/run identifier (thread_id is merged here).
- item_id: daemon-generated identifier for any universal item.
- native_item_id: provider-native item/message identifier if available; otherwise null.
Event envelope
```json
{
"event_id": "evt_...",
"sequence": 42,
"time": "2026-01-27T19:10:11Z",
"session_id": "sess_...",
"native_session_id": "provider_...",
"synthetic": false,
"source": "agent|daemon",
"type": "session.started|session.ended|item.started|item.delta|item.completed|error|permission.requested|permission.resolved|question.requested|question.resolved|agent.unparsed",
"data": { "..." : "..." },
"raw": { "..." : "..." }
}
```
Notes:
- `source=agent` for native events; `source=daemon` for synthetics.
- `synthetic` is always present and mirrors whether the event is daemon-produced.
- `raw` is always present. It may be null unless the client opts in to raw payloads; when opt-in is enabled, raw is populated for all events.
- For synthetic events derived from native payloads, include the underlying payload in `raw` when possible.
- Parsing failures emit agent.unparsed (source=daemon, synthetic=true) and should be treated as test failures.
Raw payload opt-in
- Events endpoints accept `include_raw=true` to populate the `raw` field.
- When `include_raw` is not set or false, `raw` is still present but null.
- Applies to both HTTP and SSE event streams.
Item model
```json
{
"item_id": "itm_...",
"native_item_id": "provider_item_...",
"parent_id": "itm_parent_or_null",
"kind": "message|tool_call|tool_result|system|status|unknown",
"role": "user|assistant|system|tool|null",
"content": [ { "type": "...", "...": "..." } ],
"status": "in_progress|completed|failed"
}
```
Content parts (non-exhaustive; extend as needed)
- text: `{ "type": "text", "text": "..." }`
- json: `{ "type": "json", "json": { ... } }`
- tool_call: `{ "type": "tool_call", "name": "...", "arguments": "...", "call_id": "..." }`
- tool_result: `{ "type": "tool_result", "call_id": "...", "output": "..." }`
- file_ref: `{ "type": "file_ref", "path": "...", "action": "read|write|patch", "diff": "..." }`
- reasoning: `{ "type": "reasoning", "text": "...", "visibility": "public|private" }`
- image: `{ "type": "image", "path": "...", "mime": "..." }`
- status: `{ "type": "status", "label": "...", "detail": "..." }`
Event types
session.started
```json
{ "metadata": { "...": "..." } }
```
session.ended
```json
{ "reason": "completed|error|terminated", "terminated_by": "agent|daemon" }
```
item.started
```json
{ "item": { ...Item } }
```
item.delta
```json
{ "item_id": "itm_...", "native_item_id": "provider_item_or_null", "delta": "text fragment" }
```
item.completed
```json
{ "item": { ...Item } }
```
error
```json
{ "message": "...", "code": "optional", "details": { "...": "..." } }
```
agent.unparsed
```json
{ "error": "parse failure message", "location": "agent parser name", "raw_hash": "optional" }
```
permission.requested / permission.resolved
```json
{ "permission_id": "...", "action": "...", "status": "requested|approved|denied", "metadata": { "...": "..." } }
```
question.requested / question.resolved
```json
{ "question_id": "...", "prompt": "...", "options": ["..."], "response": "...", "status": "requested|answered|rejected" }
```
Delta policy (uniform across agents)
- Always emit item.delta for messages.
- For agents without native deltas (Claude/Amp), emit a single synthetic delta containing the full final content immediately before item.completed.
- For Codex/OpenCode, forward native deltas as-is and still emit item.completed with the final content.
User messages
- If the provider emits user messages (Codex/OpenCode/Amp), map directly to message items with role=user.
- If the provider does not emit user messages (Claude), synthesize user message items from the input we send; mark source=daemon and set native_item_id=null.
Tool normalization
- Tool calls/results are always emitted as their own items (kind=tool_call/tool_result) with parent_id pointing to the originating message item.
- Codex: mcp tool call progress and tool items map directly.
- OpenCode: tool parts in message.part.updated are mapped into tool items with lifecycle states.
- Amp: tool_call/tool_result map directly.
- Claude: synthesize tool items from CLI tool usage where possible; if insufficient, omit tool items and preserve raw payloads.
OpenCode ordering rule
- OpenCode may emit message.part.updated before message.updated.
- When a part delta arrives first, create a stub item.started (source=daemon) for the parent message item, then emit item.delta.
Session lifecycle
- If an agent does not emit a session start/end, emit session.started/session.ended synthetically (source=daemon).
- session.ended uses terminated_by=daemon when our termination API is used; terminated_by=agent when the provider ends the session.
Native ID mapping
- native_session_id is the only provider session identifier.
- native_item_id preserves the provider item/message id when available; otherwise null.
- item_id is always daemon-generated.

View file

@ -88,6 +88,7 @@
- [x] Add HTTP request log with copyable curl command - [x] Add HTTP request log with copyable curl command
- [x] Add Content-Type header to CORS callout command - [x] Add Content-Type header to CORS callout command
- [x] Default inspector endpoint to current origin and auto-connect via health check - [x] Default inspector endpoint to current origin and auto-connect via health check
- [x] Update inspector to universal schema events (items, deltas, approvals, errors)
## TypeScript SDK ## TypeScript SDK
- [x] Generate OpenAPI from utoipa and run `openapi-typescript` - [x] Generate OpenAPI from utoipa and run `openapi-typescript`