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 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.
- 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)

View file

@ -12,7 +12,7 @@ description = "Universal agent API for AI coding assistants"
[workspace.dependencies]
# 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-agent-management = { path = "server/packages/agent-management" }
sandbox-agent-agent-credentials = { path = "server/packages/agent-credentials" }

View file

@ -1,6 +1,6 @@
# 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
- **Server or SDK mode**: Run as an HTTP server or with the TypeScript SDK
@ -16,14 +16,14 @@ Roadmap:
## 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 |
| Text Messages | ✓ | ✓ | ✓ | ✓ |
| Tool Calls | | ✓ | ✓ | ✓ |
| Tool Results | | ✓ | ✓ | ✓ |
| Questions (HITL) | | | ✓ | |
| Permissions (HITL) | | | ✓ | |
| Tool Calls | —* | ✓ | ✓ | ✓ |
| Tool Results | —* | ✓ | ✓ | ✓ |
| Questions (HITL) | —* | | ✓ | |
| Permissions (HITL) | —* | | ✓ | |
| Images | | ✓ | ✓ | |
| File Attachments | | ✓ | ✓ | |
| Session Lifecycle | | ✓ | ✓ | |
@ -34,13 +34,15 @@ Roadmap:
| MCP Tools | | ✓ | | |
| 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.
## Architecture
- TODO
- Embedded (runs agents locally)
- Sandboxed
- Local
- Remote/Sandboxed
## Components
@ -49,6 +51,26 @@ Want support for another agent? [Open an issue](https://github.com/anthropics/sa
- Inspector: inspect.sandboxagent.dev
- CLI: TODO
## Quickstart
### SDK
- Local
- Remote/Sandboxed
Docs
### Server
- Run server
- Auth
Docs
### CLI
Docs
## Project Goals
This project aims to solve 3 problems with agents:
@ -98,4 +120,3 @@ TODO
- the harnesses do a lot of heavy lifting
- the difference between opencode, claude, and codex is vast & vastly opinionated

View file

@ -1,12 +1,19 @@
## 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
- write integration guide
- add optional raw payloads to events via query parameters
- auto-serve frontend from cli
- verify embedded sdk works
- fix bugs in ui
- double messages
- user-sent messages
- 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
- discuss actor arch in readme + give example
- skillfile
@ -18,9 +25,18 @@
- **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
- **Codex app-server concurrency**: Run a single shared Codex app-server with multiple threads in parallel (like OpenCode), with file-write safety
- persistence
## 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:
- ralph
- 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=/build/target \
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 && \
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 \
--mount=type=cache,target=/usr/local/cargo/git \
--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 && \
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 \
--mount=type=cache,target=/usr/local/cargo/git \
--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 && \
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 \
--mount=type=cache,target=/usr/local/cargo/git \
--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 && \
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.
- 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.
- 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
@ -42,14 +46,14 @@ Synthetics
+------------------------------+------------------------+--------------------------+--------------------------------------------------------------+
| Synthetic element | When it appears | Stored as | Notes |
+------------------------------+------------------------+--------------------------+--------------------------------------------------------------+
| session.started | When agent emits no explicit start | session.started event | Mark origin=daemon |
| session.ended | When agent emits no explicit end | session.ended event | Mark origin=daemon; reason may be inferred |
| 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 source=daemon; reason may be inferred |
| 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 |
| 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 |
+------------------------------+------------------------+--------------------------+--------------------------------------------------------------+
@ -68,5 +72,9 @@ Policy:
Message normalization notes
- 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.
- 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;
}
.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 {
background: rgba(255, 59, 48, 0.1);
border: 1px solid rgba(255, 59, 48, 0.3);
@ -1071,11 +1140,38 @@
letter-spacing: 0.3px;
}
.event-type.message { color: var(--accent); }
.event-type.started { color: var(--success); }
.event-type.error { color: var(--danger); }
.event-type.question { color: var(--warning); }
.event-type.permission { color: var(--purple); }
.event-type.session,
.event-type.session-started,
.event-type.session-ended {
color: var(--success);
}
.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 {
font-size: 10px;

View file

@ -19,13 +19,14 @@ import {
createSandboxDaemonClient,
type SandboxDaemonClient,
type AgentInfo,
type AgentCapabilities,
type AgentModeInfo,
type PermissionRequest,
type QuestionRequest,
type PermissionEventData,
type QuestionEventData,
type SessionInfo,
type UniversalEvent,
type UniversalMessage,
type UniversalMessagePart
type UniversalItem,
type ContentPart
} from "sandbox-agent";
type RequestLog = {
@ -39,9 +40,38 @@ type RequestLog = {
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";
const defaultAgents = ["claude", "codex", "opencode", "amp"];
const emptyCapabilities: AgentCapabilities = {
planMode: false,
permissions: false,
questions: false,
toolCalls: false
};
const formatJson = (value: unknown) => {
if (value === null || value === undefined) return "";
@ -55,6 +85,16 @@ const formatJson = (value: unknown) => {
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 headers: string[] = [];
if (token) {
@ -69,14 +109,7 @@ const buildCurl = (method: string, url: string, body?: string, token?: string) =
.trim();
};
const getEventType = (event: UniversalEvent) => {
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 getEventType = (event: UniversalEvent) => event.type;
const formatTime = (value: string) => {
if (!value) return "";
@ -85,6 +118,128 @@ const formatTime = (value: string) => {
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 = () => {
if (typeof window === "undefined") return "http://127.0.0.1:2468";
const { origin, protocol } = window.location;
@ -381,9 +536,9 @@ export default function App() {
const appendEvents = useCallback((incoming: UniversalEvent[]) => {
if (!incoming.length) return;
setEvents((prev) => [...prev, ...incoming]);
const lastId = incoming[incoming.length - 1]?.id ?? offsetRef.current;
offsetRef.current = lastId;
setOffset(lastId);
const lastSeq = incoming[incoming.length - 1]?.sequence ?? offsetRef.current;
offsetRef.current = lastSeq;
setOffset(lastSeq);
}, []);
const fetchEvents = useCallback(async () => {
@ -478,35 +633,18 @@ export default function App() {
}
};
const toggleQuestionOption = (
requestId: string,
questionIndex: number,
optionLabel: string,
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 selectQuestionOption = (requestId: string, optionLabel: string) => {
setQuestionSelections((prev) => ({
...prev,
[requestId]: [[optionLabel]]
}));
};
const answerQuestion = async (request: QuestionRequest) => {
const answers = questionSelections[request.id] ?? [];
const answerQuestion = async (request: QuestionEventData) => {
const answers = questionSelections[request.question_id] ?? [];
try {
await getClient().replyQuestion(sessionId, request.id, { answers });
setQuestionStatus((prev) => ({ ...prev, [request.id]: "replied" }));
await getClient().replyQuestion(sessionId, request.question_id, { answers });
setQuestionStatus((prev) => ({ ...prev, [request.question_id]: "replied" }));
} catch (error) {
setEventError(getErrorMessage(error, "Unable to reply"));
}
@ -531,37 +669,134 @@ export default function App() {
};
const questionRequests = useMemo(() => {
return events
.filter((event) => "questionAsked" in event.data)
.map((event) => (event.data as { questionAsked: QuestionRequest }).questionAsked)
.filter((request) => !questionStatus[request.id]);
const latestById = new Map<string, QuestionEventData>();
for (const event of events) {
if (event.type === "question.requested" || event.type === "question.resolved") {
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]);
const permissionRequests = useMemo(() => {
return events
.filter((event) => "permissionAsked" in event.data)
.map((event) => (event.data as { permissionAsked: PermissionRequest }).permissionAsked)
.filter((request) => !permissionStatus[request.id]);
const latestById = new Map<string, PermissionEventData>();
for (const event of events) {
if (event.type === "permission.requested" || event.type === "permission.resolved") {
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]);
const transcriptMessages = useMemo(() => {
return events
.filter((event): event is UniversalEvent & { data: { message: UniversalMessage } } => "message" in event.data)
.map((event) => {
const msg = event.data.message;
const parts = ("parts" in msg ? msg.parts : []) ?? [];
const content = parts
.filter((part: UniversalMessagePart): part is UniversalMessagePart & { type: "text"; text: string } => part.type === "text" && "text" in part && typeof part.text === "string")
.map((part) => part.text)
.join("\n");
return {
id: event.id,
role: "role" in msg ? msg.role : "assistant",
content,
timestamp: event.timestamp
const transcriptEntries = useMemo(() => {
const entries: TimelineEntry[] = [];
const itemMap = new Map<string, TimelineEntry>();
const upsertItemEntry = (item: UniversalItem, time: string) => {
let entry = itemMap.get(item.item_id);
if (!entry) {
entry = {
id: item.item_id,
kind: "item",
time,
item,
deltaText: ""
};
})
.filter((msg) => msg.content);
itemMap.set(item.item_id, entry);
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]);
useEffect(() => {
@ -592,7 +827,7 @@ export default function App() {
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [transcriptMessages]);
}, [transcriptEntries]);
// Auto-load modes when agent changes
useEffect(() => {
@ -801,7 +1036,7 @@ export default function App() {
Create Session
</button>
</div>
) : transcriptMessages.length === 0 && !sessionError ? (
) : transcriptEntries.length === 0 && !sessionError ? (
<div className="empty-state">
<Terminal className="empty-state-icon" />
<div className="empty-state-title">Ready to Chat</div>
@ -811,16 +1046,59 @@ export default function App() {
</div>
) : (
<div className="messages">
{transcriptMessages.map((msg) => (
<div key={msg.id} className={`message ${msg.role === "user" ? "user" : "assistant"}`}>
<div className="avatar">
{msg.role === "user" ? "U" : "AI"}
{transcriptEntries.map((entry) => {
if (entry.kind === "meta") {
const messageClass = entry.meta?.severity === "error" ? "error" : "system";
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 className="message-content">
{msg.content}
</div>
</div>
))}
);
})}
{sessionError && (
<div className="message-error">
{sessionError}
@ -1028,13 +1306,18 @@ export default function App() {
<div className="event-list">
{[...events].reverse().map((event) => {
const type = getEventType(event);
const category = getEventCategory(type);
const eventClass = `${category} ${getEventClass(type)}`;
return (
<div key={event.id} className="event-item">
<div key={event.event_id ?? event.sequence} className="event-item">
<div className="event-header">
<span className={`event-type ${type}`}>{type}</span>
<span className="event-time">{formatTime(event.timestamp)}</span>
<span className={`event-type ${eventClass}`}>{type}</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 className="event-id">Event #{event.id}</div>
<pre className="code-block">{formatJson(event.data)}</pre>
</div>
);
@ -1052,13 +1335,11 @@ export default function App() {
) : (
<>
{questionRequests.map((request) => {
const selections = questionSelections[request.id] ?? [];
const answeredAll = request.questions.every((q, idx) => {
const answer = selections[idx] ?? [];
return answer.length > 0;
});
const selections = questionSelections[request.question_id] ?? [];
const selected = selections[0] ?? [];
const answered = selected.length > 0;
return (
<div key={request.id} className="card">
<div key={request.question_id} className="card">
<div className="card-header">
<span className="card-title">
<HelpCircle className="button-icon" style={{ marginRight: 6 }} />
@ -1066,52 +1347,35 @@ export default function App() {
</span>
<span className="pill accent">Pending</span>
</div>
{request.questions.map((question, qIdx) => (
<div key={qIdx} style={{ marginTop: 12 }}>
<div style={{ fontSize: 12, marginBottom: 8 }}>
{question.header && <strong>{question.header}: </strong>}
{question.question}
</div>
<div className="option-list">
{question.options.map((option) => {
const selected = selections[qIdx]?.includes(option.label) ?? false;
return (
<label key={option.label} className="option-item">
<input
type={question.multiSelect ? "checkbox" : "radio"}
checked={selected}
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 style={{ marginTop: 12 }}>
<div style={{ fontSize: 12, marginBottom: 8 }}>{request.prompt}</div>
<div className="option-list">
{request.options.map((option) => {
const isSelected = selected.includes(option);
return (
<label key={option} className="option-item">
<input
type="radio"
checked={isSelected}
onChange={() => selectQuestionOption(request.question_id, option)}
/>
<span>{option}</span>
</label>
);
})}
</div>
))}
</div>
<div className="card-actions">
<button
className="button success small"
disabled={!answeredAll}
disabled={!answered}
onClick={() => answerQuestion(request)}
>
Reply
</button>
<button
className="button danger small"
onClick={() => rejectQuestion(request.id)}
onClick={() => rejectQuestion(request.question_id)}
>
Reject
</button>
@ -1121,7 +1385,7 @@ export default function App() {
})}
{permissionRequests.map((request) => (
<div key={request.id} className="card">
<div key={request.permission_id} className="card">
<div className="card-header">
<span className="card-title">
<Shield className="button-icon" style={{ marginRight: 6 }} />
@ -1130,32 +1394,27 @@ export default function App() {
<span className="pill accent">Pending</span>
</div>
<div className="card-meta" style={{ marginTop: 8 }}>
{request.permission}
{request.action}
</div>
{request.patterns && request.patterns.length > 0 && (
<div className="mono muted" style={{ fontSize: 11, marginTop: 4 }}>
{request.patterns.join(", ")}
</div>
)}
{request.metadata && (
{request.metadata !== null && request.metadata !== undefined && (
<pre className="code-block">{formatJson(request.metadata)}</pre>
)}
<div className="card-actions">
<button
className="button success small"
onClick={() => replyPermission(request.id, "once")}
onClick={() => replyPermission(request.permission_id, "once")}
>
Allow Once
</button>
<button
className="button secondary small"
onClick={() => replyPermission(request.id, "always")}
onClick={() => replyPermission(request.permission_id, "always")}
>
Always
</button>
<button
className="button danger small"
onClick={() => replyPermission(request.id, "reject")}
onClick={() => replyPermission(request.permission_id, "reject")}
>
Reject
</button>
@ -1180,7 +1439,15 @@ export default function App() {
<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 className="card-header">
<span className="card-title">{agent.id}</span>
@ -1192,6 +1459,9 @@ export default function App() {
{agent.version ? `v${agent.version}` : "Version unknown"}
{agent.path && <span className="mono muted" style={{ marginLeft: 8 }}>{agent.path}</span>}
</div>
<div className="card-meta" style={{ marginTop: 8 }}>
Capabilities: {formatCapabilities(agent.capabilities ?? emptyCapabilities)}
</div>
{modesByAgent[agent.id] && modesByAgent[agent.id].length > 0 && (
<div className="card-meta" style={{ marginTop: 8 }}>
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
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: {}
@ -123,9 +127,15 @@ importers:
openapi-typescript:
specifier: ^6.7.0
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:
specifier: ^5.7.0
version: 5.9.3
vitest:
specifier: ^3.0.0
version: 3.2.4(@types/node@22.19.7)
packages:
@ -787,6 +797,12 @@ packages:
'@types/babel__traverse@7.28.0':
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':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@ -816,6 +832,40 @@ packages:
peerDependencies:
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:
resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==}
engines: {node: '>=6'}
@ -836,9 +886,16 @@ packages:
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
engines: {node: '>=12'}
any-promise@1.3.0:
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
argparse@2.0.1:
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:
resolution: {integrity: sha512-e23vBV1ZLfjb9apvfPk4rHVu2ry6RIr2Wfs+O324okSidrX7pTAnEJPCh/O5BtRlr7QtZI7ktOP3vsqr7Z5XoA==}
hasBin: true
@ -855,9 +912,27 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
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:
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:
resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==}
@ -865,6 +940,10 @@ packages:
resolution: {integrity: sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==}
engines: {node: '>=20.18.1'}
chokidar@4.0.3:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'}
chownr@3.0.0:
resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
engines: {node: '>=18'}
@ -880,6 +959,17 @@ packages:
resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==}
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:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
@ -906,6 +996,10 @@ packages:
supports-color:
optional: true
deep-eql@5.0.2:
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
engines: {node: '>=6'}
dom-serializer@2.0.0:
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
@ -946,6 +1040,9 @@ packages:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'}
es-module-lexer@1.7.0:
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
esbuild@0.21.5:
resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
engines: {node: '>=12'}
@ -960,6 +1057,13 @@ packages:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
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:
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
engines: {node: '>=8.6.0'}
@ -967,10 +1071,22 @@ packages:
fastq@1.20.1:
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:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
fix-dts-default-cjs-exports@1.0.1:
resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==}
foreground-child@3.3.1:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
@ -1026,9 +1142,16 @@ packages:
resolution: {integrity: sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==}
engines: {node: 20 || >=22}
joycon@3.1.1:
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
engines: {node: '>=10'}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
js-tokens@9.0.1:
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
js-yaml@4.1.1:
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
hasBin: true
@ -1043,10 +1166,24 @@ packages:
engines: {node: '>=6'}
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:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
loupe@3.2.1:
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
lru-cache@11.2.4:
resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==}
engines: {node: 20 || >=22}
@ -1059,6 +1196,9 @@ packages:
peerDependencies:
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:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
@ -1079,9 +1219,15 @@ packages:
resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==}
engines: {node: '>= 18'}
mlly@1.8.0:
resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
mz@2.7.0:
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@ -1097,6 +1243,10 @@ packages:
nth-check@2.1.1:
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:
resolution: {integrity: sha512-c/hfooPx+RBIOPM09GSxABOZhYPblDoyaGhqBkD/59vtpN21jEuWKDlM0KYTvqJVlSYjKs0tBcIdeXKChlSPtw==}
hasBin: true
@ -1121,6 +1271,13 @@ packages:
resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==}
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:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@ -1128,6 +1285,35 @@ packages:
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
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:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14}
@ -1148,6 +1334,14 @@ packages:
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
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:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
@ -1190,6 +1384,9 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
signal-exit@4.1.0:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
@ -1198,6 +1395,16 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
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:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
@ -1214,6 +1421,14 @@ packages:
resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==}
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:
resolution: {integrity: sha512-VL+lNrEoIXww1coLPOmiEmK/0sGigko5COxI09KzHc2VJXJsQ37UaQ+8quuxjDeA7+KnLGTWRyOXSLLR2Wb4jw==}
engines: {node: '>=12'}
@ -1222,10 +1437,46 @@ packages:
resolution: {integrity: sha512-xqUeu2JAIJpXyvskvU3uvQW8PAmHrtXp2KDuMJwQqW8Sqq0CaZBAQ+dKS3RBXVhU4wC5NjAdKrmh84241gO9cA==}
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:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
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:
resolution: {integrity: sha512-HbmNsgs58CfdJq0gpteRTxPXG26zumezOs+SB9tgky6MpqiFgQwieCn2MW70+sxpHouZ/w9LW0V6L4ZQO4y1Ug==}
engines: {node: '>=18.0.0'}
@ -1234,6 +1485,25 @@ packages:
tslib@2.8.1:
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:
resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==}
engines: {node: '>=18.0.0'}
@ -1278,6 +1548,9 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
ufo@1.6.3:
resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==}
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
@ -1295,6 +1568,11 @@ packages:
peerDependencies:
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:
resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==}
engines: {node: ^18.0.0 || >=20.0.0}
@ -1326,6 +1604,34 @@ packages:
terser:
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:
resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
engines: {node: '>=18'}
@ -1340,6 +1646,11 @@ packages:
engines: {node: '>= 8'}
hasBin: true
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
hasBin: true
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
@ -1842,6 +2153,13 @@ snapshots:
dependencies:
'@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/json-schema@7.0.15': {}
@ -1875,6 +2193,50 @@ snapshots:
transitivePeerDependencies:
- 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-regex@5.0.1: {}
@ -1887,8 +2249,12 @@ snapshots:
ansi-styles@6.2.3: {}
any-promise@1.3.0: {}
argparse@2.0.1: {}
assertion-error@2.0.1: {}
baseline-browser-mapping@2.9.18: {}
boolbase@1.0.0: {}
@ -1905,8 +2271,25 @@ snapshots:
node-releases: 2.0.27
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: {}
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:
dependencies:
boolbase: 1.0.0
@ -1930,6 +2313,10 @@ snapshots:
undici: 7.19.1
whatwg-mimetype: 4.0.0
chokidar@4.0.3:
dependencies:
readdirp: 4.1.2
chownr@3.0.0: {}
color-convert@2.0.1:
@ -1940,6 +2327,12 @@ snapshots:
commander@13.1.0: {}
commander@4.1.1: {}
confbox@0.1.8: {}
consola@3.4.2: {}
convert-source-map@2.0.0: {}
cross-spawn@7.0.6:
@ -1964,6 +2357,8 @@ snapshots:
dependencies:
ms: 2.1.3
deep-eql@5.0.2: {}
dom-serializer@2.0.0:
dependencies:
domelementtype: 2.3.0
@ -2001,6 +2396,8 @@ snapshots:
entities@7.0.1: {}
es-module-lexer@1.7.0: {}
esbuild@0.21.5:
optionalDependencies:
'@esbuild/aix-ppc64': 0.21.5
@ -2058,6 +2455,12 @@ snapshots:
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:
dependencies:
'@nodelib/fs.stat': 2.0.5
@ -2070,10 +2473,20 @@ snapshots:
dependencies:
reusify: 1.1.0
fdir@6.5.0(picomatch@4.0.3):
optionalDependencies:
picomatch: 4.0.3
fill-range@7.1.1:
dependencies:
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:
dependencies:
cross-spawn: 7.0.6
@ -2128,8 +2541,12 @@ snapshots:
dependencies:
'@isaacs/cliui': 8.0.2
joycon@3.1.1: {}
js-tokens@4.0.0: {}
js-tokens@9.0.1: {}
js-yaml@4.1.1:
dependencies:
argparse: 2.0.1
@ -2138,10 +2555,18 @@ snapshots:
json5@2.2.3: {}
lilconfig@3.1.3: {}
lines-and-columns@1.2.4: {}
load-tsconfig@0.2.5: {}
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
loupe@3.2.1: {}
lru-cache@11.2.4: {}
lru-cache@5.1.1:
@ -2152,6 +2577,10 @@ snapshots:
dependencies:
react: 18.3.1
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
merge2@1.4.1: {}
micromatch@4.0.8:
@ -2169,8 +2598,21 @@ snapshots:
dependencies:
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: {}
mz@2.7.0:
dependencies:
any-promise: 1.3.0
object-assign: 4.1.1
thenify-all: 1.6.0
nanoid@3.3.11: {}
node-releases@2.0.27: {}
@ -2181,6 +2623,8 @@ snapshots:
dependencies:
boolbase: 1.0.0
object-assign@4.1.1: {}
openapi-typescript@6.7.6:
dependencies:
ansi-colors: 4.1.3
@ -2212,10 +2656,31 @@ snapshots:
lru-cache: 11.2.4
minipass: 7.1.2
pathe@2.0.3: {}
pathval@2.0.1: {}
picocolors@1.1.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:
dependencies:
nanoid: 3.3.11
@ -2236,6 +2701,10 @@ snapshots:
dependencies:
loose-envify: 1.4.0
readdirp@4.1.2: {}
resolve-from@5.0.0: {}
resolve-pkg-maps@1.0.0: {}
reusify@1.1.0: {}
@ -2293,10 +2762,18 @@ snapshots:
shebang-regex@3.0.0: {}
siginfo@2.0.0: {}
signal-exit@4.1.0: {}
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:
dependencies:
emoji-regex: 8.0.0
@ -2317,6 +2794,20 @@ snapshots:
dependencies:
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: {}
tar@7.5.6:
@ -2327,10 +2818,37 @@ snapshots:
minizlib: 3.1.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:
dependencies:
is-number: 7.0.0
tree-kill@1.2.2: {}
ts-interface-checker@0.1.13: {}
ts-json-schema-generator@2.4.0:
dependencies:
'@types/json-schema': 7.0.15
@ -2344,6 +2862,34 @@ snapshots:
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:
dependencies:
esbuild: 0.27.2
@ -2380,6 +2926,8 @@ snapshots:
typescript@5.9.3: {}
ufo@1.6.3: {}
undici-types@6.21.0: {}
undici@5.29.0:
@ -2394,6 +2942,24 @@ snapshots:
escalade: 3.2.0
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):
dependencies:
esbuild: 0.21.5
@ -2403,6 +2969,44 @@ snapshots:
'@types/node': 22.19.7
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:
dependencies:
iconv-lite: 0.6.3
@ -2413,6 +3017,11 @@ snapshots:
dependencies:
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:
dependencies:
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
```typescript

View file

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

View file

@ -389,6 +389,12 @@ function runChecks(rootDir: string) {
console.log("==> Running TypeScript checks");
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");
run("pnpm", ["dlx", "mint", "openapi-check", "docs/openapi.json"], { cwd: rootDir });
}

View file

@ -11,6 +11,12 @@
"sandbox-agent": "bin/sandbox-agent",
"sandbox-daemon": "bin/sandbox-agent"
},
"scripts": {
"test": "vitest run"
},
"devDependencies": {
"vitest": "^3.0.0"
},
"optionalDependencies": {
"@sandbox-agent/cli-darwin-arm64": "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:types": "openapi-typescript ../../docs/openapi.json -o src/generated/openapi.ts",
"generate": "pnpm run generate:openapi && pnpm run generate:types",
"build": "pnpm run generate && tsc -p tsconfig.json",
"typecheck": "tsc --noEmit"
"build": "pnpm run generate && tsup",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"@types/node": "^22.0.0",
"openapi-typescript": "^6.7.0",
"typescript": "^5.7.0"
"tsup": "^8.0.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
},
"optionalDependencies": {
"@sandbox-agent/cli": "0.1.0"

View file

@ -1,31 +1,23 @@
import type { components } from "./generated/openapi.js";
import type {
SandboxDaemonSpawnHandle,
SandboxDaemonSpawnOptions,
} from "./spawn.js";
export type AgentInstallRequest = components["schemas"]["AgentInstallRequest"];
export type AgentModeInfo = components["schemas"]["AgentModeInfo"];
export type AgentModesResponse = components["schemas"]["AgentModesResponse"];
export type AgentInfo = components["schemas"]["AgentInfo"];
export type AgentListResponse = components["schemas"]["AgentListResponse"];
export type CreateSessionRequest = components["schemas"]["CreateSessionRequest"];
export type CreateSessionResponse = components["schemas"]["CreateSessionResponse"];
export type HealthResponse = components["schemas"]["HealthResponse"];
export type MessageRequest = components["schemas"]["MessageRequest"];
export type EventsQuery = components["schemas"]["EventsQuery"];
export type EventsResponse = components["schemas"]["EventsResponse"];
export type PermissionRequest = components["schemas"]["PermissionRequest"];
export type QuestionReplyRequest = components["schemas"]["QuestionReplyRequest"];
export type QuestionRequest = components["schemas"]["QuestionRequest"];
export type PermissionReplyRequest = components["schemas"]["PermissionReplyRequest"];
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"];
} from "./spawn.ts";
import type {
AgentInstallRequest,
AgentListResponse,
AgentModesResponse,
CreateSessionRequest,
CreateSessionResponse,
EventsQuery,
EventsResponse,
HealthResponse,
MessageRequest,
PermissionReplyRequest,
ProblemDetails,
QuestionReplyRequest,
SessionListResponse,
UniversalEvent,
} from "./types.ts";
const API_PREFIX = "/v1";
@ -179,13 +171,14 @@ export class SandboxDaemonClient {
if (done) {
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");
while (index !== -1) {
const chunk = buffer.slice(0, index);
buffer = buffer.slice(index + 2);
const dataLines = chunk
.split(/\r?\n/)
.split("\n")
.filter((line) => line.startsWith("data:"));
if (dataLines.length > 0) {
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 {
"/v1/agents": {
get: operations["list_agents"];
@ -46,12 +41,21 @@ export interface paths {
"/v1/sessions/{session_id}/questions/{question_id}/reply": {
post: operations["reply_question"];
};
"/v1/sessions/{session_id}/terminate": {
post: operations["terminate_session"];
};
}
export type webhooks = Record<string, never>;
export interface components {
schemas: {
AgentCapabilities: {
permissions: boolean;
planMode: boolean;
questions: boolean;
toolCalls: boolean;
};
AgentError: {
agent?: string | null;
details?: unknown;
@ -60,6 +64,7 @@ export interface components {
type: components["schemas"]["ErrorType"];
};
AgentInfo: {
capabilities: components["schemas"]["AgentCapabilities"];
id: string;
installed: boolean;
path?: string | null;
@ -79,25 +84,52 @@ export interface components {
AgentModesResponse: {
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;
/** @enum {string} */
type: "path";
} | {
type: "file_ref";
}) | {
text: string;
/** @enum {string} */
type: "url";
url: string;
type: "reasoning";
visibility: components["schemas"]["ReasoningVisibility"];
} | ({
data: string;
encoding?: string | null;
mime?: string | null;
path: 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: {
agent: string;
agentMode?: string | null;
@ -107,13 +139,21 @@ export interface components {
variant?: string | null;
};
CreateSessionResponse: {
agentSessionId?: string | null;
error?: components["schemas"]["AgentError"] | null;
healthy: boolean;
nativeSessionId?: string | null;
};
ErrorData: {
code?: string | null;
details?: unknown;
message: 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";
/** @enum {string} */
EventSource: "agent" | "daemon";
EventsQuery: {
includeRaw?: boolean | null;
/** Format: int64 */
limit?: number | null;
/** Format: int64 */
@ -123,32 +163,41 @@ export interface components {
events: components["schemas"]["UniversalEvent"][];
hasMore: boolean;
};
/** @enum {string} */
FileAction: "read" | "write" | "patch";
HealthResponse: {
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: {
message: string;
};
PermissionEventData: {
action: string;
metadata?: unknown;
permission_id: string;
status: components["schemas"]["PermissionStatus"];
};
/** @enum {string} */
PermissionReply: "once" | "always" | "reject";
PermissionReplyRequest: {
reply: components["schemas"]["PermissionReply"];
};
PermissionRequest: {
always: string[];
id: string;
metadata?: {
[key: string]: unknown;
};
patterns: string[];
permission: string;
sessionId: string;
tool?: components["schemas"]["PermissionToolRef"] | null;
};
PermissionToolRef: {
callId: string;
messageId: string;
};
/** @enum {string} */
PermissionStatus: "requested" | "approved" | "denied";
ProblemDetails: {
detail?: string | null;
instance?: string | null;
@ -158,38 +207,34 @@ export interface components {
type: string;
[key: string]: unknown;
};
QuestionInfo: {
custom?: boolean | null;
header?: string | null;
multiSelect?: boolean | null;
options: components["schemas"]["QuestionOption"][];
question: string;
};
QuestionOption: {
description?: string | null;
label: string;
QuestionEventData: {
options: string[];
prompt: string;
question_id: string;
response?: string | null;
status: components["schemas"]["QuestionStatus"];
};
QuestionReplyRequest: {
answers: string[][];
};
QuestionRequest: {
id: string;
questions: components["schemas"]["QuestionInfo"][];
sessionId: string;
tool?: components["schemas"]["QuestionToolRef"] | null;
};
QuestionToolRef: {
callId: string;
messageId: string;
/** @enum {string} */
QuestionStatus: "requested" | "answered" | "rejected";
/** @enum {string} */
ReasoningVisibility: "public" | "private";
/** @enum {string} */
SessionEndReason: "completed" | "error" | "terminated";
SessionEndedData: {
reason: components["schemas"]["SessionEndReason"];
terminated_by: components["schemas"]["TerminatedBy"];
};
SessionInfo: {
agent: string;
agentMode: string;
agentSessionId?: string | null;
ended: boolean;
/** Format: int64 */
eventCount: number;
model?: string | null;
nativeSessionId?: string | null;
permissionMode: string;
sessionId: string;
variant?: string | null;
@ -197,98 +242,35 @@ export interface components {
SessionListResponse: {
sessions: components["schemas"]["SessionInfo"][];
};
Started: {
details?: unknown;
message?: string | null;
SessionStartedData: {
metadata?: unknown;
};
/** @enum {string} */
TerminatedBy: "agent" | "daemon";
UniversalEvent: {
agent: string;
agentSessionId?: string | null;
data: components["schemas"]["UniversalEventData"];
event_id: string;
native_session_id?: string | null;
raw?: unknown;
/** Format: int64 */
id: number;
sessionId: string;
timestamp: string;
sequence: number;
session_id: string;
source: components["schemas"]["EventSource"];
synthetic: boolean;
time: string;
type: components["schemas"]["UniversalEventType"];
};
UniversalEventData: {
message: components["schemas"]["UniversalMessage"];
} | {
started: components["schemas"]["Started"];
} | {
error: components["schemas"]["CrashInfo"];
} | {
questionAsked: components["schemas"]["QuestionRequest"];
} | {
permissionAsked: components["schemas"]["PermissionRequest"];
} | {
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";
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"];
/** @enum {string} */
UniversalEventType: "session.started" | "session.ended" | "item.started" | "item.delta" | "item.completed" | "error" | "permission.requested" | "permission.resolved" | "question.requested" | "question.resolved" | "agent.unparsed";
UniversalItem: {
content: components["schemas"]["ContentPart"][];
item_id: string;
kind: components["schemas"]["ItemKind"];
native_item_id?: string | null;
parent_id?: string | null;
role?: components["schemas"]["ItemRole"] | null;
status: components["schemas"]["ItemStatus"];
};
};
responses: never;
@ -418,10 +400,12 @@ export interface operations {
get_events: {
parameters: {
query?: {
/** @description Last seen event id (exclusive) */
/** @description Last seen event sequence (exclusive) */
offset?: number | null;
/** @description Max events to return */
limit?: number | null;
/** @description Include raw provider payloads */
include_raw?: boolean | null;
};
path: {
/** @description Session id */
@ -444,8 +428,10 @@ export interface operations {
get_events_sse: {
parameters: {
query?: {
/** @description Last seen event id (exclusive) */
/** @description Last seen event sequence (exclusive) */
offset?: number | null;
/** @description Include raw provider payloads */
include_raw?: boolean | null;
};
path: {
/** @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,
connectSandboxDaemonClient,
createSandboxDaemonClient,
} from "./client.js";
} from "./client.ts";
export type {
SandboxDaemonClientOptions,
SandboxDaemonConnectOptions,
} from "./client.ts";
export type {
AgentCapabilities,
AgentInfo,
AgentInstallRequest,
AgentListResponse,
AgentModeInfo,
AgentModesResponse,
AgentUnparsedData,
ContentPart,
CreateSessionRequest,
CreateSessionResponse,
ErrorData,
EventSource,
EventsQuery,
EventsResponse,
FileAction,
HealthResponse,
ItemDeltaData,
ItemEventData,
ItemKind,
ItemRole,
ItemStatus,
MessageRequest,
PermissionRequest,
PermissionEventData,
PermissionReply,
PermissionReplyRequest,
PermissionStatus,
ProblemDetails,
QuestionRequest,
QuestionEventData,
QuestionReplyRequest,
QuestionStatus,
ReasoningVisibility,
SessionEndReason,
SessionEndedData,
SessionInfo,
SessionListResponse,
SessionStartedData,
TerminatedBy,
UniversalEvent,
UniversalMessage,
UniversalMessagePart,
SandboxDaemonClientOptions,
SandboxDaemonConnectOptions,
} from "./client.js";
export type { components, paths } from "./generated/openapi.js";
export type { SandboxDaemonSpawnOptions, SandboxDaemonSpawnLogMode } from "./spawn.js";
UniversalEventData,
UniversalEventType,
UniversalItem,
} from "./types.ts";
export type { components, paths } from "./generated/openapi.ts";
export type { SandboxDaemonSpawnOptions, SandboxDaemonSpawnLogMode } from "./spawn.ts";

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": {
"target": "ES2022",
"lib": ["ES2022", "DOM"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"noEmit": true,
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src",
"resolveJsonModule": true,
"declaration": true
"resolveJsonModule": true
},
"include": ["src/**/*"],
"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:
```
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:
```
cargo test -p sandbox-agent-core --test http_sse_snapshots
cargo test -p sandbox-agent --test http_sse_snapshots
```
## Universal Schema

View file

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

View file

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

View file

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

View file

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

View file

@ -10,13 +10,13 @@ use sandbox_agent_agent_management::credentials::{
extract_all_credentials, AuthType, CredentialExtractionOptions, ExtractedCredentials,
ProviderCredentials,
};
use sandbox_agent_core::router::{
use sandbox_agent::router::{
AgentInstallRequest, AppState, AuthConfig, CreateSessionRequest, MessageRequest,
PermissionReply, PermissionReplyRequest, QuestionReplyRequest,
};
use sandbox_agent_core::router::{AgentListResponse, AgentModesResponse, CreateSessionResponse, EventsResponse};
use sandbox_agent_core::router::build_router;
use sandbox_agent_core::ui;
use sandbox_agent::router::{AgentListResponse, AgentModesResponse, CreateSessionResponse, EventsResponse};
use sandbox_agent::router::build_router;
use sandbox_agent::ui;
use serde::Serialize;
use serde_json::Value;
use thiserror::Error;
@ -118,6 +118,9 @@ enum SessionsCommand {
#[command(name = "send-message")]
/// Send a message to an existing session.
SendMessage(SessionMessageArgs),
#[command(name = "terminate")]
/// Terminate a session.
Terminate(SessionTerminateArgs),
#[command(name = "get-messages")]
/// Alias for events; returns session events.
GetMessages(SessionEventsArgs),
@ -195,6 +198,8 @@ struct SessionEventsArgs {
offset: Option<u64>,
#[arg(long, short = 'l')]
limit: Option<u64>,
#[arg(long)]
include_raw: bool,
#[command(flatten)]
client: ClientArgs,
}
@ -204,6 +209,15 @@ struct SessionEventsSseArgs {
session_id: String,
#[arg(long, short = 'o')]
offset: Option<u64>,
#[arg(long)]
include_raw: bool,
#[command(flatten)]
client: ClientArgs,
}
#[derive(Args, Debug)]
struct SessionTerminateArgs {
session_id: String,
#[command(flatten)]
client: ClientArgs,
}
@ -419,16 +433,41 @@ fn run_sessions(command: &SessionsCommand, cli: &Cli) -> Result<(), CliError> {
let response = ctx.post(&path, &body)?;
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) => {
let ctx = ClientContext::new(cli, &args.client)?;
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)
}
SessionsCommand::EventsSse(args) => {
let ctx = ClientContext::new(cli, &args.client)?;
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)
}
SessionsCommand::ReplyQuestion(args) => {
@ -786,7 +825,7 @@ impl ClientContext {
fn get_with_query(
&self,
path: &str,
query: &[(&str, Option<u64>)],
query: &[(&str, Option<String>)],
) -> Result<reqwest::blocking::Response, CliError> {
let mut request = self.request(Method::GET, path);
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::testing::{test_agents_from_env, TestAgentConfig};
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_http::cors::CorsLayer;
@ -226,7 +226,11 @@ async fn poll_events_until(
.cloned()
.unwrap_or_default();
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;
}
events.extend(new_events);
@ -307,26 +311,48 @@ fn should_stop(events: &[Value]) -> bool {
fn is_assistant_message(event: &Value) -> bool {
event
.get("data")
.and_then(|data| data.get("message"))
.and_then(|message| message.get("role"))
.get("type")
.and_then(Value::as_str)
.map(|role| role == "assistant")
.map(|event_type| event_type == "item.completed")
.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 {
matches!(
event.get("type").and_then(Value::as_str),
Some("error") | Some("agent.unparsed")
)
}
fn is_unparsed_event(event: &Value) -> bool {
event
.get("data")
.and_then(|data| data.get("error"))
.is_some()
.get("type")
.and_then(Value::as_str)
.map(|value| value == "agent.unparsed")
.unwrap_or(false)
}
fn is_permission_event(event: &Value) -> bool {
event
.get("data")
.and_then(|data| data.get("permissionAsked"))
.is_some()
.get("type")
.and_then(Value::as_str)
.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> {
@ -339,7 +365,21 @@ fn truncate_permission_events(events: &[Value]) -> Vec<Value> {
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 {
assert!(
!events.iter().any(is_unparsed_event),
"agent.unparsed event encountered"
);
let normalized = events
.iter()
.enumerate()
@ -361,77 +401,100 @@ fn truncate_after_first_stop(events: &[Value]) -> Vec<Value> {
fn normalize_event(event: &Value, seq: usize) -> Value {
let mut map = Map::new();
map.insert("seq".to_string(), Value::Number(seq.into()));
if let Some(agent) = event.get("agent").and_then(Value::as_str) {
map.insert("agent".to_string(), Value::String(agent.to_string()));
if let Some(event_type) = event.get("type").and_then(Value::as_str) {
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);
if let Some(message) = data.get("message") {
map.insert("kind".to_string(), Value::String("message".to_string()));
map.insert("message".to_string(), normalize_message(message));
} else if let Some(started) = data.get("started") {
map.insert("kind".to_string(), Value::String("started".to_string()));
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()));
map.insert("error".to_string(), normalize_error(error));
} else if let Some(question) = data.get("questionAsked") {
map.insert("kind".to_string(), Value::String("question".to_string()));
map.insert("question".to_string(), normalize_question(question));
} else if let Some(permission) = data.get("permissionAsked") {
map.insert("kind".to_string(), Value::String("permission".to_string()));
map.insert("permission".to_string(), normalize_permission(permission));
} else {
map.insert("kind".to_string(), Value::String("unknown".to_string()));
match event.get("type").and_then(Value::as_str).unwrap_or("") {
"session.started" => {
map.insert("session".to_string(), Value::String("started".to_string()));
if data.get("metadata").is_some() {
map.insert("metadata".to_string(), Value::Bool(true));
}
}
"session.ended" => {
map.insert("session".to_string(), Value::String("ended".to_string()));
map.insert("ended".to_string(), normalize_session_end(data));
}
"item.started" | "item.completed" => {
if let Some(item) = data.get("item") {
map.insert("item".to_string(), normalize_item(item));
}
}
"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)
}
fn normalize_message(message: &Value) -> Value {
fn normalize_item(item: &Value) -> Value {
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()));
}
if let Some(parts) = message.get("parts").and_then(Value::as_array) {
let parts = parts.iter().map(normalize_part).collect::<Vec<_>>();
map.insert("parts".to_string(), Value::Array(parts));
} else if message.get("raw").is_some() {
map.insert("unparsed".to_string(), Value::Bool(true));
if let Some(status) = item.get("status").and_then(Value::as_str) {
map.insert("status".to_string(), Value::String(status.to_string()));
}
if let Some(content) = item.get("content").and_then(Value::as_array) {
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)
}
fn normalize_part(part: &Value) -> Value {
fn normalize_session_end(data: &Value) -> Value {
let mut map = Map::new();
if let Some(part_type) = part.get("type").and_then(Value::as_str) {
map.insert("type".to_string(), Value::String(part_type.to_string()));
if let Some(reason) = data.get("reason").and_then(Value::as_str) {
map.insert("reason".to_string(), Value::String(reason.to_string()));
}
if let Some(name) = part.get("name").and_then(Value::as_str) {
map.insert("name".to_string(), Value::String(name.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()));
if let Some(terminated_by) = data.get("terminated_by").and_then(Value::as_str) {
map.insert("terminated_by".to_string(), Value::String(terminated_by.to_string()));
}
Value::Object(map)
}
fn normalize_error(error: &Value) -> Value {
let mut map = Map::new();
if let Some(kind) = error.get("kind").and_then(Value::as_str) {
map.insert("kind".to_string(), Value::String(kind.to_string()));
if let Some(code) = error.get("code").and_then(Value::as_str) {
map.insert("code".to_string(), Value::String(code.to_string()));
}
if let Some(message) = error.get("message").and_then(Value::as_str) {
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 {
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()));
}
if let Some(questions) = question.get("questions").and_then(Value::as_array) {
map.insert("count".to_string(), Value::Number(questions.len().into()));
if let Some(options) = question.get("options").and_then(Value::as_array) {
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)
}
fn normalize_permission(permission: &Value) -> Value {
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()));
}
if let Some(value) = permission.get("permission").and_then(Value::as_str) {
map.insert("permission".to_string(), Value::String(value.to_string()));
if let Some(value) = permission.get("action").and_then(Value::as_str) {
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)
}
@ -538,8 +607,8 @@ fn normalize_create_session(value: &Value) -> Value {
if let Some(healthy) = value.get("healthy").and_then(Value::as_bool) {
map.insert("healthy".to_string(), Value::Bool(healthy));
}
if value.get("agentSessionId").is_some() {
map.insert("agentSessionId".to_string(), Value::String("<redacted>".to_string()));
if value.get("nativeSessionId").is_some() {
map.insert("nativeSessionId".to_string(), Value::String("<redacted>".to_string()));
}
if let Some(error) = value.get("error") {
map.insert("error".to_string(), error.clone());
@ -611,7 +680,7 @@ where
if !new_events.is_empty() {
if let Some(last) = new_events
.last()
.and_then(|event| event.get("id"))
.and_then(|event| event.get("sequence"))
.and_then(Value::as_u64)
{
offset = last;
@ -631,9 +700,11 @@ fn find_permission_id(events: &[Value]) -> Option<String> {
.iter()
.find_map(|event| {
event
.get("data")
.and_then(|data| data.get("permissionAsked"))
.and_then(|permission| permission.get("id"))
.get("type")
.and_then(Value::as_str)
.filter(|value| *value == "permission.requested")
.and_then(|_| event.get("data"))
.and_then(|data| data.get("permission_id"))
.and_then(Value::as_str)
.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>>)> {
let question = events.iter().find_map(|event| {
event
.get("data")
.and_then(|data| data.get("questionAsked"))
.cloned()
let event_type = event.get("type").and_then(Value::as_str)?;
if event_type != "question.requested" {
return None;
}
event.get("data").cloned()
})?;
let id = question.get("id").and_then(Value::as_str)?.to_string();
let questions = question
.get("questions")
let id = question.get("question_id").and_then(Value::as_str)?.to_string();
let options = question
.get("options")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let mut answers = Vec::new();
for question in questions {
let option = question
.get("options")
.and_then(Value::as_array)
.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());
}
if let Some(option) = options.first().and_then(Value::as_str) {
answers.push(vec![option.to_string()]);
} else {
answers.push(Vec::new());
}
Some((id, answers))
}
@ -1039,6 +1102,7 @@ async fn approval_flow_snapshots() {
|events| find_question_id_and_answers(events).is_some() || should_stop(events),
)
.await;
let question_events = truncate_question_events(&question_events);
insta::with_settings!({
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),
)
.await;
let reject_events = truncate_question_events(&reject_events);
insta::with_settings!({
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 http_body_util::BodyExt;
use sandbox_agent_agent_management::agents::AgentManager;
use sandbox_agent_core::router::{build_router, AppState, AuthConfig};
use sandbox_agent_core::ui;
use sandbox_agent::router::{build_router, AppState, AuthConfig};
use sandbox_agent::ui;
use tempfile::TempDir;
use tower::util::ServiceExt;

View file

@ -1,22 +1,45 @@
---
source: server/packages/sandbox-agent/tests/http_sse_snapshots.rs
assertion_line: 1025
expression: normalize_events(&permission_events)
---
- agent: claude
kind: started
- metadata: true
seq: 1
started:
message: session.created
- agent: claude
kind: started
session: started
source: daemon
synthetic: true
type: session.started
- metadata: true
seq: 2
started:
message: system.init
- agent: claude
kind: message
message:
parts:
- text: "<redacted>"
type: text
session: started
source: agent
synthetic: false
type: session.started
- item:
content_types:
- text
kind: message
role: assistant
status: in_progress
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
assertion_line: 1151
expression: normalize_events(&reject_events)
---
- agent: claude
kind: started
- metadata: true
seq: 1
started:
message: session.created
- agent: claude
kind: started
session: started
source: daemon
synthetic: true
type: session.started
- metadata: true
seq: 2
started:
message: system.init
- agent: claude
kind: message
message:
parts:
- text: "<redacted>"
type: text
session: started
source: agent
synthetic: false
type: session.started
- item:
content_types:
- text
kind: message
role: assistant
status: in_progress
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
assertion_line: 1045
assertion_line: 1109
expression: normalize_events(&question_events)
---
- agent: claude
kind: started
- metadata: true
seq: 1
started:
message: session.created
- agent: claude
kind: started
session: started
source: daemon
synthetic: true
type: session.started
- metadata: true
seq: 2
started:
message: system.init
- agent: claude
kind: message
message:
parts:
- text: "<redacted>"
type: text
session: started
source: agent
synthetic: false
type: session.started
- item:
content_types:
- text
kind: message
role: assistant
status: in_progress
seq: 3
- agent: claude
kind: message
message:
parts:
- text: "<redacted>"
type: text
role: assistant
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,42 +1,87 @@
---
source: server/packages/sandbox-agent/tests/http_sse_snapshots.rs
assertion_line: 1259
expression: snapshot
---
session_a:
- agent: claude
kind: started
- metadata: true
seq: 1
started:
message: session.created
- agent: claude
kind: started
session: started
source: daemon
synthetic: true
type: session.started
- metadata: true
seq: 2
started:
message: system.init
- agent: claude
kind: message
message:
parts:
- text: "<redacted>"
type: text
session: started
source: agent
synthetic: false
type: session.started
- item:
content_types:
- text
kind: message
role: assistant
status: in_progress
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:
- agent: claude
kind: started
- metadata: true
seq: 1
started:
message: session.created
- agent: claude
kind: started
session: started
source: daemon
synthetic: true
type: session.started
- metadata: true
seq: 2
started:
message: system.init
- agent: claude
kind: message
message:
parts:
- text: "<redacted>"
type: text
session: started
source: agent
synthetic: false
type: session.started
- item:
content_types:
- text
kind: message
role: assistant
status: in_progress
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
assertion_line: 742
expression: normalized
---
- agent: claude
kind: started
- metadata: true
seq: 1
started:
message: session.created
- agent: claude
kind: started
session: started
source: daemon
synthetic: true
type: session.started
- metadata: true
seq: 2
started:
message: system.init
- agent: claude
kind: message
message:
parts:
- text: "<redacted>"
type: text
session: started
source: agent
synthetic: false
type: session.started
- item:
content_types:
- text
kind: message
role: assistant
status: in_progress
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
assertion_line: 775
expression: normalized
---
- agent: claude
kind: started
- metadata: true
seq: 1
started:
message: session.created
- agent: claude
kind: started
session: started
source: daemon
synthetic: true
type: session.started
- metadata: true
seq: 2
started:
message: system.init
- agent: claude
kind: message
message:
parts:
- text: "<redacted>"
type: text
session: started
source: agent
synthetic: false
type: session.started
- item:
content_types:
- text
kind: message
role: assistant
status: in_progress
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::{
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};
use std::sync::atomic::{AtomicU64, Ordering};
pub fn event_to_universal(event: &schema::StreamJsonMessage) -> EventConversion {
let schema::StreamJsonMessage {
content,
error,
id,
tool_call,
type_,
} = event;
match type_ {
use serde_json::Value;
use crate::amp as schema;
use crate::{
ContentPart,
ErrorData,
EventConversion,
ItemDeltaData,
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 => {
let text = content.clone().unwrap_or_default();
let mut message = message_from_text("assistant", text);
if let UniversalMessage::Parsed(parsed) = &mut message {
parsed.id = id.clone();
}
EventConversion::new(UniversalEventData::Message { message })
let text = event.content.clone().unwrap_or_default();
let item = UniversalItem {
item_id: next_temp_id("tmp_amp_message"),
native_item_id: event.id.clone(),
parent_id: None,
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 => {
let tool_call = tool_call.as_ref();
let part = if let Some(tool_call) = tool_call {
let schema::ToolCall { arguments, id, name } = tool_call;
let input = match arguments {
schema::ToolCallArguments::Variant0(text) => Value::String(text.clone()),
schema::ToolCallArguments::Variant1(map) => Value::Object(map.clone()),
let tool_call = event.tool_call.clone();
let (name, arguments, call_id) = if let Some(call) = tool_call {
let arguments = match call.arguments {
schema::ToolCallArguments::Variant0(text) => text,
schema::ToolCallArguments::Variant1(map) => {
serde_json::to_string(&Value::Object(map)).unwrap_or_else(|_| "{}".to_string())
}
};
UniversalMessagePart::ToolCall {
id: Some(id.clone()),
name: name.clone(),
input,
}
(call.name, arguments, call.id)
} 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]);
if let UniversalMessage::Parsed(parsed) = &mut message {
parsed.id = id.clone();
}
EventConversion::new(UniversalEventData::Message { message })
let item = UniversalItem {
item_id: next_temp_id("tmp_amp_tool_call"),
native_item_id: Some(call_id.clone()),
parent_id: None,
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 => {
let output = content
let output = event.content.clone().unwrap_or_default();
let call_id = event
.id
.clone()
.map(Value::String)
.unwrap_or(Value::Null);
let part = UniversalMessagePart::ToolResult {
id: id.clone(),
name: None,
output,
is_error: None,
.unwrap_or_else(|| next_temp_id("tmp_amp_tool"));
let item = UniversalItem {
item_id: next_temp_id("tmp_amp_tool_result"),
native_item_id: Some(call_id.clone()),
parent_id: None,
kind: ItemKind::ToolResult,
role: Some(ItemRole::Tool),
content: vec![ContentPart::ToolResult {
call_id,
output,
}],
status: ItemStatus::Completed,
};
let message = message_from_parts("tool", vec![part]);
EventConversion::new(UniversalEventData::Message { message })
events.extend(item_events(item));
}
schema::StreamJsonMessageType::Error => {
let message = error.clone().unwrap_or_else(|| "amp error".to_string());
let crash = CrashInfo {
message,
kind: Some("amp".to_string()),
details: serde_json::to_value(event).ok(),
};
EventConversion::new(UniversalEventData::Error { error: crash })
let message = event.error.clone().unwrap_or_else(|| "amp error".to_string());
events.push(EventConversion::new(
UniversalEventType::Error,
UniversalEventData::Error(ErrorData {
message,
code: Some("amp".to_string()),
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> {
match event {
UniversalEventData::Message { message } => {
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::StreamJsonMessage {
content: Some(content),
error: None,
id: parsed.id.clone(),
tool_call: None,
type_: schema::StreamJsonMessageType::Message,
})
}
_ => Err(ConversionError::Unsupported("amp event")),
}
fn item_events(item: UniversalItem) -> Vec<EventConversion> {
vec![EventConversion::new(
UniversalEventType::ItemCompleted,
UniversalEventData::Item(ItemEventData { item }),
)]
}
pub fn message_to_universal(message: &schema::Message) -> UniversalMessage {
let schema::Message {
role,
content,
tool_calls,
} = message;
let mut parts = vec![UniversalMessagePart::Text {
text: content.clone(),
}];
for call in tool_calls {
let schema::ToolCall { arguments, id, name } = call;
let input = match arguments {
schema::ToolCallArguments::Variant0(text) => Value::String(text.clone()),
schema::ToolCallArguments::Variant1(map) => Value::Object(map.clone()),
};
parts.push(UniversalMessagePart::ToolCall {
id: Some(id.clone()),
name: name.clone(),
input,
});
fn message_events(item: UniversalItem, delta: String) -> Vec<EventConversion> {
let mut events = Vec::new();
let mut started = item.clone();
started.status = ItemStatus::InProgress;
events.push(
EventConversion::new(
UniversalEventType::ItemStarted,
UniversalEventData::Item(ItemEventData { item: started }),
)
.synthetic(),
);
if !delta.is_empty() {
events.push(
EventConversion::new(
UniversalEventType::ItemDelta,
UniversalEventData::ItemDelta(ItemDeltaData {
item_id: item.item_id.clone(),
native_item_id: item.native_item_id.clone(),
delta,
}),
)
.synthetic(),
);
}
UniversalMessage::Parsed(UniversalMessageParsed {
role: role.to_string(),
id: None,
metadata: Map::new(),
parts,
})
}
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![],
})
events.push(EventConversion::new(
UniversalEventType::ItemCompleted,
UniversalEventData::Item(ItemEventData { item }),
));
events
}

View file

@ -1,94 +1,76 @@
use std::sync::atomic::{AtomicU64, Ordering};
use serde_json::Value;
use crate::{
message_from_parts,
message_from_text,
text_only_from_parts,
ConversionError,
ContentPart,
EventConversion,
QuestionInfo,
QuestionOption,
QuestionRequest,
Started,
ItemEventData,
ItemKind,
ItemRole,
ItemStatus,
QuestionEventData,
QuestionStatus,
SessionStartedData,
UniversalEventData,
UniversalMessage,
UniversalMessageParsed,
UniversalMessagePart,
UniversalEventType,
UniversalItem,
};
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(
event: &Value,
session_id: String,
) -> EventConversion {
) -> Result<Vec<EventConversion>, String> {
let event_type = event.get("type").and_then(Value::as_str).unwrap_or("");
match event_type {
"system" => system_event_to_universal(event),
let mut conversions = match event_type {
"system" => vec![system_event_to_universal(event)],
"assistant" => assistant_event_to_universal(event),
"tool_use" => tool_use_event_to_universal(event, session_id),
"tool_result" => tool_result_event_to_universal(event),
"result" => result_event_to_universal(event),
_ => EventConversion::new(UniversalEventData::Unknown { raw: event.clone() }),
}
}
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"))
}
_ => return Err(format!("unsupported Claude event type: {event_type}")),
};
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
.get("message")
.and_then(|msg| msg.get("content"))
.and_then(Value::as_array)
.cloned()
.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 {
let block_type = block.get("type").and_then(Value::as_str).unwrap_or("");
match block_type {
"text" => {
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(),
});
}
@ -96,39 +78,50 @@ fn assistant_event_to_universal(event: &Value) -> EventConversion {
"tool_use" => {
if let Some(name) = block.get("name").and_then(Value::as_str) {
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());
parts.push(UniversalMessagePart::ToolCall {
id,
name: name.to_string(),
input,
});
let call_id = block
.get("id")
.and_then(Value::as_str)
.map(|s| s.to_string())
.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 subtype = event
.get("subtype")
.and_then(Value::as_str)
.unwrap_or("system");
let started = Started {
message: Some(format!("system.{subtype}")),
details: Some(event.clone()),
let message_item = UniversalItem {
item_id: message_id,
native_item_id: None,
parent_id: None,
kind: ItemKind::Message,
role: Some(ItemRole::Assistant),
content: message_parts.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 name = tool_use
.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
.and_then(|tool| tool.get("id"))
.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" {
if let Some(question) =
question_from_claude_input(&input, id.clone(), session_id.clone())
{
return EventConversion::new(UniversalEventData::QuestionAsked {
question_asked: question,
});
let is_question_tool = matches!(
name,
"AskUserQuestion" | "ask_user_question" | "askUserQuestion" | "ask-user-question"
);
let has_question_payload = input.get("questions").is_some();
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(
"assistant",
vec![UniversalMessagePart::ToolCall {
id,
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(id.clone()),
parent_id: None,
kind: ItemKind::ToolCall,
role: Some(ItemRole::Assistant),
content: vec![ContentPart::ToolCall {
name: name.to_string(),
input,
arguments,
call_id: id,
}],
);
EventConversion::new(UniversalEventData::Message { message })
status: ItemStatus::Completed,
};
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 output = tool_result
.and_then(|tool| tool.get("content"))
.cloned()
.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
.and_then(|tool| tool.get("id"))
.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(
"tool",
vec![UniversalMessagePart::ToolResult {
id,
name: None,
output,
is_error,
let tool_item = UniversalItem {
item_id: next_temp_id("tmp_claude_tool_result"),
native_item_id: Some(id.clone()),
parent_id: None,
kind: ItemKind::ToolResult,
role: Some(ItemRole::Tool),
content: vec![ContentPart::ToolResult {
call_id: id,
output: output_text,
}],
);
EventConversion::new(UniversalEventData::Message { message })
status: ItemStatus::Completed,
};
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
.get("result")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let session_id = event
.get("session_id")
.and_then(Value::as_str)
.map(|s| s.to_string());
let message = message_from_text("assistant", result_text);
EventConversion::new(UniversalEventData::Message { message }).with_session(session_id)
let message_item = UniversalItem {
item_id: next_temp_id("tmp_claude_result"),
native_item_id: None,
parent_id: None,
kind: ItemKind::Message,
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(
input: &Value,
tool_id: Option<String>,
session_id: String,
) -> Option<QuestionRequest> {
let questions = input.get("questions").and_then(Value::as_array)?;
let mut parsed_questions = Vec::new();
for question in questions {
let question_text = question.get("question")?.as_str()?.to_string();
let header = question
.get("header")
.and_then(Value::as_str)
.map(|s| s.to_string());
let multi_select = question
.get("multiSelect")
.and_then(Value::as_bool);
let options = question
fn item_events(item: UniversalItem, 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(),
);
}
events.push(EventConversion::new(
UniversalEventType::ItemCompleted,
UniversalEventData::Item(ItemEventData { item }),
));
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")
.and_then(Value::as_array)
.map(|options| {
options
.iter()
.filter_map(|option| {
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 })
})
.map(|opts| {
opts.iter()
.filter_map(Value::as_str)
.map(|s| s.to_string())
.collect::<Vec<_>>()
})?;
parsed_questions.push(QuestionInfo {
question: question_text,
header,
options,
multi_select,
custom: None,
});
}
Some(QuestionRequest {
id: tool_id.unwrap_or_else(|| "claude-question".to_string()),
session_id,
questions: parsed_questions,
tool: None,
})
.unwrap_or_default(),
response: None,
status: QuestionStatus::Requested,
})
}

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_json::{Map, Value};
use serde_json::Value;
use schemars::JsonSchema;
use thiserror::Error;
use utoipa::ToSchema;
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};
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct UniversalEvent {
pub id: u64,
pub timestamp: String,
pub event_id: String,
pub sequence: u64,
pub time: String,
pub session_id: String,
pub agent: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_session_id: Option<String>,
pub native_session_id: Option<String>,
pub synthetic: bool,
pub source: EventSource,
#[serde(rename = "type")]
pub event_type: UniversalEventType,
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)]
#[serde(untagged)]
pub enum UniversalEventData {
Message { message: UniversalMessage },
Started { started: Started },
Error { error: CrashInfo },
QuestionAsked {
#[serde(rename = "questionAsked")]
question_asked: QuestionRequest,
},
PermissionAsked {
#[serde(rename = "permissionAsked")]
permission_asked: PermissionRequest,
},
Unknown { raw: Value },
SessionStarted(SessionStartedData),
SessionEnded(SessionEndedData),
Item(ItemEventData),
ItemDelta(ItemDeltaData),
Error(ErrorData),
Permission(PermissionEventData),
Question(QuestionEventData),
AgentUnparsed(AgentUnparsedData),
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct Started {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub details: Option<Value>,
pub struct SessionStartedData {
pub metadata: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct CrashInfo {
pub struct SessionEndedData {
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,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kind: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub code: Option<String>,
pub details: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
pub struct UniversalMessageParsed {
pub role: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default, skip_serializing_if = "Map::is_empty")]
pub metadata: Map<String, Value>,
pub parts: Vec<UniversalMessagePart>,
pub struct AgentUnparsedData {
pub error: String,
pub location: String,
pub raw_hash: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(untagged)]
pub enum UniversalMessage {
Parsed(UniversalMessageParsed),
Unparsed {
raw: Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
error: Option<String>,
},
pub struct PermissionEventData {
pub permission_id: String,
pub action: String,
pub status: PermissionStatus,
pub metadata: Option<Value>,
}
#[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)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum UniversalMessagePart {
pub enum ContentPart {
Text { text: String },
ToolCall {
#[serde(default, skip_serializing_if = "Option::is_none")]
id: Option<String>,
name: String,
input: Value,
},
ToolResult {
#[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 },
Json { json: Value },
ToolCall { name: String, arguments: String, call_id: String },
ToolResult { call_id: String, output: String },
FileRef { path: String, action: FileAction, diff: Option<String> },
Reasoning { text: String, visibility: ReasoningVisibility },
Image { path: String, mime: Option<String> },
Status { label: String, detail: Option<String> },
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AttachmentSource {
Path { path: String },
Url { url: String },
Data {
data: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
encoding: Option<String>,
},
#[serde(rename_all = "snake_case")]
pub enum FileAction {
Read,
Write,
Patch,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct QuestionRequest {
pub id: String,
pub session_id: String,
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())
}
#[serde(rename_all = "snake_case")]
pub enum ReasoningVisibility {
Public,
Private,
}
#[derive(Debug, Clone)]
pub struct EventConversion {
pub event_type: UniversalEventType,
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 {
pub fn new(data: UniversalEventData) -> Self {
pub fn new(event_type: UniversalEventType, data: UniversalEventData) -> Self {
Self {
event_type,
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 {
self.agent_session_id = session_id;
pub fn with_native_session(mut self, session_id: Option<String>) -> Self {
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
}
}
fn message_from_text(role: &str, text: String) -> UniversalMessage {
UniversalMessage::Parsed(UniversalMessageParsed {
role: role.to_string(),
id: None,
metadata: Map::new(),
parts: vec![UniversalMessagePart::Text { text }],
})
}
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)
pub fn item_from_text(role: ItemRole, text: String) -> UniversalItem {
UniversalItem {
item_id: String::new(),
native_item_id: None,
parent_id: None,
kind: ItemKind::Message,
role: Some(role),
content: vec![ContentPart::Text { text }],
status: ItemStatus::Completed,
}
}
fn extract_message_from_value(value: &Value) -> Option<String> {
if let Some(message) = value.get("message").and_then(Value::as_str) {
return Some(message.to_string());
pub fn item_from_parts(role: ItemRole, kind: ItemKind, parts: Vec<ContentPart>) -> UniversalItem {
UniversalItem {
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 Content-Type header to CORS callout command
- [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
- [x] Generate OpenAPI from utoipa and run `openapi-typescript`