mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 04:03:31 +00:00
feat: sync universal schema and sdk updates
This commit is contained in:
parent
79bb441287
commit
f5d1a6383d
56 changed files with 6800 additions and 3974 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
39
README.md
39
README.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
16
ROADMAP.md
16
ROADMAP.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
62
docs/glossary.md
Normal 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 provider’s original event/message object stored in raw.
|
||||
1028
docs/openapi.json
1028
docs/openapi.json
File diff suppressed because it is too large
Load diff
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
611
pnpm-lock.yaml
generated
|
|
@ -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
22
research.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
97
sdks/cli/tests/launcher.test.ts
Normal file
97
sdks/cli/tests/launcher.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
8
sdks/cli/vitest.config.ts
Normal file
8
sdks/cli/vitest.config.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["tests/**/*.test.ts"],
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
45
sdks/typescript/src/types.ts
Normal file
45
sdks/typescript/src/types.ts
Normal 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"];
|
||||
305
sdks/typescript/tests/client.test.ts
Normal file
305
sdks/typescript/tests/client.test.ts
Normal 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" }),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
174
sdks/typescript/tests/integration.test.ts
Normal file
174
sdks/typescript/tests/integration.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
208
sdks/typescript/tests/sse-parser.test.ts
Normal file
208
sdks/typescript/tests/sse-parser.test.ts
Normal 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)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
9
sdks/typescript/tsup.config.ts
Normal file
9
sdks/typescript/tsup.config.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts"],
|
||||
format: ["esm"],
|
||||
dts: true,
|
||||
clean: true,
|
||||
sourcemap: true,
|
||||
});
|
||||
8
sdks/typescript/vitest.config.ts
Normal file
8
sdks/typescript/vitest.config.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["tests/**/*.test.ts"],
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
[package]
|
||||
name = "sandbox-agent-core"
|
||||
name = "sandbox-agent"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
|
|
|
|||
|
|
@ -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
657
server/packages/sandbox-agent/tests/agent_agnostic.rs
Normal file
657
server/packages/sandbox-agent/tests/agent_agnostic.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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)),
|
||||
}, {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
143
spec/universal-schema.md
Normal 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.
|
||||
1
todo.md
1
todo.md
|
|
@ -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`
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue