diff --git a/CLAUDE.md b/CLAUDE.md
index a851124..7a0af06 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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)
diff --git a/Cargo.toml b/Cargo.toml
index 5b1e82f..114ae4d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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" }
diff --git a/README.md b/README.md
index e7cdc4e..e79c5ab 100644
--- a/README.md
+++ b/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
-
diff --git a/ROADMAP.md b/ROADMAP.md
index 19e9ce6..299cf8e 100644
--- a/ROADMAP.md
+++ b/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
diff --git a/docker/release/linux-x86_64.Dockerfile b/docker/release/linux-x86_64.Dockerfile
index 906c09d..8dc7dda 100644
--- a/docker/release/linux-x86_64.Dockerfile
+++ b/docker/release/linux-x86_64.Dockerfile
@@ -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
diff --git a/docker/release/macos-aarch64.Dockerfile b/docker/release/macos-aarch64.Dockerfile
index cc048da..5b14111 100644
--- a/docker/release/macos-aarch64.Dockerfile
+++ b/docker/release/macos-aarch64.Dockerfile
@@ -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
diff --git a/docker/release/macos-x86_64.Dockerfile b/docker/release/macos-x86_64.Dockerfile
index 1797434..32f972a 100644
--- a/docker/release/macos-x86_64.Dockerfile
+++ b/docker/release/macos-x86_64.Dockerfile
@@ -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
diff --git a/docker/release/windows.Dockerfile b/docker/release/windows.Dockerfile
index 6577e68..be231e0 100644
--- a/docker/release/windows.Dockerfile
+++ b/docker/release/windows.Dockerfile
@@ -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
diff --git a/docs/conversion.md b/docs/conversion.md
index 5f6fda6..97b5792 100644
--- a/docs/conversion.md
+++ b/docs/conversion.md
@@ -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.
diff --git a/docs/glossary.md b/docs/glossary.md
new file mode 100644
index 0000000..1079b88
--- /dev/null
+++ b/docs/glossary.md
@@ -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.
diff --git a/docs/openapi.json b/docs/openapi.json
index 2c43393..46f1f2b 100644
--- a/docs/openapi.json
+++ b/docs/openapi.json
@@ -1,7 +1,7 @@
{
"openapi": "3.0.3",
"info": {
- "title": "sandbox-agent-core",
+ "title": "sandbox-agent",
"description": "",
"contact": {
"name": "Sandbox Agent Contributors"
@@ -262,7 +262,7 @@
{
"name": "offset",
"in": "query",
- "description": "Last seen event id (exclusive)",
+ "description": "Last seen event sequence (exclusive)",
"required": false,
"schema": {
"type": "integer",
@@ -282,6 +282,16 @@
"nullable": true,
"minimum": 0
}
+ },
+ {
+ "name": "include_raw",
+ "in": "query",
+ "description": "Include raw provider payloads",
+ "required": false,
+ "schema": {
+ "type": "boolean",
+ "nullable": true
+ }
}
],
"responses": {
@@ -327,7 +337,7 @@
{
"name": "offset",
"in": "query",
- "description": "Last seen event id (exclusive)",
+ "description": "Last seen event sequence (exclusive)",
"required": false,
"schema": {
"type": "integer",
@@ -335,6 +345,16 @@
"nullable": true,
"minimum": 0
}
+ },
+ {
+ "name": "include_raw",
+ "in": "query",
+ "description": "Include raw provider payloads",
+ "required": false,
+ "schema": {
+ "type": "boolean",
+ "nullable": true
+ }
}
],
"responses": {
@@ -536,10 +556,67 @@
}
}
}
+ },
+ "/v1/sessions/{session_id}/terminate": {
+ "post": {
+ "tags": [
+ "sessions"
+ ],
+ "operationId": "terminate_session",
+ "parameters": [
+ {
+ "name": "session_id",
+ "in": "path",
+ "description": "Session id",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "Session terminated"
+ },
+ "404": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ }
+ }
+ }
}
},
"components": {
"schemas": {
+ "AgentCapabilities": {
+ "type": "object",
+ "required": [
+ "planMode",
+ "permissions",
+ "questions",
+ "toolCalls"
+ ],
+ "properties": {
+ "permissions": {
+ "type": "boolean"
+ },
+ "planMode": {
+ "type": "boolean"
+ },
+ "questions": {
+ "type": "boolean"
+ },
+ "toolCalls": {
+ "type": "boolean"
+ }
+ }
+ },
"AgentError": {
"type": "object",
"required": [
@@ -570,9 +647,13 @@
"type": "object",
"required": [
"id",
- "installed"
+ "installed",
+ "capabilities"
],
"properties": {
+ "capabilities": {
+ "$ref": "#/components/schemas/AgentCapabilities"
+ },
"id": {
"type": "string"
},
@@ -645,8 +726,157 @@
}
}
},
- "AttachmentSource": {
+ "AgentUnparsedData": {
+ "type": "object",
+ "required": [
+ "error",
+ "location"
+ ],
+ "properties": {
+ "error": {
+ "type": "string"
+ },
+ "location": {
+ "type": "string"
+ },
+ "raw_hash": {
+ "type": "string",
+ "nullable": true
+ }
+ }
+ },
+ "ContentPart": {
"oneOf": [
+ {
+ "type": "object",
+ "required": [
+ "text",
+ "type"
+ ],
+ "properties": {
+ "text": {
+ "type": "string"
+ },
+ "type": {
+ "type": "string",
+ "enum": [
+ "text"
+ ]
+ }
+ }
+ },
+ {
+ "type": "object",
+ "required": [
+ "json",
+ "type"
+ ],
+ "properties": {
+ "json": {},
+ "type": {
+ "type": "string",
+ "enum": [
+ "json"
+ ]
+ }
+ }
+ },
+ {
+ "type": "object",
+ "required": [
+ "name",
+ "arguments",
+ "call_id",
+ "type"
+ ],
+ "properties": {
+ "arguments": {
+ "type": "string"
+ },
+ "call_id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "type": {
+ "type": "string",
+ "enum": [
+ "tool_call"
+ ]
+ }
+ }
+ },
+ {
+ "type": "object",
+ "required": [
+ "call_id",
+ "output",
+ "type"
+ ],
+ "properties": {
+ "call_id": {
+ "type": "string"
+ },
+ "output": {
+ "type": "string"
+ },
+ "type": {
+ "type": "string",
+ "enum": [
+ "tool_result"
+ ]
+ }
+ }
+ },
+ {
+ "type": "object",
+ "required": [
+ "path",
+ "action",
+ "type"
+ ],
+ "properties": {
+ "action": {
+ "$ref": "#/components/schemas/FileAction"
+ },
+ "diff": {
+ "type": "string",
+ "nullable": true
+ },
+ "path": {
+ "type": "string"
+ },
+ "type": {
+ "type": "string",
+ "enum": [
+ "file_ref"
+ ]
+ }
+ }
+ },
+ {
+ "type": "object",
+ "required": [
+ "text",
+ "visibility",
+ "type"
+ ],
+ "properties": {
+ "text": {
+ "type": "string"
+ },
+ "type": {
+ "type": "string",
+ "enum": [
+ "reasoning"
+ ]
+ },
+ "visibility": {
+ "$ref": "#/components/schemas/ReasoningVisibility"
+ }
+ }
+ },
{
"type": "object",
"required": [
@@ -654,13 +884,17 @@
"type"
],
"properties": {
+ "mime": {
+ "type": "string",
+ "nullable": true
+ },
"path": {
"type": "string"
},
"type": {
"type": "string",
"enum": [
- "path"
+ "image"
]
}
}
@@ -668,39 +902,21 @@
{
"type": "object",
"required": [
- "url",
+ "label",
"type"
],
"properties": {
- "type": {
- "type": "string",
- "enum": [
- "url"
- ]
- },
- "url": {
- "type": "string"
- }
- }
- },
- {
- "type": "object",
- "required": [
- "data",
- "type"
- ],
- "properties": {
- "data": {
- "type": "string"
- },
- "encoding": {
+ "detail": {
"type": "string",
"nullable": true
},
+ "label": {
+ "type": "string"
+ },
"type": {
"type": "string",
"enum": [
- "data"
+ "status"
]
}
}
@@ -710,24 +926,6 @@
"propertyName": "type"
}
},
- "CrashInfo": {
- "type": "object",
- "required": [
- "message"
- ],
- "properties": {
- "details": {
- "nullable": true
- },
- "kind": {
- "type": "string",
- "nullable": true
- },
- "message": {
- "type": "string"
- }
- }
- },
"CreateSessionRequest": {
"type": "object",
"required": [
@@ -765,10 +963,6 @@
"healthy"
],
"properties": {
- "agentSessionId": {
- "type": "string",
- "nullable": true
- },
"error": {
"allOf": [
{
@@ -779,6 +973,28 @@
},
"healthy": {
"type": "boolean"
+ },
+ "nativeSessionId": {
+ "type": "string",
+ "nullable": true
+ }
+ }
+ },
+ "ErrorData": {
+ "type": "object",
+ "required": [
+ "message"
+ ],
+ "properties": {
+ "code": {
+ "type": "string",
+ "nullable": true
+ },
+ "details": {
+ "nullable": true
+ },
+ "message": {
+ "type": "string"
}
}
},
@@ -799,9 +1015,20 @@
"timeout"
]
},
+ "EventSource": {
+ "type": "string",
+ "enum": [
+ "agent",
+ "daemon"
+ ]
+ },
"EventsQuery": {
"type": "object",
"properties": {
+ "includeRaw": {
+ "type": "boolean",
+ "nullable": true
+ },
"limit": {
"type": "integer",
"format": "int64",
@@ -834,6 +1061,14 @@
}
}
},
+ "FileAction": {
+ "type": "string",
+ "enum": [
+ "read",
+ "write",
+ "patch"
+ ]
+ },
"HealthResponse": {
"type": "object",
"required": [
@@ -845,6 +1080,64 @@
}
}
},
+ "ItemDeltaData": {
+ "type": "object",
+ "required": [
+ "item_id",
+ "delta"
+ ],
+ "properties": {
+ "delta": {
+ "type": "string"
+ },
+ "item_id": {
+ "type": "string"
+ },
+ "native_item_id": {
+ "type": "string",
+ "nullable": true
+ }
+ }
+ },
+ "ItemEventData": {
+ "type": "object",
+ "required": [
+ "item"
+ ],
+ "properties": {
+ "item": {
+ "$ref": "#/components/schemas/UniversalItem"
+ }
+ }
+ },
+ "ItemKind": {
+ "type": "string",
+ "enum": [
+ "message",
+ "tool_call",
+ "tool_result",
+ "system",
+ "status",
+ "unknown"
+ ]
+ },
+ "ItemRole": {
+ "type": "string",
+ "enum": [
+ "user",
+ "assistant",
+ "system",
+ "tool"
+ ]
+ },
+ "ItemStatus": {
+ "type": "string",
+ "enum": [
+ "in_progress",
+ "completed",
+ "failed"
+ ]
+ },
"MessageRequest": {
"type": "object",
"required": [
@@ -856,6 +1149,28 @@
}
}
},
+ "PermissionEventData": {
+ "type": "object",
+ "required": [
+ "permission_id",
+ "action",
+ "status"
+ ],
+ "properties": {
+ "action": {
+ "type": "string"
+ },
+ "metadata": {
+ "nullable": true
+ },
+ "permission_id": {
+ "type": "string"
+ },
+ "status": {
+ "$ref": "#/components/schemas/PermissionStatus"
+ }
+ }
+ },
"PermissionReply": {
"type": "string",
"enum": [
@@ -875,65 +1190,13 @@
}
}
},
- "PermissionRequest": {
- "type": "object",
- "required": [
- "id",
- "sessionId",
- "permission",
- "patterns",
- "always"
- ],
- "properties": {
- "always": {
- "type": "array",
- "items": {
- "type": "string"
- }
- },
- "id": {
- "type": "string"
- },
- "metadata": {
- "type": "object",
- "additionalProperties": {}
- },
- "patterns": {
- "type": "array",
- "items": {
- "type": "string"
- }
- },
- "permission": {
- "type": "string"
- },
- "sessionId": {
- "type": "string"
- },
- "tool": {
- "allOf": [
- {
- "$ref": "#/components/schemas/PermissionToolRef"
- }
- ],
- "nullable": true
- }
- }
- },
- "PermissionToolRef": {
- "type": "object",
- "required": [
- "messageId",
- "callId"
- ],
- "properties": {
- "callId": {
- "type": "string"
- },
- "messageId": {
- "type": "string"
- }
- }
+ "PermissionStatus": {
+ "type": "string",
+ "enum": [
+ "requested",
+ "approved",
+ "denied"
+ ]
},
"ProblemDetails": {
"type": "object",
@@ -965,48 +1228,33 @@
},
"additionalProperties": {}
},
- "QuestionInfo": {
+ "QuestionEventData": {
"type": "object",
"required": [
- "question",
- "options"
+ "question_id",
+ "prompt",
+ "options",
+ "status"
],
"properties": {
- "custom": {
- "type": "boolean",
- "nullable": true
- },
- "header": {
- "type": "string",
- "nullable": true
- },
- "multiSelect": {
- "type": "boolean",
- "nullable": true
- },
"options": {
"type": "array",
"items": {
- "$ref": "#/components/schemas/QuestionOption"
+ "type": "string"
}
},
- "question": {
+ "prompt": {
"type": "string"
- }
- }
- },
- "QuestionOption": {
- "type": "object",
- "required": [
- "label"
- ],
- "properties": {
- "description": {
+ },
+ "question_id": {
+ "type": "string"
+ },
+ "response": {
"type": "string",
"nullable": true
},
- "label": {
- "type": "string"
+ "status": {
+ "$ref": "#/components/schemas/QuestionStatus"
}
}
},
@@ -1027,48 +1275,41 @@
}
}
},
- "QuestionRequest": {
- "type": "object",
- "required": [
- "id",
- "sessionId",
- "questions"
- ],
- "properties": {
- "id": {
- "type": "string"
- },
- "questions": {
- "type": "array",
- "items": {
- "$ref": "#/components/schemas/QuestionInfo"
- }
- },
- "sessionId": {
- "type": "string"
- },
- "tool": {
- "allOf": [
- {
- "$ref": "#/components/schemas/QuestionToolRef"
- }
- ],
- "nullable": true
- }
- }
+ "QuestionStatus": {
+ "type": "string",
+ "enum": [
+ "requested",
+ "answered",
+ "rejected"
+ ]
},
- "QuestionToolRef": {
+ "ReasoningVisibility": {
+ "type": "string",
+ "enum": [
+ "public",
+ "private"
+ ]
+ },
+ "SessionEndReason": {
+ "type": "string",
+ "enum": [
+ "completed",
+ "error",
+ "terminated"
+ ]
+ },
+ "SessionEndedData": {
"type": "object",
"required": [
- "messageId",
- "callId"
+ "reason",
+ "terminated_by"
],
"properties": {
- "callId": {
- "type": "string"
+ "reason": {
+ "$ref": "#/components/schemas/SessionEndReason"
},
- "messageId": {
- "type": "string"
+ "terminated_by": {
+ "$ref": "#/components/schemas/TerminatedBy"
}
}
},
@@ -1089,10 +1330,6 @@
"agentMode": {
"type": "string"
},
- "agentSessionId": {
- "type": "string",
- "nullable": true
- },
"ended": {
"type": "boolean"
},
@@ -1105,6 +1342,10 @@
"type": "string",
"nullable": true
},
+ "nativeSessionId": {
+ "type": "string",
+ "nullable": true
+ },
"permissionMode": {
"type": "string"
},
@@ -1131,391 +1372,154 @@
}
}
},
- "Started": {
+ "SessionStartedData": {
"type": "object",
"properties": {
- "details": {
- "nullable": true
- },
- "message": {
- "type": "string",
+ "metadata": {
"nullable": true
}
}
},
+ "TerminatedBy": {
+ "type": "string",
+ "enum": [
+ "agent",
+ "daemon"
+ ]
+ },
"UniversalEvent": {
"type": "object",
"required": [
- "id",
- "timestamp",
- "sessionId",
- "agent",
+ "event_id",
+ "sequence",
+ "time",
+ "session_id",
+ "synthetic",
+ "source",
+ "type",
"data"
],
"properties": {
- "agent": {
- "type": "string"
- },
- "agentSessionId": {
- "type": "string",
- "nullable": true
- },
"data": {
"$ref": "#/components/schemas/UniversalEventData"
},
- "id": {
+ "event_id": {
+ "type": "string"
+ },
+ "native_session_id": {
+ "type": "string",
+ "nullable": true
+ },
+ "raw": {
+ "nullable": true
+ },
+ "sequence": {
"type": "integer",
"format": "int64",
"minimum": 0
},
- "sessionId": {
+ "session_id": {
"type": "string"
},
- "timestamp": {
+ "source": {
+ "$ref": "#/components/schemas/EventSource"
+ },
+ "synthetic": {
+ "type": "boolean"
+ },
+ "time": {
"type": "string"
+ },
+ "type": {
+ "$ref": "#/components/schemas/UniversalEventType"
}
}
},
"UniversalEventData": {
"oneOf": [
{
- "type": "object",
- "required": [
- "message"
- ],
- "properties": {
- "message": {
- "$ref": "#/components/schemas/UniversalMessage"
- }
- }
+ "$ref": "#/components/schemas/SessionStartedData"
},
{
- "type": "object",
- "required": [
- "started"
- ],
- "properties": {
- "started": {
- "$ref": "#/components/schemas/Started"
- }
- }
+ "$ref": "#/components/schemas/SessionEndedData"
},
{
- "type": "object",
- "required": [
- "error"
- ],
- "properties": {
- "error": {
- "$ref": "#/components/schemas/CrashInfo"
- }
- }
+ "$ref": "#/components/schemas/ItemEventData"
},
{
- "type": "object",
- "required": [
- "questionAsked"
- ],
- "properties": {
- "questionAsked": {
- "$ref": "#/components/schemas/QuestionRequest"
- }
- }
+ "$ref": "#/components/schemas/ItemDeltaData"
},
{
- "type": "object",
- "required": [
- "permissionAsked"
- ],
- "properties": {
- "permissionAsked": {
- "$ref": "#/components/schemas/PermissionRequest"
- }
- }
+ "$ref": "#/components/schemas/ErrorData"
},
{
- "type": "object",
- "required": [
- "raw"
- ],
- "properties": {
- "raw": {}
- }
+ "$ref": "#/components/schemas/PermissionEventData"
+ },
+ {
+ "$ref": "#/components/schemas/QuestionEventData"
+ },
+ {
+ "$ref": "#/components/schemas/AgentUnparsedData"
}
]
},
- "UniversalMessage": {
- "oneOf": [
- {
- "$ref": "#/components/schemas/UniversalMessageParsed"
- },
- {
- "type": "object",
- "required": [
- "raw"
- ],
- "properties": {
- "error": {
- "type": "string",
- "nullable": true
- },
- "raw": {}
- }
- }
+ "UniversalEventType": {
+ "type": "string",
+ "enum": [
+ "session.started",
+ "session.ended",
+ "item.started",
+ "item.delta",
+ "item.completed",
+ "error",
+ "permission.requested",
+ "permission.resolved",
+ "question.requested",
+ "question.resolved",
+ "agent.unparsed"
]
},
- "UniversalMessageParsed": {
+ "UniversalItem": {
"type": "object",
"required": [
- "role",
- "parts"
+ "item_id",
+ "kind",
+ "content",
+ "status"
],
"properties": {
- "id": {
+ "content": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ContentPart"
+ }
+ },
+ "item_id": {
+ "type": "string"
+ },
+ "kind": {
+ "$ref": "#/components/schemas/ItemKind"
+ },
+ "native_item_id": {
"type": "string",
"nullable": true
},
- "metadata": {
- "type": "object",
- "additionalProperties": {}
- },
- "parts": {
- "type": "array",
- "items": {
- "$ref": "#/components/schemas/UniversalMessagePart"
- }
+ "parent_id": {
+ "type": "string",
+ "nullable": true
},
"role": {
- "type": "string"
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/ItemRole"
+ }
+ ],
+ "nullable": true
+ },
+ "status": {
+ "$ref": "#/components/schemas/ItemStatus"
}
}
- },
- "UniversalMessagePart": {
- "oneOf": [
- {
- "type": "object",
- "required": [
- "text",
- "type"
- ],
- "properties": {
- "text": {
- "type": "string"
- },
- "type": {
- "type": "string",
- "enum": [
- "text"
- ]
- }
- }
- },
- {
- "type": "object",
- "required": [
- "name",
- "input",
- "type"
- ],
- "properties": {
- "id": {
- "type": "string",
- "nullable": true
- },
- "input": {},
- "name": {
- "type": "string"
- },
- "type": {
- "type": "string",
- "enum": [
- "tool_call"
- ]
- }
- }
- },
- {
- "type": "object",
- "required": [
- "output",
- "type"
- ],
- "properties": {
- "id": {
- "type": "string",
- "nullable": true
- },
- "is_error": {
- "type": "boolean",
- "nullable": true
- },
- "name": {
- "type": "string",
- "nullable": true
- },
- "output": {},
- "type": {
- "type": "string",
- "enum": [
- "tool_result"
- ]
- }
- }
- },
- {
- "type": "object",
- "required": [
- "arguments",
- "type"
- ],
- "properties": {
- "arguments": {},
- "id": {
- "type": "string",
- "nullable": true
- },
- "name": {
- "type": "string",
- "nullable": true
- },
- "raw": {
- "nullable": true
- },
- "type": {
- "type": "string",
- "enum": [
- "function_call"
- ]
- }
- }
- },
- {
- "type": "object",
- "required": [
- "result",
- "type"
- ],
- "properties": {
- "id": {
- "type": "string",
- "nullable": true
- },
- "is_error": {
- "type": "boolean",
- "nullable": true
- },
- "name": {
- "type": "string",
- "nullable": true
- },
- "raw": {
- "nullable": true
- },
- "result": {},
- "type": {
- "type": "string",
- "enum": [
- "function_result"
- ]
- }
- }
- },
- {
- "type": "object",
- "required": [
- "source",
- "type"
- ],
- "properties": {
- "filename": {
- "type": "string",
- "nullable": true
- },
- "mime_type": {
- "type": "string",
- "nullable": true
- },
- "raw": {
- "nullable": true
- },
- "source": {
- "$ref": "#/components/schemas/AttachmentSource"
- },
- "type": {
- "type": "string",
- "enum": [
- "file"
- ]
- }
- }
- },
- {
- "type": "object",
- "required": [
- "source",
- "type"
- ],
- "properties": {
- "alt": {
- "type": "string",
- "nullable": true
- },
- "mime_type": {
- "type": "string",
- "nullable": true
- },
- "raw": {
- "nullable": true
- },
- "source": {
- "$ref": "#/components/schemas/AttachmentSource"
- },
- "type": {
- "type": "string",
- "enum": [
- "image"
- ]
- }
- }
- },
- {
- "type": "object",
- "required": [
- "message",
- "type"
- ],
- "properties": {
- "message": {
- "type": "string"
- },
- "type": {
- "type": "string",
- "enum": [
- "error"
- ]
- }
- }
- },
- {
- "type": "object",
- "required": [
- "raw",
- "type"
- ],
- "properties": {
- "raw": {},
- "type": {
- "type": "string",
- "enum": [
- "unknown"
- ]
- }
- }
- }
- ],
- "discriminator": {
- "propertyName": "type"
- }
}
}
},
@@ -1533,4 +1537,4 @@
"description": "Session management"
}
]
-}
\ No newline at end of file
+}
diff --git a/frontend/packages/inspector/index.html b/frontend/packages/inspector/index.html
index e2f5f4f..2371c28 100644
--- a/frontend/packages/inspector/index.html
+++ b/frontend/packages/inspector/index.html
@@ -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;
diff --git a/frontend/packages/inspector/src/App.tsx b/frontend/packages/inspector/src/App.tsx
index 2209155..0dfcc53 100644
--- a/frontend/packages/inspector/src/App.tsx
+++ b/frontend/packages/inspector/src/App.tsx
@@ -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 (
+
+
{(part as { text: string }).text}
+
+ );
+ case "json":
+ return (
+
+
json
+
{formatJson((part as { json: unknown }).json)}
+
+ );
+ case "tool_call": {
+ const { name, arguments: args, call_id } = part as {
+ name: string;
+ arguments: string;
+ call_id: string;
+ };
+ return (
+
+
+ tool call - {name}
+ {call_id ? ` - ${call_id}` : ""}
+
+ {args ?
{args} :
No arguments
}
+
+ );
+ }
+ case "tool_result": {
+ const { call_id, output } = part as { call_id: string; output: string };
+ return (
+
+
tool result - {call_id}
+ {output ?
{output} :
No output
}
+
+ );
+ }
+ case "file_ref": {
+ const { path, action, diff } = part as { path: string; action: string; diff?: string | null };
+ return (
+
+
file - {action}
+
{path}
+ {diff &&
{diff}}
+
+ );
+ }
+ case "reasoning": {
+ const { text, visibility } = part as { text: string; visibility: string };
+ return (
+
+
reasoning - {visibility}
+
{text}
+
+ );
+ }
+ case "image": {
+ const { path, mime } = part as { path: string; mime?: string | null };
+ return (
+
+
image {mime ? `- ${mime}` : ""}
+
{path}
+
+ );
+ }
+ case "status": {
+ const { label, detail } = part as { label: string; detail?: string | null };
+ return (
+
+
status - {label}
+ {detail &&
{detail}
}
+
+ );
+ }
+ default:
+ return (
+
+
unknown
+
{formatJson(part)}
+
+ );
+ }
+};
+
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();
+ 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();
+ 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();
+
+ 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
- ) : transcriptMessages.length === 0 && !sessionError ? (
+ ) : transcriptEntries.length === 0 && !sessionError ? (
Ready to Chat
@@ -811,16 +1046,59 @@ export default function App() {
) : (
- {transcriptMessages.map((msg) => (
-
-
- {msg.role === "user" ? "U" : "AI"}
+ {transcriptEntries.map((entry) => {
+ if (entry.kind === "meta") {
+ const messageClass = entry.meta?.severity === "error" ? "error" : "system";
+ return (
+
+
{getAvatarLabel(messageClass)}
+
+
+ {entry.meta?.title ?? "Status"}
+
+ {entry.meta?.detail &&
{entry.meta.detail}
}
+
+
+ );
+ }
+
+ 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 (
+
+
{getAvatarLabel(isFailed ? "error" : messageClass)}
+
+ {(item.kind !== "message" || item.status !== "completed") && (
+
+ {kindLabel}
+ {statusLabel && (
+
+ {statusLabel}
+
+ )}
+
+ )}
+ {hasParts ? (
+ (item.content ?? []).map(renderContentPart)
+ ) : entry.deltaText ? (
+
+ {entry.deltaText}
+ {isInProgress && }
+
+ ) : (
+
No content yet.
+ )}
+
-
- {msg.content}
-
-
- ))}
+ );
+ })}
{sessionError && (
{sessionError}
@@ -1028,13 +1306,18 @@ export default function App() {
{[...events].reverse().map((event) => {
const type = getEventType(event);
+ const category = getEventCategory(type);
+ const eventClass = `${category} ${getEventClass(type)}`;
return (
-
+
- {type}
- {formatTime(event.timestamp)}
+ {type}
+ {formatTime(event.time)}
+
+
+ Event #{event.event_id || event.sequence} - seq {event.sequence} - {event.source}
+ {event.synthetic ? " (synthetic)" : ""}
-
Event #{event.id}
{formatJson(event.data)}
);
@@ -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 (
-
+
@@ -1066,52 +1347,35 @@ export default function App() {
Pending
- {request.questions.map((question, qIdx) => (
-
-
- {question.header && {question.header}: }
- {question.question}
-
-
- {question.options.map((option) => {
- const selected = selections[qIdx]?.includes(option.label) ?? false;
- return (
-
- );
- })}
-
+
+
{request.prompt}
+
+ {request.options.map((option) => {
+ const isSelected = selected.includes(option);
+ return (
+
+ );
+ })}
- ))}
+
@@ -1121,7 +1385,7 @@ export default function App() {
})}
{permissionRequests.map((request) => (
-
+
@@ -1130,32 +1394,27 @@ export default function App() {
Pending
- {request.permission}
+ {request.action}
- {request.patterns && request.patterns.length > 0 && (
-
- {request.patterns.join(", ")}
-
- )}
- {request.metadata && (
+ {request.metadata !== null && request.metadata !== undefined && (
{formatJson(request.metadata)}
)}
@@ -1180,7 +1439,15 @@ export default function App() {
No agents reported. Click refresh to check.
)}
- {(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) => (
{agent.id}
@@ -1192,6 +1459,9 @@ export default function App() {
{agent.version ? `v${agent.version}` : "Version unknown"}
{agent.path && {agent.path}}
+
+ Capabilities: {formatCapabilities(agent.capabilities ?? emptyCapabilities)}
+
{modesByAgent[agent.id] && modesByAgent[agent.id].length > 0 && (
Modes: {modesByAgent[agent.id].map((m) => m.id).join(", ")}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1d650a3..bb23b61 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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
diff --git a/research.md b/research.md
new file mode 100644
index 0000000..b0b2067
--- /dev/null
+++ b/research.md
@@ -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.
diff --git a/research/agents/claude.md b/research/agents/claude.md
index d22f4a8..49da983 100644
--- a/research/agents/claude.md
+++ b/research/agents/claude.md
@@ -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
diff --git a/resources/agent-schemas/artifacts/json-schema/codex.json b/resources/agent-schemas/artifacts/json-schema/codex.json
index decd6bd..247a167 100644
--- a/resources/agent-schemas/artifacts/json-schema/codex.json
+++ b/resources/agent-schemas/artifacts/json-schema/codex.json
@@ -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 @@
}
}
}
-}
\ No newline at end of file
+}
diff --git a/scripts/release/main.ts b/scripts/release/main.ts
index a5c5eaa..179e756 100755
--- a/scripts/release/main.ts
+++ b/scripts/release/main.ts
@@ -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 });
}
diff --git a/sdks/cli/package.json b/sdks/cli/package.json
index 05524a4..cab981d 100644
--- a/sdks/cli/package.json
+++ b/sdks/cli/package.json
@@ -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",
diff --git a/sdks/cli/tests/launcher.test.ts b/sdks/cli/tests/launcher.test.ts
new file mode 100644
index 0000000..1019bdf
--- /dev/null
+++ b/sdks/cli/tests/launcher.test.ts
@@ -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
= {
+ "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");
+ });
+});
diff --git a/sdks/cli/vitest.config.ts b/sdks/cli/vitest.config.ts
new file mode 100644
index 0000000..8676010
--- /dev/null
+++ b/sdks/cli/vitest.config.ts
@@ -0,0 +1,8 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ include: ["tests/**/*.test.ts"],
+ testTimeout: 30000,
+ },
+});
diff --git a/sdks/typescript/package.json b/sdks/typescript/package.json
index 1d0a3b6..d0a6c58 100644
--- a/sdks/typescript/package.json
+++ b/sdks/typescript/package.json
@@ -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"
diff --git a/sdks/typescript/src/client.ts b/sdks/typescript/src/client.ts
index bae0ac5..27fa5b3 100644
--- a/sdks/typescript/src/client.ts
+++ b/sdks/typescript/src/client.ts
@@ -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
diff --git a/sdks/typescript/src/generated/openapi.ts b/sdks/typescript/src/generated/openapi.ts
index 682c92d..38a093e 100644
--- a/sdks/typescript/src/generated/openapi.ts
+++ b/sdks/typescript/src/generated/openapi.ts
@@ -4,11 +4,6 @@
*/
-/** OneOf type helpers */
-type Without = { [P in Exclude]?: never };
-type XOR = (T | U) extends object ? (Without & U) | (Without & T) : T | U;
-type OneOf = T extends [infer Only] ? Only : T extends [infer A, infer B, ...infer Rest] ? OneOf<[XOR, ...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;
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"];
+ };
+ };
+ };
+ };
}
diff --git a/sdks/typescript/src/index.ts b/sdks/typescript/src/index.ts
index 91e9e86..54970be 100644
--- a/sdks/typescript/src/index.ts
+++ b/sdks/typescript/src/index.ts
@@ -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";
diff --git a/sdks/typescript/src/types.ts b/sdks/typescript/src/types.ts
new file mode 100644
index 0000000..ebdf04f
--- /dev/null
+++ b/sdks/typescript/src/types.ts
@@ -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"];
diff --git a/sdks/typescript/tests/client.test.ts b/sdks/typescript/tests/client.test.ts
new file mode 100644
index 0000000..b0e4475
--- /dev/null
+++ b/sdks/typescript/tests/client.test.ts
@@ -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 = {}
+): Mock {
+ return vi.fn().mockResolvedValue(
+ new Response(JSON.stringify(response), {
+ status,
+ headers: { "Content-Type": "application/json", ...headers },
+ })
+ );
+}
+
+function createMockFetchError(status: number, problem: unknown): Mock {
+ return vi.fn().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" }),
+ })
+ );
+ });
+ });
+});
diff --git a/sdks/typescript/tests/integration.test.ts b/sdks/typescript/tests/integration.test.ts
new file mode 100644
index 0000000..b42d692
--- /dev/null
+++ b/sdks/typescript/tests/integration.test.ts
@@ -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((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);
+ });
+});
diff --git a/sdks/typescript/tests/sse-parser.test.ts b/sdks/typescript/tests/sse-parser.test.ts
new file mode 100644
index 0000000..b115660
--- /dev/null
+++ b/sdks/typescript/tests/sse-parser.test.ts
@@ -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({
+ 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 {
+ return vi.fn().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)
+ );
+ });
+});
diff --git a/sdks/typescript/tsconfig.json b/sdks/typescript/tsconfig.json
index b05bc76..b0b2388 100644
--- a/sdks/typescript/tsconfig.json
+++ b/sdks/typescript/tsconfig.json
@@ -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"]
diff --git a/sdks/typescript/tsup.config.ts b/sdks/typescript/tsup.config.ts
new file mode 100644
index 0000000..faf3167
--- /dev/null
+++ b/sdks/typescript/tsup.config.ts
@@ -0,0 +1,9 @@
+import { defineConfig } from "tsup";
+
+export default defineConfig({
+ entry: ["src/index.ts"],
+ format: ["esm"],
+ dts: true,
+ clean: true,
+ sourcemap: true,
+});
diff --git a/sdks/typescript/vitest.config.ts b/sdks/typescript/vitest.config.ts
new file mode 100644
index 0000000..8676010
--- /dev/null
+++ b/sdks/typescript/vitest.config.ts
@@ -0,0 +1,8 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ include: ["tests/**/*.test.ts"],
+ testTimeout: 30000,
+ },
+});
diff --git a/server/CLAUDE.md b/server/CLAUDE.md
index 6e091dc..fa4023f 100644
--- a/server/CLAUDE.md
+++ b/server/CLAUDE.md
@@ -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
diff --git a/server/packages/agent-management/src/agents.rs b/server/packages/agent-management/src/agents.rs
index d24f33a..2ccf7e3 100644
--- a/server/packages/agent-management/src/agents.rs
+++ b/server/packages/agent-management/src/agents.rs
@@ -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(),
diff --git a/server/packages/openapi-gen/Cargo.toml b/server/packages/openapi-gen/Cargo.toml
index 839bd0d..adf07c5 100644
--- a/server/packages/openapi-gen/Cargo.toml
+++ b/server/packages/openapi-gen/Cargo.toml
@@ -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
diff --git a/server/packages/openapi-gen/build.rs b/server/packages/openapi-gen/build.rs
index 5bbe297..2ac2ce3 100644
--- a/server/packages/openapi-gen/build.rs
+++ b/server/packages/openapi-gen/build.rs
@@ -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() {
diff --git a/server/packages/sandbox-agent/Cargo.toml b/server/packages/sandbox-agent/Cargo.toml
index f25cc8c..da2e88f 100644
--- a/server/packages/sandbox-agent/Cargo.toml
+++ b/server/packages/sandbox-agent/Cargo.toml
@@ -1,5 +1,5 @@
[package]
-name = "sandbox-agent-core"
+name = "sandbox-agent"
version.workspace = true
edition.workspace = true
authors.workspace = true
diff --git a/server/packages/sandbox-agent/src/main.rs b/server/packages/sandbox-agent/src/main.rs
index 6f08183..7cf4205 100644
--- a/server/packages/sandbox-agent/src/main.rs
+++ b/server/packages/sandbox-agent/src/main.rs
@@ -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,
#[arg(long, short = 'l')]
limit: Option,
+ #[arg(long)]
+ include_raw: bool,
#[command(flatten)]
client: ClientArgs,
}
@@ -204,6 +209,15 @@ struct SessionEventsSseArgs {
session_id: String,
#[arg(long, short = 'o')]
offset: Option,
+ #[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::(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)],
+ query: &[(&str, Option)],
) -> Result {
let mut request = self.request(Method::GET, path);
for (key, value) in query {
diff --git a/server/packages/sandbox-agent/src/router.rs b/server/packages/sandbox-agent/src/router.rs
index f7db020..e82f619 100644
--- a/server/packages/sandbox-agent/src/router.rs
+++ b/server/packages/sandbox-agent/src/router.rs
@@ -24,21 +24,30 @@ use sandbox_agent_universal_agent_schema::{
convert_claude,
convert_codex,
convert_opencode,
- AttachmentSource,
- CrashInfo,
+ AgentUnparsedData,
EventConversion,
- PermissionRequest,
- PermissionToolRef,
- QuestionInfo,
- QuestionOption,
- QuestionRequest,
- QuestionToolRef,
- Started,
+ EventSource,
+ FileAction,
+ ItemDeltaData,
+ ItemEventData,
+ ItemKind,
+ ItemRole,
+ ItemStatus,
+ ReasoningVisibility,
+ PermissionEventData,
+ PermissionStatus,
+ QuestionEventData,
+ QuestionStatus,
+ ErrorData,
+ SessionEndedData,
+ SessionEndReason,
+ SessionStartedData,
+ TerminatedBy,
UniversalEvent,
UniversalEventData,
- UniversalMessage,
- UniversalMessageParsed,
- UniversalMessagePart,
+ UniversalEventType,
+ UniversalItem,
+ ContentPart,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
@@ -101,6 +110,7 @@ pub fn build_router(state: AppState) -> Router {
.route("/sessions", get(list_sessions))
.route("/sessions/:session_id", post(create_session))
.route("/sessions/:session_id/messages", post(post_message))
+ .route("/sessions/:session_id/terminate", post(terminate_session))
.route("/sessions/:session_id/events", get(get_events))
.route("/sessions/:session_id/events/sse", get(get_events_sse))
.route(
@@ -140,6 +150,7 @@ pub fn build_router(state: AppState) -> Router {
list_sessions,
create_session,
post_message,
+ terminate_session,
get_events,
get_events_sse,
reply_question,
@@ -151,6 +162,7 @@ pub fn build_router(state: AppState) -> Router {
AgentInstallRequest,
AgentModeInfo,
AgentModesResponse,
+ AgentCapabilities,
AgentInfo,
AgentListResponse,
SessionInfo,
@@ -163,18 +175,27 @@ pub fn build_router(state: AppState) -> Router {
EventsResponse,
UniversalEvent,
UniversalEventData,
- UniversalMessage,
- UniversalMessageParsed,
- UniversalMessagePart,
- AttachmentSource,
- Started,
- CrashInfo,
- QuestionRequest,
- QuestionInfo,
- QuestionOption,
- QuestionToolRef,
- PermissionRequest,
- PermissionToolRef,
+ UniversalEventType,
+ EventSource,
+ SessionStartedData,
+ SessionEndedData,
+ SessionEndReason,
+ TerminatedBy,
+ ItemEventData,
+ ItemDeltaData,
+ UniversalItem,
+ ItemKind,
+ ItemRole,
+ ItemStatus,
+ ContentPart,
+ FileAction,
+ ReasoningVisibility,
+ ErrorData,
+ AgentUnparsedData,
+ PermissionEventData,
+ PermissionStatus,
+ QuestionEventData,
+ QuestionStatus,
QuestionReplyRequest,
PermissionReplyRequest,
PermissionReply,
@@ -226,19 +247,37 @@ struct SessionState {
permission_mode: String,
model: Option,
variant: Option,
- agent_session_id: Option,
+ native_session_id: Option,
ended: bool,
ended_exit_code: Option,
ended_message: Option,
- next_event_id: u64,
+ ended_reason: Option,
+ terminated_by: Option,
+ next_event_sequence: u64,
+ next_item_id: u64,
events: Vec,
- pending_questions: HashSet,
- pending_permissions: HashSet,
+ pending_questions: HashMap,
+ pending_permissions: HashMap,
+ item_started: HashSet,
+ item_delta_seen: HashSet,
+ item_map: HashMap,
broadcaster: broadcast::Sender,
opencode_stream_started: bool,
codex_sender: Option>,
}
+#[derive(Debug, Clone)]
+struct PendingPermission {
+ action: String,
+ metadata: Option,
+}
+
+#[derive(Debug, Clone)]
+struct PendingQuestion {
+ prompt: String,
+ options: Vec,
+}
+
impl SessionState {
fn new(
session_id: String,
@@ -259,53 +298,36 @@ impl SessionState {
permission_mode,
model: request.model.clone(),
variant: request.variant.clone(),
- agent_session_id: None,
+ native_session_id: None,
ended: false,
ended_exit_code: None,
ended_message: None,
- next_event_id: 0,
+ ended_reason: None,
+ terminated_by: None,
+ next_event_sequence: 0,
+ next_item_id: 0,
events: Vec::new(),
- pending_questions: HashSet::new(),
- pending_permissions: HashSet::new(),
+ pending_questions: HashMap::new(),
+ pending_permissions: HashMap::new(),
+ item_started: HashSet::new(),
+ item_delta_seen: HashSet::new(),
+ item_map: HashMap::new(),
broadcaster,
opencode_stream_started: false,
codex_sender: None,
})
}
- fn record_conversion(&mut self, conversion: EventConversion) -> UniversalEvent {
- let agent_session_id = conversion
- .agent_session_id
- .clone()
- .or_else(|| self.agent_session_id.clone());
- if self.agent_session_id.is_none() {
- self.agent_session_id = conversion.agent_session_id.clone();
+ fn record_conversions(&mut self, conversions: Vec) -> Vec {
+ let mut events = Vec::new();
+ for conversion in conversions {
+ for normalized in self.normalize_conversion(conversion) {
+ if let Some(event) = self.push_event(normalized) {
+ events.push(event);
+ }
+ }
}
- self.record_event(conversion.data, agent_session_id)
- }
-
- fn record_event(
- &mut self,
- data: UniversalEventData,
- agent_session_id: Option,
- ) -> UniversalEvent {
- self.next_event_id += 1;
- let data = self.normalize_event_data(data);
- let event = UniversalEvent {
- id: self.next_event_id,
- timestamp: now_rfc3339(),
- session_id: self.session_id.clone(),
- agent: self.agent.as_str().to_string(),
- agent_session_id: agent_session_id.clone(),
- data,
- };
- self.update_pending(&event);
- self.events.push(event.clone());
- let _ = self.broadcaster.send(event.clone());
- if self.agent_session_id.is_none() {
- self.agent_session_id = agent_session_id;
- }
- event
+ events
}
fn set_codex_sender(&mut self, sender: Option>) {
@@ -316,56 +338,225 @@ impl SessionState {
self.codex_sender.clone()
}
- fn normalize_event_data(&self, mut data: UniversalEventData) -> UniversalEventData {
- match &mut data {
- UniversalEventData::QuestionAsked { question_asked } => {
- question_asked.session_id = self.session_id.clone();
+ fn normalize_conversion(&mut self, mut conversion: EventConversion) -> Vec {
+ if self.native_session_id.is_none() && conversion.native_session_id.is_some() {
+ self.native_session_id = conversion.native_session_id.clone();
+ }
+ if conversion.native_session_id.is_none() {
+ conversion.native_session_id = self.native_session_id.clone();
+ }
+
+ let mut conversions = Vec::new();
+ match conversion.event_type {
+ UniversalEventType::ItemStarted | UniversalEventType::ItemCompleted => {
+ if let UniversalEventData::Item(ref mut data) = conversion.data {
+ self.ensure_item_id(&mut data.item);
+ self.ensure_parent_id(&mut data.item);
+ if conversion.event_type == UniversalEventType::ItemCompleted
+ && data.item.kind == ItemKind::Message
+ && !self.item_delta_seen.contains(&data.item.item_id)
+ {
+ if let Some(delta) = text_delta_from_parts(&data.item.content) {
+ conversions.push(
+ EventConversion::new(
+ UniversalEventType::ItemDelta,
+ UniversalEventData::ItemDelta(ItemDeltaData {
+ item_id: data.item.item_id.clone(),
+ native_item_id: data.item.native_item_id.clone(),
+ delta,
+ }),
+ )
+ .synthetic()
+ .with_native_session(conversion.native_session_id.clone()),
+ );
+ }
+ }
+ }
}
- UniversalEventData::PermissionAsked { permission_asked } => {
- permission_asked.session_id = self.session_id.clone();
+ UniversalEventType::ItemDelta => {
+ if let UniversalEventData::ItemDelta(ref mut data) = conversion.data {
+ if data.item_id.is_empty() {
+ data.item_id = match data.native_item_id.as_ref() {
+ Some(native) => self.item_id_for_native(native),
+ None => self.next_item_id(),
+ };
+ }
+ }
}
_ => {}
}
- data
+
+ conversions.push(conversion);
+ conversions
+ }
+
+ fn push_event(&mut self, conversion: EventConversion) -> Option {
+ if conversion.event_type == UniversalEventType::ItemStarted {
+ if let UniversalEventData::Item(ref data) = conversion.data {
+ if self.item_started.contains(&data.item.item_id) {
+ return None;
+ }
+ }
+ }
+
+ self.next_event_sequence += 1;
+ let sequence = self.next_event_sequence;
+ let event = UniversalEvent {
+ event_id: format!("evt_{sequence}"),
+ sequence,
+ time: now_rfc3339(),
+ session_id: self.session_id.clone(),
+ native_session_id: conversion.native_session_id.clone(),
+ synthetic: conversion.synthetic,
+ source: conversion.source,
+ event_type: conversion.event_type,
+ data: conversion.data,
+ raw: conversion.raw,
+ };
+
+ self.update_pending(&event);
+ self.update_item_tracking(&event);
+ self.events.push(event.clone());
+ let _ = self.broadcaster.send(event.clone());
+ if self.native_session_id.is_none() {
+ self.native_session_id = event.native_session_id.clone();
+ }
+ Some(event)
}
fn update_pending(&mut self, event: &UniversalEvent) {
- match &event.data {
- UniversalEventData::QuestionAsked { question_asked } => {
- self.pending_questions.insert(question_asked.id.clone());
+ match event.event_type {
+ UniversalEventType::QuestionRequested => {
+ if let UniversalEventData::Question(data) = &event.data {
+ self.pending_questions.insert(
+ data.question_id.clone(),
+ PendingQuestion {
+ prompt: data.prompt.clone(),
+ options: data.options.clone(),
+ },
+ );
+ }
}
- UniversalEventData::PermissionAsked { permission_asked } => {
- self.pending_permissions
- .insert(permission_asked.id.clone());
+ UniversalEventType::QuestionResolved => {
+ if let UniversalEventData::Question(data) = &event.data {
+ self.pending_questions.remove(&data.question_id);
+ }
+ }
+ UniversalEventType::PermissionRequested => {
+ if let UniversalEventData::Permission(data) = &event.data {
+ self.pending_permissions
+ .insert(
+ data.permission_id.clone(),
+ PendingPermission {
+ action: data.action.clone(),
+ metadata: data.metadata.clone(),
+ },
+ );
+ }
+ }
+ UniversalEventType::PermissionResolved => {
+ if let UniversalEventData::Permission(data) = &event.data {
+ self.pending_permissions.remove(&data.permission_id);
+ }
}
_ => {}
}
}
- fn take_question(&mut self, question_id: &str) -> bool {
+ fn update_item_tracking(&mut self, event: &UniversalEvent) {
+ match event.event_type {
+ UniversalEventType::ItemStarted | UniversalEventType::ItemCompleted => {
+ if let UniversalEventData::Item(data) = &event.data {
+ self.item_started.insert(data.item.item_id.clone());
+ if let Some(native) = data.item.native_item_id.as_ref() {
+ self.item_map
+ .insert(native.clone(), data.item.item_id.clone());
+ }
+ }
+ }
+ UniversalEventType::ItemDelta => {
+ if let UniversalEventData::ItemDelta(data) = &event.data {
+ self.item_delta_seen.insert(data.item_id.clone());
+ if let Some(native) = data.native_item_id.as_ref() {
+ self.item_map
+ .insert(native.clone(), data.item_id.clone());
+ }
+ }
+ }
+ _ => {}
+ }
+ }
+
+ fn take_question(&mut self, question_id: &str) -> Option {
self.pending_questions.remove(question_id)
}
- fn take_permission(&mut self, permission_id: &str) -> bool {
+ fn take_permission(&mut self, permission_id: &str) -> Option {
self.pending_permissions.remove(permission_id)
}
- fn mark_ended(&mut self, exit_code: Option, message: String) {
+ fn mark_ended(
+ &mut self,
+ exit_code: Option,
+ message: String,
+ reason: SessionEndReason,
+ terminated_by: TerminatedBy,
+ ) {
self.ended = true;
self.ended_exit_code = exit_code;
self.ended_message = Some(message);
+ self.ended_reason = Some(reason);
+ self.terminated_by = Some(terminated_by);
}
fn ended_error(&self) -> Option {
if !self.ended {
return None;
}
+ if matches!(self.terminated_by, Some(TerminatedBy::Daemon)) {
+ return Some(SandboxError::InvalidRequest {
+ message: "session terminated".to_string(),
+ });
+ }
Some(SandboxError::AgentProcessExited {
agent: self.agent.as_str().to_string(),
exit_code: self.ended_exit_code,
stderr: self.ended_message.clone(),
})
}
+
+ fn ensure_item_id(&mut self, item: &mut UniversalItem) {
+ if item.item_id.is_empty() {
+ if let Some(native) = item.native_item_id.as_ref() {
+ item.item_id = self.item_id_for_native(native);
+ } else {
+ item.item_id = self.next_item_id();
+ }
+ }
+ }
+
+ fn ensure_parent_id(&mut self, item: &mut UniversalItem) {
+ let Some(parent_id) = item.parent_id.clone() else { return };
+ if parent_id.starts_with("itm_") {
+ return;
+ }
+ let mapped = self.item_id_for_native(&parent_id);
+ item.parent_id = Some(mapped);
+ }
+
+ fn item_id_for_native(&mut self, native: &str) -> String {
+ if let Some(item_id) = self.item_map.get(native) {
+ return item_id.clone();
+ }
+ let item_id = self.next_item_id();
+ self.item_map.insert(native.to_string(), item_id.clone());
+ item_id
+ }
+
+ fn next_item_id(&mut self) -> String {
+ self.next_item_id += 1;
+ format!("itm_{}", self.next_item_id)
+ }
}
#[derive(Debug)]
@@ -433,19 +624,27 @@ impl SessionManager {
let mut session = SessionState::new(session_id.clone(), agent_id, &request)?;
if agent_id == AgentId::Opencode {
let opencode_session_id = self.create_opencode_session().await?;
- session.agent_session_id = Some(opencode_session_id);
+ session.native_session_id = Some(opencode_session_id);
}
- let started = Started {
- message: Some("session.created".to_string()),
- details: None,
- };
- session.record_event(
- UniversalEventData::Started { started },
- session.agent_session_id.clone(),
- );
+ let metadata = json!({
+ "agent": request.agent,
+ "agentMode": session.agent_mode,
+ "permissionMode": session.permission_mode,
+ "model": request.model,
+ "variant": request.variant,
+ });
+ let started = EventConversion::new(
+ UniversalEventType::SessionStarted,
+ UniversalEventData::SessionStarted(SessionStartedData {
+ metadata: Some(metadata),
+ }),
+ )
+ .synthetic()
+ .with_native_session(session.native_session_id.clone());
+ session.record_conversions(vec![started]);
- let agent_session_id = session.agent_session_id.clone();
+ let native_session_id = session.native_session_id.clone();
let mut sessions = self.sessions.lock().await;
sessions.insert(session_id.clone(), session);
drop(sessions);
@@ -457,7 +656,7 @@ impl SessionManager {
Ok(CreateSessionResponse {
healthy: true,
error: None,
- agent_session_id,
+ native_session_id,
})
}
@@ -521,11 +720,39 @@ impl SessionManager {
Ok(())
}
+ async fn terminate_session(&self, session_id: String) -> Result<(), SandboxError> {
+ let mut sessions = self.sessions.lock().await;
+ let session = sessions.get_mut(&session_id).ok_or_else(|| SandboxError::SessionNotFound {
+ session_id: session_id.clone(),
+ })?;
+ if session.ended {
+ return Ok(());
+ }
+ session.mark_ended(
+ None,
+ "terminated by daemon".to_string(),
+ SessionEndReason::Terminated,
+ TerminatedBy::Daemon,
+ );
+ let ended = EventConversion::new(
+ UniversalEventType::SessionEnded,
+ UniversalEventData::SessionEnded(SessionEndedData {
+ reason: SessionEndReason::Terminated,
+ terminated_by: TerminatedBy::Daemon,
+ }),
+ )
+ .synthetic()
+ .with_native_session(session.native_session_id.clone());
+ session.record_conversions(vec![ended]);
+ Ok(())
+ }
+
async fn events(
&self,
session_id: &str,
offset: u64,
limit: Option,
+ include_raw: bool,
) -> Result {
let sessions = self.sessions.lock().await;
let session = sessions.get(session_id).ok_or_else(|| SandboxError::SessionNotFound {
@@ -535,8 +762,14 @@ impl SessionManager {
let mut events: Vec = session
.events
.iter()
- .filter(|event| event.id > offset)
+ .filter(|event| event.sequence > offset)
.cloned()
+ .map(|mut event| {
+ if !include_raw {
+ event.raw = None;
+ }
+ event
+ })
.collect();
let has_more = if let Some(limit) = limit {
@@ -565,7 +798,7 @@ impl SessionManager {
permission_mode: state.permission_mode.clone(),
model: state.model.clone(),
variant: state.variant.clone(),
- agent_session_id: state.agent_session_id.clone(),
+ native_session_id: state.native_session_id.clone(),
ended: state.ended,
event_count: state.events.len() as u64,
})
@@ -584,7 +817,7 @@ impl SessionManager {
let initial_events = session
.events
.iter()
- .filter(|event| event.id > offset)
+ .filter(|event| event.sequence > offset)
.cloned()
.collect::>();
let receiver = session.broadcaster.subscribe();
@@ -600,12 +833,13 @@ impl SessionManager {
question_id: &str,
answers: Vec>,
) -> Result<(), SandboxError> {
- let (agent, agent_session_id) = {
+ let (agent, native_session_id, pending_question) = {
let mut sessions = self.sessions.lock().await;
let session = sessions.get_mut(session_id).ok_or_else(|| SandboxError::SessionNotFound {
session_id: session_id.to_string(),
})?;
- if !session.take_question(question_id) {
+ let pending = session.take_question(question_id);
+ if pending.is_none() {
return Err(SandboxError::InvalidRequest {
message: format!("unknown question id: {question_id}"),
});
@@ -613,11 +847,16 @@ impl SessionManager {
if let Some(err) = session.ended_error() {
return Err(err);
}
- (session.agent, session.agent_session_id.clone())
+ (session.agent, session.native_session_id.clone(), pending)
};
+ let response = answers
+ .first()
+ .and_then(|inner| inner.first())
+ .cloned();
+
if agent == AgentId::Opencode {
- let agent_session_id = agent_session_id.ok_or_else(|| SandboxError::InvalidRequest {
+ let agent_session_id = native_session_id.clone().ok_or_else(|| SandboxError::InvalidRequest {
message: "missing OpenCode session id".to_string(),
})?;
self.opencode_question_reply(&agent_session_id, question_id, answers)
@@ -626,6 +865,24 @@ impl SessionManager {
// TODO: Forward question replies to subprocess agents.
}
+ if let Some(pending) = pending_question {
+ let resolved = EventConversion::new(
+ UniversalEventType::QuestionResolved,
+ UniversalEventData::Question(QuestionEventData {
+ question_id: question_id.to_string(),
+ prompt: pending.prompt,
+ options: pending.options,
+ response,
+ status: QuestionStatus::Answered,
+ }),
+ )
+ .synthetic()
+ .with_native_session(native_session_id);
+ let _ = self
+ .record_conversions(session_id, vec![resolved])
+ .await;
+ }
+
Ok(())
}
@@ -634,12 +891,13 @@ impl SessionManager {
session_id: &str,
question_id: &str,
) -> Result<(), SandboxError> {
- let (agent, agent_session_id) = {
+ let (agent, native_session_id, pending_question) = {
let mut sessions = self.sessions.lock().await;
let session = sessions.get_mut(session_id).ok_or_else(|| SandboxError::SessionNotFound {
session_id: session_id.to_string(),
})?;
- if !session.take_question(question_id) {
+ let pending = session.take_question(question_id);
+ if pending.is_none() {
return Err(SandboxError::InvalidRequest {
message: format!("unknown question id: {question_id}"),
});
@@ -647,11 +905,11 @@ impl SessionManager {
if let Some(err) = session.ended_error() {
return Err(err);
}
- (session.agent, session.agent_session_id.clone())
+ (session.agent, session.native_session_id.clone(), pending)
};
if agent == AgentId::Opencode {
- let agent_session_id = agent_session_id.ok_or_else(|| SandboxError::InvalidRequest {
+ let agent_session_id = native_session_id.clone().ok_or_else(|| SandboxError::InvalidRequest {
message: "missing OpenCode session id".to_string(),
})?;
self.opencode_question_reject(&agent_session_id, question_id)
@@ -660,6 +918,24 @@ impl SessionManager {
// TODO: Forward question rejections to subprocess agents.
}
+ if let Some(pending) = pending_question {
+ let resolved = EventConversion::new(
+ UniversalEventType::QuestionResolved,
+ UniversalEventData::Question(QuestionEventData {
+ question_id: question_id.to_string(),
+ prompt: pending.prompt,
+ options: pending.options,
+ response: None,
+ status: QuestionStatus::Rejected,
+ }),
+ )
+ .synthetic()
+ .with_native_session(native_session_id);
+ let _ = self
+ .record_conversions(session_id, vec![resolved])
+ .await;
+ }
+
Ok(())
}
@@ -669,12 +945,14 @@ impl SessionManager {
permission_id: &str,
reply: PermissionReply,
) -> Result<(), SandboxError> {
- let (agent, agent_session_id, codex_sender, codex_metadata) = {
+ let reply_for_status = reply.clone();
+ let (agent, native_session_id, codex_sender, pending_permission) = {
let mut sessions = self.sessions.lock().await;
let session = sessions.get_mut(session_id).ok_or_else(|| SandboxError::SessionNotFound {
session_id: session_id.to_string(),
})?;
- if !session.take_permission(permission_id) {
+ let pending = session.take_permission(permission_id);
+ if pending.is_none() {
return Err(SandboxError::InvalidRequest {
message: format!("unknown permission id: {permission_id}"),
});
@@ -682,18 +960,6 @@ impl SessionManager {
if let Some(err) = session.ended_error() {
return Err(err);
}
- let codex_metadata = if session.agent == AgentId::Codex {
- session.events.iter().find_map(|event| {
- if let UniversalEventData::PermissionAsked { permission_asked } = &event.data {
- if permission_asked.id == permission_id {
- return Some(permission_asked.metadata.clone());
- }
- }
- None
- })
- } else {
- None
- };
let codex_sender = if session.agent == AgentId::Codex {
session.codex_sender()
} else {
@@ -701,9 +967,9 @@ impl SessionManager {
};
(
session.agent,
- session.agent_session_id.clone(),
+ session.native_session_id.clone(),
codex_sender,
- codex_metadata,
+ pending,
)
};
@@ -711,9 +977,10 @@ impl SessionManager {
let sender = codex_sender.ok_or_else(|| SandboxError::InvalidRequest {
message: "codex session not active".to_string(),
})?;
- let metadata = codex_metadata.ok_or_else(|| SandboxError::InvalidRequest {
+ let pending = pending_permission.clone().ok_or_else(|| SandboxError::InvalidRequest {
message: "missing codex permission metadata".to_string(),
})?;
+ let metadata = pending.metadata.clone().unwrap_or(Value::Null);
let request_id = codex_request_id_from_metadata(&metadata)
.or_else(|| codex_request_id_from_string(permission_id))
.ok_or_else(|| SandboxError::InvalidRequest {
@@ -725,14 +992,14 @@ impl SessionManager {
.unwrap_or("");
let response_value = match request_kind {
"commandExecution" => {
- let decision = codex_command_decision_for_reply(reply);
+ let decision = codex_command_decision_for_reply(reply.clone());
let response = codex_schema::CommandExecutionRequestApprovalResponse { decision };
serde_json::to_value(response).map_err(|err| SandboxError::InvalidRequest {
message: err.to_string(),
})?
}
"fileChange" => {
- let decision = codex_file_change_decision_for_reply(reply);
+ let decision = codex_file_change_decision_for_reply(reply.clone());
let response = codex_schema::FileChangeRequestApprovalResponse { decision };
serde_json::to_value(response).map_err(|err| SandboxError::InvalidRequest {
message: err.to_string(),
@@ -755,15 +1022,36 @@ impl SessionManager {
message: "codex session not active".to_string(),
})?;
} else if agent == AgentId::Opencode {
- let agent_session_id = agent_session_id.ok_or_else(|| SandboxError::InvalidRequest {
+ let agent_session_id = native_session_id.clone().ok_or_else(|| SandboxError::InvalidRequest {
message: "missing OpenCode session id".to_string(),
})?;
- self.opencode_permission_reply(&agent_session_id, permission_id, reply)
+ self.opencode_permission_reply(&agent_session_id, permission_id, reply.clone())
.await?;
} else {
// TODO: Forward permission replies to subprocess agents.
}
+ if let Some(pending) = pending_permission {
+ let status = match reply_for_status {
+ PermissionReply::Reject => PermissionStatus::Denied,
+ PermissionReply::Once | PermissionReply::Always => PermissionStatus::Approved,
+ };
+ let resolved = EventConversion::new(
+ UniversalEventType::PermissionResolved,
+ UniversalEventData::Permission(PermissionEventData {
+ permission_id: permission_id.to_string(),
+ action: pending.action,
+ status,
+ metadata: pending.metadata,
+ }),
+ )
+ .synthetic()
+ .with_native_session(native_session_id);
+ let _ = self
+ .record_conversions(session_id, vec![resolved])
+ .await;
+ }
+
Ok(())
}
@@ -841,16 +1129,21 @@ impl SessionManager {
if agent == AgentId::Codex {
if let Some(state) = codex_state.as_mut() {
let outcome = state.handle_line(&line);
- if let Some(conversion) = outcome.conversion {
- let _ = self.record_conversion(&session_id, conversion).await;
+ if !outcome.conversions.is_empty() {
+ let _ = self
+ .record_conversions(&session_id, outcome.conversions)
+ .await;
}
if outcome.should_terminate {
terminate_early = true;
break;
}
}
- } else if let Some(conversion) = parse_agent_line(agent, &line, &session_id) {
- let _ = self.record_conversion(&session_id, conversion).await;
+ } else {
+ let conversions = parse_agent_line(agent, &line, &session_id);
+ if !conversions.is_empty() {
+ let _ = self.record_conversions(&session_id, conversions).await;
+ }
}
}
@@ -866,7 +1159,17 @@ impl SessionManager {
}
let status = tokio::task::spawn_blocking(move || child.wait()).await;
match status {
- Ok(Ok(status)) if status.success() => {}
+ Ok(Ok(status)) if status.success() => {
+ let message = format!("agent exited with status {:?}", status);
+ self.mark_session_ended(
+ &session_id,
+ status.code(),
+ &message,
+ SessionEndReason::Completed,
+ TerminatedBy::Agent,
+ )
+ .await;
+ }
Ok(Ok(status)) => {
let message = format!("agent exited with status {:?}", status);
if !terminate_early {
@@ -878,7 +1181,13 @@ impl SessionManager {
)
.await;
}
- self.mark_session_ended(&session_id, status.code(), &message)
+ self.mark_session_ended(
+ &session_id,
+ status.code(),
+ &message,
+ SessionEndReason::Error,
+ TerminatedBy::Agent,
+ )
.await;
}
Ok(Err(err)) => {
@@ -892,7 +1201,13 @@ impl SessionManager {
)
.await;
}
- self.mark_session_ended(&session_id, None, &message)
+ self.mark_session_ended(
+ &session_id,
+ None,
+ &message,
+ SessionEndReason::Error,
+ TerminatedBy::Daemon,
+ )
.await;
}
Err(err) => {
@@ -906,35 +1221,28 @@ impl SessionManager {
)
.await;
}
- self.mark_session_ended(&session_id, None, &message)
+ self.mark_session_ended(
+ &session_id,
+ None,
+ &message,
+ SessionEndReason::Error,
+ TerminatedBy::Daemon,
+ )
.await;
}
}
}
- async fn record_conversion(
+ async fn record_conversions(
&self,
session_id: &str,
- conversion: EventConversion,
- ) -> Result {
+ conversions: Vec,
+ ) -> Result, SandboxError> {
let mut sessions = self.sessions.lock().await;
let session = sessions.get_mut(session_id).ok_or_else(|| SandboxError::SessionNotFound {
session_id: session_id.to_string(),
})?;
- Ok(session.record_conversion(conversion))
- }
-
- async fn record_event(
- &self,
- session_id: &str,
- data: UniversalEventData,
- agent_session_id: Option,
- ) -> Result {
- let mut sessions = self.sessions.lock().await;
- let session = sessions.get_mut(session_id).ok_or_else(|| SandboxError::SessionNotFound {
- session_id: session_id.to_string(),
- })?;
- Ok(session.record_event(data, agent_session_id))
+ Ok(session.record_conversions(conversions))
}
async fn record_error(
@@ -944,28 +1252,48 @@ impl SessionManager {
kind: Option,
details: Option,
) {
- let error = CrashInfo { message, kind, details };
- let _ = self
- .record_event(
- session_id,
- UniversalEventData::Error { error },
- None,
- )
- .await;
+ let error = ErrorData {
+ message,
+ code: kind,
+ details,
+ };
+ let conversion = EventConversion::new(
+ UniversalEventType::Error,
+ UniversalEventData::Error(error),
+ )
+ .synthetic();
+ let _ = self.record_conversions(session_id, vec![conversion]).await;
}
- async fn mark_session_ended(&self, session_id: &str, exit_code: Option, message: &str) {
+ async fn mark_session_ended(
+ &self,
+ session_id: &str,
+ exit_code: Option,
+ message: &str,
+ reason: SessionEndReason,
+ terminated_by: TerminatedBy,
+ ) {
let mut sessions = self.sessions.lock().await;
if let Some(session) = sessions.get_mut(session_id) {
if session.ended {
return;
}
- session.mark_ended(exit_code, message.to_string());
+ session.mark_ended(exit_code, message.to_string(), reason.clone(), terminated_by.clone());
+ let ended = EventConversion::new(
+ UniversalEventType::SessionEnded,
+ UniversalEventData::SessionEnded(SessionEndedData {
+ reason,
+ terminated_by,
+ }),
+ )
+ .synthetic()
+ .with_native_session(session.native_session_id.clone());
+ session.record_conversions(vec![ended]);
}
}
async fn ensure_opencode_stream(self: &Arc, session_id: String) -> Result<(), SandboxError> {
- let agent_session_id = {
+ let native_session_id = {
let mut sessions = self.sessions.lock().await;
let session = sessions.get_mut(&session_id).ok_or_else(|| SandboxError::SessionNotFound {
session_id: session_id.clone(),
@@ -973,24 +1301,24 @@ impl SessionManager {
if session.opencode_stream_started {
return Ok(());
}
- let agent_session_id = session.agent_session_id.clone().ok_or_else(|| SandboxError::InvalidRequest {
+ let native_session_id = session.native_session_id.clone().ok_or_else(|| SandboxError::InvalidRequest {
message: "missing OpenCode session id".to_string(),
})?;
session.opencode_stream_started = true;
- agent_session_id
+ native_session_id
};
let manager = Arc::clone(self);
tokio::spawn(async move {
manager
- .stream_opencode_events(session_id, agent_session_id)
+ .stream_opencode_events(session_id, native_session_id)
.await;
});
Ok(())
}
- async fn stream_opencode_events(self: Arc, session_id: String, agent_session_id: String) {
+ async fn stream_opencode_events(self: Arc, session_id: String, native_session_id: String) {
let base_url = match self.ensure_opencode_server().await {
Ok(base_url) => base_url,
Err(err) => {
@@ -1005,6 +1333,8 @@ impl SessionManager {
&session_id,
None,
"opencode server unavailable",
+ SessionEndReason::Error,
+ TerminatedBy::Daemon,
)
.await;
return;
@@ -1026,6 +1356,8 @@ impl SessionManager {
&session_id,
None,
"opencode sse connection failed",
+ SessionEndReason::Error,
+ TerminatedBy::Daemon,
)
.await;
return;
@@ -1046,6 +1378,8 @@ impl SessionManager {
&session_id,
None,
"opencode sse error",
+ SessionEndReason::Error,
+ TerminatedBy::Daemon,
)
.await;
return;
@@ -1068,6 +1402,8 @@ impl SessionManager {
&session_id,
None,
"opencode sse stream error",
+ SessionEndReason::Error,
+ TerminatedBy::Daemon,
)
.await;
return;
@@ -1078,25 +1414,26 @@ impl SessionManager {
let value: Value = match serde_json::from_str(&event_payload) {
Ok(value) => value,
Err(err) => {
- let conversion = EventConversion::new(unparsed_message(
- &event_payload,
+ let conversion = agent_unparsed(
+ "opencode",
&err.to_string(),
- ));
- let _ = self.record_conversion(&session_id, conversion).await;
+ Value::String(event_payload.clone()),
+ );
+ let _ = self.record_conversions(&session_id, vec![conversion]).await;
continue;
}
};
- if !opencode_event_matches_session(&value, &agent_session_id) {
+ if !opencode_event_matches_session(&value, &native_session_id) {
continue;
}
- let conversion = match serde_json::from_value(value.clone()) {
- Ok(event) => convert_opencode::event_to_universal(&event),
- Err(err) => EventConversion::new(unparsed_message(
- &value.to_string(),
- &err.to_string(),
- )),
+ let conversions = match serde_json::from_value(value.clone()) {
+ Ok(event) => match convert_opencode::event_to_universal(&event) {
+ Ok(conversions) => conversions,
+ Err(err) => vec![agent_unparsed("opencode", &err, value.clone())],
+ },
+ Err(err) => vec![agent_unparsed("opencode", &err.to_string(), value.clone())],
};
- let _ = self.record_conversion(&session_id, conversion).await;
+ let _ = self.record_conversions(&session_id, conversions).await;
}
}
}
@@ -1224,7 +1561,7 @@ impl SessionManager {
prompt: &str,
) -> Result<(), SandboxError> {
let base_url = self.ensure_opencode_server().await?;
- let session_id = session.agent_session_id.as_ref().ok_or_else(|| SandboxError::InvalidRequest {
+ let session_id = session.native_session_id.as_ref().ok_or_else(|| SandboxError::InvalidRequest {
message: "missing OpenCode session id".to_string(),
})?;
let url = format!("{base_url}/session/{session_id}/prompt");
@@ -1416,6 +1753,15 @@ pub struct AgentModesResponse {
pub modes: Vec,
}
+#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct AgentCapabilities {
+ pub plan_mode: bool,
+ pub permissions: bool,
+ pub questions: bool,
+ pub tool_calls: bool,
+}
+
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct AgentInfo {
@@ -1425,6 +1771,7 @@ pub struct AgentInfo {
pub version: Option,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option,
+ pub capabilities: AgentCapabilities,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
@@ -1442,7 +1789,7 @@ pub struct SessionInfo {
pub permission_mode: String,
pub model: Option,
pub variant: Option,
- pub agent_session_id: Option,
+ pub native_session_id: Option,
pub ended: bool,
pub event_count: u64,
}
@@ -1481,7 +1828,7 @@ pub struct CreateSessionResponse {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option,
#[serde(default, skip_serializing_if = "Option::is_none")]
- pub agent_session_id: Option,
+ pub native_session_id: Option,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
@@ -1497,6 +1844,8 @@ pub struct EventsQuery {
pub offset: Option,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub include_raw: Option,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
@@ -1633,6 +1982,7 @@ async fn list_agents(
installed,
version,
path: path.map(|path| path.to_string_lossy().to_string()),
+ capabilities: agent_capabilities_for(agent_id),
}
})
.collect::>()
@@ -1705,13 +2055,32 @@ async fn post_message(
Ok(StatusCode::NO_CONTENT)
}
+#[utoipa::path(
+ post,
+ path = "/v1/sessions/{session_id}/terminate",
+ params(("session_id" = String, Path, description = "Session id")),
+ responses(
+ (status = 204, description = "Session terminated"),
+ (status = 404, body = ProblemDetails)
+ ),
+ tag = "sessions"
+)]
+async fn terminate_session(
+ State(state): State>,
+ Path(session_id): Path,
+) -> Result {
+ state.session_manager.terminate_session(session_id).await?;
+ Ok(StatusCode::NO_CONTENT)
+}
+
#[utoipa::path(
get,
path = "/v1/sessions/{session_id}/events",
params(
("session_id" = String, Path, description = "Session id"),
- ("offset" = Option, Query, description = "Last seen event id (exclusive)"),
- ("limit" = Option, Query, description = "Max events to return")
+ ("offset" = Option, Query, description = "Last seen event sequence (exclusive)"),
+ ("limit" = Option, Query, description = "Max events to return"),
+ ("include_raw" = Option, Query, description = "Include raw provider payloads")
),
responses(
(status = 200, body = EventsResponse),
@@ -1727,7 +2096,7 @@ async fn get_events(
let offset = query.offset.unwrap_or(0);
let response = state
.session_manager
- .events(&session_id, offset, query.limit)
+ .events(&session_id, offset, query.limit, query.include_raw.unwrap_or(false))
.await?;
Ok(Json(response))
}
@@ -1737,7 +2106,8 @@ async fn get_events(
path = "/v1/sessions/{session_id}/events/sse",
params(
("session_id" = String, Path, description = "Session id"),
- ("offset" = Option, Query, description = "Last seen event id (exclusive)")
+ ("offset" = Option, Query, description = "Last seen event sequence (exclusive)"),
+ ("include_raw" = Option, Query, description = "Include raw provider payloads")
),
responses((status = 200, description = "SSE event stream")),
tag = "sessions"
@@ -1748,6 +2118,7 @@ async fn get_events_sse(
Query(query): Query,
) -> Result>>, ApiError> {
let offset = query.offset.unwrap_or(0);
+ let include_raw = query.include_raw.unwrap_or(false);
let subscription = state
.session_manager
.subscribe(&session_id, offset)
@@ -1755,15 +2126,26 @@ async fn get_events_sse(
let initial_events = subscription.initial_events;
let receiver = subscription.receiver;
- let initial_stream = stream::iter(initial_events.into_iter().map(|event| {
+ let initial_stream = stream::iter(initial_events.into_iter().map(move |mut event| {
+ if !include_raw {
+ event.raw = None;
+ }
Ok::(to_sse_event(event))
}));
- let live_stream = BroadcastStream::new(receiver).filter_map(|result| async move {
+ let live_stream = BroadcastStream::new(receiver).filter_map(move |result| {
+ let include_raw = include_raw;
+ async move {
match result {
- Ok(event) => Some(Ok::(to_sse_event(event))),
+ Ok(mut event) => {
+ if !include_raw {
+ event.raw = None;
+ }
+ Some(Ok::(to_sse_event(event)))
+ }
Err(_) => None,
}
+ }
});
let stream = initial_stream.chain(live_stream);
@@ -1855,6 +2237,37 @@ fn all_agents() -> [AgentId; 4] {
]
}
+fn agent_capabilities_for(agent: AgentId) -> AgentCapabilities {
+ match agent {
+ // Headless Claude CLI does not expose AskUserQuestion and does not emit tool_result,
+ // so we keep these capabilities off until we switch to an SDK-backed wrapper.
+ AgentId::Claude => AgentCapabilities {
+ plan_mode: false,
+ permissions: false,
+ questions: false,
+ tool_calls: false,
+ },
+ AgentId::Codex => AgentCapabilities {
+ plan_mode: true,
+ permissions: true,
+ questions: false,
+ tool_calls: true,
+ },
+ AgentId::Opencode => AgentCapabilities {
+ plan_mode: false,
+ permissions: false,
+ questions: false,
+ tool_calls: true,
+ },
+ AgentId::Amp => AgentCapabilities {
+ plan_mode: false,
+ permissions: false,
+ questions: false,
+ tool_calls: true,
+ },
+ }
+}
+
fn parse_agent_id(agent: &str) -> Result {
AgentId::parse(agent).ok_or_else(|| SandboxError::UnsupportedAgent {
agent: agent.to_string(),
@@ -2040,7 +2453,7 @@ fn build_spawn_options(
options.variant = session.variant.clone();
options.agent_mode = Some(session.agent_mode.clone());
options.permission_mode = Some(session.permission_mode.clone());
- options.session_id = session.agent_session_id.clone().or_else(|| {
+ options.session_id = session.native_session_id.clone().or_else(|| {
if session.agent == AgentId::Opencode {
Some(session.session_id.clone())
} else {
@@ -2101,7 +2514,7 @@ fn write_lines(mut stdin: std::process::ChildStdin, mut receiver: mpsc::Unbounde
#[derive(Default)]
struct CodexLineOutcome {
- conversion: Option,
+ conversions: Vec,
should_terminate: bool,
}
@@ -2171,11 +2584,29 @@ impl CodexAppServerState {
}
let value: Value = match serde_json::from_str(trimmed) {
Ok(value) => value,
- Err(_) => return CodexLineOutcome::default(),
+ Err(err) => {
+ return CodexLineOutcome {
+ conversions: vec![agent_unparsed(
+ "codex",
+ &err.to_string(),
+ Value::String(trimmed.to_string()),
+ )],
+ should_terminate: false,
+ };
+ }
};
let message: codex_schema::JsonrpcMessage = match serde_json::from_value(value.clone()) {
Ok(message) => message,
- Err(_) => return CodexLineOutcome::default(),
+ Err(err) => {
+ return CodexLineOutcome {
+ conversions: vec![agent_unparsed(
+ "codex",
+ &err.to_string(),
+ value,
+ )],
+ should_terminate: false,
+ };
+ }
};
match message {
@@ -2194,36 +2625,73 @@ impl CodexAppServerState {
| codex_schema::ServerNotification::Error(_)
);
if codex_should_emit_notification(¬ification) {
- let conversion = convert_codex::notification_to_universal(¬ification);
- CodexLineOutcome {
- conversion: Some(conversion),
- should_terminate,
+ match convert_codex::notification_to_universal(¬ification) {
+ Ok(conversions) => CodexLineOutcome {
+ conversions,
+ should_terminate,
+ },
+ Err(err) => CodexLineOutcome {
+ conversions: vec![agent_unparsed(
+ "codex",
+ &err,
+ value,
+ )],
+ should_terminate,
+ },
}
} else {
CodexLineOutcome {
- conversion: None,
+ conversions: Vec::new(),
should_terminate,
}
}
} else {
- CodexLineOutcome::default()
+ CodexLineOutcome {
+ conversions: vec![agent_unparsed(
+ "codex",
+ "invalid notification",
+ value,
+ )],
+ should_terminate: false,
+ }
}
}
codex_schema::JsonrpcMessage::Request(_) => {
if let Ok(request) =
serde_json::from_value::(value.clone())
{
- let conversion = codex_request_to_universal(&request);
- CodexLineOutcome {
- conversion: Some(conversion),
- should_terminate: false,
+ match codex_request_to_universal(&request) {
+ Ok(mut conversions) => {
+ for conversion in &mut conversions {
+ conversion.raw = Some(value.clone());
+ }
+ CodexLineOutcome {
+ conversions,
+ should_terminate: false,
+ }
+ }
+ Err(err) => CodexLineOutcome {
+ conversions: vec![agent_unparsed(
+ "codex",
+ &err,
+ value,
+ )],
+ should_terminate: false,
+ },
}
} else {
- CodexLineOutcome::default()
+ CodexLineOutcome {
+ conversions: vec![agent_unparsed(
+ "codex",
+ "invalid request",
+ value,
+ )],
+ should_terminate: false,
+ }
}
}
codex_schema::JsonrpcMessage::Error(error) => CodexLineOutcome {
- conversion: Some(codex_rpc_error_to_universal(&error)),
+ conversions: vec![codex_rpc_error_to_universal(&error)],
should_terminate: true,
},
}
@@ -2327,7 +2795,6 @@ impl CodexAppServerState {
}],
model: self.model.clone(),
output_schema: None,
- personality: None,
sandbox_policy: self.sandbox_policy.clone(),
summary: None,
thread_id,
@@ -2386,20 +2853,11 @@ fn codex_sandbox_policy(mode: Option<&str>) -> Option bool {
- match notification {
- codex_schema::ServerNotification::ThreadStarted(_)
- | codex_schema::ServerNotification::TurnStarted(_)
- | codex_schema::ServerNotification::Error(_) => true,
- codex_schema::ServerNotification::ItemCompleted(params) => matches!(
- params.item,
- codex_schema::ThreadItem::UserMessage { .. }
- | codex_schema::ThreadItem::AgentMessage { .. }
- ),
- _ => false,
- }
+ let _ = notification;
+ true
}
-fn codex_request_to_universal(request: &codex_schema::ServerRequest) -> EventConversion {
+fn codex_request_to_universal(request: &codex_schema::ServerRequest) -> Result, String> {
match request {
codex_schema::ServerRequest::ItemCommandExecutionRequestApproval { id, params } => {
let mut metadata = serde_json::Map::new();
@@ -2420,23 +2878,17 @@ fn codex_request_to_universal(request: &codex_schema::ServerRequest) -> EventCon
if let Some(reason) = params.reason.as_ref() {
metadata.insert("reason".to_string(), Value::String(reason.clone()));
}
- let permission = PermissionRequest {
- id: id.to_string(),
- session_id: params.thread_id.clone(),
- permission: "commandExecution".to_string(),
- patterns: params
- .command
- .as_ref()
- .map(|command| vec![command.clone()])
- .unwrap_or_default(),
- metadata,
- always: Vec::new(),
- tool: None,
+ let permission = PermissionEventData {
+ permission_id: id.to_string(),
+ action: "commandExecution".to_string(),
+ status: PermissionStatus::Requested,
+ metadata: Some(Value::Object(metadata)),
};
- EventConversion::new(UniversalEventData::PermissionAsked {
- permission_asked: permission,
- })
- .with_session(Some(params.thread_id.clone()))
+ Ok(vec![EventConversion::new(
+ UniversalEventType::PermissionRequested,
+ UniversalEventData::Permission(permission),
+ )
+ .with_native_session(Some(params.thread_id.clone()))])
}
codex_schema::ServerRequest::ItemFileChangeRequestApproval { id, params } => {
let mut metadata = serde_json::Map::new();
@@ -2457,43 +2909,33 @@ fn codex_request_to_universal(request: &codex_schema::ServerRequest) -> EventCon
if let Some(grant_root) = params.grant_root.as_ref() {
metadata.insert("grantRoot".to_string(), Value::String(grant_root.clone()));
}
- let permission = PermissionRequest {
- id: id.to_string(),
- session_id: params.thread_id.clone(),
- permission: "fileChange".to_string(),
- patterns: params
- .grant_root
- .as_ref()
- .map(|root| vec![root.clone()])
- .unwrap_or_default(),
- metadata,
- always: Vec::new(),
- tool: None,
+ let permission = PermissionEventData {
+ permission_id: id.to_string(),
+ action: "fileChange".to_string(),
+ status: PermissionStatus::Requested,
+ metadata: Some(Value::Object(metadata)),
};
- EventConversion::new(UniversalEventData::PermissionAsked {
- permission_asked: permission,
- })
- .with_session(Some(params.thread_id.clone()))
+ Ok(vec![EventConversion::new(
+ UniversalEventType::PermissionRequested,
+ UniversalEventData::Permission(permission),
+ )
+ .with_native_session(Some(params.thread_id.clone()))])
}
- _ => EventConversion::new(UniversalEventData::Unknown {
- raw: serde_json::to_value(request).unwrap_or(Value::Null),
- }),
+ _ => Err("unsupported codex request".to_string()),
}
}
fn codex_rpc_error_to_universal(error: &codex_schema::JsonrpcError) -> EventConversion {
- let message = error.error.message.clone();
- let crash = CrashInfo {
- message,
- kind: Some("jsonrpc.error".to_string()),
+ let data = ErrorData {
+ message: error.error.message.clone(),
+ code: Some("jsonrpc.error".to_string()),
details: serde_json::to_value(error).ok(),
};
- EventConversion::new(UniversalEventData::Error { error: crash })
+ EventConversion::new(UniversalEventType::Error, UniversalEventData::Error(data))
}
-fn codex_request_id_from_metadata(
- metadata: &serde_json::Map,
-) -> Option {
+fn codex_request_id_from_metadata(metadata: &Value) -> Option {
+ let metadata = metadata.as_object()?;
let value = metadata.get("codexRequestId")?;
codex_request_id_from_value(value)
}
@@ -2533,47 +2975,40 @@ fn codex_file_change_decision_for_reply(
}
}
-fn parse_agent_line(agent: AgentId, line: &str, session_id: &str) -> Option {
+fn parse_agent_line(agent: AgentId, line: &str, session_id: &str) -> Vec {
let trimmed = line.trim();
if trimmed.is_empty() {
- return None;
+ return Vec::new();
}
let value: Value = match serde_json::from_str(trimmed) {
Ok(value) => value,
Err(err) => {
- return Some(EventConversion::new(unparsed_message(
- trimmed,
+ return vec![agent_unparsed(
+ agent.as_str(),
&err.to_string(),
- )));
+ Value::String(trimmed.to_string()),
+ )];
}
};
- let conversion = match agent {
- AgentId::Claude => {
- convert_claude::event_to_universal_with_session(&value, session_id.to_string())
- }
+ match agent {
+ AgentId::Claude => convert_claude::event_to_universal_with_session(&value, session_id.to_string())
+ .unwrap_or_else(|err| vec![agent_unparsed("claude", &err, value)]),
AgentId::Codex => match serde_json::from_value(value.clone()) {
- Ok(notification) => convert_codex::notification_to_universal(¬ification),
- Err(err) => EventConversion::new(unparsed_message(
- &value.to_string(),
- &err.to_string(),
- )),
+ Ok(notification) => convert_codex::notification_to_universal(¬ification)
+ .unwrap_or_else(|err| vec![agent_unparsed("codex", &err, value)]),
+ Err(err) => vec![agent_unparsed("codex", &err.to_string(), value)],
},
AgentId::Opencode => match serde_json::from_value(value.clone()) {
- Ok(event) => convert_opencode::event_to_universal(&event),
- Err(err) => EventConversion::new(unparsed_message(
- &value.to_string(),
- &err.to_string(),
- )),
+ Ok(event) => convert_opencode::event_to_universal(&event)
+ .unwrap_or_else(|err| vec![agent_unparsed("opencode", &err, value)]),
+ Err(err) => vec![agent_unparsed("opencode", &err.to_string(), value)],
},
AgentId::Amp => match serde_json::from_value(value.clone()) {
- Ok(event) => convert_amp::event_to_universal(&event),
- Err(err) => EventConversion::new(unparsed_message(
- &value.to_string(),
- &err.to_string(),
- )),
+ Ok(event) => convert_amp::event_to_universal(&event)
+ .unwrap_or_else(|err| vec![agent_unparsed("amp", &err, value)]),
+ Err(err) => vec![agent_unparsed("amp", &err.to_string(), value)],
},
- };
- Some(conversion)
+ }
}
fn opencode_event_matches_session(value: &Value, session_id: &str) -> bool {
@@ -2720,13 +3155,34 @@ fn ensure_custom_mode(modes: &mut Vec) {
});
}
-fn unparsed_message(raw: &str, error: &str) -> UniversalEventData {
- UniversalEventData::Message {
- message: UniversalMessage::Unparsed {
- raw: Value::String(raw.to_string()),
- error: Some(error.to_string()),
- },
+fn text_delta_from_parts(parts: &[ContentPart]) -> Option {
+ let mut delta = String::new();
+ for part in parts {
+ if let ContentPart::Text { text } = part {
+ if !delta.is_empty() {
+ delta.push_str("\n");
+ }
+ delta.push_str(text);
+ }
}
+ if delta.is_empty() {
+ None
+ } else {
+ Some(delta)
+ }
+}
+
+fn agent_unparsed(location: &str, error: &str, raw: Value) -> EventConversion {
+ EventConversion::new(
+ UniversalEventType::AgentUnparsed,
+ UniversalEventData::AgentUnparsed(AgentUnparsedData {
+ error: error.to_string(),
+ location: location.to_string(),
+ raw_hash: None,
+ }),
+ )
+ .synthetic()
+ .with_raw(Some(raw))
}
fn now_rfc3339() -> String {
@@ -2749,7 +3205,7 @@ struct SessionSnapshot {
permission_mode: String,
model: Option,
variant: Option,
- agent_session_id: Option,
+ native_session_id: Option,
}
impl From<&SessionState> for SessionSnapshot {
@@ -2761,7 +3217,7 @@ impl From<&SessionState> for SessionSnapshot {
permission_mode: session.permission_mode.clone(),
model: session.model.clone(),
variant: session.variant.clone(),
- agent_session_id: session.agent_session_id.clone(),
+ native_session_id: session.native_session_id.clone(),
}
}
}
diff --git a/server/packages/sandbox-agent/tests/agent_agnostic.rs b/server/packages/sandbox-agent/tests/agent_agnostic.rs
new file mode 100644
index 0000000..1de37dc
--- /dev/null
+++ b/server/packages/sandbox-agent/tests/agent_agnostic.rs
@@ -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>,
+}
+
+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,
+) -> (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) -> 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(
+ app: &Router,
+ session_id: &str,
+ timeout: Duration,
+ mut stop: F,
+) -> Vec
+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 {
+ 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 {
+ 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 {
+ event.get("sequence").and_then(Value::as_u64)
+}
+
+fn find_item_event_seq(events: &[Value], event_type: &str, item_id: &str) -> Option {
+ 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 {
+ 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 {
+ 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>> {
+ 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 {
+ 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");
+ }
+}
diff --git a/server/packages/sandbox-agent/tests/http_sse_snapshots.rs b/server/packages/sandbox-agent/tests/http_sse_snapshots.rs
index 3294305..8352f2b 100644
--- a/server/packages/sandbox-agent/tests/http_sse_snapshots.rs
+++ b/server/packages/sandbox-agent/tests/http_sse_snapshots.rs
@@ -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 {
@@ -339,7 +365,21 @@ fn truncate_permission_events(events: &[Value]) -> Vec {
events.to_vec()
}
+fn truncate_question_events(events: &[Value]) -> Vec {
+ 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 {
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("".to_string()));
+ }
+ if data.get("native_item_id").is_some() {
+ delta.insert("native_item_id".to_string(), Value::String("".to_string()));
+ }
+ if data.get("delta").is_some() {
+ delta.insert("delta".to_string(), Value::String("".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::>();
- 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::>();
+ 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("".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("".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("".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("".to_string()));
+ if value.get("nativeSessionId").is_some() {
+ map.insert("nativeSessionId".to_string(), Value::String("".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 {
.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 {
fn find_question_id_and_answers(events: &[Value]) -> Option<(String, Vec>)> {
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)),
}, {
diff --git a/server/packages/sandbox-agent/tests/inspector_ui.rs b/server/packages/sandbox-agent/tests/inspector_ui.rs
index ea57d38..f98a758 100644
--- a/server/packages/sandbox-agent/tests/inspector_ui.rs
+++ b/server/packages/sandbox-agent/tests/inspector_ui.rs
@@ -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;
diff --git a/server/packages/sandbox-agent/tests/snapshots/http_sse_snapshots__approval_flow_snapshots@permission_events_claude.snap b/server/packages/sandbox-agent/tests/snapshots/http_sse_snapshots__approval_flow_snapshots@permission_events_claude.snap
index 6f1749c..e318618 100644
--- a/server/packages/sandbox-agent/tests/snapshots/http_sse_snapshots__approval_flow_snapshots@permission_events_claude.snap
+++ b/server/packages/sandbox-agent/tests/snapshots/http_sse_snapshots__approval_flow_snapshots@permission_events_claude.snap
@@ -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: ""
- 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: ""
+ item_id: ""
+ native_item_id: ""
+ 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
diff --git a/server/packages/sandbox-agent/tests/snapshots/http_sse_snapshots__approval_flow_snapshots@question_reject_events_claude.snap b/server/packages/sandbox-agent/tests/snapshots/http_sse_snapshots__approval_flow_snapshots@question_reject_events_claude.snap
index 57d467f..8cb0493 100644
--- a/server/packages/sandbox-agent/tests/snapshots/http_sse_snapshots__approval_flow_snapshots@question_reject_events_claude.snap
+++ b/server/packages/sandbox-agent/tests/snapshots/http_sse_snapshots__approval_flow_snapshots@question_reject_events_claude.snap
@@ -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: ""
- 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: ""
+ item_id: ""
+ native_item_id: ""
+ 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
diff --git a/server/packages/sandbox-agent/tests/snapshots/http_sse_snapshots__approval_flow_snapshots@question_reply_events_claude.snap b/server/packages/sandbox-agent/tests/snapshots/http_sse_snapshots__approval_flow_snapshots@question_reply_events_claude.snap
index da5765c..90cd95f 100644
--- a/server/packages/sandbox-agent/tests/snapshots/http_sse_snapshots__approval_flow_snapshots@question_reply_events_claude.snap
+++ b/server/packages/sandbox-agent/tests/snapshots/http_sse_snapshots__approval_flow_snapshots@question_reply_events_claude.snap
@@ -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: ""
- 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: ""
- type: text
- role: assistant
+ source: daemon
+ synthetic: true
+ type: item.started
+- delta:
+ delta: ""
+ item_id: ""
+ native_item_id: ""
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
diff --git a/server/packages/sandbox-agent/tests/snapshots/http_sse_snapshots__run_concurrency_snapshot@concurrency_events_claude.snap b/server/packages/sandbox-agent/tests/snapshots/http_sse_snapshots__run_concurrency_snapshot@concurrency_events_claude.snap
index 1951476..3047e92 100644
--- a/server/packages/sandbox-agent/tests/snapshots/http_sse_snapshots__run_concurrency_snapshot@concurrency_events_claude.snap
+++ b/server/packages/sandbox-agent/tests/snapshots/http_sse_snapshots__run_concurrency_snapshot@concurrency_events_claude.snap
@@ -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: ""
- 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: ""
+ item_id: ""
+ native_item_id: ""
+ 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: ""
- 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: ""
+ item_id: ""
+ native_item_id: ""
+ 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
diff --git a/server/packages/sandbox-agent/tests/snapshots/http_sse_snapshots__run_http_events_snapshot@http_events_claude.snap b/server/packages/sandbox-agent/tests/snapshots/http_sse_snapshots__run_http_events_snapshot@http_events_claude.snap
index 310840e..19d0fb3 100644
--- a/server/packages/sandbox-agent/tests/snapshots/http_sse_snapshots__run_http_events_snapshot@http_events_claude.snap
+++ b/server/packages/sandbox-agent/tests/snapshots/http_sse_snapshots__run_http_events_snapshot@http_events_claude.snap
@@ -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: ""
- 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: ""
+ item_id: ""
+ native_item_id: ""
+ 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
diff --git a/server/packages/sandbox-agent/tests/snapshots/http_sse_snapshots__run_sse_events_snapshot@sse_events_claude.snap b/server/packages/sandbox-agent/tests/snapshots/http_sse_snapshots__run_sse_events_snapshot@sse_events_claude.snap
index 310840e..4c732b3 100644
--- a/server/packages/sandbox-agent/tests/snapshots/http_sse_snapshots__run_sse_events_snapshot@sse_events_claude.snap
+++ b/server/packages/sandbox-agent/tests/snapshots/http_sse_snapshots__run_sse_events_snapshot@sse_events_claude.snap
@@ -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: ""
- 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: ""
+ item_id: ""
+ native_item_id: ""
+ 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
diff --git a/server/packages/universal-agent-schema/src/agents/amp.rs b/server/packages/universal-agent-schema/src/agents/amp.rs
index a4a3255..5361557 100644
--- a/server/packages/universal-agent-schema/src/agents/amp.rs
+++ b/server/packages/universal-agent-schema/src/agents/amp.rs
@@ -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, 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 {
- 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 {
+ 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 {
+ 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 {
- 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
}
diff --git a/server/packages/universal-agent-schema/src/agents/claude.rs b/server/packages/universal-agent-schema/src/agents/claude.rs
index f147c47..de15d9c 100644
--- a/server/packages/universal-agent-schema/src/agents/claude.rs
+++ b/server/packages/universal-agent-schema/src/agents/claude.rs
@@ -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, 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 {
- 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 {
- 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 {
+ 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 {
+ 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 {
+ 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 {
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,
- session_id: String,
-) -> Option {
- 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 {
+ 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, synthetic_start: bool) -> Vec {
+ 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 {
+ 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::>()
+ })
+ .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::>()
- })?;
- 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,
})
}
diff --git a/server/packages/universal-agent-schema/src/agents/codex.rs b/server/packages/universal-agent-schema/src/agents/codex.rs
index fda62ab..e7ddf53 100644
--- a/server/packages/universal-agent-schema/src/agents/codex.rs
+++ b/server/packages/universal-agent-schema/src/agents/codex.rs
@@ -1,98 +1,206 @@
-use crate::{
- AttachmentSource,
- ConversionError,
- CrashInfo,
- EventConversion,
- Started,
- UniversalEventData,
- UniversalMessage,
- UniversalMessageParsed,
- UniversalMessagePart,
-};
+use serde_json::Value;
+
use crate::codex as schema;
-use serde_json::{Map, Value};
+use crate::{
+ ContentPart,
+ ErrorData,
+ EventConversion,
+ ItemDeltaData,
+ ItemEventData,
+ ItemKind,
+ ItemRole,
+ ItemStatus,
+ ReasoningVisibility,
+ SessionEndedData,
+ SessionEndReason,
+ SessionStartedData,
+ TerminatedBy,
+ UniversalEventData,
+ UniversalEventType,
+ UniversalItem,
+};
-/// Convert a Codex ServerNotification to a universal event.
-/// This is the main entry point for handling Codex events.
-pub fn notification_to_universal(notification: &schema::ServerNotification) -> EventConversion {
+/// Convert a Codex ServerNotification to universal events.
+pub fn notification_to_universal(
+ notification: &schema::ServerNotification,
+) -> Result, String> {
+ let raw = serde_json::to_value(notification).ok();
match notification {
- // Thread lifecycle
schema::ServerNotification::ThreadStarted(params) => {
- thread_started_to_universal(params)
+ let data = SessionStartedData {
+ metadata: serde_json::to_value(¶ms.thread).ok(),
+ };
+ Ok(vec![
+ EventConversion::new(
+ UniversalEventType::SessionStarted,
+ UniversalEventData::SessionStarted(data),
+ )
+ .with_native_session(Some(params.thread.id.clone()))
+ .with_raw(raw),
+ ])
}
- schema::ServerNotification::TurnStarted(params) => {
- turn_started_to_universal(params)
- }
- schema::ServerNotification::TurnCompleted(params) => {
- turn_completed_to_universal(params)
- }
-
- // Item lifecycle
+ schema::ServerNotification::ThreadCompacted(params) => Ok(vec![status_event(
+ "thread.compacted",
+ serde_json::to_string(params).ok(),
+ Some(params.thread_id.clone()),
+ raw,
+ )]),
+ schema::ServerNotification::ThreadTokenUsageUpdated(params) => Ok(vec![status_event(
+ "thread.token_usage.updated",
+ serde_json::to_string(params).ok(),
+ Some(params.thread_id.clone()),
+ raw,
+ )]),
+ schema::ServerNotification::TurnStarted(params) => Ok(vec![status_event(
+ "turn.started",
+ serde_json::to_string(¶ms.turn).ok(),
+ Some(params.thread_id.clone()),
+ raw,
+ )]),
+ schema::ServerNotification::TurnCompleted(params) => Ok(vec![status_event(
+ "turn.completed",
+ serde_json::to_string(¶ms.turn).ok(),
+ Some(params.thread_id.clone()),
+ raw,
+ )]),
+ schema::ServerNotification::TurnDiffUpdated(params) => Ok(vec![status_event(
+ "turn.diff.updated",
+ serde_json::to_string(params).ok(),
+ Some(params.thread_id.clone()),
+ raw,
+ )]),
+ schema::ServerNotification::TurnPlanUpdated(params) => Ok(vec![status_event(
+ "turn.plan.updated",
+ serde_json::to_string(params).ok(),
+ Some(params.thread_id.clone()),
+ raw,
+ )]),
schema::ServerNotification::ItemStarted(params) => {
- item_started_to_universal(params)
+ let item = thread_item_to_item(¶ms.item, ItemStatus::InProgress);
+ Ok(vec![
+ EventConversion::new(
+ UniversalEventType::ItemStarted,
+ UniversalEventData::Item(ItemEventData { item }),
+ )
+ .with_native_session(Some(params.thread_id.clone()))
+ .with_raw(raw),
+ ])
}
schema::ServerNotification::ItemCompleted(params) => {
- item_completed_to_universal(params)
+ let item = thread_item_to_item(¶ms.item, ItemStatus::Completed);
+ Ok(vec![
+ EventConversion::new(
+ UniversalEventType::ItemCompleted,
+ UniversalEventData::Item(ItemEventData { item }),
+ )
+ .with_native_session(Some(params.thread_id.clone()))
+ .with_raw(raw),
+ ])
}
-
- // Streaming deltas
- schema::ServerNotification::ItemAgentMessageDelta(params) => {
- agent_message_delta_to_universal(params)
- }
- schema::ServerNotification::ItemReasoningTextDelta(params) => {
- reasoning_text_delta_to_universal(params)
- }
- schema::ServerNotification::ItemReasoningSummaryTextDelta(params) => {
- reasoning_summary_delta_to_universal(params)
- }
- schema::ServerNotification::ItemCommandExecutionOutputDelta(params) => {
- command_output_delta_to_universal(params)
- }
- schema::ServerNotification::ItemFileChangeOutputDelta(params) => {
- file_change_delta_to_universal(params)
- }
-
- // Errors
+ schema::ServerNotification::ItemAgentMessageDelta(params) => Ok(vec![
+ EventConversion::new(
+ UniversalEventType::ItemDelta,
+ UniversalEventData::ItemDelta(ItemDeltaData {
+ item_id: String::new(),
+ native_item_id: Some(params.item_id.clone()),
+ delta: params.delta.clone(),
+ }),
+ )
+ .with_native_session(Some(params.thread_id.clone()))
+ .with_raw(raw),
+ ]),
+ schema::ServerNotification::ItemReasoningTextDelta(params) => Ok(vec![
+ EventConversion::new(
+ UniversalEventType::ItemDelta,
+ UniversalEventData::ItemDelta(ItemDeltaData {
+ item_id: String::new(),
+ native_item_id: Some(params.item_id.clone()),
+ delta: params.delta.clone(),
+ }),
+ )
+ .with_native_session(Some(params.thread_id.clone()))
+ .with_raw(raw),
+ ]),
+ schema::ServerNotification::ItemReasoningSummaryTextDelta(params) => Ok(vec![
+ EventConversion::new(
+ UniversalEventType::ItemDelta,
+ UniversalEventData::ItemDelta(ItemDeltaData {
+ item_id: String::new(),
+ native_item_id: Some(params.item_id.clone()),
+ delta: params.delta.clone(),
+ }),
+ )
+ .with_native_session(Some(params.thread_id.clone()))
+ .with_raw(raw),
+ ]),
+ schema::ServerNotification::ItemCommandExecutionOutputDelta(params) => Ok(vec![
+ EventConversion::new(
+ UniversalEventType::ItemDelta,
+ UniversalEventData::ItemDelta(ItemDeltaData {
+ item_id: String::new(),
+ native_item_id: Some(params.item_id.clone()),
+ delta: params.delta.clone(),
+ }),
+ )
+ .with_native_session(Some(params.thread_id.clone()))
+ .with_raw(raw),
+ ]),
+ schema::ServerNotification::ItemFileChangeOutputDelta(params) => Ok(vec![
+ EventConversion::new(
+ UniversalEventType::ItemDelta,
+ UniversalEventData::ItemDelta(ItemDeltaData {
+ item_id: String::new(),
+ native_item_id: Some(params.item_id.clone()),
+ delta: params.delta.clone(),
+ }),
+ )
+ .with_native_session(Some(params.thread_id.clone()))
+ .with_raw(raw),
+ ]),
+ schema::ServerNotification::ItemCommandExecutionTerminalInteraction(params) => Ok(vec![
+ EventConversion::new(
+ UniversalEventType::ItemDelta,
+ UniversalEventData::ItemDelta(ItemDeltaData {
+ item_id: String::new(),
+ native_item_id: Some(params.item_id.clone()),
+ delta: params.stdin.clone(),
+ }),
+ )
+ .with_native_session(Some(params.thread_id.clone()))
+ .with_raw(raw),
+ ]),
+ schema::ServerNotification::ItemMcpToolCallProgress(params) => Ok(vec![status_event(
+ "mcp.progress",
+ serde_json::to_string(params).ok(),
+ Some(params.thread_id.clone()),
+ raw,
+ )]),
+ schema::ServerNotification::ItemReasoningSummaryPartAdded(params) => Ok(vec![
+ status_event(
+ "reasoning.summary.part_added",
+ serde_json::to_string(params).ok(),
+ Some(params.thread_id.clone()),
+ raw,
+ ),
+ ]),
schema::ServerNotification::Error(params) => {
- error_notification_to_universal(params)
+ let data = ErrorData {
+ message: params.error.message.clone(),
+ code: None,
+ details: serde_json::to_value(params).ok(),
+ };
+ Ok(vec![
+ EventConversion::new(UniversalEventType::Error, UniversalEventData::Error(data))
+ .with_native_session(Some(params.thread_id.clone()))
+ .with_raw(raw),
+ ])
}
-
- // Token usage updates
- schema::ServerNotification::ThreadTokenUsageUpdated(params) => {
- token_usage_to_universal(params)
- }
-
- // Turn diff updates
- schema::ServerNotification::TurnDiffUpdated(params) => {
- turn_diff_to_universal(params)
- }
-
- // Plan updates
- schema::ServerNotification::TurnPlanUpdated(params) => {
- turn_plan_to_universal(params)
- }
-
- // Terminal interaction
- schema::ServerNotification::ItemCommandExecutionTerminalInteraction(params) => {
- terminal_interaction_to_universal(params)
- }
-
- // MCP tool call progress
- schema::ServerNotification::ItemMcpToolCallProgress(params) => {
- mcp_progress_to_universal(params)
- }
-
- // Reasoning summary part added
- schema::ServerNotification::ItemReasoningSummaryPartAdded(params) => {
- reasoning_summary_part_to_universal(params)
- }
-
- // Context compacted
- schema::ServerNotification::ThreadCompacted(params) => {
- context_compacted_to_universal(params)
- }
-
- // Account/auth notifications (less relevant for message conversion)
+ schema::ServerNotification::RawResponseItemCompleted(params) => Ok(vec![status_event(
+ "raw.item.completed",
+ serde_json::to_string(params).ok(),
+ Some(params.thread_id.clone()),
+ raw,
+ )]),
schema::ServerNotification::AccountUpdated(_)
| schema::ServerNotification::AccountRateLimitsUpdated(_)
| schema::ServerNotification::AccountLoginCompleted(_)
@@ -102,632 +210,318 @@ pub fn notification_to_universal(notification: &schema::ServerNotification) -> E
| schema::ServerNotification::SessionConfigured(_)
| schema::ServerNotification::DeprecationNotice(_)
| schema::ServerNotification::ConfigWarning(_)
- | schema::ServerNotification::WindowsWorldWritableWarning(_)
- | schema::ServerNotification::RawResponseItemCompleted(_) => {
- EventConversion::new(UniversalEventData::Unknown {
- raw: serde_json::to_value(notification).unwrap_or(Value::Null),
- })
- }
+ | schema::ServerNotification::WindowsWorldWritableWarning(_) => Ok(vec![status_event(
+ "notice",
+ serde_json::to_string(notification).ok(),
+ None,
+ raw,
+ )]),
}
}
-fn thread_started_to_universal(params: &schema::ThreadStartedNotification) -> EventConversion {
- let started = Started {
- message: Some("thread/started".to_string()),
- details: serde_json::to_value(¶ms.thread).ok(),
- };
- EventConversion::new(UniversalEventData::Started { started })
- .with_session(Some(params.thread.id.clone()))
-}
-
-fn turn_started_to_universal(params: &schema::TurnStartedNotification) -> EventConversion {
- let started = Started {
- message: Some("turn/started".to_string()),
- details: serde_json::to_value(¶ms.turn).ok(),
- };
- EventConversion::new(UniversalEventData::Started { started })
- .with_session(Some(params.thread_id.clone()))
-}
-
-fn turn_completed_to_universal(params: &schema::TurnCompletedNotification) -> EventConversion {
- // Convert all items in the turn to messages
- let items = ¶ms.turn.items;
- if items.is_empty() {
- // If no items, just emit as unknown with the turn data
- return EventConversion::new(UniversalEventData::Unknown {
- raw: serde_json::to_value(params).unwrap_or(Value::Null),
- })
- .with_session(Some(params.thread_id.clone()));
- }
-
- // Return the last item as a message (most relevant for completion)
- if let Some(last_item) = items.last() {
- let message = thread_item_to_message(last_item);
- EventConversion::new(UniversalEventData::Message { message })
- .with_session(Some(params.thread_id.clone()))
- } else {
- EventConversion::new(UniversalEventData::Unknown {
- raw: serde_json::to_value(params).unwrap_or(Value::Null),
- })
- .with_session(Some(params.thread_id.clone()))
- }
-}
-
-fn item_started_to_universal(params: &schema::ItemStartedNotification) -> EventConversion {
- let message = thread_item_to_message(¶ms.item);
- EventConversion::new(UniversalEventData::Message { message })
- .with_session(Some(params.thread_id.clone()))
-}
-
-fn item_completed_to_universal(params: &schema::ItemCompletedNotification) -> EventConversion {
- let message = thread_item_to_message(¶ms.item);
- EventConversion::new(UniversalEventData::Message { message })
- .with_session(Some(params.thread_id.clone()))
-}
-
-fn agent_message_delta_to_universal(
- params: &schema::AgentMessageDeltaNotification,
-) -> EventConversion {
- let message = UniversalMessage::Parsed(UniversalMessageParsed {
- role: "assistant".to_string(),
- id: Some(params.item_id.clone()),
- metadata: Map::from_iter([
- ("delta".to_string(), Value::Bool(true)),
- ("turnId".to_string(), Value::String(params.turn_id.clone())),
- ]),
- parts: vec![UniversalMessagePart::Text {
- text: params.delta.clone(),
- }],
- });
- EventConversion::new(UniversalEventData::Message { message })
- .with_session(Some(params.thread_id.clone()))
-}
-
-fn reasoning_text_delta_to_universal(
- params: &schema::ReasoningTextDeltaNotification,
-) -> EventConversion {
- let message = UniversalMessage::Parsed(UniversalMessageParsed {
- role: "assistant".to_string(),
- id: Some(params.item_id.clone()),
- metadata: Map::from_iter([
- ("delta".to_string(), Value::Bool(true)),
- ("itemType".to_string(), Value::String("reasoning".to_string())),
- ("turnId".to_string(), Value::String(params.turn_id.clone())),
- ]),
- parts: vec![UniversalMessagePart::Text {
- text: params.delta.clone(),
- }],
- });
- EventConversion::new(UniversalEventData::Message { message })
- .with_session(Some(params.thread_id.clone()))
-}
-
-fn reasoning_summary_delta_to_universal(
- params: &schema::ReasoningSummaryTextDeltaNotification,
-) -> EventConversion {
- let message = UniversalMessage::Parsed(UniversalMessageParsed {
- role: "assistant".to_string(),
- id: Some(params.item_id.clone()),
- metadata: Map::from_iter([
- ("delta".to_string(), Value::Bool(true)),
- ("itemType".to_string(), Value::String("reasoning_summary".to_string())),
- ("turnId".to_string(), Value::String(params.turn_id.clone())),
- ]),
- parts: vec![UniversalMessagePart::Text {
- text: params.delta.clone(),
- }],
- });
- EventConversion::new(UniversalEventData::Message { message })
- .with_session(Some(params.thread_id.clone()))
-}
-
-fn command_output_delta_to_universal(
- params: &schema::CommandExecutionOutputDeltaNotification,
-) -> EventConversion {
- let message = UniversalMessage::Parsed(UniversalMessageParsed {
- role: "assistant".to_string(),
- id: Some(params.item_id.clone()),
- metadata: Map::from_iter([
- ("delta".to_string(), Value::Bool(true)),
- ("itemType".to_string(), Value::String("commandExecution".to_string())),
- ("turnId".to_string(), Value::String(params.turn_id.clone())),
- ]),
- parts: vec![UniversalMessagePart::Text {
- text: params.delta.clone(),
- }],
- });
- EventConversion::new(UniversalEventData::Message { message })
- .with_session(Some(params.thread_id.clone()))
-}
-
-fn file_change_delta_to_universal(
- params: &schema::FileChangeOutputDeltaNotification,
-) -> EventConversion {
- let message = UniversalMessage::Parsed(UniversalMessageParsed {
- role: "assistant".to_string(),
- id: Some(params.item_id.clone()),
- metadata: Map::from_iter([
- ("delta".to_string(), Value::Bool(true)),
- ("itemType".to_string(), Value::String("fileChange".to_string())),
- ("turnId".to_string(), Value::String(params.turn_id.clone())),
- ]),
- parts: vec![UniversalMessagePart::Text {
- text: params.delta.clone(),
- }],
- });
- EventConversion::new(UniversalEventData::Message { message })
- .with_session(Some(params.thread_id.clone()))
-}
-
-fn error_notification_to_universal(params: &schema::ErrorNotification) -> EventConversion {
- let crash = CrashInfo {
- message: params.error.message.clone(),
- kind: Some("error".to_string()),
- details: serde_json::to_value(params).ok(),
- };
- EventConversion::new(UniversalEventData::Error { error: crash })
- .with_session(Some(params.thread_id.clone()))
-}
-
-fn token_usage_to_universal(
- params: &schema::ThreadTokenUsageUpdatedNotification,
-) -> EventConversion {
- EventConversion::new(UniversalEventData::Unknown {
- raw: serde_json::to_value(params).unwrap_or(Value::Null),
- })
- .with_session(Some(params.thread_id.clone()))
-}
-
-fn turn_diff_to_universal(params: &schema::TurnDiffUpdatedNotification) -> EventConversion {
- EventConversion::new(UniversalEventData::Unknown {
- raw: serde_json::to_value(params).unwrap_or(Value::Null),
- })
- .with_session(Some(params.thread_id.clone()))
-}
-
-fn turn_plan_to_universal(params: &schema::TurnPlanUpdatedNotification) -> EventConversion {
- EventConversion::new(UniversalEventData::Unknown {
- raw: serde_json::to_value(params).unwrap_or(Value::Null),
- })
- .with_session(Some(params.thread_id.clone()))
-}
-
-fn terminal_interaction_to_universal(
- params: &schema::TerminalInteractionNotification,
-) -> EventConversion {
- EventConversion::new(UniversalEventData::Unknown {
- raw: serde_json::to_value(params).unwrap_or(Value::Null),
- })
- .with_session(Some(params.thread_id.clone()))
-}
-
-fn mcp_progress_to_universal(params: &schema::McpToolCallProgressNotification) -> EventConversion {
- EventConversion::new(UniversalEventData::Unknown {
- raw: serde_json::to_value(params).unwrap_or(Value::Null),
- })
- .with_session(Some(params.thread_id.clone()))
-}
-
-fn reasoning_summary_part_to_universal(
- params: &schema::ReasoningSummaryPartAddedNotification,
-) -> EventConversion {
- // This notification signals a new summary part was added, but doesn't contain the text itself
- // Return as Unknown with all metadata
- EventConversion::new(UniversalEventData::Unknown {
- raw: serde_json::to_value(params).unwrap_or(Value::Null),
- })
- .with_session(Some(params.thread_id.clone()))
-}
-
-fn context_compacted_to_universal(
- params: &schema::ContextCompactedNotification,
-) -> EventConversion {
- EventConversion::new(UniversalEventData::Unknown {
- raw: serde_json::to_value(params).unwrap_or(Value::Null),
- })
- .with_session(Some(params.thread_id.clone()))
-}
-
-/// Convert a ThreadItem to a UniversalMessage
-pub fn thread_item_to_message(item: &schema::ThreadItem) -> UniversalMessage {
+fn thread_item_to_item(item: &schema::ThreadItem, status: ItemStatus) -> UniversalItem {
match item {
- schema::ThreadItem::UserMessage { content, id } => {
- user_message_to_universal(content, id)
- }
- schema::ThreadItem::AgentMessage { id, text } => {
- agent_message_to_universal(id, text)
- }
+ schema::ThreadItem::UserMessage { content, id } => UniversalItem {
+ item_id: String::new(),
+ native_item_id: Some(id.clone()),
+ parent_id: None,
+ kind: ItemKind::Message,
+ role: Some(ItemRole::User),
+ content: content.iter().map(user_input_to_content).collect(),
+ status,
+ },
+ schema::ThreadItem::AgentMessage { id, text } => UniversalItem {
+ item_id: String::new(),
+ native_item_id: Some(id.clone()),
+ parent_id: None,
+ kind: ItemKind::Message,
+ role: Some(ItemRole::Assistant),
+ content: vec![ContentPart::Text { text: text.clone() }],
+ status,
+ },
schema::ThreadItem::Reasoning { content, id, summary } => {
- reasoning_to_universal(id, content, summary)
+ let mut parts = Vec::new();
+ for line in content {
+ parts.push(ContentPart::Reasoning {
+ text: line.clone(),
+ visibility: ReasoningVisibility::Private,
+ });
+ }
+ for line in summary {
+ parts.push(ContentPart::Reasoning {
+ text: line.clone(),
+ visibility: ReasoningVisibility::Public,
+ });
+ }
+ UniversalItem {
+ item_id: String::new(),
+ native_item_id: Some(id.clone()),
+ parent_id: None,
+ kind: ItemKind::Message,
+ role: Some(ItemRole::Assistant),
+ content: parts,
+ status,
+ }
}
schema::ThreadItem::CommandExecution {
aggregated_output,
command,
- command_actions: _,
cwd,
- duration_ms,
- exit_code,
id,
- process_id: _,
- status,
+ status: exec_status,
+ ..
} => {
- command_execution_to_universal(
- id,
- command,
- cwd,
- aggregated_output.as_deref(),
- exit_code.as_ref(),
- duration_ms.as_ref(),
+ let mut parts = Vec::new();
+ if let Some(output) = aggregated_output {
+ parts.push(ContentPart::ToolResult {
+ call_id: id.clone(),
+ output: output.clone(),
+ });
+ }
+ parts.push(ContentPart::Json {
+ json: serde_json::json!({
+ "command": command,
+ "cwd": cwd,
+ "status": format!("{:?}", exec_status)
+ }),
+ });
+ UniversalItem {
+ item_id: String::new(),
+ native_item_id: Some(id.clone()),
+ parent_id: None,
+ kind: ItemKind::ToolResult,
+ role: Some(ItemRole::Tool),
+ content: parts,
status,
- )
- }
- schema::ThreadItem::FileChange { changes, id, status } => {
- file_change_to_universal(id, changes, status)
+ }
}
+ schema::ThreadItem::FileChange { changes, id, status: file_status } => UniversalItem {
+ item_id: String::new(),
+ native_item_id: Some(id.clone()),
+ parent_id: None,
+ kind: ItemKind::ToolResult,
+ role: Some(ItemRole::Tool),
+ content: vec![ContentPart::Json {
+ json: serde_json::json!({
+ "changes": changes,
+ "status": format!("{:?}", file_status)
+ }),
+ }],
+ status,
+ },
schema::ThreadItem::McpToolCall {
arguments,
- duration_ms: _,
error,
id,
result,
server,
- status,
+ status: tool_status,
tool,
+ ..
} => {
- mcp_tool_call_to_universal(id, server, tool, arguments, result.as_ref(), error.as_ref(), status)
+ let mut parts = Vec::new();
+ if matches!(tool_status, schema::McpToolCallStatus::Completed) {
+ let output = result
+ .as_ref()
+ .and_then(|value| serde_json::to_string(value).ok())
+ .unwrap_or_else(|| "".to_string());
+ parts.push(ContentPart::ToolResult {
+ call_id: id.clone(),
+ output,
+ });
+ } else if matches!(tool_status, schema::McpToolCallStatus::Failed) {
+ let output = error
+ .as_ref()
+ .map(|value| value.message.clone())
+ .unwrap_or_else(|| "".to_string());
+ parts.push(ContentPart::ToolResult {
+ call_id: id.clone(),
+ output,
+ });
+ } else {
+ let arguments = serde_json::to_string(arguments).unwrap_or_else(|_| "{}".to_string());
+ parts.push(ContentPart::ToolCall {
+ name: format!("{server}.{tool}"),
+ arguments,
+ call_id: id.clone(),
+ });
+ }
+ let kind = if matches!(tool_status, schema::McpToolCallStatus::Completed)
+ || matches!(tool_status, schema::McpToolCallStatus::Failed)
+ {
+ ItemKind::ToolResult
+ } else {
+ ItemKind::ToolCall
+ };
+ let role = if kind == ItemKind::ToolResult {
+ ItemRole::Tool
+ } else {
+ ItemRole::Assistant
+ };
+ UniversalItem {
+ item_id: String::new(),
+ native_item_id: Some(id.clone()),
+ parent_id: None,
+ kind,
+ role: Some(role),
+ content: parts,
+ status,
+ }
}
schema::ThreadItem::CollabAgentToolCall {
- agents_states: _,
id,
prompt,
- receiver_thread_ids: _,
- sender_thread_id,
- status,
tool,
+ status: tool_status,
+ ..
} => {
- collab_tool_call_to_universal(id, tool, prompt.as_deref(), sender_thread_id, status)
- }
- schema::ThreadItem::WebSearch { id, query } => {
- web_search_to_universal(id, query)
- }
- schema::ThreadItem::ImageView { id, path } => {
- image_view_to_universal(id, path)
- }
- schema::ThreadItem::EnteredReviewMode { id, review } => {
- review_mode_to_universal(id, review, true)
- }
- schema::ThreadItem::ExitedReviewMode { id, review } => {
- review_mode_to_universal(id, review, false)
+ let mut parts = Vec::new();
+ if matches!(tool_status, schema::CollabAgentToolCallStatus::Completed) {
+ parts.push(ContentPart::ToolResult {
+ call_id: id.clone(),
+ output: prompt.clone().unwrap_or_default(),
+ });
+ } else {
+ parts.push(ContentPart::ToolCall {
+ name: tool.to_string(),
+ arguments: prompt.clone().unwrap_or_default(),
+ call_id: id.clone(),
+ });
+ }
+ let kind = if matches!(tool_status, schema::CollabAgentToolCallStatus::Completed) {
+ ItemKind::ToolResult
+ } else {
+ ItemKind::ToolCall
+ };
+ let role = if kind == ItemKind::ToolResult {
+ ItemRole::Tool
+ } else {
+ ItemRole::Assistant
+ };
+ UniversalItem {
+ item_id: String::new(),
+ native_item_id: Some(id.clone()),
+ parent_id: None,
+ kind,
+ role: Some(role),
+ content: parts,
+ status,
+ }
}
+ schema::ThreadItem::WebSearch { id, query } => UniversalItem {
+ item_id: String::new(),
+ native_item_id: Some(id.clone()),
+ parent_id: None,
+ kind: ItemKind::ToolCall,
+ role: Some(ItemRole::Assistant),
+ content: vec![ContentPart::ToolCall {
+ name: "web_search".to_string(),
+ arguments: query.clone(),
+ call_id: id.clone(),
+ }],
+ status,
+ },
+ schema::ThreadItem::ImageView { id, path } => UniversalItem {
+ item_id: String::new(),
+ native_item_id: Some(id.clone()),
+ parent_id: None,
+ kind: ItemKind::Message,
+ role: Some(ItemRole::Assistant),
+ content: vec![ContentPart::Image {
+ path: path.clone(),
+ mime: None,
+ }],
+ status,
+ },
+ schema::ThreadItem::EnteredReviewMode { id, review } => status_item_internal(
+ id,
+ "review.entered",
+ Some(review.clone()),
+ status,
+ ),
+ schema::ThreadItem::ExitedReviewMode { id, review } => status_item_internal(
+ id,
+ "review.exited",
+ Some(review.clone()),
+ status,
+ ),
}
}
-fn user_message_to_universal(content: &[schema::UserInput], id: &str) -> UniversalMessage {
- let parts: Vec = content.iter().map(user_input_to_part).collect();
- UniversalMessage::Parsed(UniversalMessageParsed {
- role: "user".to_string(),
- id: Some(id.to_string()),
- metadata: Map::new(),
- parts,
- })
+fn status_item(label: &str, detail: Option) -> UniversalItem {
+ UniversalItem {
+ item_id: String::new(),
+ native_item_id: None,
+ parent_id: None,
+ kind: ItemKind::Status,
+ role: Some(ItemRole::System),
+ content: vec![ContentPart::Status {
+ label: label.to_string(),
+ detail,
+ }],
+ status: ItemStatus::Completed,
+ }
}
-fn user_input_to_part(input: &schema::UserInput) -> UniversalMessagePart {
+fn status_item_internal(id: &str, label: &str, detail: Option, status: ItemStatus) -> UniversalItem {
+ UniversalItem {
+ item_id: String::new(),
+ native_item_id: Some(id.to_string()),
+ parent_id: None,
+ kind: ItemKind::Status,
+ role: Some(ItemRole::System),
+ content: vec![ContentPart::Status {
+ label: label.to_string(),
+ detail,
+ }],
+ status,
+ }
+}
+
+fn status_event(
+ label: &str,
+ detail: Option,
+ session_id: Option,
+ raw: Option,
+) -> EventConversion {
+ EventConversion::new(
+ UniversalEventType::ItemCompleted,
+ UniversalEventData::Item(ItemEventData {
+ item: status_item(label, detail),
+ }),
+ )
+ .with_native_session(session_id)
+ .with_raw(raw)
+}
+
+fn user_input_to_content(input: &schema::UserInput) -> ContentPart {
match input {
- schema::UserInput::Text { text, text_elements: _ } => {
- UniversalMessagePart::Text { text: text.clone() }
- }
- schema::UserInput::Image { image_url } => {
- UniversalMessagePart::Image {
- source: AttachmentSource::Url { url: image_url.clone() },
- mime_type: None,
- alt: None,
- raw: None,
- }
- }
- schema::UserInput::LocalImage { path } => {
- UniversalMessagePart::Image {
- source: AttachmentSource::Path { path: path.clone() },
- mime_type: None,
- alt: None,
- raw: None,
- }
- }
- schema::UserInput::Skill { name, path } => {
- UniversalMessagePart::Unknown {
- raw: serde_json::json!({
- "type": "skill",
- "name": name,
- "path": path,
- }),
- }
- }
- }
-}
-
-fn agent_message_to_universal(id: &str, text: &str) -> UniversalMessage {
- UniversalMessage::Parsed(UniversalMessageParsed {
- role: "assistant".to_string(),
- id: Some(id.to_string()),
- metadata: Map::from_iter([
- ("itemType".to_string(), Value::String("agentMessage".to_string())),
- ]),
- parts: vec![UniversalMessagePart::Text {
- text: text.to_string(),
- }],
- })
-}
-
-fn reasoning_to_universal(
- id: &str,
- content: &[String],
- summary: &[String],
-) -> UniversalMessage {
- let mut metadata = Map::new();
- metadata.insert("itemType".to_string(), Value::String("reasoning".to_string()));
- if !summary.is_empty() {
- metadata.insert(
- "summary".to_string(),
- Value::Array(summary.iter().map(|s| Value::String(s.clone())).collect()),
- );
- }
-
- let parts: Vec = content
- .iter()
- .map(|text| UniversalMessagePart::Text { text: text.clone() })
- .collect();
-
- UniversalMessage::Parsed(UniversalMessageParsed {
- role: "assistant".to_string(),
- id: Some(id.to_string()),
- metadata,
- parts,
- })
-}
-
-fn command_execution_to_universal(
- id: &str,
- command: &str,
- cwd: &str,
- aggregated_output: Option<&str>,
- exit_code: Option<&i32>,
- duration_ms: Option<&i64>,
- status: &schema::CommandExecutionStatus,
-) -> UniversalMessage {
- let mut metadata = Map::new();
- metadata.insert("itemType".to_string(), Value::String("commandExecution".to_string()));
- metadata.insert("command".to_string(), Value::String(command.to_string()));
- metadata.insert("cwd".to_string(), Value::String(cwd.to_string()));
- metadata.insert("status".to_string(), Value::String(format!("{:?}", status)));
- if let Some(code) = exit_code {
- metadata.insert("exitCode".to_string(), Value::Number((*code).into()));
- }
- if let Some(ms) = duration_ms {
- metadata.insert("durationMs".to_string(), Value::Number((*ms).into()));
- }
-
- let parts = if let Some(output) = aggregated_output {
- vec![UniversalMessagePart::Text {
- text: output.to_string(),
- }]
- } else {
- vec![]
- };
-
- UniversalMessage::Parsed(UniversalMessageParsed {
- role: "assistant".to_string(),
- id: Some(id.to_string()),
- metadata,
- parts,
- })
-}
-
-fn file_change_to_universal(
- id: &str,
- changes: &[schema::FileUpdateChange],
- status: &schema::PatchApplyStatus,
-) -> UniversalMessage {
- let mut metadata = Map::new();
- metadata.insert("itemType".to_string(), Value::String("fileChange".to_string()));
- metadata.insert("status".to_string(), Value::String(format!("{:?}", status)));
-
- let parts: Vec = changes
- .iter()
- .map(|change| {
- let raw = serde_json::to_value(change).unwrap_or(Value::Null);
- UniversalMessagePart::Unknown { raw }
- })
- .collect();
-
- UniversalMessage::Parsed(UniversalMessageParsed {
- role: "assistant".to_string(),
- id: Some(id.to_string()),
- metadata,
- parts,
- })
-}
-
-fn mcp_tool_call_to_universal(
- id: &str,
- server: &str,
- tool: &str,
- arguments: &Value,
- result: Option<&schema::McpToolCallResult>,
- error: Option<&schema::McpToolCallError>,
- status: &schema::McpToolCallStatus,
-) -> UniversalMessage {
- let mut metadata = Map::new();
- metadata.insert("itemType".to_string(), Value::String("mcpToolCall".to_string()));
- metadata.insert("server".to_string(), Value::String(server.to_string()));
- metadata.insert("status".to_string(), Value::String(format!("{:?}", status)));
-
- let is_error = error.is_some();
- let result_value = if let Some(res) = result {
- serde_json::to_value(res).unwrap_or(Value::Null)
- } else if let Some(err) = error {
- serde_json::to_value(err).unwrap_or(Value::Null)
- } else {
- Value::Null
- };
-
- let parts = vec![
- UniversalMessagePart::ToolCall {
- id: Some(id.to_string()),
- name: tool.to_string(),
- input: arguments.clone(),
+ schema::UserInput::Text { text, .. } => ContentPart::Text { text: text.clone() },
+ schema::UserInput::Image { image_url } => ContentPart::Image {
+ path: image_url.clone(),
+ mime: None,
},
- UniversalMessagePart::ToolResult {
- id: Some(id.to_string()),
- name: Some(tool.to_string()),
- output: result_value,
- is_error: Some(is_error),
+ schema::UserInput::LocalImage { path } => ContentPart::Image {
+ path: path.clone(),
+ mime: None,
+ },
+ schema::UserInput::Skill { name, path } => ContentPart::Json {
+ json: serde_json::json!({
+ "type": "skill",
+ "name": name,
+ "path": path,
+ }),
},
- ];
-
- UniversalMessage::Parsed(UniversalMessageParsed {
- role: "assistant".to_string(),
- id: Some(id.to_string()),
- metadata,
- parts,
- })
-}
-
-fn collab_tool_call_to_universal(
- id: &str,
- tool: &schema::CollabAgentTool,
- prompt: Option<&str>,
- sender_thread_id: &str,
- status: &schema::CollabAgentToolCallStatus,
-) -> UniversalMessage {
- let mut metadata = Map::new();
- metadata.insert("itemType".to_string(), Value::String("collabAgentToolCall".to_string()));
- metadata.insert("tool".to_string(), Value::String(format!("{:?}", tool)));
- metadata.insert("senderThreadId".to_string(), Value::String(sender_thread_id.to_string()));
- metadata.insert("status".to_string(), Value::String(format!("{:?}", status)));
-
- let parts = if let Some(p) = prompt {
- vec![UniversalMessagePart::Text { text: p.to_string() }]
- } else {
- vec![]
- };
-
- UniversalMessage::Parsed(UniversalMessageParsed {
- role: "assistant".to_string(),
- id: Some(id.to_string()),
- metadata,
- parts,
- })
-}
-
-fn web_search_to_universal(id: &str, query: &str) -> UniversalMessage {
- let mut metadata = Map::new();
- metadata.insert("itemType".to_string(), Value::String("webSearch".to_string()));
-
- UniversalMessage::Parsed(UniversalMessageParsed {
- role: "assistant".to_string(),
- id: Some(id.to_string()),
- metadata,
- parts: vec![UniversalMessagePart::Text {
- text: query.to_string(),
- }],
- })
-}
-
-fn image_view_to_universal(id: &str, path: &str) -> UniversalMessage {
- let mut metadata = Map::new();
- metadata.insert("itemType".to_string(), Value::String("imageView".to_string()));
-
- UniversalMessage::Parsed(UniversalMessageParsed {
- role: "assistant".to_string(),
- id: Some(id.to_string()),
- metadata,
- parts: vec![UniversalMessagePart::Image {
- source: AttachmentSource::Path { path: path.to_string() },
- mime_type: None,
- alt: None,
- raw: None,
- }],
- })
-}
-
-fn review_mode_to_universal(id: &str, review: &str, entered: bool) -> UniversalMessage {
- let item_type = if entered { "enteredReviewMode" } else { "exitedReviewMode" };
- let mut metadata = Map::new();
- metadata.insert("itemType".to_string(), Value::String(item_type.to_string()));
- metadata.insert("review".to_string(), Value::String(review.to_string()));
-
- UniversalMessage::Parsed(UniversalMessageParsed {
- role: "assistant".to_string(),
- id: Some(id.to_string()),
- metadata,
- parts: vec![],
- })
-}
-
-/// Convert a universal event back to a Codex ServerNotification.
-/// Note: This is a best-effort conversion and may not preserve all information.
-pub fn universal_event_to_codex(
- event: &UniversalEventData,
-) -> Result {
- match event {
- UniversalEventData::Message { message } => {
- let parsed = match message {
- UniversalMessage::Parsed(parsed) => parsed,
- UniversalMessage::Unparsed { .. } => {
- return Err(ConversionError::Unsupported("unparsed message"))
- }
- };
-
- // Extract text content
- let text = parsed
- .parts
- .iter()
- .filter_map(|part| {
- if let UniversalMessagePart::Text { text } = part {
- Some(text.as_str())
- } else {
- None
- }
- })
- .collect::>()
- .join("\n");
-
- let id = parsed.id.clone().unwrap_or_else(|| "msg".to_string());
- let thread_id = "unknown".to_string();
- let turn_id = "unknown".to_string();
-
- // Create an ItemCompletedNotification with an AgentMessage item
- let item = schema::ThreadItem::AgentMessage {
- id,
- text,
- };
-
- Ok(schema::ServerNotification::ItemCompleted(
- schema::ItemCompletedNotification {
- item,
- thread_id,
- turn_id,
- },
- ))
- }
- UniversalEventData::Error { error } => {
- let turn_error = schema::TurnError {
- message: error.message.clone(),
- additional_details: error.details.as_ref().and_then(|d| {
- d.get("additionalDetails")
- .and_then(Value::as_str)
- .map(|s| s.to_string())
- }),
- codex_error_info: None,
- };
-
- Ok(schema::ServerNotification::Error(schema::ErrorNotification {
- error: turn_error,
- thread_id: "unknown".to_string(),
- turn_id: "unknown".to_string(),
- will_retry: false,
- }))
- }
- _ => Err(ConversionError::Unsupported("codex event type")),
}
}
+
+pub fn session_ended_event(thread_id: &str, reason: SessionEndReason) -> EventConversion {
+ EventConversion::new(
+ UniversalEventType::SessionEnded,
+ UniversalEventData::SessionEnded(SessionEndedData {
+ reason,
+ terminated_by: TerminatedBy::Agent,
+ }),
+ )
+ .with_native_session(Some(thread_id.to_string()))
+}
diff --git a/server/packages/universal-agent-schema/src/agents/opencode.rs b/server/packages/universal-agent-schema/src/agents/opencode.rs
index c27a8dd..b1bc3d9 100644
--- a/server/packages/universal-agent-schema/src/agents/opencode.rs
+++ b/server/packages/universal-agent-schema/src/agents/opencode.rs
@@ -1,958 +1,505 @@
-use crate::{
- extract_message_from_value,
- AttachmentSource,
- ConversionError,
- CrashInfo,
- EventConversion,
- PermissionRequest,
- PermissionToolRef,
- QuestionInfo,
- QuestionOption,
- QuestionRequest,
- QuestionToolRef,
- Started,
- UniversalEventData,
- UniversalMessage,
- UniversalMessageParsed,
- UniversalMessagePart,
-};
-use crate::opencode as schema;
-use serde::{Deserialize, Serialize};
-use serde_json::{Map, Value};
+use serde_json::Value;
-pub fn event_to_universal(event: &schema::Event) -> EventConversion {
+use crate::opencode as schema;
+use crate::{
+ ContentPart,
+ EventConversion,
+ ItemDeltaData,
+ ItemEventData,
+ ItemKind,
+ ItemRole,
+ ItemStatus,
+ PermissionEventData,
+ PermissionStatus,
+ QuestionEventData,
+ QuestionStatus,
+ SessionStartedData,
+ UniversalEventData,
+ UniversalEventType,
+ UniversalItem,
+};
+
+pub fn event_to_universal(event: &schema::Event) -> Result, String> {
+ let raw = serde_json::to_value(event).ok();
match event {
schema::Event::MessageUpdated(updated) => {
let schema::EventMessageUpdated { properties, type_: _ } = updated;
let schema::EventMessageUpdatedProperties { info } = properties;
- let (message, session_id) = message_from_opencode(info);
- EventConversion::new(UniversalEventData::Message { message })
- .with_session(session_id)
+ let (mut item, completed, session_id) = message_to_item(info);
+ item.status = if completed { ItemStatus::Completed } else { ItemStatus::InProgress };
+ let event_type = if completed {
+ UniversalEventType::ItemCompleted
+ } else {
+ UniversalEventType::ItemStarted
+ };
+ let conversion = EventConversion::new(
+ event_type,
+ UniversalEventData::Item(ItemEventData { item }),
+ )
+ .with_native_session(session_id)
+ .with_raw(raw);
+ Ok(vec![conversion])
}
schema::Event::MessagePartUpdated(updated) => {
let schema::EventMessagePartUpdated { properties, type_: _ } = updated;
let schema::EventMessagePartUpdatedProperties { part, delta } = properties;
- let (message, session_id) = part_to_message(part, delta.as_ref());
- EventConversion::new(UniversalEventData::Message { message })
- .with_session(session_id)
+ let mut events = Vec::new();
+ let (session_id, message_id) = part_session_message(part);
+
+ match part {
+ schema::Part::Variant0(text_part) => {
+ let schema::TextPart { text, .. } = text_part;
+ let delta_text = delta.as_ref().unwrap_or(&text).clone();
+ let stub = stub_message_item(&message_id, ItemRole::Assistant);
+ events.push(
+ EventConversion::new(
+ UniversalEventType::ItemStarted,
+ UniversalEventData::Item(ItemEventData { item: stub }),
+ )
+ .synthetic()
+ .with_raw(raw.clone()),
+ );
+ events.push(
+ EventConversion::new(
+ UniversalEventType::ItemDelta,
+ UniversalEventData::ItemDelta(ItemDeltaData {
+ item_id: String::new(),
+ native_item_id: Some(message_id.clone()),
+ delta: delta_text,
+ }),
+ )
+ .with_native_session(session_id.clone())
+ .with_raw(raw.clone()),
+ );
+ }
+ schema::Part::Variant2(reasoning_part) => {
+ let delta_text = delta
+ .as_ref()
+ .cloned()
+ .unwrap_or_else(|| reasoning_part.text.clone());
+ let stub = stub_message_item(&message_id, ItemRole::Assistant);
+ events.push(
+ EventConversion::new(
+ UniversalEventType::ItemStarted,
+ UniversalEventData::Item(ItemEventData { item: stub }),
+ )
+ .synthetic()
+ .with_raw(raw.clone()),
+ );
+ events.push(
+ EventConversion::new(
+ UniversalEventType::ItemDelta,
+ UniversalEventData::ItemDelta(ItemDeltaData {
+ item_id: String::new(),
+ native_item_id: Some(message_id.clone()),
+ delta: delta_text,
+ }),
+ )
+ .with_native_session(session_id.clone())
+ .with_raw(raw.clone()),
+ );
+ }
+ schema::Part::Variant3(file_part) => {
+ let file_content = file_part_to_content(file_part);
+ let item = UniversalItem {
+ item_id: String::new(),
+ native_item_id: Some(message_id.clone()),
+ parent_id: None,
+ kind: ItemKind::Message,
+ role: Some(ItemRole::Assistant),
+ content: vec![file_content],
+ status: ItemStatus::Completed,
+ };
+ events.push(
+ EventConversion::new(
+ UniversalEventType::ItemCompleted,
+ UniversalEventData::Item(ItemEventData { item }),
+ )
+ .with_native_session(session_id.clone())
+ .with_raw(raw.clone()),
+ );
+ }
+ schema::Part::Variant4(tool_part) => {
+ let tool_events = tool_part_to_events(&tool_part, &message_id);
+ for event in tool_events {
+ events.push(event.with_native_session(session_id.clone()).with_raw(raw.clone()));
+ }
+ }
+ schema::Part::Variant1 { .. } => {
+ let detail = serde_json::to_string(part).unwrap_or_else(|_| "subtask".to_string());
+ let item = status_item("subtask", Some(detail));
+ events.push(
+ EventConversion::new(
+ UniversalEventType::ItemCompleted,
+ UniversalEventData::Item(ItemEventData { item }),
+ )
+ .with_native_session(session_id.clone())
+ .with_raw(raw.clone()),
+ );
+ }
+ schema::Part::Variant5(_)
+ | schema::Part::Variant6(_)
+ | schema::Part::Variant7(_)
+ | schema::Part::Variant8(_)
+ | schema::Part::Variant9(_)
+ | schema::Part::Variant10(_)
+ | schema::Part::Variant11(_) => {
+ let detail = serde_json::to_string(part).unwrap_or_else(|_| "part".to_string());
+ let item = status_item("part.updated", Some(detail));
+ events.push(
+ EventConversion::new(
+ UniversalEventType::ItemCompleted,
+ UniversalEventData::Item(ItemEventData { item }),
+ )
+ .with_native_session(session_id.clone())
+ .with_raw(raw.clone()),
+ );
+ }
+ }
+
+ Ok(events)
}
schema::Event::QuestionAsked(asked) => {
let schema::EventQuestionAsked { properties, type_: _ } = asked;
- let question = question_request_from_opencode(properties);
- let session_id = question.session_id.clone();
- EventConversion::new(UniversalEventData::QuestionAsked { question_asked: question })
- .with_session(Some(session_id))
+ let question = question_from_opencode(properties);
+ let conversion = EventConversion::new(
+ UniversalEventType::QuestionRequested,
+ UniversalEventData::Question(question),
+ )
+ .with_native_session(Some(properties.session_id.to_string()))
+ .with_raw(raw);
+ Ok(vec![conversion])
}
schema::Event::PermissionAsked(asked) => {
let schema::EventPermissionAsked { properties, type_: _ } = asked;
- let permission = permission_request_from_opencode(properties);
- let session_id = permission.session_id.clone();
- EventConversion::new(UniversalEventData::PermissionAsked { permission_asked: permission })
- .with_session(Some(session_id))
+ let permission = permission_from_opencode(properties);
+ let conversion = EventConversion::new(
+ UniversalEventType::PermissionRequested,
+ UniversalEventData::Permission(permission),
+ )
+ .with_native_session(Some(properties.session_id.to_string()))
+ .with_raw(raw);
+ Ok(vec![conversion])
}
schema::Event::SessionCreated(created) => {
let schema::EventSessionCreated { properties, type_: _ } = created;
- let schema::EventSessionCreatedProperties { info } = properties;
- let details = serde_json::to_value(info).ok();
- let started = Started {
- message: Some("session.created".to_string()),
- details,
- };
- EventConversion::new(UniversalEventData::Started { started })
+ let metadata = serde_json::to_value(&properties.info).ok();
+ let conversion = EventConversion::new(
+ UniversalEventType::SessionStarted,
+ UniversalEventData::SessionStarted(SessionStartedData { metadata }),
+ )
+ .with_native_session(Some(properties.info.id.to_string()))
+ .with_raw(raw);
+ Ok(vec![conversion])
+ }
+ schema::Event::SessionStatus(status) => {
+ let schema::EventSessionStatus { properties, type_: _ } = status;
+ let detail = serde_json::to_string(&properties.status).unwrap_or_else(|_| "status".to_string());
+ let item = status_item("session.status", Some(detail));
+ let conversion = EventConversion::new(
+ UniversalEventType::ItemCompleted,
+ UniversalEventData::Item(ItemEventData { item }),
+ )
+ .with_native_session(Some(properties.session_id.clone()))
+ .with_raw(raw);
+ Ok(vec![conversion])
+ }
+ schema::Event::SessionIdle(idle) => {
+ let schema::EventSessionIdle { properties, type_: _ } = idle;
+ let item = status_item("session.idle", None);
+ let conversion = EventConversion::new(
+ UniversalEventType::ItemCompleted,
+ UniversalEventData::Item(ItemEventData { item }),
+ )
+ .with_native_session(Some(properties.session_id.clone()))
+ .with_raw(raw);
+ Ok(vec![conversion])
}
schema::Event::SessionError(error) => {
let schema::EventSessionError { properties, type_: _ } = error;
- let schema::EventSessionErrorProperties {
- error: _error,
- session_id,
- } = properties;
- let message = extract_message_from_value(&serde_json::to_value(properties).unwrap_or(Value::Null))
- .unwrap_or_else(|| "opencode session error".to_string());
- let crash = CrashInfo {
- message,
- kind: Some("session.error".to_string()),
- details: serde_json::to_value(properties).ok(),
- };
- EventConversion::new(UniversalEventData::Error { error: crash })
- .with_session(session_id.clone())
+ let detail = serde_json::to_string(&properties.error).unwrap_or_else(|_| "session error".to_string());
+ let item = status_item("session.error", Some(detail));
+ let conversion = EventConversion::new(
+ UniversalEventType::ItemCompleted,
+ UniversalEventData::Item(ItemEventData { item }),
+ )
+ .with_native_session(properties.session_id.clone())
+ .with_raw(raw);
+ Ok(vec![conversion])
}
- _ => EventConversion::new(UniversalEventData::Unknown {
- raw: serde_json::to_value(event).unwrap_or(Value::Null),
- }),
+ _ => Err("unsupported opencode event".to_string()),
}
}
-pub fn universal_event_to_opencode(event: &UniversalEventData) -> Result {
- match event {
- UniversalEventData::QuestionAsked { question_asked } => {
- let properties = question_request_to_opencode(question_asked)?;
- Ok(schema::Event::QuestionAsked(schema::EventQuestionAsked {
- properties,
- type_: "question.asked".to_string(),
- }))
- }
- UniversalEventData::PermissionAsked { permission_asked } => {
- let properties = permission_request_to_opencode(permission_asked)?;
- Ok(schema::Event::PermissionAsked(schema::EventPermissionAsked {
- properties,
- type_: "permission.asked".to_string(),
- }))
- }
- _ => Err(ConversionError::Unsupported("opencode event")),
- }
-}
-
-pub fn universal_message_to_parts(
- message: &UniversalMessage,
-) -> Result, ConversionError> {
- let parsed = match message {
- UniversalMessage::Parsed(parsed) => parsed,
- UniversalMessage::Unparsed { .. } => {
- return Err(ConversionError::Unsupported("unparsed message"))
- }
- };
- let mut parts = Vec::new();
- for part in &parsed.parts {
- match part {
- UniversalMessagePart::Text { text } => {
- parts.push(text_part_input_from_text(text));
- }
- UniversalMessagePart::ToolCall { .. }
- | UniversalMessagePart::ToolResult { .. }
- | UniversalMessagePart::FunctionCall { .. }
- | UniversalMessagePart::FunctionResult { .. }
- | UniversalMessagePart::File { .. }
- | UniversalMessagePart::Image { .. }
- | UniversalMessagePart::Error { .. }
- | UniversalMessagePart::Unknown { .. } => {
- return Err(ConversionError::Unsupported("non-text part"))
- }
- }
- }
- if parts.is_empty() {
- return Err(ConversionError::MissingField("parts"));
- }
- Ok(parts)
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-#[serde(untagged)]
-pub enum OpencodePartInput {
- Text(schema::TextPartInput),
- File(schema::FilePartInput),
-}
-
-pub fn universal_message_to_part_inputs(
- message: &UniversalMessage,
-) -> Result, ConversionError> {
- let parsed = match message {
- UniversalMessage::Parsed(parsed) => parsed,
- UniversalMessage::Unparsed { .. } => {
- return Err(ConversionError::Unsupported("unparsed message"))
- }
- };
- universal_parts_to_part_inputs(&parsed.parts)
-}
-
-pub fn universal_parts_to_part_inputs(
- parts: &[UniversalMessagePart],
-) -> Result, ConversionError> {
- let mut inputs = Vec::new();
- for part in parts {
- inputs.push(universal_part_to_opencode_input(part)?);
- }
- if inputs.is_empty() {
- return Err(ConversionError::MissingField("parts"));
- }
- Ok(inputs)
-}
-
-pub fn universal_part_to_opencode_input(
- part: &UniversalMessagePart,
-) -> Result {
- match part {
- UniversalMessagePart::Text { text } => Ok(OpencodePartInput::Text(
- text_part_input_from_text(text),
- )),
- UniversalMessagePart::File {
- source,
- mime_type,
- filename,
- ..
- } => Ok(OpencodePartInput::File(file_part_input_from_universal(
- source,
- mime_type.as_deref(),
- filename.as_ref(),
- )?)),
- UniversalMessagePart::Image {
- source, mime_type, ..
- } => Ok(OpencodePartInput::File(file_part_input_from_universal(
- source,
- mime_type.as_deref(),
- None,
- )?)),
- UniversalMessagePart::ToolCall { .. }
- | UniversalMessagePart::ToolResult { .. }
- | UniversalMessagePart::FunctionCall { .. }
- | UniversalMessagePart::FunctionResult { .. }
- | UniversalMessagePart::Error { .. }
- | UniversalMessagePart::Unknown { .. } => {
- Err(ConversionError::Unsupported("unsupported part"))
- }
- }
-}
-
-fn text_part_input_from_text(text: &str) -> schema::TextPartInput {
- schema::TextPartInput {
- id: None,
- ignored: None,
- metadata: Map::new(),
- synthetic: None,
- text: text.to_string(),
- time: None,
- type_: "text".to_string(),
- }
-}
-
-pub fn text_part_input_to_universal(part: &schema::TextPartInput) -> UniversalMessage {
- let schema::TextPartInput {
- id,
- ignored,
- metadata,
- synthetic,
- text,
- time,
- type_,
- } = part;
- let mut metadata = metadata.clone();
- if let Some(id) = id {
- metadata.insert("partId".to_string(), Value::String(id.clone()));
- }
- if let Some(ignored) = ignored {
- metadata.insert("ignored".to_string(), Value::Bool(*ignored));
- }
- if let Some(synthetic) = synthetic {
- metadata.insert("synthetic".to_string(), Value::Bool(*synthetic));
- }
- if let Some(time) = time {
- metadata.insert(
- "time".to_string(),
- serde_json::to_value(time).unwrap_or(Value::Null),
- );
- }
- metadata.insert("type".to_string(), Value::String(type_.clone()));
- UniversalMessage::Parsed(UniversalMessageParsed {
- role: "user".to_string(),
- id: None,
- metadata,
- parts: vec![UniversalMessagePart::Text { text: text.clone() }],
- })
-}
-
-fn file_part_input_from_universal(
- source: &AttachmentSource,
- mime_type: Option<&str>,
- filename: Option<&String>,
-) -> Result {
- let mime = mime_type.ok_or(ConversionError::MissingField("mime_type"))?;
- let url = attachment_source_to_opencode_url(source, mime)?;
- Ok(schema::FilePartInput {
- filename: filename.cloned(),
- id: None,
- mime: mime.to_string(),
- source: None,
- type_: "file".to_string(),
- url,
- })
-}
-
-fn attachment_source_to_opencode_url(
- source: &AttachmentSource,
- mime_type: &str,
-) -> Result {
- match source {
- AttachmentSource::Url { url } => Ok(url.clone()),
- AttachmentSource::Path { path } => Ok(format!("file://{}", path)),
- AttachmentSource::Data { data, encoding } => {
- let encoding = encoding.as_deref().unwrap_or("base64");
- if encoding != "base64" {
- return Err(ConversionError::Unsupported("opencode data encoding"));
- }
- Ok(format!("data:{};base64,{}", mime_type, data))
- }
- }
-}
-
-fn message_from_opencode(message: &schema::Message) -> (UniversalMessage, Option) {
+fn message_to_item(message: &schema::Message) -> (UniversalItem, bool, Option) {
match message {
schema::Message::UserMessage(user) => {
let schema::UserMessage {
- agent,
id,
- model,
- role,
session_id,
- summary,
- system,
- time,
- tools,
- variant,
+ role: _,
+ ..
} = user;
- let mut metadata = Map::new();
- metadata.insert("agent".to_string(), Value::String(agent.clone()));
- metadata.insert(
- "model".to_string(),
- serde_json::to_value(model).unwrap_or(Value::Null),
- );
- metadata.insert(
- "time".to_string(),
- serde_json::to_value(time).unwrap_or(Value::Null),
- );
- metadata.insert(
- "tools".to_string(),
- serde_json::to_value(tools).unwrap_or(Value::Null),
- );
- if let Some(summary) = summary {
- metadata.insert(
- "summary".to_string(),
- serde_json::to_value(summary).unwrap_or(Value::Null),
- );
- }
- if let Some(system) = system {
- metadata.insert("system".to_string(), Value::String(system.clone()));
- }
- if let Some(variant) = variant {
- metadata.insert("variant".to_string(), Value::String(variant.clone()));
- }
- let parsed = UniversalMessageParsed {
- role: role.clone(),
- id: Some(id.clone()),
- metadata,
- parts: Vec::new(),
- };
(
- UniversalMessage::Parsed(parsed),
+ UniversalItem {
+ item_id: String::new(),
+ native_item_id: Some(id.clone()),
+ parent_id: None,
+ kind: ItemKind::Message,
+ role: Some(ItemRole::User),
+ content: Vec::new(),
+ status: ItemStatus::Completed,
+ },
+ true,
Some(session_id.clone()),
)
}
schema::Message::AssistantMessage(assistant) => {
let schema::AssistantMessage {
- agent,
- cost,
- error,
- finish,
id,
- mode,
- model_id,
- parent_id,
- path,
- provider_id,
- role,
session_id,
- summary,
time,
- tokens,
+ ..
} = assistant;
- let mut metadata = Map::new();
- metadata.insert("agent".to_string(), Value::String(agent.clone()));
- metadata.insert(
- "cost".to_string(),
- serde_json::to_value(cost).unwrap_or(Value::Null),
- );
- metadata.insert("mode".to_string(), Value::String(mode.clone()));
- metadata.insert("modelId".to_string(), Value::String(model_id.clone()));
- metadata.insert("providerId".to_string(), Value::String(provider_id.clone()));
- metadata.insert("parentId".to_string(), Value::String(parent_id.clone()));
- metadata.insert(
- "path".to_string(),
- serde_json::to_value(path).unwrap_or(Value::Null),
- );
- metadata.insert(
- "tokens".to_string(),
- serde_json::to_value(tokens).unwrap_or(Value::Null),
- );
- metadata.insert(
- "time".to_string(),
- serde_json::to_value(time).unwrap_or(Value::Null),
- );
- if let Some(error) = error {
- metadata.insert(
- "error".to_string(),
- serde_json::to_value(error).unwrap_or(Value::Null),
- );
- }
- if let Some(finish) = finish {
- metadata.insert("finish".to_string(), Value::String(finish.clone()));
- }
- if let Some(summary) = summary {
- metadata.insert(
- "summary".to_string(),
- serde_json::to_value(summary).unwrap_or(Value::Null),
- );
- }
- let parsed = UniversalMessageParsed {
- role: role.clone(),
- id: Some(id.clone()),
- metadata,
- parts: Vec::new(),
- };
+ let completed = time.completed.is_some();
(
- UniversalMessage::Parsed(parsed),
+ UniversalItem {
+ item_id: String::new(),
+ native_item_id: Some(id.clone()),
+ parent_id: None,
+ kind: ItemKind::Message,
+ role: Some(ItemRole::Assistant),
+ content: Vec::new(),
+ status: if completed { ItemStatus::Completed } else { ItemStatus::InProgress },
+ },
+ completed,
Some(session_id.clone()),
)
}
}
}
-fn part_to_message(part: &schema::Part, delta: Option<&String>) -> (UniversalMessage, Option) {
+fn part_session_message(part: &schema::Part) -> (Option, String) {
match part {
schema::Part::Variant0(text_part) => {
- let schema::TextPart {
- id,
- ignored,
- message_id,
- metadata,
- session_id,
- synthetic,
- text,
- time,
- type_,
- } = text_part;
- let mut part_metadata = base_part_metadata(message_id, id, delta);
- part_metadata.insert("type".to_string(), Value::String(type_.clone()));
- if let Some(ignored) = ignored {
- part_metadata.insert("ignored".to_string(), Value::Bool(*ignored));
- }
- if let Some(synthetic) = synthetic {
- part_metadata.insert("synthetic".to_string(), Value::Bool(*synthetic));
- }
- if let Some(time) = time {
- part_metadata.insert(
- "time".to_string(),
- serde_json::to_value(time).unwrap_or(Value::Null),
- );
- }
- if !metadata.is_empty() {
- part_metadata.insert(
- "partMetadata".to_string(),
- Value::Object(metadata.clone()),
- );
- }
- let parsed = UniversalMessageParsed {
- role: "assistant".to_string(),
- id: Some(message_id.clone()),
- metadata: part_metadata,
- parts: vec![UniversalMessagePart::Text { text: text.clone() }],
- };
- (UniversalMessage::Parsed(parsed), Some(session_id.clone()))
+ (Some(text_part.session_id.clone()), text_part.message_id.clone())
}
schema::Part::Variant1 {
- agent: _agent,
- command: _command,
- description: _description,
- id,
- message_id,
- model: _model,
- prompt: _prompt,
session_id,
- type_: _type,
- } => unknown_part_message(message_id, id, session_id, serde_json::to_value(part).unwrap_or(Value::Null), delta),
- schema::Part::Variant2(reasoning_part) => {
- let schema::ReasoningPart {
- id,
- message_id,
- metadata: _metadata,
- session_id,
- text: _text,
- time: _time,
- type_: _type,
- } = reasoning_part;
- unknown_part_message(
- message_id,
- id,
- session_id,
- serde_json::to_value(reasoning_part).unwrap_or(Value::Null),
- delta,
- )
- }
- schema::Part::Variant3(file_part) => {
- let schema::FilePart {
- filename: _filename,
- id,
- message_id,
- mime: _mime,
- session_id,
- source: _source,
- type_: _type,
- url: _url,
- } = file_part;
- let part_metadata = base_part_metadata(message_id, id, delta);
- let part = file_part_to_universal_part(file_part);
- let parsed = UniversalMessageParsed {
- role: "assistant".to_string(),
- id: Some(message_id.clone()),
- metadata: part_metadata,
- parts: vec![part],
- };
- (UniversalMessage::Parsed(parsed), Some(session_id.clone()))
- }
- schema::Part::Variant4(tool_part) => {
- let schema::ToolPart {
- call_id,
- id,
- message_id,
- metadata,
- session_id,
- state,
- tool,
- type_,
- } = tool_part;
- let mut part_metadata = base_part_metadata(message_id, id, delta);
- part_metadata.insert("type".to_string(), Value::String(type_.clone()));
- part_metadata.insert("callId".to_string(), Value::String(call_id.clone()));
- part_metadata.insert("tool".to_string(), Value::String(tool.clone()));
- if !metadata.is_empty() {
- part_metadata.insert(
- "partMetadata".to_string(),
- Value::Object(metadata.clone()),
- );
- }
- let (mut parts, state_meta) = tool_state_to_parts(call_id, tool, state);
- if let Some(state_meta) = state_meta {
- part_metadata.insert("toolState".to_string(), state_meta);
- }
- let parsed = UniversalMessageParsed {
- role: "assistant".to_string(),
- id: Some(message_id.clone()),
- metadata: part_metadata,
- parts: parts.drain(..).collect(),
- };
- (UniversalMessage::Parsed(parsed), Some(session_id.clone()))
- }
- schema::Part::Variant5(step_start) => {
- let schema::StepStartPart {
- id,
- message_id,
- session_id,
- snapshot: _snapshot,
- type_: _type,
- } = step_start;
- unknown_part_message(
- message_id,
- id,
- session_id,
- serde_json::to_value(step_start).unwrap_or(Value::Null),
- delta,
- )
- }
- schema::Part::Variant6(step_finish) => {
- let schema::StepFinishPart {
- cost: _cost,
- id,
- message_id,
- reason: _reason,
- session_id,
- snapshot: _snapshot,
- tokens: _tokens,
- type_: _type,
- } = step_finish;
- unknown_part_message(
- message_id,
- id,
- session_id,
- serde_json::to_value(step_finish).unwrap_or(Value::Null),
- delta,
- )
- }
- schema::Part::Variant7(snapshot_part) => {
- let schema::SnapshotPart {
- id,
- message_id,
- session_id,
- snapshot: _snapshot,
- type_: _type,
- } = snapshot_part;
- unknown_part_message(
- message_id,
- id,
- session_id,
- serde_json::to_value(snapshot_part).unwrap_or(Value::Null),
- delta,
- )
- }
- schema::Part::Variant8(patch_part) => {
- let schema::PatchPart {
- files: _files,
- hash: _hash,
- id,
- message_id,
- session_id,
- type_: _type,
- } = patch_part;
- unknown_part_message(
- message_id,
- id,
- session_id,
- serde_json::to_value(patch_part).unwrap_or(Value::Null),
- delta,
- )
- }
- schema::Part::Variant9(agent_part) => {
- let schema::AgentPart {
- id,
- message_id,
- name: _name,
- session_id,
- source: _source,
- type_: _type,
- } = agent_part;
- unknown_part_message(
- message_id,
- id,
- session_id,
- serde_json::to_value(agent_part).unwrap_or(Value::Null),
- delta,
- )
- }
- schema::Part::Variant10(retry_part) => {
- let schema::RetryPart {
- attempt: _attempt,
- error: _error,
- id,
- message_id,
- session_id,
- time: _time,
- type_: _type,
- } = retry_part;
- unknown_part_message(
- message_id,
- id,
- session_id,
- serde_json::to_value(retry_part).unwrap_or(Value::Null),
- delta,
- )
- }
- schema::Part::Variant11(compaction_part) => {
- let schema::CompactionPart {
- auto: _auto,
- id,
- message_id,
- session_id,
- type_: _type,
- } = compaction_part;
- unknown_part_message(
- message_id,
- id,
- session_id,
- serde_json::to_value(compaction_part).unwrap_or(Value::Null),
- delta,
- )
- }
+ message_id,
+ ..
+ } => (Some(session_id.clone()), message_id.clone()),
+ schema::Part::Variant2(reasoning_part) => (
+ Some(reasoning_part.session_id.clone()),
+ reasoning_part.message_id.clone(),
+ ),
+ schema::Part::Variant3(file_part) => (
+ Some(file_part.session_id.clone()),
+ file_part.message_id.clone(),
+ ),
+ schema::Part::Variant4(tool_part) => (
+ Some(tool_part.session_id.clone()),
+ tool_part.message_id.clone(),
+ ),
+ schema::Part::Variant5(step_part) => (
+ Some(step_part.session_id.clone()),
+ step_part.message_id.clone(),
+ ),
+ schema::Part::Variant6(step_part) => (
+ Some(step_part.session_id.clone()),
+ step_part.message_id.clone(),
+ ),
+ schema::Part::Variant7(snapshot_part) => (
+ Some(snapshot_part.session_id.clone()),
+ snapshot_part.message_id.clone(),
+ ),
+ schema::Part::Variant8(patch_part) => (
+ Some(patch_part.session_id.clone()),
+ patch_part.message_id.clone(),
+ ),
+ schema::Part::Variant9(agent_part) => (
+ Some(agent_part.session_id.clone()),
+ agent_part.message_id.clone(),
+ ),
+ schema::Part::Variant10(retry_part) => (
+ Some(retry_part.session_id.clone()),
+ retry_part.message_id.clone(),
+ ),
+ schema::Part::Variant11(compaction_part) => (
+ Some(compaction_part.session_id.clone()),
+ compaction_part.message_id.clone(),
+ ),
}
}
-fn base_part_metadata(message_id: &str, part_id: &str, delta: Option<&String>) -> Map {
- let mut metadata = Map::new();
- metadata.insert("messageId".to_string(), Value::String(message_id.to_string()));
- metadata.insert("partId".to_string(), Value::String(part_id.to_string()));
- if let Some(delta) = delta {
- metadata.insert("delta".to_string(), Value::String(delta.clone()));
- }
- metadata
-}
-
-fn unknown_part_message(
- message_id: &str,
- part_id: &str,
- session_id: &str,
- raw: Value,
- delta: Option<&String>,
-) -> (UniversalMessage, Option) {
- let metadata = base_part_metadata(message_id, part_id, delta);
- let parsed = UniversalMessageParsed {
- role: "assistant".to_string(),
- id: Some(message_id.to_string()),
- metadata,
- parts: vec![UniversalMessagePart::Unknown { raw }],
- };
- (UniversalMessage::Parsed(parsed), Some(session_id.to_string()))
-}
-
-fn file_part_to_universal_part(file_part: &schema::FilePart) -> UniversalMessagePart {
- let schema::FilePart {
- filename,
- id: _id,
- message_id: _message_id,
- mime,
- session_id: _session_id,
- source: _source,
- type_: _type,
- url,
- } = file_part;
- let raw = serde_json::to_value(file_part).unwrap_or(Value::Null);
- let source = AttachmentSource::Url { url: url.clone() };
- if mime.starts_with("image/") {
- UniversalMessagePart::Image {
- source,
- mime_type: Some(mime.clone()),
- alt: filename.clone(),
- raw: Some(raw),
- }
- } else {
- UniversalMessagePart::File {
- source,
- mime_type: Some(mime.clone()),
- filename: filename.clone(),
- raw: Some(raw),
- }
+fn stub_message_item(message_id: &str, role: ItemRole) -> UniversalItem {
+ UniversalItem {
+ item_id: String::new(),
+ native_item_id: Some(message_id.to_string()),
+ parent_id: None,
+ kind: ItemKind::Message,
+ role: Some(role),
+ content: Vec::new(),
+ status: ItemStatus::InProgress,
}
}
-fn tool_state_to_parts(
- call_id: &str,
- tool: &str,
- state: &schema::ToolState,
-) -> (Vec, Option) {
+fn tool_part_to_events(tool_part: &schema::ToolPart, message_id: &str) -> Vec {
+ let schema::ToolPart {
+ call_id,
+ state,
+ tool,
+ ..
+ } = tool_part;
+ let mut events = Vec::new();
match state {
schema::ToolState::Pending(state) => {
- let schema::ToolStatePending { input, raw, status } = state;
- let mut meta = Map::new();
- meta.insert("status".to_string(), Value::String(status.clone()));
- meta.insert("raw".to_string(), Value::String(raw.clone()));
- meta.insert("input".to_string(), Value::Object(input.clone()));
- (
- vec![UniversalMessagePart::ToolCall {
- id: Some(call_id.to_string()),
- name: tool.to_string(),
- input: Value::Object(input.clone()),
+ let arguments = serde_json::to_string(&Value::Object(state.input.clone()))
+ .unwrap_or_else(|_| "{}".to_string());
+ let item = UniversalItem {
+ item_id: String::new(),
+ native_item_id: Some(call_id.clone()),
+ parent_id: Some(message_id.to_string()),
+ kind: ItemKind::ToolCall,
+ role: Some(ItemRole::Assistant),
+ content: vec![ContentPart::ToolCall {
+ name: tool.clone(),
+ arguments,
+ call_id: call_id.clone(),
}],
- Some(Value::Object(meta)),
- )
+ status: ItemStatus::InProgress,
+ };
+ events.push(EventConversion::new(
+ UniversalEventType::ItemStarted,
+ UniversalEventData::Item(ItemEventData { item }),
+ ));
}
schema::ToolState::Running(state) => {
- let schema::ToolStateRunning {
- input,
- metadata,
- status,
- time,
- title,
- } = state;
- let mut meta = Map::new();
- meta.insert("status".to_string(), Value::String(status.clone()));
- meta.insert("input".to_string(), Value::Object(input.clone()));
- meta.insert("metadata".to_string(), Value::Object(metadata.clone()));
- meta.insert(
- "time".to_string(),
- serde_json::to_value(time).unwrap_or(Value::Null),
- );
- if let Some(title) = title {
- meta.insert("title".to_string(), Value::String(title.clone()));
- }
- (
- vec![UniversalMessagePart::ToolCall {
- id: Some(call_id.to_string()),
- name: tool.to_string(),
- input: Value::Object(input.clone()),
+ let arguments = serde_json::to_string(&Value::Object(state.input.clone()))
+ .unwrap_or_else(|_| "{}".to_string());
+ let item = UniversalItem {
+ item_id: String::new(),
+ native_item_id: Some(call_id.clone()),
+ parent_id: Some(message_id.to_string()),
+ kind: ItemKind::ToolCall,
+ role: Some(ItemRole::Assistant),
+ content: vec![ContentPart::ToolCall {
+ name: tool.clone(),
+ arguments,
+ call_id: call_id.clone(),
}],
- Some(Value::Object(meta)),
- )
+ status: ItemStatus::InProgress,
+ };
+ events.push(EventConversion::new(
+ UniversalEventType::ItemStarted,
+ UniversalEventData::Item(ItemEventData { item }),
+ ));
}
schema::ToolState::Completed(state) => {
- let schema::ToolStateCompleted {
- attachments,
- input,
- metadata,
+ let output = state.output.clone();
+ let mut content = vec![ContentPart::ToolResult {
+ call_id: call_id.clone(),
output,
- status,
- time,
- title,
- } = state;
- let mut meta = Map::new();
- meta.insert("status".to_string(), Value::String(status.clone()));
- meta.insert("input".to_string(), Value::Object(input.clone()));
- meta.insert("metadata".to_string(), Value::Object(metadata.clone()));
- meta.insert(
- "time".to_string(),
- serde_json::to_value(time).unwrap_or(Value::Null),
- );
- meta.insert("title".to_string(), Value::String(title.clone()));
- if !attachments.is_empty() {
- meta.insert(
- "attachments".to_string(),
- serde_json::to_value(attachments).unwrap_or(Value::Null),
- );
- }
- let mut parts = vec![UniversalMessagePart::ToolResult {
- id: Some(call_id.to_string()),
- name: Some(tool.to_string()),
- output: Value::String(output.clone()),
- is_error: Some(false),
}];
- for attachment in attachments {
- parts.push(file_part_to_universal_part(attachment));
+ for attachment in &state.attachments {
+ content.push(file_part_to_content(attachment));
}
- (parts, Some(Value::Object(meta)))
+ let item = UniversalItem {
+ item_id: String::new(),
+ native_item_id: Some(call_id.clone()),
+ parent_id: Some(message_id.to_string()),
+ kind: ItemKind::ToolResult,
+ role: Some(ItemRole::Tool),
+ content,
+ status: ItemStatus::Completed,
+ };
+ events.push(EventConversion::new(
+ UniversalEventType::ItemCompleted,
+ UniversalEventData::Item(ItemEventData { item }),
+ ));
}
schema::ToolState::Error(state) => {
- let schema::ToolStateError {
- error,
- input,
- metadata,
- status,
- time,
- } = state;
- let mut meta = Map::new();
- meta.insert("status".to_string(), Value::String(status.clone()));
- meta.insert("error".to_string(), Value::String(error.clone()));
- meta.insert("input".to_string(), Value::Object(input.clone()));
- meta.insert("metadata".to_string(), Value::Object(metadata.clone()));
- meta.insert(
- "time".to_string(),
- serde_json::to_value(time).unwrap_or(Value::Null),
- );
- (
- vec![UniversalMessagePart::ToolResult {
- id: Some(call_id.to_string()),
- name: Some(tool.to_string()),
- output: Value::String(error.clone()),
- is_error: Some(true),
+ let output = state.error.clone();
+ let item = UniversalItem {
+ item_id: String::new(),
+ native_item_id: Some(call_id.clone()),
+ parent_id: Some(message_id.to_string()),
+ kind: ItemKind::ToolResult,
+ role: Some(ItemRole::Tool),
+ content: vec![ContentPart::ToolResult {
+ call_id: call_id.clone(),
+ output,
}],
- Some(Value::Object(meta)),
- )
+ status: ItemStatus::Failed,
+ };
+ events.push(EventConversion::new(
+ UniversalEventType::ItemCompleted,
+ UniversalEventData::Item(ItemEventData { item }),
+ ));
}
}
+ events
}
-fn question_request_from_opencode(request: &schema::QuestionRequest) -> QuestionRequest {
- let schema::QuestionRequest {
- id,
- questions,
- session_id,
- tool,
- } = request;
- QuestionRequest {
- id: id.clone().into(),
- session_id: session_id.clone().into(),
- questions: questions
- .iter()
- .map(|question| {
- let schema::QuestionInfo {
- custom,
- header,
- multiple,
- options,
- question,
- } = question;
- QuestionInfo {
- question: question.clone(),
- header: Some(header.clone()),
- options: options
- .iter()
- .map(|opt| {
- let schema::QuestionOption { description, label } = opt;
- QuestionOption {
- label: label.clone(),
- description: Some(description.clone()),
- }
- })
- .collect(),
- multi_select: *multiple,
- custom: *custom,
- }
- })
- .collect(),
- tool: tool.as_ref().map(|tool| {
- let schema::QuestionRequestTool { message_id, call_id } = tool;
- QuestionToolRef {
- message_id: message_id.clone(),
- call_id: call_id.clone(),
- }
- }),
+fn file_part_to_content(file_part: &schema::FilePart) -> ContentPart {
+ let path = file_part.url.clone();
+ let action = if file_part.mime.starts_with("image/") {
+ crate::FileAction::Read
+ } else {
+ crate::FileAction::Read
+ };
+ ContentPart::FileRef {
+ path,
+ action,
+ diff: None,
}
}
-fn permission_request_from_opencode(request: &schema::PermissionRequest) -> PermissionRequest {
- let schema::PermissionRequest {
- always,
- id,
- metadata,
- patterns,
- permission,
- session_id,
- tool,
- } = request;
- PermissionRequest {
- id: id.clone().into(),
- session_id: session_id.clone().into(),
- permission: permission.clone(),
- patterns: patterns.clone(),
- metadata: metadata.clone(),
- always: always.clone(),
- tool: tool.as_ref().map(|tool| {
- let schema::PermissionRequestTool { message_id, call_id } = tool;
- PermissionToolRef {
- message_id: message_id.clone(),
- call_id: call_id.clone(),
- }
- }),
+fn status_item(label: &str, detail: Option) -> UniversalItem {
+ UniversalItem {
+ item_id: String::new(),
+ native_item_id: None,
+ parent_id: None,
+ kind: ItemKind::Status,
+ role: Some(ItemRole::System),
+ content: vec![ContentPart::Status {
+ label: label.to_string(),
+ detail,
+ }],
+ status: ItemStatus::Completed,
}
}
-fn question_request_to_opencode(request: &QuestionRequest) -> Result {
- let id = schema::QuestionRequestId::try_from(request.id.as_str())
- .map_err(|err| ConversionError::InvalidValue(err.to_string()))?;
- let session_id = schema::QuestionRequestSessionId::try_from(request.session_id.as_str())
- .map_err(|err| ConversionError::InvalidValue(err.to_string()))?;
- let questions = request
+fn question_from_opencode(request: &schema::QuestionRequest) -> QuestionEventData {
+ let prompt = request
.questions
- .iter()
- .map(|question| schema::QuestionInfo {
- question: question.question.clone(),
- header: question
- .header
- .clone()
- .unwrap_or_else(|| "Question".to_string()),
- options: question
- .options
+ .first()
+ .map(|q| q.question.clone())
+ .unwrap_or_default();
+ let options = request
+ .questions
+ .first()
+ .map(|q| {
+ q.options
.iter()
- .map(|opt| schema::QuestionOption {
- label: opt.label.clone(),
- description: opt.description.clone().unwrap_or_default(),
- })
- .collect(),
- multiple: question.multi_select,
- custom: question.custom,
+ .map(|opt| opt.label.clone())
+ .collect::>()
})
- .collect();
-
- Ok(schema::QuestionRequest {
- id,
- session_id,
- questions,
- tool: request.tool.as_ref().map(|tool| schema::QuestionRequestTool {
- message_id: tool.message_id.clone(),
- call_id: tool.call_id.clone(),
- }),
- })
+ .unwrap_or_default();
+ QuestionEventData {
+ question_id: request.id.clone().into(),
+ prompt,
+ options,
+ response: None,
+ status: QuestionStatus::Requested,
+ }
}
-fn permission_request_to_opencode(
- request: &PermissionRequest,
-) -> Result {
- let id = schema::PermissionRequestId::try_from(request.id.as_str())
- .map_err(|err| ConversionError::InvalidValue(err.to_string()))?;
- let session_id = schema::PermissionRequestSessionId::try_from(request.session_id.as_str())
- .map_err(|err| ConversionError::InvalidValue(err.to_string()))?;
- Ok(schema::PermissionRequest {
- id,
- session_id,
- permission: request.permission.clone(),
- patterns: request.patterns.clone(),
- metadata: request.metadata.clone(),
- always: request.always.clone(),
- tool: request.tool.as_ref().map(|tool| schema::PermissionRequestTool {
- message_id: tool.message_id.clone(),
- call_id: tool.call_id.clone(),
- }),
- })
+fn permission_from_opencode(request: &schema::PermissionRequest) -> PermissionEventData {
+ PermissionEventData {
+ permission_id: request.id.clone().into(),
+ action: request.permission.clone(),
+ status: PermissionStatus::Requested,
+ metadata: serde_json::to_value(request).ok(),
+ }
}
diff --git a/server/packages/universal-agent-schema/src/lib.rs b/server/packages/universal-agent-schema/src/lib.rs
index fcecee2..232eb3f 100644
--- a/server/packages/universal-agent-schema/src/lib.rs
+++ b/server/packages/universal-agent-schema/src/lib.rs
@@ -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,
+ pub native_session_id: Option,
+ pub synthetic: bool,
+ pub source: EventSource,
+ #[serde(rename = "type")]
+ pub event_type: UniversalEventType,
pub data: UniversalEventData,
+ pub raw: Option,
+}
+
+#[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,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub details: Option,
+pub struct SessionStartedData {
+ pub metadata: Option