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, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct CrashInfo { +pub struct SessionEndedData { + pub reason: SessionEndReason, + pub terminated_by: TerminatedBy, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum SessionEndReason { + Completed, + Error, + Terminated, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum TerminatedBy { + Agent, + Daemon, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] +pub struct ItemEventData { + pub item: UniversalItem, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] +pub struct ItemDeltaData { + pub item_id: String, + pub native_item_id: Option, + pub delta: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] +pub struct ErrorData { pub message: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub kind: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] + pub code: Option, pub details: Option, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] -pub struct UniversalMessageParsed { - pub role: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub id: Option, - #[serde(default, skip_serializing_if = "Map::is_empty")] - pub metadata: Map, - pub parts: Vec, +pub struct AgentUnparsedData { + pub error: String, + pub location: String, + pub raw_hash: Option, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] -#[serde(untagged)] -pub enum UniversalMessage { - Parsed(UniversalMessageParsed), - Unparsed { - raw: Value, - #[serde(default, skip_serializing_if = "Option::is_none")] - error: Option, - }, +pub struct PermissionEventData { + pub permission_id: String, + pub action: String, + pub status: PermissionStatus, + pub metadata: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum PermissionStatus { + Requested, + Approved, + Denied, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] +pub struct QuestionEventData { + pub question_id: String, + pub prompt: String, + pub options: Vec, + pub response: Option, + pub status: QuestionStatus, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum QuestionStatus { + Requested, + Answered, + Rejected, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] +pub struct UniversalItem { + pub item_id: String, + pub native_item_id: Option, + pub parent_id: Option, + pub kind: ItemKind, + pub role: Option, + pub content: Vec, + pub status: ItemStatus, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum ItemKind { + Message, + ToolCall, + ToolResult, + System, + Status, + Unknown, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum ItemRole { + User, + Assistant, + System, + Tool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum ItemStatus { + InProgress, + Completed, + Failed, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] #[serde(tag = "type", rename_all = "snake_case")] -pub enum UniversalMessagePart { +pub enum ContentPart { Text { text: String }, - ToolCall { - #[serde(default, skip_serializing_if = "Option::is_none")] - id: Option, - name: String, - input: Value, - }, - ToolResult { - #[serde(default, skip_serializing_if = "Option::is_none")] - id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - name: Option, - output: Value, - #[serde(default, skip_serializing_if = "Option::is_none")] - is_error: Option, - }, - FunctionCall { - #[serde(default, skip_serializing_if = "Option::is_none")] - id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - name: Option, - arguments: Value, - #[serde(default, skip_serializing_if = "Option::is_none")] - raw: Option, - }, - FunctionResult { - #[serde(default, skip_serializing_if = "Option::is_none")] - id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - name: Option, - result: Value, - #[serde(default, skip_serializing_if = "Option::is_none")] - is_error: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - raw: Option, - }, - File { - source: AttachmentSource, - #[serde(default, skip_serializing_if = "Option::is_none")] - mime_type: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - filename: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - raw: Option, - }, - Image { - source: AttachmentSource, - #[serde(default, skip_serializing_if = "Option::is_none")] - mime_type: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - alt: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - raw: Option, - }, - Error { message: String }, - Unknown { raw: Value }, + Json { json: Value }, + ToolCall { name: String, arguments: String, call_id: String }, + ToolResult { call_id: String, output: String }, + FileRef { path: String, action: FileAction, diff: Option }, + Reasoning { text: String, visibility: ReasoningVisibility }, + Image { path: String, mime: Option }, + Status { label: String, detail: Option }, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum AttachmentSource { - Path { path: String }, - Url { url: String }, - Data { - data: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - encoding: Option, - }, +#[serde(rename_all = "snake_case")] +pub enum FileAction { + Read, + Write, + Patch, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct QuestionRequest { - pub id: String, - pub session_id: String, - pub questions: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub tool: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct QuestionInfo { - pub question: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub header: Option, - pub options: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub multi_select: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub custom: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct QuestionOption { - pub label: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub description: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct QuestionToolRef { - pub message_id: String, - pub call_id: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct PermissionRequest { - pub id: String, - pub session_id: String, - pub permission: String, - pub patterns: Vec, - #[serde(default, skip_serializing_if = "Map::is_empty")] - pub metadata: Map, - pub always: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub tool: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct PermissionToolRef { - pub message_id: String, - pub call_id: String, -} - -#[derive(Debug, Error)] -pub enum ConversionError { - #[error("unsupported conversion: {0}")] - Unsupported(&'static str), - #[error("missing field: {0}")] - MissingField(&'static str), - #[error("invalid value: {0}")] - InvalidValue(String), - #[error("serde error: {0}")] - Serde(String), -} - -impl From for ConversionError { - fn from(err: serde_json::Error) -> Self { - Self::Serde(err.to_string()) - } +#[serde(rename_all = "snake_case")] +pub enum ReasoningVisibility { + Public, + Private, } #[derive(Debug, Clone)] pub struct EventConversion { + pub event_type: UniversalEventType, pub data: UniversalEventData, - pub agent_session_id: Option, + pub native_session_id: Option, + pub source: EventSource, + pub synthetic: bool, + pub raw: Option, } impl EventConversion { - pub fn new(data: UniversalEventData) -> Self { + pub fn new(event_type: UniversalEventType, data: UniversalEventData) -> Self { Self { + event_type, data, - agent_session_id: None, + native_session_id: None, + source: EventSource::Agent, + synthetic: false, + raw: None, } } - pub fn with_session(mut self, session_id: Option) -> Self { - self.agent_session_id = session_id; + pub fn with_native_session(mut self, session_id: Option) -> Self { + self.native_session_id = session_id; + self + } + + pub fn with_raw(mut self, raw: Option) -> Self { + self.raw = raw; + self + } + + pub fn synthetic(mut self) -> Self { + self.synthetic = true; + self.source = EventSource::Daemon; + self + } + + pub fn with_source(mut self, source: EventSource) -> Self { + self.source = source; self } } -fn message_from_text(role: &str, text: String) -> UniversalMessage { - UniversalMessage::Parsed(UniversalMessageParsed { - role: role.to_string(), - id: None, - metadata: Map::new(), - parts: vec![UniversalMessagePart::Text { text }], - }) -} - -fn message_from_parts(role: &str, parts: Vec) -> UniversalMessage { - UniversalMessage::Parsed(UniversalMessageParsed { - role: role.to_string(), - id: None, - metadata: Map::new(), - parts, - }) -} - -fn text_only_from_parts(parts: &[UniversalMessagePart]) -> Result { - let mut text = String::new(); - for part in parts { - match part { - UniversalMessagePart::Text { text: part_text } => { - if !text.is_empty() { - text.push_str("\n"); - } - text.push_str(part_text); - } - UniversalMessagePart::ToolCall { .. } => { - return Err(ConversionError::Unsupported("tool call part")) - } - UniversalMessagePart::ToolResult { .. } => { - return Err(ConversionError::Unsupported("tool result part")) - } - UniversalMessagePart::FunctionCall { .. } => { - return Err(ConversionError::Unsupported("function call part")) - } - UniversalMessagePart::FunctionResult { .. } => { - return Err(ConversionError::Unsupported("function result part")) - } - UniversalMessagePart::File { .. } => { - return Err(ConversionError::Unsupported("file part")) - } - UniversalMessagePart::Image { .. } => { - return Err(ConversionError::Unsupported("image part")) - } - UniversalMessagePart::Error { .. } => { - return Err(ConversionError::Unsupported("error part")) - } - UniversalMessagePart::Unknown { .. } => { - return Err(ConversionError::Unsupported("unknown part")) - } - } - } - if text.is_empty() { - Err(ConversionError::MissingField("text part")) - } else { - Ok(text) +pub fn item_from_text(role: ItemRole, text: String) -> UniversalItem { + UniversalItem { + item_id: String::new(), + native_item_id: None, + parent_id: None, + kind: ItemKind::Message, + role: Some(role), + content: vec![ContentPart::Text { text }], + status: ItemStatus::Completed, } } -fn extract_message_from_value(value: &Value) -> Option { - if let Some(message) = value.get("message").and_then(Value::as_str) { - return Some(message.to_string()); +pub fn item_from_parts(role: ItemRole, kind: ItemKind, parts: Vec) -> UniversalItem { + UniversalItem { + item_id: String::new(), + native_item_id: None, + parent_id: None, + kind, + role: Some(role), + content: parts, + status: ItemStatus::Completed, } - if let Some(message) = value.get("error").and_then(|v| v.get("message")).and_then(Value::as_str) { - return Some(message.to_string()); - } - if let Some(message) = value.get("data").and_then(|v| v.get("message")).and_then(Value::as_str) { - return Some(message.to_string()); - } - None } - - - - diff --git a/spec/universal-schema.json b/spec/universal-schema.json index 25d144d..0170a82 100644 --- a/spec/universal-schema.json +++ b/spec/universal-schema.json @@ -3,287 +3,65 @@ "title": "UniversalEvent", "type": "object", "required": [ - "agent", "data", - "id", - "sessionId", - "timestamp" + "event_id", + "sequence", + "session_id", + "source", + "synthetic", + "time", + "type" ], "properties": { - "agent": { + "data": { + "$ref": "#/definitions/UniversalEventData" + }, + "event_id": { "type": "string" }, - "agentSessionId": { + "native_session_id": { "type": [ "string", "null" ] }, - "data": { - "$ref": "#/definitions/UniversalEventData" - }, - "id": { + "raw": true, + "sequence": { "type": "integer", "format": "uint64", "minimum": 0.0 }, - "sessionId": { + "session_id": { "type": "string" }, - "timestamp": { + "source": { + "$ref": "#/definitions/EventSource" + }, + "synthetic": { + "type": "boolean" + }, + "time": { "type": "string" + }, + "type": { + "$ref": "#/definitions/UniversalEventType" } }, "definitions": { - "AttachmentSource": { - "oneOf": [ - { - "type": "object", - "required": [ - "path", - "type" - ], - "properties": { - "path": { - "type": "string" - }, - "type": { - "type": "string", - "enum": [ - "path" - ] - } - } - }, - { - "type": "object", - "required": [ - "type", - "url" - ], - "properties": { - "type": { - "type": "string", - "enum": [ - "url" - ] - }, - "url": { - "type": "string" - } - } - }, - { - "type": "object", - "required": [ - "data", - "type" - ], - "properties": { - "data": { - "type": "string" - }, - "encoding": { - "type": [ - "string", - "null" - ] - }, - "type": { - "type": "string", - "enum": [ - "data" - ] - } - } - } - ] - }, - "CrashInfo": { + "AgentUnparsedData": { "type": "object", "required": [ - "message" + "error", + "location" ], "properties": { - "details": true, - "kind": { - "type": [ - "string", - "null" - ] - }, - "message": { - "type": "string" - } - } - }, - "PermissionRequest": { - "type": "object", - "required": [ - "always", - "id", - "patterns", - "permission", - "sessionId" - ], - "properties": { - "always": { - "type": "array", - "items": { - "type": "string" - } - }, - "id": { + "error": { "type": "string" }, - "metadata": { - "type": "object", - "additionalProperties": true - }, - "patterns": { - "type": "array", - "items": { - "type": "string" - } - }, - "permission": { + "location": { "type": "string" }, - "sessionId": { - "type": "string" - }, - "tool": { - "anyOf": [ - { - "$ref": "#/definitions/PermissionToolRef" - }, - { - "type": "null" - } - ] - } - } - }, - "PermissionToolRef": { - "type": "object", - "required": [ - "callId", - "messageId" - ], - "properties": { - "callId": { - "type": "string" - }, - "messageId": { - "type": "string" - } - } - }, - "QuestionInfo": { - "type": "object", - "required": [ - "options", - "question" - ], - "properties": { - "custom": { - "type": [ - "boolean", - "null" - ] - }, - "header": { - "type": [ - "string", - "null" - ] - }, - "multiSelect": { - "type": [ - "boolean", - "null" - ] - }, - "options": { - "type": "array", - "items": { - "$ref": "#/definitions/QuestionOption" - } - }, - "question": { - "type": "string" - } - } - }, - "QuestionOption": { - "type": "object", - "required": [ - "label" - ], - "properties": { - "description": { - "type": [ - "string", - "null" - ] - }, - "label": { - "type": "string" - } - } - }, - "QuestionRequest": { - "type": "object", - "required": [ - "id", - "questions", - "sessionId" - ], - "properties": { - "id": { - "type": "string" - }, - "questions": { - "type": "array", - "items": { - "$ref": "#/definitions/QuestionInfo" - } - }, - "sessionId": { - "type": "string" - }, - "tool": { - "anyOf": [ - { - "$ref": "#/definitions/QuestionToolRef" - }, - { - "type": "null" - } - ] - } - } - }, - "QuestionToolRef": { - "type": "object", - "required": [ - "callId", - "messageId" - ], - "properties": { - "callId": { - "type": "string" - }, - "messageId": { - "type": "string" - } - } - }, - "Started": { - "type": "object", - "properties": { - "details": true, - "message": { + "raw_hash": { "type": [ "string", "null" @@ -291,125 +69,7 @@ } } }, - "UniversalEventData": { - "anyOf": [ - { - "type": "object", - "required": [ - "message" - ], - "properties": { - "message": { - "$ref": "#/definitions/UniversalMessage" - } - } - }, - { - "type": "object", - "required": [ - "started" - ], - "properties": { - "started": { - "$ref": "#/definitions/Started" - } - } - }, - { - "type": "object", - "required": [ - "error" - ], - "properties": { - "error": { - "$ref": "#/definitions/CrashInfo" - } - } - }, - { - "type": "object", - "required": [ - "questionAsked" - ], - "properties": { - "questionAsked": { - "$ref": "#/definitions/QuestionRequest" - } - } - }, - { - "type": "object", - "required": [ - "permissionAsked" - ], - "properties": { - "permissionAsked": { - "$ref": "#/definitions/PermissionRequest" - } - } - }, - { - "type": "object", - "required": [ - "raw" - ], - "properties": { - "raw": true - } - } - ] - }, - "UniversalMessage": { - "anyOf": [ - { - "$ref": "#/definitions/UniversalMessageParsed" - }, - { - "type": "object", - "required": [ - "raw" - ], - "properties": { - "error": { - "type": [ - "string", - "null" - ] - }, - "raw": true - } - } - ] - }, - "UniversalMessageParsed": { - "type": "object", - "required": [ - "parts", - "role" - ], - "properties": { - "id": { - "type": [ - "string", - "null" - ] - }, - "metadata": { - "type": "object", - "additionalProperties": true - }, - "parts": { - "type": "array", - "items": { - "$ref": "#/definitions/UniversalMessagePart" - } - }, - "role": { - "type": "string" - } - } - }, - "UniversalMessagePart": { + "ContentPart": { "oneOf": [ { "type": "object", @@ -432,18 +92,34 @@ { "type": "object", "required": [ - "input", + "json", + "type" + ], + "properties": { + "json": true, + "type": { + "type": "string", + "enum": [ + "json" + ] + } + } + }, + { + "type": "object", + "required": [ + "arguments", + "call_id", "name", "type" ], "properties": { - "id": { - "type": [ - "string", - "null" - ] + "arguments": { + "type": "string" + }, + "call_id": { + "type": "string" }, - "input": true, "name": { "type": "string" }, @@ -458,29 +134,17 @@ { "type": "object", "required": [ + "call_id", "output", "type" ], "properties": { - "id": { - "type": [ - "string", - "null" - ] + "call_id": { + "type": "string" }, - "is_error": { - "type": [ - "boolean", - "null" - ] + "output": { + "type": "string" }, - "name": { - "type": [ - "string", - "null" - ] - }, - "output": true, "type": { "type": "string", "enum": [ @@ -492,28 +156,27 @@ { "type": "object", "required": [ - "arguments", + "action", + "path", "type" ], "properties": { - "arguments": true, - "id": { + "action": { + "$ref": "#/definitions/FileAction" + }, + "diff": { "type": [ "string", "null" ] }, - "name": { - "type": [ - "string", - "null" - ] + "path": { + "type": "string" }, - "raw": true, "type": { "type": "string", "enum": [ - "function_call" + "file_ref" ] } } @@ -521,91 +184,40 @@ { "type": "object", "required": [ - "result", - "type" + "text", + "type", + "visibility" ], "properties": { - "id": { - "type": [ - "string", - "null" - ] + "text": { + "type": "string" }, - "is_error": { - "type": [ - "boolean", - "null" - ] - }, - "name": { - "type": [ - "string", - "null" - ] - }, - "raw": true, - "result": true, "type": { "type": "string", "enum": [ - "function_result" + "reasoning" ] + }, + "visibility": { + "$ref": "#/definitions/ReasoningVisibility" } } }, { "type": "object", "required": [ - "source", + "path", "type" ], "properties": { - "filename": { + "mime": { "type": [ "string", "null" ] }, - "mime_type": { - "type": [ - "string", - "null" - ] - }, - "raw": true, - "source": { - "$ref": "#/definitions/AttachmentSource" - }, - "type": { - "type": "string", - "enum": [ - "file" - ] - } - } - }, - { - "type": "object", - "required": [ - "source", - "type" - ], - "properties": { - "alt": { - "type": [ - "string", - "null" - ] - }, - "mime_type": { - "type": [ - "string", - "null" - ] - }, - "raw": true, - "source": { - "$ref": "#/definitions/AttachmentSource" + "path": { + "type": "string" }, "type": { "type": "string", @@ -618,38 +230,324 @@ { "type": "object", "required": [ - "message", + "label", "type" ], "properties": { - "message": { + "detail": { + "type": [ + "string", + "null" + ] + }, + "label": { "type": "string" }, "type": { "type": "string", "enum": [ - "error" - ] - } - } - }, - { - "type": "object", - "required": [ - "raw", - "type" - ], - "properties": { - "raw": true, - "type": { - "type": "string", - "enum": [ - "unknown" + "status" ] } } } ] + }, + "ErrorData": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "code": { + "type": [ + "string", + "null" + ] + }, + "details": true, + "message": { + "type": "string" + } + } + }, + "EventSource": { + "type": "string", + "enum": [ + "agent", + "daemon" + ] + }, + "FileAction": { + "type": "string", + "enum": [ + "read", + "write", + "patch" + ] + }, + "ItemDeltaData": { + "type": "object", + "required": [ + "delta", + "item_id" + ], + "properties": { + "delta": { + "type": "string" + }, + "item_id": { + "type": "string" + }, + "native_item_id": { + "type": [ + "string", + "null" + ] + } + } + }, + "ItemEventData": { + "type": "object", + "required": [ + "item" + ], + "properties": { + "item": { + "$ref": "#/definitions/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" + ] + }, + "PermissionEventData": { + "type": "object", + "required": [ + "action", + "permission_id", + "status" + ], + "properties": { + "action": { + "type": "string" + }, + "metadata": true, + "permission_id": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/PermissionStatus" + } + } + }, + "PermissionStatus": { + "type": "string", + "enum": [ + "requested", + "approved", + "denied" + ] + }, + "QuestionEventData": { + "type": "object", + "required": [ + "options", + "prompt", + "question_id", + "status" + ], + "properties": { + "options": { + "type": "array", + "items": { + "type": "string" + } + }, + "prompt": { + "type": "string" + }, + "question_id": { + "type": "string" + }, + "response": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/definitions/QuestionStatus" + } + } + }, + "QuestionStatus": { + "type": "string", + "enum": [ + "requested", + "answered", + "rejected" + ] + }, + "ReasoningVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ] + }, + "SessionEndReason": { + "type": "string", + "enum": [ + "completed", + "error", + "terminated" + ] + }, + "SessionEndedData": { + "type": "object", + "required": [ + "reason", + "terminated_by" + ], + "properties": { + "reason": { + "$ref": "#/definitions/SessionEndReason" + }, + "terminated_by": { + "$ref": "#/definitions/TerminatedBy" + } + } + }, + "SessionStartedData": { + "type": "object", + "properties": { + "metadata": true + } + }, + "TerminatedBy": { + "type": "string", + "enum": [ + "agent", + "daemon" + ] + }, + "UniversalEventData": { + "anyOf": [ + { + "$ref": "#/definitions/SessionStartedData" + }, + { + "$ref": "#/definitions/SessionEndedData" + }, + { + "$ref": "#/definitions/ItemEventData" + }, + { + "$ref": "#/definitions/ItemDeltaData" + }, + { + "$ref": "#/definitions/ErrorData" + }, + { + "$ref": "#/definitions/PermissionEventData" + }, + { + "$ref": "#/definitions/QuestionEventData" + }, + { + "$ref": "#/definitions/AgentUnparsedData" + } + ] + }, + "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" + ] + }, + "UniversalItem": { + "type": "object", + "required": [ + "content", + "item_id", + "kind", + "status" + ], + "properties": { + "content": { + "type": "array", + "items": { + "$ref": "#/definitions/ContentPart" + } + }, + "item_id": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/ItemKind" + }, + "native_item_id": { + "type": [ + "string", + "null" + ] + }, + "parent_id": { + "type": [ + "string", + "null" + ] + }, + "role": { + "anyOf": [ + { + "$ref": "#/definitions/ItemRole" + }, + { + "type": "null" + } + ] + }, + "status": { + "$ref": "#/definitions/ItemStatus" + } + } } } } \ No newline at end of file diff --git a/spec/universal-schema.md b/spec/universal-schema.md new file mode 100644 index 0000000..ba73122 --- /dev/null +++ b/spec/universal-schema.md @@ -0,0 +1,143 @@ +# Universal Schema (Single Version, Breaking) + +This document defines the canonical universal session + event model. It replaces prior versions; there is no v2. The design prioritizes compatibility with native agent APIs and fills gaps with explicit synthetics. + +Principles +- Most-compatible-first: choose semantics that map cleanly to native APIs (Codex/OpenCode/Amp/Claude). +- Uniform behavior: clients should not special-case agents; the daemon normalizes differences. +- Synthetics fill gaps: when a provider lacks a feature (session start/end, deltas, user messages), we synthesize events with `source=daemon`. +- Raw preservation: always keep native payloads in `raw` for agent-sourced events. +- UI coverage: update the inspector/UI to the new schema and ensure UI tests cover all session features (messages, deltas, tools, permissions, questions, errors, termination). + +Identifiers +- session_id: daemon-generated session identifier. +- native_session_id: provider thread/session/run identifier (thread_id is merged here). +- item_id: daemon-generated identifier for any universal item. +- native_item_id: provider-native item/message identifier if available; otherwise null. + +Event envelope +```json +{ + "event_id": "evt_...", + "sequence": 42, + "time": "2026-01-27T19:10:11Z", + "session_id": "sess_...", + "native_session_id": "provider_...", + "synthetic": false, + "source": "agent|daemon", + "type": "session.started|session.ended|item.started|item.delta|item.completed|error|permission.requested|permission.resolved|question.requested|question.resolved|agent.unparsed", + "data": { "..." : "..." }, + "raw": { "..." : "..." } +} +``` + +Notes: +- `source=agent` for native events; `source=daemon` for synthetics. +- `synthetic` is always present and mirrors whether the event is daemon-produced. +- `raw` is always present. It may be null unless the client opts in to raw payloads; when opt-in is enabled, raw is populated for all events. +- For synthetic events derived from native payloads, include the underlying payload in `raw` when possible. +- Parsing failures emit agent.unparsed (source=daemon, synthetic=true) and should be treated as test failures. + +Raw payload opt-in +- Events endpoints accept `include_raw=true` to populate the `raw` field. +- When `include_raw` is not set or false, `raw` is still present but null. +- Applies to both HTTP and SSE event streams. + +Item model +```json +{ + "item_id": "itm_...", + "native_item_id": "provider_item_...", + "parent_id": "itm_parent_or_null", + "kind": "message|tool_call|tool_result|system|status|unknown", + "role": "user|assistant|system|tool|null", + "content": [ { "type": "...", "...": "..." } ], + "status": "in_progress|completed|failed" +} +``` + +Content parts (non-exhaustive; extend as needed) +- text: `{ "type": "text", "text": "..." }` +- json: `{ "type": "json", "json": { ... } }` +- tool_call: `{ "type": "tool_call", "name": "...", "arguments": "...", "call_id": "..." }` +- tool_result: `{ "type": "tool_result", "call_id": "...", "output": "..." }` +- file_ref: `{ "type": "file_ref", "path": "...", "action": "read|write|patch", "diff": "..." }` +- reasoning: `{ "type": "reasoning", "text": "...", "visibility": "public|private" }` +- image: `{ "type": "image", "path": "...", "mime": "..." }` +- status: `{ "type": "status", "label": "...", "detail": "..." }` + +Event types + +session.started +```json +{ "metadata": { "...": "..." } } +``` + +session.ended +```json +{ "reason": "completed|error|terminated", "terminated_by": "agent|daemon" } +``` + +item.started +```json +{ "item": { ...Item } } +``` + +item.delta +```json +{ "item_id": "itm_...", "native_item_id": "provider_item_or_null", "delta": "text fragment" } +``` + +item.completed +```json +{ "item": { ...Item } } +``` + +error +```json +{ "message": "...", "code": "optional", "details": { "...": "..." } } +``` + +agent.unparsed +```json +{ "error": "parse failure message", "location": "agent parser name", "raw_hash": "optional" } +``` + +permission.requested / permission.resolved +```json +{ "permission_id": "...", "action": "...", "status": "requested|approved|denied", "metadata": { "...": "..." } } +``` + +question.requested / question.resolved +```json +{ "question_id": "...", "prompt": "...", "options": ["..."], "response": "...", "status": "requested|answered|rejected" } +``` + +Delta policy (uniform across agents) +- Always emit item.delta for messages. +- For agents without native deltas (Claude/Amp), emit a single synthetic delta containing the full final content immediately before item.completed. +- For Codex/OpenCode, forward native deltas as-is and still emit item.completed with the final content. + +User messages +- If the provider emits user messages (Codex/OpenCode/Amp), map directly to message items with role=user. +- If the provider does not emit user messages (Claude), synthesize user message items from the input we send; mark source=daemon and set native_item_id=null. + +Tool normalization +- Tool calls/results are always emitted as their own items (kind=tool_call/tool_result) with parent_id pointing to the originating message item. +- Codex: mcp tool call progress and tool items map directly. +- OpenCode: tool parts in message.part.updated are mapped into tool items with lifecycle states. +- Amp: tool_call/tool_result map directly. +- Claude: synthesize tool items from CLI tool usage where possible; if insufficient, omit tool items and preserve raw payloads. + +OpenCode ordering rule +- OpenCode may emit message.part.updated before message.updated. +- When a part delta arrives first, create a stub item.started (source=daemon) for the parent message item, then emit item.delta. + +Session lifecycle +- If an agent does not emit a session start/end, emit session.started/session.ended synthetically (source=daemon). +- session.ended uses terminated_by=daemon when our termination API is used; terminated_by=agent when the provider ends the session. + +Native ID mapping +- native_session_id is the only provider session identifier. +- native_item_id preserves the provider item/message id when available; otherwise null. +- item_id is always daemon-generated. diff --git a/todo.md b/todo.md index b01f8d6..8e659fa 100644 --- a/todo.md +++ b/todo.md @@ -88,6 +88,7 @@ - [x] Add HTTP request log with copyable curl command - [x] Add Content-Type header to CORS callout command - [x] Default inspector endpoint to current origin and auto-connect via health check +- [x] Update inspector to universal schema events (items, deltas, approvals, errors) ## TypeScript SDK - [x] Generate OpenAPI from utoipa and run `openapi-typescript`