mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 06:04:43 +00:00
Merge pull request #160 from rivet-dev/02-11-chore_fix_bad_merge
chore: fix bad merge
This commit is contained in:
commit
1dd45908a3
32 changed files with 3654 additions and 15543 deletions
|
|
@ -8,7 +8,7 @@ edition = "2021"
|
|||
authors = [ "Rivet Gaming, LLC <developer@rivet.gg>" ]
|
||||
license = "Apache-2.0"
|
||||
repository = "https://github.com/rivet-dev/sandbox-agent"
|
||||
description = "Universal API for automatic coding agents in sandboxes. Supports Claude Code, Codex, OpenCode, Cursor, Amp, and Pi."
|
||||
description = "Universal API for automatic coding agents in sandboxes. Supports Claude Code, Codex, OpenCode, and Amp."
|
||||
|
||||
[workspace.dependencies]
|
||||
# Internal crates
|
||||
|
|
|
|||
23
README.md
23
README.md
|
|
@ -31,10 +31,7 @@ Sandbox Agent solves three problems:
|
|||
## Features
|
||||
|
||||
- **Universal Agent API**: Single interface to control Claude Code, Codex, OpenCode, Cursor, Amp, and Pi with full feature coverage
|
||||
- **Streaming Events**: Real-time SSE stream of everything the agent does — tool calls, permission requests, file edits, and more
|
||||
- **Universal Session Schema**: [Standardized schema](https://sandboxagent.dev/docs/session-transcript-schema) that normalizes all agent event formats for storage and replay
|
||||
- **Human-in-the-Loop**: Approve or deny tool executions and answer agent questions remotely over HTTP
|
||||
- **Automatic Agent Installation**: Agents are installed on-demand when first used — no setup required
|
||||
- **Universal Session Schema**: Standardized schema that normalizes all agent event formats for storage and replay
|
||||
- **Runs Inside Any Sandbox**: Lightweight static Rust binary. One curl command to install inside E2B, Daytona, Vercel Sandboxes, or Docker
|
||||
- **Server or SDK Mode**: Run as an HTTP server or embed with the TypeScript SDK
|
||||
- **OpenAPI Spec**: [Well documented](https://sandboxagent.dev/docs/api-reference) and easy to integrate from any language
|
||||
|
|
@ -83,11 +80,11 @@ Import the SDK directly into your Node or browser application. Full type safety
|
|||
**Install**
|
||||
|
||||
```bash
|
||||
npm install sandbox-agent
|
||||
npm install sandbox-agent@0.2.x
|
||||
```
|
||||
|
||||
```bash
|
||||
bun add sandbox-agent
|
||||
bun add sandbox-agent@0.2.x
|
||||
# Optional: allow Bun to run postinstall scripts for native binaries (required for SandboxAgent.start()).
|
||||
bun pm trust @sandbox-agent/cli-linux-x64 @sandbox-agent/cli-darwin-arm64 @sandbox-agent/cli-darwin-x64 @sandbox-agent/cli-win32-x64
|
||||
```
|
||||
|
|
@ -141,7 +138,7 @@ Run as an HTTP server and connect from any language. Deploy to E2B, Daytona, Ver
|
|||
|
||||
```bash
|
||||
# Install it
|
||||
curl -fsSL https://releases.rivet.dev/sandbox-agent/latest/install.sh | sh
|
||||
curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh
|
||||
# Run it
|
||||
sandbox-agent server --token "$SANDBOX_TOKEN" --host 127.0.0.1 --port 2468
|
||||
```
|
||||
|
|
@ -168,12 +165,12 @@ sandbox-agent server --no-token --host 127.0.0.1 --port 2468
|
|||
Install the CLI wrapper (optional but convenient):
|
||||
|
||||
```bash
|
||||
npm install -g @sandbox-agent/cli
|
||||
npm install -g @sandbox-agent/cli@0.2.x
|
||||
```
|
||||
|
||||
```bash
|
||||
# Allow Bun to run postinstall scripts for native binaries.
|
||||
bun add -g @sandbox-agent/cli
|
||||
bun add -g @sandbox-agent/cli@0.2.x
|
||||
bun pm -g trust @sandbox-agent/cli-linux-x64 @sandbox-agent/cli-darwin-arm64 @sandbox-agent/cli-darwin-x64 @sandbox-agent/cli-win32-x64
|
||||
```
|
||||
|
||||
|
|
@ -188,11 +185,11 @@ sandbox-agent api sessions send-message-stream my-session --message "Hello" --en
|
|||
You can also use npx like:
|
||||
|
||||
```bash
|
||||
npx sandbox-agent --help
|
||||
npx @sandbox-agent/cli@0.2.x --help
|
||||
```
|
||||
|
||||
```bash
|
||||
bunx sandbox-agent --help
|
||||
bunx @sandbox-agent/cli@0.2.x --help
|
||||
```
|
||||
|
||||
[CLI documentation](https://sandboxagent.dev/docs/cli)
|
||||
|
|
@ -209,10 +206,6 @@ Debug sessions and events with the built-in Inspector UI (e.g., `http://localhos
|
|||
|
||||
[Explore API](https://sandboxagent.dev/docs/api-reference) — [View Specification](https://github.com/rivet-dev/sandbox-agent/blob/main/docs/openapi.json)
|
||||
|
||||
### Session Transcript Schema
|
||||
|
||||
All events follow a [session transcript schema](https://sandboxagent.dev/docs/session-transcript-schema) that normalizes differences between agents.
|
||||
|
||||
### Tip: Extract credentials
|
||||
|
||||
Often you need to use your personal API tokens to test agents on sandboxes:
|
||||
|
|
|
|||
492
docs/cli.mdx
492
docs/cli.mdx
|
|
@ -1,12 +1,17 @@
|
|||
---
|
||||
title: "CLI Reference"
|
||||
description: "Complete CLI reference for sandbox-agent."
|
||||
description: "CLI reference for sandbox-agent."
|
||||
sidebarTitle: "CLI"
|
||||
---
|
||||
|
||||
## Server
|
||||
Global flags (available on all commands):
|
||||
|
||||
Start the HTTP server:
|
||||
- `-t, --token <TOKEN>`: require/use bearer auth
|
||||
- `-n, --no-token`: disable auth
|
||||
|
||||
## server
|
||||
|
||||
Run the HTTP server.
|
||||
|
||||
```bash
|
||||
sandbox-agent server [OPTIONS]
|
||||
|
|
@ -14,32 +19,27 @@ sandbox-agent server [OPTIONS]
|
|||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `-t, --token <TOKEN>` | - | Authentication token for all requests |
|
||||
| `-n, --no-token` | - | Disable authentication (local dev only) |
|
||||
| `-H, --host <HOST>` | `127.0.0.1` | Host to bind to |
|
||||
| `-p, --port <PORT>` | `2468` | Port to bind to |
|
||||
| `-O, --cors-allow-origin <ORIGIN>` | - | CORS origin to allow (repeatable) |
|
||||
| `-M, --cors-allow-method <METHOD>` | all | CORS allowed method (repeatable) |
|
||||
| `-A, --cors-allow-header <HEADER>` | all | CORS allowed header (repeatable) |
|
||||
| `-C, --cors-allow-credentials` | - | Enable CORS credentials |
|
||||
| `--no-telemetry` | - | Disable anonymous telemetry |
|
||||
| `--log-to-file` | - | Redirect server logs to a daily log file |
|
||||
| `-H, --host <HOST>` | `127.0.0.1` | Host to bind |
|
||||
| `-p, --port <PORT>` | `2468` | Port to bind |
|
||||
| `-O, --cors-allow-origin <ORIGIN>` | - | Allowed CORS origin (repeatable) |
|
||||
| `-M, --cors-allow-method <METHOD>` | all | Allowed CORS method (repeatable) |
|
||||
| `-A, --cors-allow-header <HEADER>` | all | Allowed CORS header (repeatable) |
|
||||
| `-C, --cors-allow-credentials` | false | Enable CORS credentials |
|
||||
| `--no-telemetry` | false | Disable anonymous telemetry |
|
||||
|
||||
```bash
|
||||
sandbox-agent server --token "$TOKEN" --port 3000
|
||||
sandbox-agent server --port 3000
|
||||
```
|
||||
|
||||
Server logs print to stdout/stderr by default. Use `--log-to-file` or `SANDBOX_AGENT_LOG_TO_FILE=1` to redirect logs to a daily log file under the sandbox-agent data directory (for example, `~/.local/share/sandbox-agent/logs`). Override the directory with `SANDBOX_AGENT_LOG_DIR`, or set `SANDBOX_AGENT_LOG_STDOUT=1` to force stdout/stderr.
|
||||
Notes:
|
||||
|
||||
HTTP request logging is enabled by default. Control it with:
|
||||
- `SANDBOX_AGENT_LOG_HTTP=0` to disable request logs
|
||||
- `SANDBOX_AGENT_LOG_HTTP_HEADERS=1` to include request headers (Authorization is redacted)
|
||||
- Server logs are redirected to files by default.
|
||||
- Set `SANDBOX_AGENT_LOG_STDOUT=1` to force stdout/stderr logging.
|
||||
- Use `SANDBOX_AGENT_LOG_DIR` to override log directory.
|
||||
|
||||
---
|
||||
## install-agent
|
||||
|
||||
## Install Agent (Local)
|
||||
|
||||
Install an agent without running the server:
|
||||
Install or reinstall a single agent.
|
||||
|
||||
```bash
|
||||
sandbox-agent install-agent <AGENT> [OPTIONS]
|
||||
|
|
@ -47,17 +47,17 @@ sandbox-agent install-agent <AGENT> [OPTIONS]
|
|||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `-r, --reinstall` | Force reinstall even if already installed |
|
||||
| `-r, --reinstall` | Force reinstall |
|
||||
| `--agent-version <VERSION>` | Override agent package version |
|
||||
| `--agent-process-version <VERSION>` | Override agent process version |
|
||||
|
||||
```bash
|
||||
sandbox-agent install-agent claude --reinstall
|
||||
```
|
||||
|
||||
---
|
||||
## opencode (experimental)
|
||||
|
||||
## OpenCode (Experimental)
|
||||
|
||||
Start (or reuse) a sandbox-agent daemon and attach an OpenCode session (uses `opencode attach`):
|
||||
Start/reuse daemon and run `opencode attach` against `/opencode`.
|
||||
|
||||
```bash
|
||||
sandbox-agent opencode [OPTIONS]
|
||||
|
|
@ -65,27 +65,20 @@ sandbox-agent opencode [OPTIONS]
|
|||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `-t, --token <TOKEN>` | - | Authentication token for all requests |
|
||||
| `-n, --no-token` | - | Disable authentication (local dev only) |
|
||||
| `-H, --host <HOST>` | `127.0.0.1` | Host to bind to |
|
||||
| `-p, --port <PORT>` | `2468` | Port to bind to |
|
||||
| `--session-title <TITLE>` | - | Title for the OpenCode session |
|
||||
| `-H, --host <HOST>` | `127.0.0.1` | Daemon host |
|
||||
| `-p, --port <PORT>` | `2468` | Daemon port |
|
||||
| `--session-title <TITLE>` | - | Reserved option (currently no-op) |
|
||||
| `--yolo` | false | OpenCode attach mode flag |
|
||||
|
||||
```bash
|
||||
sandbox-agent opencode --token "$TOKEN"
|
||||
sandbox-agent opencode
|
||||
```
|
||||
|
||||
The daemon logs to a per-host log file under the sandbox-agent data directory (for example, `~/.local/share/sandbox-agent/daemon/daemon-127-0-0-1-2468.log`).
|
||||
## daemon
|
||||
|
||||
Existing installs are reused and missing binaries are installed automatically.
|
||||
Manage the background daemon.
|
||||
|
||||
---
|
||||
|
||||
## Daemon
|
||||
|
||||
Manage the background daemon. See the [Daemon](/daemon) docs for details on lifecycle and auto-upgrade.
|
||||
|
||||
### Start
|
||||
### daemon start
|
||||
|
||||
```bash
|
||||
sandbox-agent daemon start [OPTIONS]
|
||||
|
|
@ -93,16 +86,16 @@ sandbox-agent daemon start [OPTIONS]
|
|||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `-H, --host <HOST>` | `127.0.0.1` | Host to bind to |
|
||||
| `-p, --port <PORT>` | `2468` | Port to bind to |
|
||||
| `-t, --token <TOKEN>` | - | Authentication token |
|
||||
| `-n, --no-token` | - | Disable authentication |
|
||||
| `-H, --host <HOST>` | `127.0.0.1` | Host |
|
||||
| `-p, --port <PORT>` | `2468` | Port |
|
||||
| `--upgrade` | false | Use ensure-running + upgrade behavior |
|
||||
|
||||
```bash
|
||||
sandbox-agent daemon start --no-token
|
||||
sandbox-agent daemon start
|
||||
sandbox-agent daemon start --upgrade
|
||||
```
|
||||
|
||||
### Stop
|
||||
### daemon stop
|
||||
|
||||
```bash
|
||||
sandbox-agent daemon stop [OPTIONS]
|
||||
|
|
@ -110,10 +103,10 @@ sandbox-agent daemon stop [OPTIONS]
|
|||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `-H, --host <HOST>` | `127.0.0.1` | Host of the daemon |
|
||||
| `-p, --port <PORT>` | `2468` | Port of the daemon |
|
||||
| `-H, --host <HOST>` | `127.0.0.1` | Host |
|
||||
| `-p, --port <PORT>` | `2468` | Port |
|
||||
|
||||
### Status
|
||||
### daemon status
|
||||
|
||||
```bash
|
||||
sandbox-agent daemon status [OPTIONS]
|
||||
|
|
@ -121,16 +114,12 @@ sandbox-agent daemon status [OPTIONS]
|
|||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `-H, --host <HOST>` | `127.0.0.1` | Host of the daemon |
|
||||
| `-p, --port <PORT>` | `2468` | Port of the daemon |
|
||||
| `-H, --host <HOST>` | `127.0.0.1` | Host |
|
||||
| `-p, --port <PORT>` | `2468` | Port |
|
||||
|
||||
---
|
||||
## credentials
|
||||
|
||||
## Credentials
|
||||
|
||||
### Extract
|
||||
|
||||
Extract locally discovered credentials:
|
||||
### credentials extract
|
||||
|
||||
```bash
|
||||
sandbox-agent credentials extract [OPTIONS]
|
||||
|
|
@ -138,20 +127,17 @@ sandbox-agent credentials extract [OPTIONS]
|
|||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `-a, --agent <AGENT>` | Filter by agent (`claude`, `codex`, `opencode`, `amp`, `pi`) |
|
||||
| `-p, --provider <PROVIDER>` | Filter by provider (`anthropic`, `openai`) |
|
||||
| `-d, --home-dir <DIR>` | Custom home directory for credential search |
|
||||
| `-r, --reveal` | Show full credential values (default: redacted) |
|
||||
| `--no-oauth` | Exclude OAuth credentials |
|
||||
| `-a, --agent <AGENT>` | Filter by `claude`, `codex`, `opencode`, or `amp` |
|
||||
| `-p, --provider <PROVIDER>` | Filter by provider |
|
||||
| `-d, --home-dir <DIR>` | Override home dir |
|
||||
| `--no-oauth` | Skip OAuth sources |
|
||||
| `-r, --reveal` | Show full credential values |
|
||||
|
||||
```bash
|
||||
sandbox-agent credentials extract --agent claude --reveal
|
||||
sandbox-agent credentials extract --provider anthropic
|
||||
```
|
||||
|
||||
### Extract as Environment Variables
|
||||
|
||||
Output credentials as shell environment variables:
|
||||
### credentials extract-env
|
||||
|
||||
```bash
|
||||
sandbox-agent credentials extract-env [OPTIONS]
|
||||
|
|
@ -159,378 +145,28 @@ sandbox-agent credentials extract-env [OPTIONS]
|
|||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `-e, --export` | Prefix each line with `export` |
|
||||
| `-d, --home-dir <DIR>` | Custom home directory for credential search |
|
||||
| `--no-oauth` | Exclude OAuth credentials |
|
||||
| `-e, --export` | Prefix output with `export` |
|
||||
| `-d, --home-dir <DIR>` | Override home dir |
|
||||
| `--no-oauth` | Skip OAuth sources |
|
||||
|
||||
```bash
|
||||
# Source directly into shell
|
||||
eval "$(sandbox-agent credentials extract-env --export)"
|
||||
```
|
||||
|
||||
---
|
||||
## api
|
||||
|
||||
## API Commands
|
||||
API subcommands for scripting.
|
||||
|
||||
The `sandbox-agent api` subcommand mirrors the HTTP API for scripting without client code.
|
||||
|
||||
All API commands support:
|
||||
Shared option:
|
||||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `-e, --endpoint <URL>` | `http://127.0.0.1:2468` | API endpoint |
|
||||
| `-t, --token <TOKEN>` | - | Authentication token |
|
||||
| `-e, --endpoint <URL>` | `http://127.0.0.1:2468` | Target server |
|
||||
|
||||
---
|
||||
|
||||
### Agents
|
||||
|
||||
#### List Agents
|
||||
### api agents
|
||||
|
||||
```bash
|
||||
sandbox-agent api agents list
|
||||
sandbox-agent api agents list [--endpoint <URL>]
|
||||
sandbox-agent api agents install <AGENT> [--reinstall] [--endpoint <URL>]
|
||||
```
|
||||
|
||||
#### Install Agent
|
||||
|
||||
```bash
|
||||
sandbox-agent api agents install <AGENT> [OPTIONS]
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `-r, --reinstall` | Force reinstall |
|
||||
|
||||
```bash
|
||||
sandbox-agent api agents install claude --reinstall
|
||||
```
|
||||
|
||||
#### Get Agent Modes
|
||||
|
||||
```bash
|
||||
sandbox-agent api agents modes <AGENT>
|
||||
```
|
||||
|
||||
```bash
|
||||
sandbox-agent api agents modes claude
|
||||
```
|
||||
|
||||
#### Get Agent Models
|
||||
|
||||
```bash
|
||||
sandbox-agent api agents models <AGENT>
|
||||
```
|
||||
|
||||
```bash
|
||||
sandbox-agent api agents models claude
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Sessions
|
||||
|
||||
#### List Sessions
|
||||
|
||||
```bash
|
||||
sandbox-agent api sessions list
|
||||
```
|
||||
|
||||
#### Create Session
|
||||
|
||||
```bash
|
||||
sandbox-agent api sessions create <SESSION_ID> [OPTIONS]
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `-a, --agent <AGENT>` | Agent identifier (required) |
|
||||
| `-g, --agent-mode <MODE>` | Agent mode |
|
||||
| `-p, --permission-mode <MODE>` | Permission mode (`default`, `plan`, `bypass`, `acceptEdits`) |
|
||||
| `-m, --model <MODEL>` | Model override |
|
||||
| `-v, --variant <VARIANT>` | Model variant |
|
||||
| `-A, --agent-version <VERSION>` | Agent version |
|
||||
| `--mcp-config <PATH>` | JSON file with MCP server config (see `mcp` docs) |
|
||||
| `--skill <PATH>` | Skill directory or `SKILL.md` path (repeatable) |
|
||||
|
||||
```bash
|
||||
sandbox-agent api sessions create my-session \
|
||||
--agent claude \
|
||||
--agent-mode code \
|
||||
--permission-mode default
|
||||
```
|
||||
|
||||
`acceptEdits` passes through to Claude, auto-approves file changes for Codex, and is treated as `default` for other agents.
|
||||
|
||||
#### Send Message
|
||||
|
||||
```bash
|
||||
sandbox-agent api sessions send-message <SESSION_ID> [OPTIONS]
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `-m, --message <TEXT>` | Message text (required) |
|
||||
|
||||
```bash
|
||||
sandbox-agent api sessions send-message my-session \
|
||||
--message "Summarize the repository"
|
||||
```
|
||||
|
||||
#### Send Message (Streaming)
|
||||
|
||||
Send a message and stream the response:
|
||||
|
||||
```bash
|
||||
sandbox-agent api sessions send-message-stream <SESSION_ID> [OPTIONS]
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `-m, --message <TEXT>` | Message text (required) |
|
||||
| `--include-raw` | Include raw agent data |
|
||||
|
||||
```bash
|
||||
sandbox-agent api sessions send-message-stream my-session \
|
||||
--message "Help me debug this"
|
||||
```
|
||||
|
||||
#### Terminate Session
|
||||
|
||||
```bash
|
||||
sandbox-agent api sessions terminate <SESSION_ID>
|
||||
```
|
||||
|
||||
```bash
|
||||
sandbox-agent api sessions terminate my-session
|
||||
```
|
||||
|
||||
#### Get Events
|
||||
|
||||
Fetch session events:
|
||||
|
||||
```bash
|
||||
sandbox-agent api sessions events <SESSION_ID> [OPTIONS]
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `-o, --offset <N>` | Event offset |
|
||||
| `-l, --limit <N>` | Max events to return |
|
||||
| `--include-raw` | Include raw agent data |
|
||||
|
||||
```bash
|
||||
sandbox-agent api sessions events my-session --offset 0 --limit 50
|
||||
```
|
||||
|
||||
`get-messages` is an alias for `events`.
|
||||
|
||||
#### Stream Events (SSE)
|
||||
|
||||
Stream session events via Server-Sent Events:
|
||||
|
||||
```bash
|
||||
sandbox-agent api sessions events-sse <SESSION_ID> [OPTIONS]
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `-o, --offset <N>` | Event offset to start from |
|
||||
| `--include-raw` | Include raw agent data |
|
||||
|
||||
```bash
|
||||
sandbox-agent api sessions events-sse my-session --offset 0
|
||||
```
|
||||
|
||||
#### Reply to Question
|
||||
|
||||
```bash
|
||||
sandbox-agent api sessions reply-question <SESSION_ID> <QUESTION_ID> [OPTIONS]
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `-a, --answers <JSON>` | JSON array of answers (required) |
|
||||
|
||||
```bash
|
||||
sandbox-agent api sessions reply-question my-session q1 \
|
||||
--answers '[["yes"]]'
|
||||
```
|
||||
|
||||
#### Reject Question
|
||||
|
||||
```bash
|
||||
sandbox-agent api sessions reject-question <SESSION_ID> <QUESTION_ID>
|
||||
```
|
||||
|
||||
```bash
|
||||
sandbox-agent api sessions reject-question my-session q1
|
||||
```
|
||||
|
||||
#### Reply to Permission
|
||||
|
||||
```bash
|
||||
sandbox-agent api sessions reply-permission <SESSION_ID> <PERMISSION_ID> [OPTIONS]
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `-r, --reply <REPLY>` | `once`, `always`, or `reject` (required) |
|
||||
|
||||
```bash
|
||||
sandbox-agent api sessions reply-permission my-session perm1 --reply once
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Filesystem
|
||||
|
||||
#### List Entries
|
||||
|
||||
```bash
|
||||
sandbox-agent api fs entries [OPTIONS]
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--path <PATH>` | Directory path (default: `.`) |
|
||||
| `--session-id <SESSION_ID>` | Resolve relative paths from the session working directory |
|
||||
|
||||
```bash
|
||||
sandbox-agent api fs entries --path ./workspace
|
||||
```
|
||||
|
||||
#### Read File
|
||||
|
||||
`api fs read` writes raw bytes to stdout.
|
||||
|
||||
```bash
|
||||
sandbox-agent api fs read <PATH> [OPTIONS]
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--session-id <SESSION_ID>` | Resolve relative paths from the session working directory |
|
||||
|
||||
```bash
|
||||
sandbox-agent api fs read ./notes.txt > ./notes.txt
|
||||
```
|
||||
|
||||
#### Write File
|
||||
|
||||
```bash
|
||||
sandbox-agent api fs write <PATH> [OPTIONS]
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--content <TEXT>` | Write UTF-8 content |
|
||||
| `--from-file <PATH>` | Read content from a local file |
|
||||
| `--session-id <SESSION_ID>` | Resolve relative paths from the session working directory |
|
||||
|
||||
```bash
|
||||
sandbox-agent api fs write ./hello.txt --content "hello"
|
||||
sandbox-agent api fs write ./image.bin --from-file ./image.bin
|
||||
```
|
||||
|
||||
#### Delete Entry
|
||||
|
||||
```bash
|
||||
sandbox-agent api fs delete <PATH> [OPTIONS]
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--recursive` | Delete directories recursively |
|
||||
| `--session-id <SESSION_ID>` | Resolve relative paths from the session working directory |
|
||||
|
||||
```bash
|
||||
sandbox-agent api fs delete ./old.log
|
||||
```
|
||||
|
||||
#### Create Directory
|
||||
|
||||
```bash
|
||||
sandbox-agent api fs mkdir <PATH> [OPTIONS]
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--session-id <SESSION_ID>` | Resolve relative paths from the session working directory |
|
||||
|
||||
```bash
|
||||
sandbox-agent api fs mkdir ./cache
|
||||
```
|
||||
|
||||
#### Move/Rename
|
||||
|
||||
```bash
|
||||
sandbox-agent api fs move <FROM> <TO> [OPTIONS]
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--overwrite` | Overwrite destination if it exists |
|
||||
| `--session-id <SESSION_ID>` | Resolve relative paths from the session working directory |
|
||||
|
||||
```bash
|
||||
sandbox-agent api fs move ./a.txt ./b.txt --overwrite
|
||||
```
|
||||
|
||||
#### Stat
|
||||
|
||||
```bash
|
||||
sandbox-agent api fs stat <PATH> [OPTIONS]
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--session-id <SESSION_ID>` | Resolve relative paths from the session working directory |
|
||||
|
||||
```bash
|
||||
sandbox-agent api fs stat ./notes.txt
|
||||
```
|
||||
|
||||
#### Upload Batch (tar)
|
||||
|
||||
```bash
|
||||
sandbox-agent api fs upload-batch --tar <PATH> [OPTIONS]
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--tar <PATH>` | Tar archive to extract |
|
||||
| `--path <PATH>` | Destination directory |
|
||||
| `--session-id <SESSION_ID>` | Resolve relative paths from the session working directory |
|
||||
|
||||
```bash
|
||||
sandbox-agent api fs upload-batch --tar ./skills.tar --path ./skills
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CLI to HTTP Mapping
|
||||
|
||||
| CLI Command | HTTP Endpoint |
|
||||
|-------------|---------------|
|
||||
| `api agents list` | `GET /v1/agents` |
|
||||
| `api agents install` | `POST /v1/agents/{agent}/install` |
|
||||
| `api agents modes` | `GET /v1/agents/{agent}/modes` |
|
||||
| `api agents models` | `GET /v1/agents/{agent}/models` |
|
||||
| `api sessions list` | `GET /v1/sessions` |
|
||||
| `api sessions create` | `POST /v1/sessions/{sessionId}` |
|
||||
| `api sessions send-message` | `POST /v1/sessions/{sessionId}/messages` |
|
||||
| `api sessions send-message-stream` | `POST /v1/sessions/{sessionId}/messages/stream` |
|
||||
| `api sessions terminate` | `POST /v1/sessions/{sessionId}/terminate` |
|
||||
| `api sessions events` | `GET /v1/sessions/{sessionId}/events` |
|
||||
| `api sessions events-sse` | `GET /v1/sessions/{sessionId}/events/sse` |
|
||||
| `api sessions reply-question` | `POST /v1/sessions/{sessionId}/questions/{questionId}/reply` |
|
||||
| `api sessions reject-question` | `POST /v1/sessions/{sessionId}/questions/{questionId}/reject` |
|
||||
| `api sessions reply-permission` | `POST /v1/sessions/{sessionId}/permissions/{permissionId}/reply` |
|
||||
| `api fs entries` | `GET /v1/fs/entries` |
|
||||
| `api fs read` | `GET /v1/fs/file` |
|
||||
| `api fs write` | `PUT /v1/fs/file` |
|
||||
| `api fs delete` | `DELETE /v1/fs/entry` |
|
||||
| `api fs mkdir` | `POST /v1/fs/mkdir` |
|
||||
| `api fs move` | `POST /v1/fs/move` |
|
||||
| `api fs stat` | `GET /v1/fs/stat` |
|
||||
| `api fs upload-batch` | `POST /v1/fs/upload-batch` |
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ title: "Inspector"
|
|||
description: "Debug and inspect agent sessions with the Inspector UI."
|
||||
---
|
||||
|
||||
The Inspector is a web-based GUI for debugging and inspecting Sandbox Agent sessions. Use it to view events, send messages, and troubleshoot agent behavior in real-time.
|
||||
The Inspector is a web UI for inspecting Sandbox Agent sessions. Use it to view events, inspect payloads, and troubleshoot behavior.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/inspector.png" alt="Sandbox Agent Inspector" />
|
||||
|
|
@ -11,35 +11,32 @@ The Inspector is a web-based GUI for debugging and inspecting Sandbox Agent sess
|
|||
|
||||
## Open the Inspector
|
||||
|
||||
The Inspector UI is served at `/ui/` on your sandbox-agent server. For example, if your server is running at `http://localhost:2468`, open `http://localhost:2468/ui/` in your browser.
|
||||
The Inspector is served at `/ui/` on your Sandbox Agent server.
|
||||
For example, if your server runs at `http://localhost:2468`, open `http://localhost:2468/ui/`.
|
||||
|
||||
You can also generate a pre-filled Inspector URL with authentication from the TypeScript SDK:
|
||||
You can also generate a pre-filled Inspector URL from the SDK:
|
||||
|
||||
```typescript
|
||||
import { buildInspectorUrl } from "sandbox-agent";
|
||||
|
||||
const url = buildInspectorUrl({
|
||||
baseUrl: "http://127.0.0.1:2468",
|
||||
token: process.env.SANDBOX_TOKEN,
|
||||
});
|
||||
|
||||
console.log(url);
|
||||
// http://127.0.0.1:2468/ui/?token=...
|
||||
// http://127.0.0.1:2468/ui/
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **Session list**: View all active sessions and their status
|
||||
- **Event stream**: See events in real-time as they arrive (SSE or polling)
|
||||
- **Event details**: Expand any event to see its full JSON payload
|
||||
- **Send messages**: Post messages to a session directly from the UI
|
||||
- **Agent selection**: Switch between agents and modes
|
||||
- **Request log**: View raw HTTP requests and responses for debugging
|
||||
- **Pi concurrent sessions**: Pi sessions run concurrently by default via per-session runtime processes
|
||||
- Session list
|
||||
- Event stream view
|
||||
- Event JSON inspector
|
||||
- Prompt testing
|
||||
- Request/response debugging
|
||||
|
||||
## When to Use
|
||||
## When to use
|
||||
|
||||
The Inspector is useful for:
|
||||
|
||||
- **Development**: Test your integration without writing client code
|
||||
- **Debugging**: Inspect event payloads and timing issues
|
||||
- **Learning**: Understand how agents respond to different prompts
|
||||
- Development: validate session behavior quickly
|
||||
- Debugging: inspect raw event payloads
|
||||
- Integration work: compare UI behavior with SDK/API calls
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "sandbox-agent",
|
||||
"description": "Universal API for automatic coding agents in sandboxes. Supports Claude Code, Codex, OpenCode, Cursor, Amp, and Pi.",
|
||||
"description": "Universal API for automatic coding agents in sandboxes. Supports Claude Code, Codex, OpenCode, and Amp.",
|
||||
"contact": {
|
||||
"name": "Rivet Gaming, LLC",
|
||||
"email": "developer@rivet.gg"
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@sandbox-agent/example-shared": "workspace:*",
|
||||
"computesdk": "latest"
|
||||
"computesdk": "latest",
|
||||
"sandbox-agent": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "latest",
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@ import {
|
|||
type ExplicitComputeConfig,
|
||||
type ProviderName,
|
||||
} from "computesdk";
|
||||
import { runPrompt, waitForHealth } from "@sandbox-agent/example-shared";
|
||||
import { SandboxAgent } from "sandbox-agent";
|
||||
import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
|
|
@ -140,8 +141,15 @@ export async function runComputeSdkExample(): Promise<void> {
|
|||
process.once("SIGINT", handleExit);
|
||||
process.once("SIGTERM", handleExit);
|
||||
|
||||
await runPrompt(baseUrl);
|
||||
await cleanup();
|
||||
const client = await SandboxAgent.connect({ baseUrl });
|
||||
const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/root", mcpServers: [] } });
|
||||
const sessionId = session.id;
|
||||
|
||||
console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`);
|
||||
console.log(" Press Ctrl+C to stop.");
|
||||
|
||||
// Keep alive until SIGINT/SIGTERM triggers cleanup above
|
||||
await new Promise(() => {});
|
||||
}
|
||||
|
||||
const isDirectRun = Boolean(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Daytona, Image } from "@daytonaio/sdk";
|
||||
import { SandboxAgent } from "sandbox-agent";
|
||||
import { detectAgent, buildInspectorUrl, generateSessionId, waitForHealth } from "@sandbox-agent/example-shared";
|
||||
import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared";
|
||||
|
||||
const daytona = new Daytona();
|
||||
|
||||
|
|
@ -13,7 +13,7 @@ if (process.env.OPENAI_API_KEY)
|
|||
// Build a custom image with sandbox-agent pre-installed (slower first run, faster subsequent runs)
|
||||
const image = Image.base("ubuntu:22.04").runCommands(
|
||||
"apt-get update && apt-get install -y curl ca-certificates",
|
||||
"curl -fsSL https://releases.rivet.dev/sandbox-agent/latest/install.sh | sh",
|
||||
"curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh",
|
||||
);
|
||||
|
||||
console.log("Creating Daytona sandbox (first run builds the base image and may take a few minutes, subsequent runs are fast)...");
|
||||
|
|
@ -29,8 +29,8 @@ console.log("Waiting for server...");
|
|||
await waitForHealth({ baseUrl });
|
||||
|
||||
const client = await SandboxAgent.connect({ baseUrl });
|
||||
const sessionId = generateSessionId();
|
||||
await client.createSession(sessionId, { agent: detectAgent() });
|
||||
const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/root", mcpServers: [] } });
|
||||
const sessionId = session.id;
|
||||
|
||||
console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`);
|
||||
console.log(" Press Ctrl+C to stop.");
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Daytona } from "@daytonaio/sdk";
|
||||
import { SandboxAgent } from "sandbox-agent";
|
||||
import { detectAgent, buildInspectorUrl, generateSessionId, waitForHealth } from "@sandbox-agent/example-shared";
|
||||
import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared";
|
||||
|
||||
const daytona = new Daytona();
|
||||
|
||||
|
|
@ -17,7 +17,7 @@ const sandbox = await daytona.create({ envVars, autoStopInterval: 0 });
|
|||
// Install sandbox-agent and start server
|
||||
console.log("Installing sandbox-agent...");
|
||||
await sandbox.process.executeCommand(
|
||||
"curl -fsSL https://releases.rivet.dev/sandbox-agent/latest/install.sh | sh",
|
||||
"curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh",
|
||||
);
|
||||
|
||||
await sandbox.process.executeCommand(
|
||||
|
|
@ -30,8 +30,8 @@ console.log("Waiting for server...");
|
|||
await waitForHealth({ baseUrl });
|
||||
|
||||
const client = await SandboxAgent.connect({ baseUrl });
|
||||
const sessionId = generateSessionId();
|
||||
await client.createSession(sessionId, { agent: detectAgent() });
|
||||
const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/root", mcpServers: [] } });
|
||||
const sessionId = session.id;
|
||||
|
||||
console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`);
|
||||
console.log(" Press Ctrl+C to stop.");
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import Docker from "dockerode";
|
||||
import { SandboxAgent } from "sandbox-agent";
|
||||
import { detectAgent, buildInspectorUrl, generateSessionId, waitForHealth } from "@sandbox-agent/example-shared";
|
||||
import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared";
|
||||
|
||||
const IMAGE = "alpine:latest";
|
||||
const PORT = 3000;
|
||||
|
|
@ -25,7 +25,7 @@ const container = await docker.createContainer({
|
|||
Image: IMAGE,
|
||||
Cmd: ["sh", "-c", [
|
||||
"apk add --no-cache curl ca-certificates libstdc++ libgcc bash",
|
||||
"curl -fsSL https://releases.rivet.dev/sandbox-agent/latest/install.sh | sh",
|
||||
"curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh",
|
||||
"sandbox-agent install-agent claude",
|
||||
"sandbox-agent install-agent codex",
|
||||
`sandbox-agent server --no-token --host 0.0.0.0 --port ${PORT}`,
|
||||
|
|
@ -46,8 +46,8 @@ const baseUrl = `http://127.0.0.1:${PORT}`;
|
|||
await waitForHealth({ baseUrl });
|
||||
|
||||
const client = await SandboxAgent.connect({ baseUrl });
|
||||
const sessionId = generateSessionId();
|
||||
await client.createSession(sessionId, { agent: detectAgent() });
|
||||
const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/root", mcpServers: [] } });
|
||||
const sessionId = session.id;
|
||||
|
||||
console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`);
|
||||
console.log(" Press Ctrl+C to stop.");
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Sandbox } from "@e2b/code-interpreter";
|
||||
import { SandboxAgent } from "sandbox-agent";
|
||||
import { detectAgent, buildInspectorUrl, generateSessionId, waitForHealth } from "@sandbox-agent/example-shared";
|
||||
import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared";
|
||||
|
||||
const envs: Record<string, string> = {};
|
||||
if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
|
||||
|
|
@ -16,7 +16,7 @@ const run = async (cmd: string) => {
|
|||
};
|
||||
|
||||
console.log("Installing sandbox-agent...");
|
||||
await run("curl -fsSL https://releases.rivet.dev/sandbox-agent/latest/install.sh | sh");
|
||||
await run("curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh");
|
||||
|
||||
console.log("Installing agents...");
|
||||
await run("sandbox-agent install-agent claude");
|
||||
|
|
@ -31,8 +31,8 @@ console.log("Waiting for server...");
|
|||
await waitForHealth({ baseUrl });
|
||||
|
||||
const client = await SandboxAgent.connect({ baseUrl });
|
||||
const sessionId = generateSessionId();
|
||||
await client.createSession(sessionId, { agent: detectAgent() });
|
||||
const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/root", mcpServers: [] } });
|
||||
const sessionId = session.id;
|
||||
|
||||
console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`);
|
||||
console.log(" Press Ctrl+C to stop.");
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { SandboxAgent } from "sandbox-agent";
|
||||
import { detectAgent, buildInspectorUrl, generateSessionId } from "@sandbox-agent/example-shared";
|
||||
import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared";
|
||||
import { startDockerSandbox } from "@sandbox-agent/example-shared/docker";
|
||||
import * as tar from "tar";
|
||||
import fs from "node:fs";
|
||||
|
|
@ -47,8 +47,8 @@ const readmeText = new TextDecoder().decode(readmeBytes);
|
|||
console.log(` README.md content: ${readmeText.trim()}`);
|
||||
|
||||
console.log("Creating session...");
|
||||
const sessionId = generateSessionId();
|
||||
await client.createSession(sessionId, { agent: detectAgent() });
|
||||
const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/opt/my-project", mcpServers: [] } });
|
||||
const sessionId = session.id;
|
||||
console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`);
|
||||
console.log(' Try: "read the README in /opt/my-project"');
|
||||
console.log(" Press Ctrl+C to stop.");
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { SandboxAgent } from "sandbox-agent";
|
||||
import { detectAgent, buildInspectorUrl, generateSessionId } from "@sandbox-agent/example-shared";
|
||||
import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared";
|
||||
import { startDockerSandbox } from "@sandbox-agent/example-shared/docker";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
|
@ -36,15 +36,17 @@ const skillResult = await client.writeFsFile(
|
|||
);
|
||||
console.log(` Skill: ${skillResult.path} (${skillResult.bytesWritten} bytes)`);
|
||||
|
||||
// Create a session with the uploaded skill as a local source.
|
||||
// Configure the uploaded skill.
|
||||
console.log("Configuring custom skill...");
|
||||
await client.setSkillsConfig(
|
||||
{ directory: "/", skillName: "random-number" },
|
||||
{ sources: [{ type: "local", source: "/opt/skills/random-number" }] },
|
||||
);
|
||||
|
||||
// Create a session.
|
||||
console.log("Creating session with custom skill...");
|
||||
const sessionId = generateSessionId();
|
||||
await client.createSession(sessionId, {
|
||||
agent: detectAgent(),
|
||||
skills: {
|
||||
sources: [{ type: "local", source: "/opt/skills/random-number" }],
|
||||
},
|
||||
});
|
||||
const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/root", mcpServers: [] } });
|
||||
const sessionId = session.id;
|
||||
console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`);
|
||||
console.log(' Try: "generate a random number between 1 and 100"');
|
||||
console.log(" Press Ctrl+C to stop.");
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { SandboxAgent } from "sandbox-agent";
|
||||
import { detectAgent, buildInspectorUrl, generateSessionId } from "@sandbox-agent/example-shared";
|
||||
import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared";
|
||||
import { startDockerSandbox } from "@sandbox-agent/example-shared/docker";
|
||||
|
||||
console.log("Starting sandbox...");
|
||||
|
|
@ -7,17 +7,16 @@ const { baseUrl, cleanup } = await startDockerSandbox({
|
|||
port: 3001,
|
||||
});
|
||||
|
||||
console.log("Creating session with skill source...");
|
||||
console.log("Configuring skill source...");
|
||||
const client = await SandboxAgent.connect({ baseUrl });
|
||||
const sessionId = generateSessionId();
|
||||
await client.createSession(sessionId, {
|
||||
agent: detectAgent(),
|
||||
skills: {
|
||||
sources: [
|
||||
{ type: "github", source: "rivet-dev/skills", skills: ["sandbox-agent"] },
|
||||
],
|
||||
},
|
||||
});
|
||||
await client.setSkillsConfig(
|
||||
{ directory: "/", skillName: "rivet-dev-skills" },
|
||||
{ sources: [{ type: "github", source: "rivet-dev/skills", skills: ["sandbox-agent"] }] },
|
||||
);
|
||||
|
||||
console.log("Creating session...");
|
||||
const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/root", mcpServers: [] } });
|
||||
const sessionId = session.id;
|
||||
console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`);
|
||||
console.log(' Try: "How do I start sandbox-agent?"');
|
||||
console.log(" Press Ctrl+C to stop.");
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Sandbox } from "@vercel/sandbox";
|
||||
import { SandboxAgent } from "sandbox-agent";
|
||||
import { detectAgent, buildInspectorUrl, generateSessionId, waitForHealth } from "@sandbox-agent/example-shared";
|
||||
import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared";
|
||||
|
||||
const envs: Record<string, string> = {};
|
||||
if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
|
||||
|
|
@ -22,7 +22,7 @@ const run = async (cmd: string, args: string[] = []) => {
|
|||
};
|
||||
|
||||
console.log("Installing sandbox-agent...");
|
||||
await run("sh", ["-c", "curl -fsSL https://releases.rivet.dev/sandbox-agent/latest/install.sh | sh"]);
|
||||
await run("sh", ["-c", "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh"]);
|
||||
|
||||
console.log("Installing agents...");
|
||||
await run("sandbox-agent", ["install-agent", "claude"]);
|
||||
|
|
@ -42,8 +42,8 @@ console.log("Waiting for server...");
|
|||
await waitForHealth({ baseUrl });
|
||||
|
||||
const client = await SandboxAgent.connect({ baseUrl });
|
||||
const sessionId = generateSessionId();
|
||||
await client.createSession(sessionId, { agent: detectAgent() });
|
||||
const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/root", mcpServers: [] } });
|
||||
const sessionId = session.id;
|
||||
|
||||
console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`);
|
||||
console.log(" Press Ctrl+C to stop.");
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,17 +1,26 @@
|
|||
import { Plus, RefreshCw } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { AgentInfo, AgentModelInfo, AgentModeInfo, SessionInfo, SkillSource } from "sandbox-agent";
|
||||
import type { McpServerEntry } from "../App";
|
||||
import type { AgentInfo } from "sandbox-agent";
|
||||
|
||||
type AgentModeInfo = { id: string; name: string; description: string };
|
||||
type AgentModelInfo = { id: string; name?: string };
|
||||
import SessionCreateMenu, { type SessionConfig } from "./SessionCreateMenu";
|
||||
|
||||
type SessionListItem = {
|
||||
sessionId: string;
|
||||
agent: string;
|
||||
ended: boolean;
|
||||
};
|
||||
|
||||
const agentLabels: Record<string, string> = {
|
||||
claude: "Claude Code",
|
||||
codex: "Codex",
|
||||
opencode: "OpenCode",
|
||||
amp: "Amp",
|
||||
pi: "Pi",
|
||||
mock: "Mock"
|
||||
cursor: "Cursor"
|
||||
};
|
||||
const persistenceDocsUrl = "https://sandboxagent.dev/docs/session-persistence";
|
||||
|
||||
const SessionSidebar = ({
|
||||
sessions,
|
||||
|
|
@ -28,22 +37,13 @@ const SessionSidebar = ({
|
|||
modesByAgent,
|
||||
modelsByAgent,
|
||||
defaultModelByAgent,
|
||||
modesLoadingByAgent,
|
||||
modelsLoadingByAgent,
|
||||
modesErrorByAgent,
|
||||
modelsErrorByAgent,
|
||||
mcpServers,
|
||||
onMcpServersChange,
|
||||
mcpConfigError,
|
||||
skillSources,
|
||||
onSkillSourcesChange
|
||||
}: {
|
||||
sessions: SessionInfo[];
|
||||
sessions: SessionListItem[];
|
||||
selectedSessionId: string;
|
||||
onSelectSession: (session: SessionInfo) => void;
|
||||
onSelectSession: (session: SessionListItem) => void;
|
||||
onRefresh: () => void;
|
||||
onCreateSession: (agentId: string, config: SessionConfig) => void;
|
||||
onSelectAgent: (agentId: string) => void;
|
||||
onSelectAgent: (agentId: string) => Promise<void>;
|
||||
agents: AgentInfo[];
|
||||
agentsLoading: boolean;
|
||||
agentsError: string | null;
|
||||
|
|
@ -52,15 +52,6 @@ const SessionSidebar = ({
|
|||
modesByAgent: Record<string, AgentModeInfo[]>;
|
||||
modelsByAgent: Record<string, AgentModelInfo[]>;
|
||||
defaultModelByAgent: Record<string, string>;
|
||||
modesLoadingByAgent: Record<string, boolean>;
|
||||
modelsLoadingByAgent: Record<string, boolean>;
|
||||
modesErrorByAgent: Record<string, string | null>;
|
||||
modelsErrorByAgent: Record<string, string | null>;
|
||||
mcpServers: McpServerEntry[];
|
||||
onMcpServersChange: (servers: McpServerEntry[]) => void;
|
||||
mcpConfigError: string | null;
|
||||
skillSources: SkillSource[];
|
||||
onSkillSourcesChange: (sources: SkillSource[]) => void;
|
||||
}) => {
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
|
@ -100,17 +91,8 @@ const SessionSidebar = ({
|
|||
modesByAgent={modesByAgent}
|
||||
modelsByAgent={modelsByAgent}
|
||||
defaultModelByAgent={defaultModelByAgent}
|
||||
modesLoadingByAgent={modesLoadingByAgent}
|
||||
modelsLoadingByAgent={modelsLoadingByAgent}
|
||||
modesErrorByAgent={modesErrorByAgent}
|
||||
modelsErrorByAgent={modelsErrorByAgent}
|
||||
mcpServers={mcpServers}
|
||||
onMcpServersChange={onMcpServersChange}
|
||||
mcpConfigError={mcpConfigError}
|
||||
skillSources={skillSources}
|
||||
onSkillSourcesChange={onSkillSourcesChange}
|
||||
onSelectAgent={onSelectAgent}
|
||||
onCreateSession={onCreateSession}
|
||||
onSelectAgent={onSelectAgent}
|
||||
open={showMenu}
|
||||
onClose={() => setShowMenu(false)}
|
||||
/>
|
||||
|
|
@ -135,13 +117,19 @@ const SessionSidebar = ({
|
|||
<div className="session-item-id">{session.sessionId}</div>
|
||||
<div className="session-item-meta">
|
||||
<span className="session-item-agent">{agentLabels[session.agent] ?? session.agent}</span>
|
||||
<span className="session-item-events">{session.eventCount} events</span>
|
||||
{session.ended && <span className="session-item-ended">ended</span>}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="session-persistence-note">
|
||||
Sessions are persisted in your browser using IndexedDB.{" "}
|
||||
<a href={persistenceDocsUrl} target="_blank" rel="noreferrer">
|
||||
Configure persistence
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,15 +1,13 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Terminal, Check, ArrowRight } from 'lucide-react';
|
||||
|
||||
const ADAPTERS = [
|
||||
{ label: 'Claude Code', color: '#D97757', x: 20, y: 70, logo: '/logos/claude.svg' },
|
||||
{ label: 'Codex', color: '#10A37F', x: 132, y: 70, logo: 'openai' },
|
||||
{ label: 'Pi', color: '#06B6D4', x: 244, y: 70, logo: 'pi' },
|
||||
{ label: 'Amp', color: '#F59E0B', x: 76, y: 155, logo: '/logos/amp.svg' },
|
||||
{ label: 'OpenCode', color: '#8B5CF6', x: 188, y: 155, logo: 'opencode' },
|
||||
const AGENT_PROCESSES = [
|
||||
{ label: 'Claude Code', color: '#D97757', x: 35, y: 70, logo: '/logos/claude.svg' },
|
||||
{ label: 'Codex', color: '#10A37F', x: 185, y: 70, logo: 'openai' },
|
||||
{ label: 'Amp', color: '#F59E0B', x: 35, y: 155, logo: '/logos/amp.svg' },
|
||||
{ label: 'OpenCode', color: '#8B5CF6', x: 185, y: 155, logo: 'opencode' },
|
||||
];
|
||||
|
||||
function UniversalAPIDiagram() {
|
||||
|
|
@ -17,22 +15,29 @@ function UniversalAPIDiagram() {
|
|||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setActiveIndex((prev) => (prev + 1) % ADAPTERS.length);
|
||||
setActiveIndex((prev) => (prev + 1) % AGENT_PROCESSES.length);
|
||||
}, 2000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative w-full aspect-[16/9] bg-[#050505] rounded-2xl border border-white/10 overflow-hidden flex items-center justify-center shadow-2xl">
|
||||
{/* Background Dots - color changes with active adapter */}
|
||||
<div className="relative w-full aspect-[16/9] bg-[#050505] rounded-xl border border-white/10 overflow-hidden flex items-center justify-center">
|
||||
{/* Background Grid */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.15] pointer-events-none transition-all duration-1000"
|
||||
className="absolute inset-0 opacity-[0.03] pointer-events-none"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(circle, ${ADAPTERS[activeIndex].color} 1px, transparent 1px)`,
|
||||
backgroundSize: '24px 24px',
|
||||
backgroundImage:
|
||||
'linear-gradient(#fff 1px, transparent 1px), linear-gradient(90deg, #fff 1px, transparent 1px)',
|
||||
backgroundSize: '40px 40px',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Dynamic Background Glow */}
|
||||
<div
|
||||
className="absolute top-1/2 right-1/4 -translate-y-1/2 w-64 h-64 blur-[100px] rounded-full transition-colors duration-1000 opacity-20"
|
||||
style={{ backgroundColor: AGENT_PROCESSES[activeIndex].color }}
|
||||
/>
|
||||
|
||||
<svg viewBox="0 0 800 450" className="w-full h-full relative z-10">
|
||||
<defs>
|
||||
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
|
|
@ -44,14 +49,13 @@ function UniversalAPIDiagram() {
|
|||
</filter>
|
||||
</defs>
|
||||
|
||||
{/* YOUR APP NODE - Glass dark effect with backdrop blur */}
|
||||
<foreignObject x="60" y="175" width="180" height="100">
|
||||
<div
|
||||
className="w-full h-full rounded-2xl border border-white/10 bg-black/40 backdrop-blur-md flex items-center justify-center"
|
||||
>
|
||||
<span className="text-white text-xl font-bold">Your App</span>
|
||||
</div>
|
||||
</foreignObject>
|
||||
{/* YOUR APP NODE */}
|
||||
<g transform="translate(60, 175)">
|
||||
<rect width="180" height="100" rx="16" fill="#0A0A0A" stroke="#333" strokeWidth="2" />
|
||||
<text x="90" y="55" fill="#FFFFFF" textAnchor="middle" fontSize="20" fontWeight="700">
|
||||
Your App
|
||||
</text>
|
||||
</g>
|
||||
|
||||
{/* HTTP/SSE LINE */}
|
||||
<g>
|
||||
|
|
@ -69,60 +73,55 @@ function UniversalAPIDiagram() {
|
|||
</text>
|
||||
</g>
|
||||
|
||||
{/* SANDBOX BOUNDARY - Glass dark effect with backdrop blur */}
|
||||
<foreignObject x="360" y="45" width="410" height="360">
|
||||
<div className="w-full h-full rounded-3xl border border-white/10 bg-black/40 backdrop-blur-md">
|
||||
<div className="text-white text-sm font-extrabold tracking-[0.2em] text-center pt-4">
|
||||
SANDBOX
|
||||
</div>
|
||||
</div>
|
||||
</foreignObject>
|
||||
{/* SANDBOX BOUNDARY */}
|
||||
<g transform="translate(360, 45)">
|
||||
<rect width="380" height="360" rx="24" fill="#080808" stroke="#333" strokeWidth="1.5" />
|
||||
<rect width="380" height="45" rx="12" fill="rgba(255,255,255,0.02)" />
|
||||
<text x="190" y="28" fill="#FFFFFF" textAnchor="middle" fontSize="14" fontWeight="800" letterSpacing="0.2em">
|
||||
SANDBOX
|
||||
</text>
|
||||
|
||||
{/* SANDBOX AGENT SDK */}
|
||||
<g transform="translate(385, 110)">
|
||||
<rect width="360" height="270" rx="20" fill="rgba(0,0,0,0.4)" stroke="rgba(255,255,255,0.2)" strokeWidth="1" />
|
||||
<text x="180" y="35" fill="#FFFFFF" textAnchor="middle" fontSize="18" fontWeight="800">
|
||||
{/* SANDBOX AGENT SDK */}
|
||||
<g transform="translate(25, 65)">
|
||||
<rect width="330" height="270" rx="20" fill="#0D0D0F" stroke="#3B82F6" strokeWidth="2" />
|
||||
<text x="165" y="35" fill="#FFFFFF" textAnchor="middle" fontSize="18" fontWeight="800">
|
||||
Sandbox Agent Server
|
||||
</text>
|
||||
<line x1="40" y1="50" x2="290" y2="50" stroke="#333" strokeWidth="1" />
|
||||
|
||||
{/* PROVIDER ADAPTERS */}
|
||||
{ADAPTERS.map((p, i) => {
|
||||
{/* PROVIDER AGENT PROCESSES */}
|
||||
{AGENT_PROCESSES.map((p, i) => {
|
||||
const isActive = i === activeIndex;
|
||||
return (
|
||||
<g key={i} transform={`translate(${p.x}, ${p.y})`}>
|
||||
<rect
|
||||
width="95"
|
||||
height="58"
|
||||
rx="10"
|
||||
width="110"
|
||||
height="65"
|
||||
rx="12"
|
||||
fill={isActive ? '#1A1A1E' : '#111'}
|
||||
stroke={isActive ? p.color : '#333'}
|
||||
strokeWidth={isActive ? 2 : 1.5}
|
||||
/>
|
||||
<g opacity={isActive ? 1 : 0.4}>
|
||||
{p.logo === 'openai' ? (
|
||||
<svg x="36.75" y="8" width="22" height="22" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z" fill="#ffffff" />
|
||||
<svg x="43" y="10" width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v 2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v 2.9994l-2.5974 1.4997-2.6067-1.4997Z" fill="#ffffff" />
|
||||
</svg>
|
||||
) : p.logo === 'opencode' ? (
|
||||
<svg x="38.5" y="8" width="17" height="22" viewBox="0 0 32 40" fill="none">
|
||||
<svg x="43" y="10" width="19" height="24" viewBox="0 0 32 40" fill="none">
|
||||
<path d="M24 32H8V16H24V32Z" fill="#4B4646"/>
|
||||
<path d="M24 8H8V32H24V8ZM32 40H0V0H32V40Z" fill="#F1ECEC"/>
|
||||
</svg>
|
||||
) : p.logo === 'pi' ? (
|
||||
<svg x="36.75" y="8" width="22" height="22" viewBox="0 0 800 800" fill="none">
|
||||
<path fill="#fff" fillRule="evenodd" d="M165.29 165.29H517.36V400H400V517.36H282.65V634.72H165.29ZM282.65 282.65V400H400V282.65Z"/>
|
||||
<path fill="#fff" d="M517.36 400H634.72V634.72H517.36Z"/>
|
||||
</svg>
|
||||
) : (
|
||||
<image href={p.logo} x="36.75" y="8" width="22" height="22" filter="url(#invert-white)" />
|
||||
<image href={p.logo} x="43" y="10" width="24" height="24" filter="url(#invert-white)" />
|
||||
)}
|
||||
</g>
|
||||
<text
|
||||
x="47.5"
|
||||
y="46"
|
||||
x="55"
|
||||
y="52"
|
||||
fill="#FFFFFF"
|
||||
textAnchor="middle"
|
||||
fontSize="10"
|
||||
fontSize="11"
|
||||
fontWeight="600"
|
||||
opacity={isActive ? 1 : 0.4}
|
||||
>
|
||||
|
|
@ -134,18 +133,19 @@ function UniversalAPIDiagram() {
|
|||
|
||||
{/* Active Agent Label */}
|
||||
<text
|
||||
x="180"
|
||||
x="165"
|
||||
y="250"
|
||||
fill={ADAPTERS[activeIndex].color}
|
||||
fill={AGENT_PROCESSES[activeIndex].color}
|
||||
textAnchor="middle"
|
||||
fontSize="12"
|
||||
fontSize="10"
|
||||
fontWeight="800"
|
||||
fontFamily="monospace"
|
||||
letterSpacing="0.1em"
|
||||
>
|
||||
CONNECTED TO {ADAPTERS[activeIndex].label.toUpperCase()}
|
||||
CONNECTED TO {AGENT_PROCESSES[activeIndex].label.toUpperCase()}
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -166,104 +166,47 @@ const CopyInstallButton = () => {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="relative group w-full sm:w-auto">
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="w-full sm:w-auto inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md border border-white/10 px-4 py-2 text-sm text-zinc-300 transition-colors hover:border-white/20 hover:text-white font-mono"
|
||||
>
|
||||
{copied ? <Check className="h-4 w-4 text-green-400" /> : <Terminal className="h-4 w-4" />}
|
||||
{installCommand}
|
||||
</button>
|
||||
<div className="absolute left-1/2 -translate-x-1/2 top-full mt-3 opacity-0 translate-y-2 group-hover:opacity-100 group-hover:translate-y-0 transition-all duration-200 ease-out text-xs text-zinc-500 whitespace-nowrap pointer-events-none font-mono">
|
||||
Give this to your coding agent
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className='inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md border border-white/10 bg-white/5 px-4 py-2 text-sm text-white subpixel-antialiased shadow-sm transition-colors hover:border-white/20'
|
||||
>
|
||||
{copied ? <Check className='h-4 w-4' /> : <Terminal className='h-4 w-4' />}
|
||||
{installCommand}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export function Hero() {
|
||||
const [scrollOpacity, setScrollOpacity] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
const scrollY = window.scrollY;
|
||||
const windowHeight = window.innerHeight;
|
||||
const isMobile = window.innerWidth < 1024;
|
||||
|
||||
const fadeStart = windowHeight * (isMobile ? 0.3 : 0.15);
|
||||
const fadeEnd = windowHeight * (isMobile ? 0.7 : 0.5);
|
||||
const opacity = 1 - Math.min(1, Math.max(0, (scrollY - fadeStart) / (fadeEnd - fadeStart)));
|
||||
setScrollOpacity(opacity);
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section className="relative flex min-h-screen flex-col overflow-hidden">
|
||||
{/* Background gradient */}
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-zinc-900/20 via-transparent to-transparent pointer-events-none" />
|
||||
<section className="relative pt-44 pb-24 overflow-hidden">
|
||||
<div className="max-w-7xl mx-auto px-6 relative z-10">
|
||||
<div className="flex flex-col lg:flex-row items-center gap-16">
|
||||
<div className="flex-1 text-center lg:text-left">
|
||||
<h1 className="mb-6 text-3xl font-medium leading-[1.1] tracking-tight text-white sm:text-4xl md:text-5xl lg:text-6xl">
|
||||
Run Coding Agents in Sandboxes.<br />
|
||||
<span className="text-zinc-400">Control Them Over HTTP.</span>
|
||||
</h1>
|
||||
<p className="mt-6 text-lg text-zinc-500 leading-relaxed max-w-xl mx-auto lg:mx-0">
|
||||
The Sandbox Agent SDK is a server that runs inside your sandbox. Your app connects remotely to control Claude Code, Codex, OpenCode, or Amp — streaming events, handling permissions, managing sessions.
|
||||
</p>
|
||||
|
||||
{/* Main content */}
|
||||
<div
|
||||
className="flex flex-1 flex-col justify-start pt-32 lg:justify-center lg:pt-0 lg:pb-20 px-6"
|
||||
style={{ opacity: scrollOpacity, filter: `blur(${(1 - scrollOpacity) * 8}px)` }}
|
||||
>
|
||||
<div className="mx-auto w-full max-w-7xl">
|
||||
<div className="flex flex-col gap-12 lg:flex-row lg:items-center lg:justify-between lg:gap-16 xl:gap-24">
|
||||
{/* Left side - Text content */}
|
||||
<div className="max-w-xl lg:max-w-2xl">
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="mb-6 text-3xl font-medium leading-[1.1] tracking-tight text-white md:text-5xl"
|
||||
<div className="mt-10 flex flex-col items-center gap-4 sm:flex-row sm:justify-center lg:justify-start">
|
||||
<a
|
||||
href="/docs"
|
||||
className='inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md border border-white/10 bg-white px-4 py-2 text-sm text-black subpixel-antialiased shadow-sm transition-colors hover:bg-zinc-200'
|
||||
>
|
||||
Run Coding Agents in Sandboxes.
|
||||
<br />
|
||||
<span className="text-zinc-400">Control Them Over HTTP.</span>
|
||||
</motion.h1>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.1 }}
|
||||
className="mb-8 text-lg text-zinc-500 leading-relaxed"
|
||||
>
|
||||
The Sandbox Agent SDK is a server that runs inside your sandbox. Your app connects remotely to control Claude Code, Codex, OpenCode, Amp, or Pi — streaming events, handling permissions, managing sessions.
|
||||
</motion.p>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="flex flex-col gap-3 sm:flex-row"
|
||||
>
|
||||
<a
|
||||
href="/docs"
|
||||
className="selection-dark inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md bg-white px-5 py-2.5 text-sm font-medium text-black transition-colors hover:bg-zinc-200"
|
||||
>
|
||||
Read the Docs
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</a>
|
||||
<CopyInstallButton />
|
||||
</motion.div>
|
||||
Read the Docs
|
||||
<ArrowRight className='h-4 w-4' />
|
||||
</a>
|
||||
<CopyInstallButton />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side - Diagram */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.3 }}
|
||||
className="flex-1 w-full max-w-2xl"
|
||||
>
|
||||
<UniversalAPIDiagram />
|
||||
</motion.div>
|
||||
<div className="flex-1 w-full max-w-2xl">
|
||||
<UniversalAPIDiagram />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
648
pnpm-lock.yaml
generated
648
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,20 +0,0 @@
|
|||
use std::env;
|
||||
|
||||
use reqwest::blocking::ClientBuilder;
|
||||
|
||||
const NO_SYSTEM_PROXY_ENV: &str = "SANDBOX_AGENT_NO_SYSTEM_PROXY";
|
||||
|
||||
fn disable_system_proxy() -> bool {
|
||||
env::var(NO_SYSTEM_PROXY_ENV)
|
||||
.map(|value| matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub(crate) fn blocking_client_builder() -> ClientBuilder {
|
||||
let builder = reqwest::blocking::Client::builder();
|
||||
if disable_system_proxy() {
|
||||
builder.no_proxy()
|
||||
} else {
|
||||
builder
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
pub mod agents;
|
||||
pub mod credentials;
|
||||
mod http_client;
|
||||
pub mod testing;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ use std::env;
|
|||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use reqwest::blocking::Client;
|
||||
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
|
||||
use reqwest::StatusCode;
|
||||
use thiserror::Error;
|
||||
|
|
@ -35,7 +36,6 @@ pub enum TestAgentConfigError {
|
|||
const AGENTS_ENV: &str = "SANDBOX_TEST_AGENTS";
|
||||
const ANTHROPIC_ENV: &str = "SANDBOX_TEST_ANTHROPIC_API_KEY";
|
||||
const OPENAI_ENV: &str = "SANDBOX_TEST_OPENAI_API_KEY";
|
||||
const PI_ENV: &str = "SANDBOX_TEST_PI";
|
||||
const ANTHROPIC_MODELS_URL: &str = "https://api.anthropic.com/v1/models";
|
||||
const OPENAI_MODELS_URL: &str = "https://api.openai.com/v1/models";
|
||||
const ANTHROPIC_VERSION: &str = "2023-06-01";
|
||||
|
|
@ -64,6 +64,7 @@ pub fn test_agents_from_env() -> Result<Vec<TestAgentConfig>, TestAgentConfigErr
|
|||
AgentId::Opencode,
|
||||
AgentId::Amp,
|
||||
AgentId::Pi,
|
||||
AgentId::Cursor,
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
|
|
@ -74,12 +75,6 @@ pub fn test_agents_from_env() -> Result<Vec<TestAgentConfig>, TestAgentConfigErr
|
|||
agents
|
||||
};
|
||||
|
||||
let include_pi = pi_tests_enabled() && find_in_path(AgentId::Pi.binary_name());
|
||||
if !include_pi && agents.iter().any(|agent| *agent == AgentId::Pi) {
|
||||
eprintln!("Skipping Pi tests: set {PI_ENV}=1 and ensure pi is on PATH.");
|
||||
}
|
||||
agents.retain(|agent| *agent != AgentId::Pi || include_pi);
|
||||
|
||||
agents.sort_by(|a, b| a.as_str().cmp(b.as_str()));
|
||||
agents.dedup();
|
||||
|
||||
|
|
@ -144,22 +139,7 @@ pub fn test_agents_from_env() -> Result<Vec<TestAgentConfig>, TestAgentConfigErr
|
|||
}
|
||||
credentials_with(anthropic_cred.clone(), openai_cred.clone())
|
||||
}
|
||||
AgentId::Pi => {
|
||||
if anthropic_cred.is_none() && openai_cred.is_none() {
|
||||
return Err(TestAgentConfigError::MissingCredentials {
|
||||
agent,
|
||||
missing: format!("{ANTHROPIC_ENV} or {OPENAI_ENV}"),
|
||||
});
|
||||
}
|
||||
if let Some(cred) = anthropic_cred.as_ref() {
|
||||
ensure_anthropic_ok(&mut health_cache, cred)?;
|
||||
}
|
||||
if let Some(cred) = openai_cred.as_ref() {
|
||||
ensure_openai_ok(&mut health_cache, cred)?;
|
||||
}
|
||||
credentials_with(anthropic_cred.clone(), openai_cred.clone())
|
||||
}
|
||||
AgentId::Cursor => credentials_with(None, None),
|
||||
AgentId::Pi | AgentId::Cursor => credentials_with(None, None),
|
||||
AgentId::Mock => credentials_with(None, None),
|
||||
};
|
||||
configs.push(TestAgentConfig { agent, credentials });
|
||||
|
|
@ -195,7 +175,7 @@ fn ensure_openai_ok(
|
|||
fn health_check_anthropic(credentials: &ProviderCredentials) -> Result<(), TestAgentConfigError> {
|
||||
let credentials = credentials.clone();
|
||||
run_blocking_check("anthropic", move || {
|
||||
let client = crate::http_client::blocking_client_builder()
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.build()
|
||||
.map_err(|err| TestAgentConfigError::HealthCheckFailed {
|
||||
|
|
@ -249,7 +229,7 @@ fn health_check_anthropic(credentials: &ProviderCredentials) -> Result<(), TestA
|
|||
fn health_check_openai(credentials: &ProviderCredentials) -> Result<(), TestAgentConfigError> {
|
||||
let credentials = credentials.clone();
|
||||
run_blocking_check("openai", move || {
|
||||
let client = crate::http_client::blocking_client_builder()
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.build()
|
||||
.map_err(|err| TestAgentConfigError::HealthCheckFailed {
|
||||
|
|
@ -321,15 +301,14 @@ where
|
|||
}
|
||||
|
||||
fn detect_system_agents() -> Vec<AgentId> {
|
||||
let mut candidates = vec![
|
||||
let candidates = [
|
||||
AgentId::Claude,
|
||||
AgentId::Codex,
|
||||
AgentId::Opencode,
|
||||
AgentId::Amp,
|
||||
AgentId::Pi,
|
||||
AgentId::Cursor,
|
||||
];
|
||||
if pi_tests_enabled() && find_in_path(AgentId::Pi.binary_name()) {
|
||||
candidates.push(AgentId::Pi);
|
||||
}
|
||||
let install_dir = default_install_dir();
|
||||
candidates
|
||||
.into_iter()
|
||||
|
|
@ -371,15 +350,6 @@ fn read_env_key(name: &str) -> Option<String> {
|
|||
})
|
||||
}
|
||||
|
||||
fn pi_tests_enabled() -> bool {
|
||||
env::var(PI_ENV)
|
||||
.map(|value| {
|
||||
let value = value.trim().to_ascii_lowercase();
|
||||
value == "1" || value == "true" || value == "yes"
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn credentials_with(
|
||||
anthropic_cred: Option<ProviderCredentials>,
|
||||
openai_cred: Option<ProviderCredentials>,
|
||||
|
|
|
|||
|
|
@ -774,7 +774,6 @@ enum CredentialAgent {
|
|||
Codex,
|
||||
Opencode,
|
||||
Amp,
|
||||
Pi,
|
||||
}
|
||||
|
||||
fn credentials_to_output(credentials: ExtractedCredentials, reveal: bool) -> CredentialsOutput {
|
||||
|
|
@ -877,31 +876,6 @@ fn select_token_for_agent(
|
|||
)))
|
||||
}
|
||||
}
|
||||
CredentialAgent::Pi => {
|
||||
if let Some(provider) = provider {
|
||||
return select_token_for_provider(credentials, provider);
|
||||
}
|
||||
if let Some(openai) = credentials.openai.as_ref() {
|
||||
return Ok(openai.api_key.clone());
|
||||
}
|
||||
if let Some(anthropic) = credentials.anthropic.as_ref() {
|
||||
return Ok(anthropic.api_key.clone());
|
||||
}
|
||||
if credentials.other.len() == 1 {
|
||||
if let Some((_, cred)) = credentials.other.iter().next() {
|
||||
return Ok(cred.api_key.clone());
|
||||
}
|
||||
}
|
||||
let available = available_providers(credentials);
|
||||
if available.is_empty() {
|
||||
Err(CliError::Server("no credentials found for pi".to_string()))
|
||||
} else {
|
||||
Err(CliError::Server(format!(
|
||||
"multiple providers available for pi: {} (use --provider)",
|
||||
available.join(", ")
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,30 +0,0 @@
|
|||
use std::env;
|
||||
|
||||
use reqwest::blocking::ClientBuilder as BlockingClientBuilder;
|
||||
use reqwest::ClientBuilder;
|
||||
|
||||
const NO_SYSTEM_PROXY_ENV: &str = "SANDBOX_AGENT_NO_SYSTEM_PROXY";
|
||||
|
||||
fn disable_system_proxy() -> bool {
|
||||
env::var(NO_SYSTEM_PROXY_ENV)
|
||||
.map(|value| matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn client_builder() -> ClientBuilder {
|
||||
let builder = reqwest::Client::builder();
|
||||
if disable_system_proxy() {
|
||||
builder.no_proxy()
|
||||
} else {
|
||||
builder
|
||||
}
|
||||
}
|
||||
|
||||
pub fn blocking_client_builder() -> BlockingClientBuilder {
|
||||
let builder = reqwest::blocking::Client::builder();
|
||||
if disable_system_proxy() {
|
||||
builder.no_proxy()
|
||||
} else {
|
||||
builder
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,8 @@
|
|||
//! Sandbox agent core utilities.
|
||||
|
||||
mod acp_runtime;
|
||||
mod agent_server_logs;
|
||||
mod opencode_session_manager;
|
||||
mod universal_events;
|
||||
mod acp_proxy_runtime;
|
||||
pub mod cli;
|
||||
pub mod daemon;
|
||||
pub mod http_client;
|
||||
pub mod opencode_compat;
|
||||
pub mod router;
|
||||
pub mod server_logs;
|
||||
pub mod telemetry;
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ use std::str::FromStr;
|
|||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::extract::{Path, Query, State};
|
||||
|
|
@ -32,16 +31,16 @@ use crate::router::{
|
|||
is_question_tool_action, AgentModelInfo, AppState, CreateSessionRequest, PermissionReply,
|
||||
SessionInfo,
|
||||
};
|
||||
use sandbox_agent_agent_management::agents::AgentId;
|
||||
use sandbox_agent_agent_management::credentials::{
|
||||
extract_all_credentials, CredentialExtractionOptions, ExtractedCredentials,
|
||||
};
|
||||
use sandbox_agent_error::SandboxError;
|
||||
use sandbox_agent_universal_agent_schema::{
|
||||
use crate::universal_events::{
|
||||
ContentPart, FileAction, ItemDeltaData, ItemEventData, ItemKind, ItemRole, ItemStatus,
|
||||
PermissionEventData, PermissionStatus, QuestionEventData, QuestionStatus, UniversalEvent,
|
||||
UniversalEventData, UniversalEventType, UniversalItem,
|
||||
};
|
||||
use sandbox_agent_agent_credentials::{
|
||||
extract_all_credentials, CredentialExtractionOptions, ExtractedCredentials,
|
||||
};
|
||||
use sandbox_agent_agent_management::agents::AgentId;
|
||||
use sandbox_agent_error::SandboxError;
|
||||
|
||||
static SESSION_COUNTER: AtomicU64 = AtomicU64::new(1);
|
||||
static MESSAGE_COUNTER: AtomicU64 = AtomicU64::new(1);
|
||||
|
|
@ -53,7 +52,6 @@ const OPENCODE_EVENT_LOG_SIZE: usize = 4096;
|
|||
const OPENCODE_DEFAULT_MODEL_ID: &str = "mock";
|
||||
const OPENCODE_DEFAULT_PROVIDER_ID: &str = "mock";
|
||||
const OPENCODE_DEFAULT_AGENT_MODE: &str = "build";
|
||||
const OPENCODE_MODEL_CACHE_TTL: Duration = Duration::from_secs(30);
|
||||
const OPENCODE_MODEL_CHANGE_AFTER_SESSION_CREATE_ERROR: &str = "OpenCode compatibility currently does not support changing the model after creating a session. Export with /export and load in to a new session.";
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
|
|
@ -153,6 +151,9 @@ impl OpenCodeSessionRecord {
|
|||
if let Some(url) = &self.share_url {
|
||||
map.insert("share".to_string(), json!({"url": url}));
|
||||
}
|
||||
if let Some(permission_mode) = &self.permission_mode {
|
||||
map.insert("permissionMode".to_string(), json!(permission_mode));
|
||||
}
|
||||
Value::Object(map)
|
||||
}
|
||||
}
|
||||
|
|
@ -164,7 +165,7 @@ fn session_info_to_opencode_value(info: &SessionInfo, default_project_id: &str)
|
|||
.clone()
|
||||
.unwrap_or_else(|| format!("Session {}", info.session_id));
|
||||
let directory = info.directory.clone().unwrap_or_default();
|
||||
json!({
|
||||
let mut value = json!({
|
||||
"id": info.session_id,
|
||||
"slug": format!("session-{}", info.session_id),
|
||||
"projectID": default_project_id,
|
||||
|
|
@ -175,7 +176,15 @@ fn session_info_to_opencode_value(info: &SessionInfo, default_project_id: &str)
|
|||
"created": info.created_at,
|
||||
"updated": info.updated_at,
|
||||
}
|
||||
})
|
||||
});
|
||||
if let Some(obj) = value.as_object_mut() {
|
||||
obj.insert("agent".to_string(), json!(info.agent));
|
||||
obj.insert("permissionMode".to_string(), json!(info.permission_mode));
|
||||
if let Some(model) = &info.model {
|
||||
obj.insert("model".to_string(), json!(model));
|
||||
}
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
|
|
@ -303,8 +312,6 @@ struct OpenCodeModelCache {
|
|||
group_names: HashMap<String, String>,
|
||||
default_group: String,
|
||||
default_model: String,
|
||||
cached_at: Instant,
|
||||
had_discovery_errors: bool,
|
||||
/// Group IDs that have valid credentials available
|
||||
connected: Vec<String>,
|
||||
}
|
||||
|
|
@ -818,8 +825,6 @@ fn available_agent_ids() -> Vec<AgentId> {
|
|||
AgentId::Codex,
|
||||
AgentId::Opencode,
|
||||
AgentId::Amp,
|
||||
AgentId::Pi,
|
||||
AgentId::Cursor,
|
||||
AgentId::Mock,
|
||||
]
|
||||
}
|
||||
|
|
@ -838,30 +843,18 @@ async fn opencode_model_cache(state: &OpenCodeAppState) -> OpenCodeModelCache {
|
|||
// spawning duplicate provider/model fetches.
|
||||
let mut slot = state.opencode.model_cache.lock().await;
|
||||
if let Some(cache) = slot.as_ref() {
|
||||
if cache.cached_at.elapsed() < OPENCODE_MODEL_CACHE_TTL {
|
||||
info!(
|
||||
entries = cache.entries.len(),
|
||||
groups = cache.group_names.len(),
|
||||
connected = cache.connected.len(),
|
||||
"opencode model cache hit"
|
||||
);
|
||||
return cache.clone();
|
||||
}
|
||||
info!(
|
||||
entries = cache.entries.len(),
|
||||
groups = cache.group_names.len(),
|
||||
connected = cache.connected.len(),
|
||||
"opencode model cache hit"
|
||||
);
|
||||
return cache.clone();
|
||||
}
|
||||
let previous_cache = slot.clone();
|
||||
|
||||
let started = std::time::Instant::now();
|
||||
info!("opencode model cache miss; building cache");
|
||||
let mut cache = build_opencode_model_cache(state).await;
|
||||
if let Some(previous_cache) = previous_cache {
|
||||
if cache.had_discovery_errors
|
||||
&& cache.entries.is_empty()
|
||||
&& !previous_cache.entries.is_empty()
|
||||
{
|
||||
cache = previous_cache;
|
||||
cache.cached_at = Instant::now();
|
||||
}
|
||||
}
|
||||
let cache = build_opencode_model_cache(state).await;
|
||||
info!(
|
||||
elapsed_ms = started.elapsed().as_millis() as u64,
|
||||
entries = cache.entries.len(),
|
||||
|
|
@ -902,7 +895,6 @@ async fn build_opencode_model_cache(state: &OpenCodeAppState) -> OpenCodeModelCa
|
|||
let mut group_agents: HashMap<String, AgentId> = HashMap::new();
|
||||
let mut group_names: HashMap<String, String> = HashMap::new();
|
||||
let mut default_model: Option<String> = None;
|
||||
let mut had_discovery_errors = false;
|
||||
|
||||
let agents = available_agent_ids();
|
||||
let manager = state.inner.session_manager();
|
||||
|
|
@ -920,10 +912,6 @@ async fn build_opencode_model_cache(state: &OpenCodeAppState) -> OpenCodeModelCa
|
|||
let response = match response {
|
||||
Ok(response) => response,
|
||||
Err(err) => {
|
||||
had_discovery_errors = true;
|
||||
let (group_id, group_name) = fallback_group_for_agent(agent);
|
||||
group_agents.entry(group_id.clone()).or_insert(agent);
|
||||
group_names.entry(group_id).or_insert(group_name);
|
||||
warn!(
|
||||
agent = agent.as_str(),
|
||||
elapsed_ms = elapsed.as_millis() as u64,
|
||||
|
|
@ -941,12 +929,6 @@ async fn build_opencode_model_cache(state: &OpenCodeAppState) -> OpenCodeModelCa
|
|||
"opencode model cache fetched agent models"
|
||||
);
|
||||
|
||||
if response.models.is_empty() {
|
||||
let (group_id, group_name) = fallback_group_for_agent(agent);
|
||||
group_agents.entry(group_id.clone()).or_insert(agent);
|
||||
group_names.entry(group_id).or_insert(group_name);
|
||||
}
|
||||
|
||||
let first_model_id = response.models.first().map(|model| model.id.clone());
|
||||
for model in response.models {
|
||||
let model_id = model.id.clone();
|
||||
|
|
@ -1031,25 +1013,10 @@ async fn build_opencode_model_cache(state: &OpenCodeAppState) -> OpenCodeModelCa
|
|||
}
|
||||
}
|
||||
|
||||
// Build connected list based on credential availability
|
||||
// Build connected list conservatively for deterministic compat behavior.
|
||||
let mut connected = Vec::new();
|
||||
for group_id in group_names.keys() {
|
||||
let is_connected = match group_agents.get(group_id) {
|
||||
Some(AgentId::Claude) | Some(AgentId::Amp) => has_anthropic,
|
||||
Some(AgentId::Codex) => has_openai,
|
||||
Some(AgentId::Opencode) => {
|
||||
// Check the specific provider for opencode groups (e.g., "opencode:anthropic")
|
||||
match opencode_group_provider(group_id) {
|
||||
Some("anthropic") => has_anthropic,
|
||||
Some("openai") => has_openai,
|
||||
_ => has_anthropic || has_openai,
|
||||
}
|
||||
}
|
||||
Some(AgentId::Pi) => true,
|
||||
Some(AgentId::Cursor) => true,
|
||||
Some(AgentId::Mock) => true,
|
||||
None => false,
|
||||
};
|
||||
let is_connected = matches!(group_agents.get(group_id), Some(AgentId::Mock));
|
||||
if is_connected {
|
||||
connected.push(group_id.clone());
|
||||
}
|
||||
|
|
@ -1063,8 +1030,6 @@ async fn build_opencode_model_cache(state: &OpenCodeAppState) -> OpenCodeModelCa
|
|||
group_names,
|
||||
default_group,
|
||||
default_model,
|
||||
cached_at: Instant::now(),
|
||||
had_discovery_errors,
|
||||
connected,
|
||||
};
|
||||
info!(
|
||||
|
|
@ -1079,19 +1044,6 @@ async fn build_opencode_model_cache(state: &OpenCodeAppState) -> OpenCodeModelCa
|
|||
cache
|
||||
}
|
||||
|
||||
fn fallback_group_for_agent(agent: AgentId) -> (String, String) {
|
||||
if agent == AgentId::Opencode {
|
||||
return (
|
||||
"opencode".to_string(),
|
||||
agent_display_name(agent).to_string(),
|
||||
);
|
||||
}
|
||||
(
|
||||
agent.as_str().to_string(),
|
||||
agent_display_name(agent).to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve_agent_from_model(
|
||||
cache: &OpenCodeModelCache,
|
||||
provider_id: &str,
|
||||
|
|
@ -1205,8 +1157,6 @@ fn agent_display_name(agent: AgentId) -> &'static str {
|
|||
AgentId::Codex => "Codex",
|
||||
AgentId::Opencode => "OpenCode",
|
||||
AgentId::Amp => "Amp",
|
||||
AgentId::Pi => "Pi",
|
||||
AgentId::Cursor => "Cursor",
|
||||
AgentId::Mock => "Mock",
|
||||
}
|
||||
}
|
||||
|
|
@ -3295,9 +3245,6 @@ async fn oc_config_providers(State(state): State<Arc<OpenCodeAppState>>) -> impl
|
|||
.or_default()
|
||||
.push(entry);
|
||||
}
|
||||
for group_id in cache.group_names.keys() {
|
||||
grouped.entry(group_id.clone()).or_default();
|
||||
}
|
||||
let mut providers = Vec::new();
|
||||
let mut defaults = serde_json::Map::new();
|
||||
for (group_id, entries) in grouped {
|
||||
|
|
@ -4886,9 +4833,6 @@ async fn oc_provider_list(State(state): State<Arc<OpenCodeAppState>>) -> impl In
|
|||
.or_default()
|
||||
.push(entry);
|
||||
}
|
||||
for group_id in cache.group_names.keys() {
|
||||
grouped.entry(group_id.clone()).or_default();
|
||||
}
|
||||
let mut providers = Vec::new();
|
||||
let mut defaults = serde_json::Map::new();
|
||||
for (group_id, entries) in grouped {
|
||||
|
|
@ -5834,7 +5778,7 @@ pub struct OpenCodeApiDoc;
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use sandbox_agent_universal_agent_schema::ReasoningVisibility;
|
||||
use crate::universal_events::ReasoningVisibility;
|
||||
|
||||
fn assistant_item(content: Vec<ContentPart>) -> UniversalItem {
|
||||
UniversalItem {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -11,7 +11,6 @@ use serde::Serialize;
|
|||
use time::OffsetDateTime;
|
||||
use tokio::time::Instant;
|
||||
|
||||
use crate::http_client;
|
||||
static TELEMETRY_ENABLED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
const TELEMETRY_URL: &str = "https://tc.rivet.dev";
|
||||
|
|
@ -83,7 +82,7 @@ pub fn log_enabled_message() {
|
|||
|
||||
pub fn spawn_telemetry_task() {
|
||||
tokio::spawn(async move {
|
||||
let client = match http_client::client_builder()
|
||||
let client = match Client::builder()
|
||||
.timeout(Duration::from_millis(TELEMETRY_TIMEOUT_MS))
|
||||
.build()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -3,15 +3,6 @@ use serde::{Deserialize, Serialize};
|
|||
use serde_json::Value;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
pub use sandbox_agent_extracted_agent_schemas::{amp, claude, codex, opencode, pi};
|
||||
|
||||
pub mod agents;
|
||||
|
||||
pub use agents::{
|
||||
amp as convert_amp, claude as convert_claude, codex as convert_codex,
|
||||
opencode as convert_opencode, pi as convert_pi,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
pub struct UniversalEvent {
|
||||
pub event_id: String,
|
||||
|
|
@ -87,13 +78,10 @@ pub struct SessionStartedData {
|
|||
pub struct SessionEndedData {
|
||||
pub reason: SessionEndReason,
|
||||
pub terminated_by: TerminatedBy,
|
||||
/// Error message when reason is Error
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub message: Option<String>,
|
||||
/// Process exit code when reason is Error
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub exit_code: Option<i32>,
|
||||
/// Agent stderr output when reason is Error
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub stderr: Option<StderrOutput>,
|
||||
}
|
||||
|
|
@ -116,15 +104,11 @@ pub enum TurnPhase {
|
|||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
pub struct StderrOutput {
|
||||
/// First N lines of stderr (if truncated) or full stderr (if not truncated)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub head: Option<String>,
|
||||
/// Last N lines of stderr (only present if truncated)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tail: Option<String>,
|
||||
/// Whether the output was truncated
|
||||
pub truncated: bool,
|
||||
/// Total number of lines in stderr
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub total_lines: Option<usize>,
|
||||
}
|
||||
|
|
@ -226,7 +210,7 @@ pub enum ItemKind {
|
|||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ItemRole {
|
||||
User,
|
||||
|
|
@ -235,7 +219,7 @@ pub enum ItemRole {
|
|||
Tool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ItemStatus {
|
||||
InProgress,
|
||||
|
|
@ -294,93 +278,3 @@ pub enum ReasoningVisibility {
|
|||
Public,
|
||||
Private,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EventConversion {
|
||||
pub event_type: UniversalEventType,
|
||||
pub data: UniversalEventData,
|
||||
pub native_session_id: Option<String>,
|
||||
pub source: EventSource,
|
||||
pub synthetic: bool,
|
||||
pub raw: Option<Value>,
|
||||
}
|
||||
|
||||
impl EventConversion {
|
||||
pub fn new(event_type: UniversalEventType, data: UniversalEventData) -> Self {
|
||||
Self {
|
||||
event_type,
|
||||
data,
|
||||
native_session_id: None,
|
||||
source: EventSource::Agent,
|
||||
synthetic: false,
|
||||
raw: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_native_session(mut self, session_id: Option<String>) -> Self {
|
||||
self.native_session_id = session_id;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_raw(mut self, raw: Option<Value>) -> Self {
|
||||
self.raw = raw;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn synthetic(mut self) -> Self {
|
||||
self.synthetic = true;
|
||||
self.source = EventSource::Daemon;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_source(mut self, source: EventSource) -> Self {
|
||||
self.source = source;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn turn_started_event(turn_id: Option<String>, metadata: Option<Value>) -> EventConversion {
|
||||
EventConversion::new(
|
||||
UniversalEventType::TurnStarted,
|
||||
UniversalEventData::Turn(TurnEventData {
|
||||
phase: TurnPhase::Started,
|
||||
turn_id,
|
||||
metadata,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn turn_ended_event(turn_id: Option<String>, metadata: Option<Value>) -> EventConversion {
|
||||
EventConversion::new(
|
||||
UniversalEventType::TurnEnded,
|
||||
UniversalEventData::Turn(TurnEventData {
|
||||
phase: TurnPhase::Ended,
|
||||
turn_id,
|
||||
metadata,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn item_from_parts(role: ItemRole, kind: ItemKind, parts: Vec<ContentPart>) -> UniversalItem {
|
||||
UniversalItem {
|
||||
item_id: String::new(),
|
||||
native_item_id: None,
|
||||
parent_id: None,
|
||||
kind,
|
||||
role: Some(role),
|
||||
content: parts,
|
||||
status: ItemStatus::Completed,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,769 +0,0 @@
|
|||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::pi as schema;
|
||||
use crate::{
|
||||
ContentPart, EventConversion, ItemDeltaData, ItemEventData, ItemKind, ItemRole, ItemStatus,
|
||||
ReasoningVisibility, UniversalEventData, UniversalEventType, UniversalItem,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct PiEventConverter {
|
||||
tool_result_buffers: HashMap<String, String>,
|
||||
tool_result_started: HashSet<String>,
|
||||
message_completed: HashSet<String>,
|
||||
message_errors: HashSet<String>,
|
||||
message_reasoning: HashMap<String, String>,
|
||||
message_text: HashMap<String, String>,
|
||||
last_message_id: Option<String>,
|
||||
message_started: HashSet<String>,
|
||||
message_counter: u64,
|
||||
}
|
||||
|
||||
impl PiEventConverter {
|
||||
pub fn event_value_to_universal(
|
||||
&mut self,
|
||||
raw: &Value,
|
||||
) -> Result<Vec<EventConversion>, String> {
|
||||
let event_type = raw
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| "missing event type".to_string())?;
|
||||
let native_session_id = extract_session_id(raw);
|
||||
|
||||
let conversions = match event_type {
|
||||
"message_start" => self.message_start(raw),
|
||||
"message_update" => self.message_update(raw),
|
||||
"message_end" => self.message_end(raw),
|
||||
"tool_execution_start" => self.tool_execution_start(raw),
|
||||
"tool_execution_update" => self.tool_execution_update(raw),
|
||||
"tool_execution_end" => self.tool_execution_end(raw),
|
||||
"agent_start"
|
||||
| "agent_end"
|
||||
| "turn_start"
|
||||
| "turn_end"
|
||||
| "auto_compaction"
|
||||
| "auto_compaction_start"
|
||||
| "auto_compaction_end"
|
||||
| "auto_retry"
|
||||
| "auto_retry_start"
|
||||
| "auto_retry_end"
|
||||
| "hook_error" => Ok(vec![status_event(event_type, raw)]),
|
||||
"extension_ui_request" | "extension_ui_response" | "extension_error" => {
|
||||
Ok(vec![status_event(event_type, raw)])
|
||||
}
|
||||
other => Err(format!("unsupported Pi event type: {other}")),
|
||||
}?;
|
||||
|
||||
Ok(conversions
|
||||
.into_iter()
|
||||
.map(|conversion| attach_metadata(conversion, &native_session_id, raw))
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn next_synthetic_message_id(&mut self) -> String {
|
||||
self.message_counter += 1;
|
||||
format!("pi_msg_{}", self.message_counter)
|
||||
}
|
||||
|
||||
fn ensure_message_id(&mut self, message_id: Option<String>) -> String {
|
||||
if let Some(id) = message_id {
|
||||
self.last_message_id = Some(id.clone());
|
||||
return id;
|
||||
}
|
||||
if let Some(id) = self.last_message_id.clone() {
|
||||
return id;
|
||||
}
|
||||
let id = self.next_synthetic_message_id();
|
||||
self.last_message_id = Some(id.clone());
|
||||
id
|
||||
}
|
||||
|
||||
fn ensure_message_started(&mut self, message_id: &str) -> Option<EventConversion> {
|
||||
if !self.message_started.insert(message_id.to_string()) {
|
||||
return None;
|
||||
}
|
||||
let item = UniversalItem {
|
||||
item_id: String::new(),
|
||||
native_item_id: Some(message_id.to_string()),
|
||||
parent_id: None,
|
||||
kind: ItemKind::Message,
|
||||
role: Some(ItemRole::Assistant),
|
||||
content: Vec::new(),
|
||||
status: ItemStatus::InProgress,
|
||||
};
|
||||
Some(
|
||||
EventConversion::new(
|
||||
UniversalEventType::ItemStarted,
|
||||
UniversalEventData::Item(ItemEventData { item }),
|
||||
)
|
||||
.synthetic(),
|
||||
)
|
||||
}
|
||||
|
||||
fn clear_last_message_id(&mut self, message_id: Option<&str>) {
|
||||
if message_id.is_none() || self.last_message_id.as_deref() == message_id {
|
||||
self.last_message_id = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn event_to_universal(
|
||||
&mut self,
|
||||
event: &schema::RpcEvent,
|
||||
) -> Result<Vec<EventConversion>, String> {
|
||||
let raw = serde_json::to_value(event).map_err(|err| err.to_string())?;
|
||||
self.event_value_to_universal(&raw)
|
||||
}
|
||||
|
||||
fn message_start(&mut self, raw: &Value) -> Result<Vec<EventConversion>, String> {
|
||||
let message = raw.get("message");
|
||||
if is_user_role(message) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let message_id = self.ensure_message_id(extract_message_id(raw));
|
||||
self.message_completed.remove(&message_id);
|
||||
self.message_started.insert(message_id.clone());
|
||||
let content = message.and_then(parse_message_content).unwrap_or_default();
|
||||
let entry = self.message_text.entry(message_id.clone()).or_default();
|
||||
for part in &content {
|
||||
if let ContentPart::Text { text } = part {
|
||||
entry.push_str(text);
|
||||
}
|
||||
}
|
||||
let item = UniversalItem {
|
||||
item_id: String::new(),
|
||||
native_item_id: Some(message_id),
|
||||
parent_id: None,
|
||||
kind: ItemKind::Message,
|
||||
role: Some(ItemRole::Assistant),
|
||||
content,
|
||||
status: ItemStatus::InProgress,
|
||||
};
|
||||
Ok(vec![EventConversion::new(
|
||||
UniversalEventType::ItemStarted,
|
||||
UniversalEventData::Item(ItemEventData { item }),
|
||||
)])
|
||||
}
|
||||
|
||||
fn message_update(&mut self, raw: &Value) -> Result<Vec<EventConversion>, String> {
|
||||
let assistant_event = raw
|
||||
.get("assistantMessageEvent")
|
||||
.or_else(|| raw.get("assistant_message_event"))
|
||||
.ok_or_else(|| "missing assistantMessageEvent".to_string())?;
|
||||
let event_type = assistant_event
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
let message_id = extract_message_id(raw)
|
||||
.or_else(|| extract_message_id(assistant_event))
|
||||
.or_else(|| self.last_message_id.clone());
|
||||
|
||||
match event_type {
|
||||
"start" => {
|
||||
if let Some(id) = message_id {
|
||||
self.last_message_id = Some(id);
|
||||
}
|
||||
Ok(Vec::new())
|
||||
}
|
||||
"text_start" | "text_delta" | "text_end" => {
|
||||
let Some(delta) = extract_delta_text(assistant_event) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let message_id = self.ensure_message_id(message_id);
|
||||
let entry = self.message_text.entry(message_id.clone()).or_default();
|
||||
entry.push_str(&delta);
|
||||
let mut conversions = Vec::new();
|
||||
if let Some(start) = self.ensure_message_started(&message_id) {
|
||||
conversions.push(start);
|
||||
}
|
||||
conversions.push(item_delta(Some(message_id), delta));
|
||||
Ok(conversions)
|
||||
}
|
||||
"thinking_start" | "thinking_delta" | "thinking_end" => {
|
||||
let Some(delta) = extract_delta_text(assistant_event) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let message_id = self.ensure_message_id(message_id);
|
||||
let entry = self
|
||||
.message_reasoning
|
||||
.entry(message_id.clone())
|
||||
.or_default();
|
||||
entry.push_str(&delta);
|
||||
let mut conversions = Vec::new();
|
||||
if let Some(start) = self.ensure_message_started(&message_id) {
|
||||
conversions.push(start);
|
||||
}
|
||||
conversions.push(item_delta(Some(message_id), delta));
|
||||
Ok(conversions)
|
||||
}
|
||||
"toolcall_start"
|
||||
| "toolcall_delta"
|
||||
| "toolcall_end"
|
||||
| "toolcall_args_start"
|
||||
| "toolcall_args_delta"
|
||||
| "toolcall_args_end" => Ok(Vec::new()),
|
||||
"done" => {
|
||||
let message_id = self.ensure_message_id(message_id);
|
||||
if self.message_errors.remove(&message_id) {
|
||||
self.message_text.remove(&message_id);
|
||||
self.message_reasoning.remove(&message_id);
|
||||
self.message_started.remove(&message_id);
|
||||
self.clear_last_message_id(Some(&message_id));
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
if self.message_completed.contains(&message_id) {
|
||||
self.clear_last_message_id(Some(&message_id));
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let message = raw
|
||||
.get("message")
|
||||
.or_else(|| assistant_event.get("message"));
|
||||
let conversion = self.complete_message(Some(message_id.clone()), message);
|
||||
self.message_completed.insert(message_id.clone());
|
||||
self.clear_last_message_id(Some(&message_id));
|
||||
Ok(vec![conversion])
|
||||
}
|
||||
"error" => {
|
||||
let message_id = self.ensure_message_id(message_id);
|
||||
if self.message_completed.contains(&message_id) {
|
||||
self.clear_last_message_id(Some(&message_id));
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let error_text = assistant_event
|
||||
.get("error")
|
||||
.or_else(|| raw.get("error"))
|
||||
.map(value_to_string)
|
||||
.unwrap_or_else(|| "Pi message error".to_string());
|
||||
self.message_reasoning.remove(&message_id);
|
||||
self.message_text.remove(&message_id);
|
||||
self.message_errors.insert(message_id.clone());
|
||||
self.message_started.remove(&message_id);
|
||||
self.message_completed.insert(message_id.clone());
|
||||
self.clear_last_message_id(Some(&message_id));
|
||||
let item = UniversalItem {
|
||||
item_id: String::new(),
|
||||
native_item_id: Some(message_id),
|
||||
parent_id: None,
|
||||
kind: ItemKind::Message,
|
||||
role: Some(ItemRole::Assistant),
|
||||
content: vec![ContentPart::Text { text: error_text }],
|
||||
status: ItemStatus::Failed,
|
||||
};
|
||||
Ok(vec![EventConversion::new(
|
||||
UniversalEventType::ItemCompleted,
|
||||
UniversalEventData::Item(ItemEventData { item }),
|
||||
)])
|
||||
}
|
||||
other => Err(format!("unsupported assistantMessageEvent: {other}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn message_end(&mut self, raw: &Value) -> Result<Vec<EventConversion>, String> {
|
||||
let message = raw.get("message");
|
||||
if is_user_role(message) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let message_id = self
|
||||
.ensure_message_id(extract_message_id(raw).or_else(|| self.last_message_id.clone()));
|
||||
if self.message_errors.remove(&message_id) {
|
||||
self.message_text.remove(&message_id);
|
||||
self.message_reasoning.remove(&message_id);
|
||||
self.message_started.remove(&message_id);
|
||||
self.clear_last_message_id(Some(&message_id));
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
if self.message_completed.contains(&message_id) {
|
||||
self.clear_last_message_id(Some(&message_id));
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let conversion = self.complete_message(Some(message_id.clone()), message);
|
||||
self.message_completed.insert(message_id.clone());
|
||||
self.clear_last_message_id(Some(&message_id));
|
||||
Ok(vec![conversion])
|
||||
}
|
||||
|
||||
fn complete_message(
|
||||
&mut self,
|
||||
message_id: Option<String>,
|
||||
message: Option<&Value>,
|
||||
) -> EventConversion {
|
||||
let mut content = message.and_then(parse_message_content).unwrap_or_default();
|
||||
let failed = message_is_failed(message);
|
||||
let message_error_text = extract_message_error_text(message);
|
||||
|
||||
if let Some(id) = message_id.clone() {
|
||||
if content.is_empty() {
|
||||
if let Some(text) = self.message_text.remove(&id) {
|
||||
if !text.is_empty() {
|
||||
content.push(ContentPart::Text { text });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.message_text.remove(&id);
|
||||
}
|
||||
|
||||
if let Some(reasoning) = self.message_reasoning.remove(&id) {
|
||||
if !reasoning.trim().is_empty()
|
||||
&& !content
|
||||
.iter()
|
||||
.any(|part| matches!(part, ContentPart::Reasoning { .. }))
|
||||
{
|
||||
content.push(ContentPart::Reasoning {
|
||||
text: reasoning,
|
||||
visibility: ReasoningVisibility::Private,
|
||||
});
|
||||
}
|
||||
}
|
||||
self.message_started.remove(&id);
|
||||
}
|
||||
|
||||
if failed && content.is_empty() {
|
||||
if let Some(text) = message_error_text {
|
||||
content.push(ContentPart::Text { text });
|
||||
}
|
||||
}
|
||||
|
||||
let item = UniversalItem {
|
||||
item_id: String::new(),
|
||||
native_item_id: message_id,
|
||||
parent_id: None,
|
||||
kind: ItemKind::Message,
|
||||
role: Some(ItemRole::Assistant),
|
||||
content,
|
||||
status: if failed {
|
||||
ItemStatus::Failed
|
||||
} else {
|
||||
ItemStatus::Completed
|
||||
},
|
||||
};
|
||||
EventConversion::new(
|
||||
UniversalEventType::ItemCompleted,
|
||||
UniversalEventData::Item(ItemEventData { item }),
|
||||
)
|
||||
}
|
||||
|
||||
fn tool_execution_start(&mut self, raw: &Value) -> Result<Vec<EventConversion>, String> {
|
||||
let tool_call_id =
|
||||
extract_tool_call_id(raw).ok_or_else(|| "missing toolCallId".to_string())?;
|
||||
let tool_name = extract_tool_name(raw).unwrap_or_else(|| "tool".to_string());
|
||||
let arguments = raw
|
||||
.get("args")
|
||||
.or_else(|| raw.get("arguments"))
|
||||
.map(value_to_string)
|
||||
.unwrap_or_else(|| "{}".to_string());
|
||||
let item = UniversalItem {
|
||||
item_id: String::new(),
|
||||
native_item_id: Some(tool_call_id.clone()),
|
||||
parent_id: None,
|
||||
kind: ItemKind::ToolCall,
|
||||
role: Some(ItemRole::Assistant),
|
||||
content: vec![ContentPart::ToolCall {
|
||||
name: tool_name,
|
||||
arguments,
|
||||
call_id: tool_call_id,
|
||||
}],
|
||||
status: ItemStatus::InProgress,
|
||||
};
|
||||
Ok(vec![EventConversion::new(
|
||||
UniversalEventType::ItemStarted,
|
||||
UniversalEventData::Item(ItemEventData { item }),
|
||||
)])
|
||||
}
|
||||
|
||||
fn tool_execution_update(&mut self, raw: &Value) -> Result<Vec<EventConversion>, String> {
|
||||
let tool_call_id = match extract_tool_call_id(raw) {
|
||||
Some(id) => id,
|
||||
None => return Ok(Vec::new()),
|
||||
};
|
||||
let partial = match raw
|
||||
.get("partialResult")
|
||||
.or_else(|| raw.get("partial_result"))
|
||||
{
|
||||
Some(value) => value_to_string(value),
|
||||
None => return Ok(Vec::new()),
|
||||
};
|
||||
let prior = self
|
||||
.tool_result_buffers
|
||||
.get(&tool_call_id)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let delta = delta_from_partial(&prior, &partial);
|
||||
self.tool_result_buffers
|
||||
.insert(tool_call_id.clone(), partial);
|
||||
|
||||
let mut conversions = Vec::new();
|
||||
if self.tool_result_started.insert(tool_call_id.clone()) {
|
||||
let item = UniversalItem {
|
||||
item_id: String::new(),
|
||||
native_item_id: Some(tool_call_id.clone()),
|
||||
parent_id: None,
|
||||
kind: ItemKind::ToolResult,
|
||||
role: Some(ItemRole::Tool),
|
||||
content: vec![ContentPart::ToolResult {
|
||||
call_id: tool_call_id.clone(),
|
||||
output: String::new(),
|
||||
}],
|
||||
status: ItemStatus::InProgress,
|
||||
};
|
||||
conversions.push(
|
||||
EventConversion::new(
|
||||
UniversalEventType::ItemStarted,
|
||||
UniversalEventData::Item(ItemEventData { item }),
|
||||
)
|
||||
.synthetic(),
|
||||
);
|
||||
}
|
||||
|
||||
if !delta.is_empty() {
|
||||
conversions.push(
|
||||
EventConversion::new(
|
||||
UniversalEventType::ItemDelta,
|
||||
UniversalEventData::ItemDelta(ItemDeltaData {
|
||||
item_id: String::new(),
|
||||
native_item_id: Some(tool_call_id.clone()),
|
||||
delta,
|
||||
}),
|
||||
)
|
||||
.synthetic(),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(conversions)
|
||||
}
|
||||
|
||||
fn tool_execution_end(&mut self, raw: &Value) -> Result<Vec<EventConversion>, String> {
|
||||
let tool_call_id =
|
||||
extract_tool_call_id(raw).ok_or_else(|| "missing toolCallId".to_string())?;
|
||||
self.tool_result_buffers.remove(&tool_call_id);
|
||||
self.tool_result_started.remove(&tool_call_id);
|
||||
|
||||
let output = raw
|
||||
.get("result")
|
||||
.and_then(extract_result_content)
|
||||
.unwrap_or_default();
|
||||
let is_error = raw.get("isError").and_then(Value::as_bool).unwrap_or(false);
|
||||
let item = UniversalItem {
|
||||
item_id: String::new(),
|
||||
native_item_id: Some(tool_call_id.clone()),
|
||||
parent_id: None,
|
||||
kind: ItemKind::ToolResult,
|
||||
role: Some(ItemRole::Tool),
|
||||
content: vec![ContentPart::ToolResult {
|
||||
call_id: tool_call_id,
|
||||
output,
|
||||
}],
|
||||
status: if is_error {
|
||||
ItemStatus::Failed
|
||||
} else {
|
||||
ItemStatus::Completed
|
||||
},
|
||||
};
|
||||
Ok(vec![EventConversion::new(
|
||||
UniversalEventType::ItemCompleted,
|
||||
UniversalEventData::Item(ItemEventData { item }),
|
||||
)])
|
||||
}
|
||||
}
|
||||
|
||||
pub fn event_to_universal(event: &schema::RpcEvent) -> Result<Vec<EventConversion>, String> {
|
||||
PiEventConverter::default().event_to_universal(event)
|
||||
}
|
||||
|
||||
pub fn event_value_to_universal(raw: &Value) -> Result<Vec<EventConversion>, String> {
|
||||
PiEventConverter::default().event_value_to_universal(raw)
|
||||
}
|
||||
|
||||
fn attach_metadata(
|
||||
conversion: EventConversion,
|
||||
native_session_id: &Option<String>,
|
||||
raw: &Value,
|
||||
) -> EventConversion {
|
||||
conversion
|
||||
.with_native_session(native_session_id.clone())
|
||||
.with_raw(Some(raw.clone()))
|
||||
}
|
||||
|
||||
fn status_event(label: &str, raw: &Value) -> EventConversion {
|
||||
let detail = raw
|
||||
.get("error")
|
||||
.or_else(|| raw.get("message"))
|
||||
.map(value_to_string);
|
||||
let item = UniversalItem {
|
||||
item_id: String::new(),
|
||||
native_item_id: None,
|
||||
parent_id: None,
|
||||
kind: ItemKind::Status,
|
||||
role: Some(ItemRole::System),
|
||||
content: vec![ContentPart::Status {
|
||||
label: pi_status_label(label),
|
||||
detail,
|
||||
}],
|
||||
status: ItemStatus::Completed,
|
||||
};
|
||||
EventConversion::new(
|
||||
UniversalEventType::ItemCompleted,
|
||||
UniversalEventData::Item(ItemEventData { item }),
|
||||
)
|
||||
}
|
||||
|
||||
fn pi_status_label(label: &str) -> String {
|
||||
match label {
|
||||
"turn_end" => "turn.completed".to_string(),
|
||||
"agent_end" => "session.idle".to_string(),
|
||||
_ => format!("pi.{label}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn item_delta(message_id: Option<String>, delta: String) -> EventConversion {
|
||||
EventConversion::new(
|
||||
UniversalEventType::ItemDelta,
|
||||
UniversalEventData::ItemDelta(ItemDeltaData {
|
||||
item_id: String::new(),
|
||||
native_item_id: message_id,
|
||||
delta,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn is_user_role(message: Option<&Value>) -> bool {
|
||||
message
|
||||
.and_then(|msg| msg.get("role"))
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|role| role == "user")
|
||||
}
|
||||
|
||||
fn extract_session_id(value: &Value) -> Option<String> {
|
||||
extract_string(value, &["sessionId"])
|
||||
.or_else(|| extract_string(value, &["session_id"]))
|
||||
.or_else(|| extract_string(value, &["session", "id"]))
|
||||
.or_else(|| extract_string(value, &["message", "sessionId"]))
|
||||
}
|
||||
|
||||
fn extract_message_id(value: &Value) -> Option<String> {
|
||||
extract_string(value, &["messageId"])
|
||||
.or_else(|| extract_string(value, &["message_id"]))
|
||||
.or_else(|| extract_string(value, &["message", "id"]))
|
||||
.or_else(|| extract_string(value, &["message", "messageId"]))
|
||||
.or_else(|| extract_string(value, &["assistantMessageEvent", "messageId"]))
|
||||
}
|
||||
|
||||
fn extract_tool_call_id(value: &Value) -> Option<String> {
|
||||
extract_string(value, &["toolCallId"]).or_else(|| extract_string(value, &["tool_call_id"]))
|
||||
}
|
||||
|
||||
fn extract_tool_name(value: &Value) -> Option<String> {
|
||||
extract_string(value, &["toolName"]).or_else(|| extract_string(value, &["tool_name"]))
|
||||
}
|
||||
|
||||
fn extract_string(value: &Value, path: &[&str]) -> Option<String> {
|
||||
let mut current = value;
|
||||
for key in path {
|
||||
current = current.get(*key)?;
|
||||
}
|
||||
current.as_str().map(|value| value.to_string())
|
||||
}
|
||||
|
||||
fn extract_delta_text(event: &Value) -> Option<String> {
|
||||
if let Some(value) = event.get("delta") {
|
||||
return Some(value_to_string(value));
|
||||
}
|
||||
if let Some(value) = event.get("text") {
|
||||
return Some(value_to_string(value));
|
||||
}
|
||||
if let Some(value) = event.get("partial") {
|
||||
return extract_text_from_value(value);
|
||||
}
|
||||
if let Some(value) = event.get("content") {
|
||||
return extract_text_from_value(value);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_text_from_value(value: &Value) -> Option<String> {
|
||||
if let Some(text) = value.as_str() {
|
||||
return Some(text.to_string());
|
||||
}
|
||||
if let Some(text) = value.get("text").and_then(Value::as_str) {
|
||||
return Some(text.to_string());
|
||||
}
|
||||
if let Some(text) = value.get("content").and_then(Value::as_str) {
|
||||
return Some(text.to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_result_content(value: &Value) -> Option<String> {
|
||||
let content = value.get("content").and_then(Value::as_str);
|
||||
let text = value.get("text").and_then(Value::as_str);
|
||||
content
|
||||
.or(text)
|
||||
.map(|value| value.to_string())
|
||||
.or_else(|| Some(value_to_string(value)))
|
||||
}
|
||||
|
||||
fn parse_message_content(message: &Value) -> Option<Vec<ContentPart>> {
|
||||
if let Some(text) = message.as_str() {
|
||||
return Some(vec![ContentPart::Text {
|
||||
text: text.to_string(),
|
||||
}]);
|
||||
}
|
||||
let content_value = message
|
||||
.get("content")
|
||||
.or_else(|| message.get("text"))
|
||||
.or_else(|| message.get("value"))?;
|
||||
let mut parts = Vec::new();
|
||||
match content_value {
|
||||
Value::String(text) => parts.push(ContentPart::Text { text: text.clone() }),
|
||||
Value::Array(items) => {
|
||||
for item in items {
|
||||
if let Some(part) = content_part_from_value(item) {
|
||||
parts.push(part);
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Object(_) => {
|
||||
if let Some(part) = content_part_from_value(content_value) {
|
||||
parts.push(part);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Some(parts)
|
||||
}
|
||||
|
||||
fn message_is_failed(message: Option<&Value>) -> bool {
|
||||
message
|
||||
.and_then(|value| {
|
||||
value
|
||||
.get("stopReason")
|
||||
.or_else(|| value.get("stop_reason"))
|
||||
.and_then(Value::as_str)
|
||||
})
|
||||
.is_some_and(|reason| reason == "error" || reason == "aborted")
|
||||
}
|
||||
|
||||
fn extract_message_error_text(message: Option<&Value>) -> Option<String> {
|
||||
let value = message?;
|
||||
|
||||
if let Some(text) = value
|
||||
.get("errorMessage")
|
||||
.or_else(|| value.get("error_message"))
|
||||
.and_then(Value::as_str)
|
||||
{
|
||||
let trimmed = text.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let error = value.get("error")?;
|
||||
if let Some(text) = error.as_str() {
|
||||
let trimmed = text.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
if let Some(text) = error
|
||||
.get("errorMessage")
|
||||
.or_else(|| error.get("error_message"))
|
||||
.or_else(|| error.get("message"))
|
||||
.and_then(Value::as_str)
|
||||
{
|
||||
let trimmed = text.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn content_part_from_value(value: &Value) -> Option<ContentPart> {
|
||||
if let Some(text) = value.as_str() {
|
||||
return Some(ContentPart::Text {
|
||||
text: text.to_string(),
|
||||
});
|
||||
}
|
||||
let part_type = value.get("type").and_then(Value::as_str);
|
||||
match part_type {
|
||||
Some("text") | Some("markdown") => {
|
||||
extract_text_from_value(value).map(|text| ContentPart::Text { text })
|
||||
}
|
||||
Some("thinking") | Some("reasoning") => {
|
||||
extract_text_from_value(value).map(|text| ContentPart::Reasoning {
|
||||
text,
|
||||
visibility: ReasoningVisibility::Private,
|
||||
})
|
||||
}
|
||||
Some("image") => value
|
||||
.get("path")
|
||||
.or_else(|| value.get("url"))
|
||||
.and_then(|path| {
|
||||
path.as_str().map(|path| ContentPart::Image {
|
||||
path: path.to_string(),
|
||||
mime: value
|
||||
.get("mime")
|
||||
.or_else(|| value.get("mimeType"))
|
||||
.and_then(Value::as_str)
|
||||
.map(|mime| mime.to_string()),
|
||||
})
|
||||
}),
|
||||
Some("tool_call") | Some("toolcall") => {
|
||||
let name = value
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("tool")
|
||||
.to_string();
|
||||
let arguments = value
|
||||
.get("arguments")
|
||||
.or_else(|| value.get("args"))
|
||||
.map(value_to_string)
|
||||
.unwrap_or_else(|| "{}".to_string());
|
||||
let call_id = value
|
||||
.get("call_id")
|
||||
.or_else(|| value.get("callId"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
Some(ContentPart::ToolCall {
|
||||
name,
|
||||
arguments,
|
||||
call_id,
|
||||
})
|
||||
}
|
||||
Some("tool_result") => {
|
||||
let call_id = value
|
||||
.get("call_id")
|
||||
.or_else(|| value.get("callId"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let output = value
|
||||
.get("output")
|
||||
.or_else(|| value.get("content"))
|
||||
.map(value_to_string)
|
||||
.unwrap_or_default();
|
||||
Some(ContentPart::ToolResult { call_id, output })
|
||||
}
|
||||
_ => Some(ContentPart::Json {
|
||||
json: value.clone(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn value_to_string(value: &Value) -> String {
|
||||
if let Some(text) = value.as_str() {
|
||||
text.to_string()
|
||||
} else {
|
||||
value.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn delta_from_partial(previous: &str, next: &str) -> String {
|
||||
if next.starts_with(previous) {
|
||||
next[previous.len()..].to_string()
|
||||
} else {
|
||||
next.to_string()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,414 +0,0 @@
|
|||
use sandbox_agent_universal_agent_schema::convert_pi::PiEventConverter;
|
||||
use sandbox_agent_universal_agent_schema::pi as pi_schema;
|
||||
use sandbox_agent_universal_agent_schema::{
|
||||
ContentPart, ItemKind, ItemRole, ItemStatus, UniversalEventData, UniversalEventType,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
fn parse_event(value: serde_json::Value) -> pi_schema::RpcEvent {
|
||||
serde_json::from_value(value).expect("pi event")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pi_message_flow_converts() {
|
||||
let mut converter = PiEventConverter::default();
|
||||
|
||||
let start_event = parse_event(json!({
|
||||
"type": "message_start",
|
||||
"sessionId": "session-1",
|
||||
"messageId": "msg-1",
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": [{ "type": "text", "text": "Hello" }]
|
||||
}
|
||||
}));
|
||||
let start_events = converter
|
||||
.event_to_universal(&start_event)
|
||||
.expect("start conversions");
|
||||
assert_eq!(start_events[0].event_type, UniversalEventType::ItemStarted);
|
||||
if let UniversalEventData::Item(item) = &start_events[0].data {
|
||||
assert_eq!(item.item.kind, ItemKind::Message);
|
||||
assert_eq!(item.item.role, Some(ItemRole::Assistant));
|
||||
assert_eq!(item.item.status, ItemStatus::InProgress);
|
||||
} else {
|
||||
panic!("expected item event");
|
||||
}
|
||||
|
||||
let update_event = parse_event(json!({
|
||||
"type": "message_update",
|
||||
"sessionId": "session-1",
|
||||
"messageId": "msg-1",
|
||||
"assistantMessageEvent": { "type": "text_delta", "delta": " world" }
|
||||
}));
|
||||
let update_events = converter
|
||||
.event_to_universal(&update_event)
|
||||
.expect("update conversions");
|
||||
assert_eq!(update_events[0].event_type, UniversalEventType::ItemDelta);
|
||||
|
||||
let end_event = parse_event(json!({
|
||||
"type": "message_end",
|
||||
"sessionId": "session-1",
|
||||
"messageId": "msg-1",
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": [{ "type": "text", "text": "Hello world" }]
|
||||
}
|
||||
}));
|
||||
let end_events = converter
|
||||
.event_to_universal(&end_event)
|
||||
.expect("end conversions");
|
||||
assert_eq!(end_events[0].event_type, UniversalEventType::ItemCompleted);
|
||||
if let UniversalEventData::Item(item) = &end_events[0].data {
|
||||
assert_eq!(item.item.kind, ItemKind::Message);
|
||||
assert_eq!(item.item.role, Some(ItemRole::Assistant));
|
||||
assert_eq!(item.item.status, ItemStatus::Completed);
|
||||
} else {
|
||||
panic!("expected item event");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pi_user_message_echo_is_skipped() {
|
||||
let mut converter = PiEventConverter::default();
|
||||
|
||||
// Pi may echo the user message as a message_start with role "user".
|
||||
// The daemon already records synthetic user events, so the converter
|
||||
// must skip these to avoid a duplicate assistant-looking bubble.
|
||||
let start_event = parse_event(json!({
|
||||
"type": "message_start",
|
||||
"sessionId": "session-1",
|
||||
"messageId": "user-msg-1",
|
||||
"message": {
|
||||
"role": "user",
|
||||
"content": [{ "type": "text", "text": "hello!" }]
|
||||
}
|
||||
}));
|
||||
let events = converter
|
||||
.event_to_universal(&start_event)
|
||||
.expect("user message_start should not error");
|
||||
assert!(
|
||||
events.is_empty(),
|
||||
"user message_start should produce no events, got {}",
|
||||
events.len()
|
||||
);
|
||||
|
||||
let end_event = parse_event(json!({
|
||||
"type": "message_end",
|
||||
"sessionId": "session-1",
|
||||
"messageId": "user-msg-1",
|
||||
"message": {
|
||||
"role": "user",
|
||||
"content": [{ "type": "text", "text": "hello!" }]
|
||||
}
|
||||
}));
|
||||
let events = converter
|
||||
.event_to_universal(&end_event)
|
||||
.expect("user message_end should not error");
|
||||
assert!(
|
||||
events.is_empty(),
|
||||
"user message_end should produce no events, got {}",
|
||||
events.len()
|
||||
);
|
||||
|
||||
// A subsequent assistant message should still work normally.
|
||||
let assistant_start = parse_event(json!({
|
||||
"type": "message_start",
|
||||
"sessionId": "session-1",
|
||||
"messageId": "msg-1",
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": [{ "type": "text", "text": "Hello! How can I help?" }]
|
||||
}
|
||||
}));
|
||||
let events = converter
|
||||
.event_to_universal(&assistant_start)
|
||||
.expect("assistant message_start");
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events[0].event_type, UniversalEventType::ItemStarted);
|
||||
if let UniversalEventData::Item(item) = &events[0].data {
|
||||
assert_eq!(item.item.role, Some(ItemRole::Assistant));
|
||||
} else {
|
||||
panic!("expected item event");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pi_tool_execution_converts_with_partial_deltas() {
|
||||
let mut converter = PiEventConverter::default();
|
||||
|
||||
let start_event = parse_event(json!({
|
||||
"type": "tool_execution_start",
|
||||
"sessionId": "session-1",
|
||||
"toolCallId": "call-1",
|
||||
"toolName": "bash",
|
||||
"args": { "command": "ls" }
|
||||
}));
|
||||
let start_events = converter
|
||||
.event_to_universal(&start_event)
|
||||
.expect("tool start");
|
||||
assert_eq!(start_events[0].event_type, UniversalEventType::ItemStarted);
|
||||
if let UniversalEventData::Item(item) = &start_events[0].data {
|
||||
assert_eq!(item.item.kind, ItemKind::ToolCall);
|
||||
assert_eq!(item.item.role, Some(ItemRole::Assistant));
|
||||
match &item.item.content[0] {
|
||||
ContentPart::ToolCall { name, .. } => assert_eq!(name, "bash"),
|
||||
_ => panic!("expected tool call content"),
|
||||
}
|
||||
}
|
||||
|
||||
let update_event = parse_event(json!({
|
||||
"type": "tool_execution_update",
|
||||
"sessionId": "session-1",
|
||||
"toolCallId": "call-1",
|
||||
"partialResult": "foo"
|
||||
}));
|
||||
let update_events = converter
|
||||
.event_to_universal(&update_event)
|
||||
.expect("tool update");
|
||||
assert!(update_events
|
||||
.iter()
|
||||
.any(|event| event.event_type == UniversalEventType::ItemDelta));
|
||||
|
||||
let update_event2 = parse_event(json!({
|
||||
"type": "tool_execution_update",
|
||||
"sessionId": "session-1",
|
||||
"toolCallId": "call-1",
|
||||
"partialResult": "foobar"
|
||||
}));
|
||||
let update_events2 = converter
|
||||
.event_to_universal(&update_event2)
|
||||
.expect("tool update 2");
|
||||
let delta = update_events2
|
||||
.iter()
|
||||
.find_map(|event| match &event.data {
|
||||
UniversalEventData::ItemDelta(data) => Some(data.delta.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_default();
|
||||
assert_eq!(delta, "bar");
|
||||
|
||||
let end_event = parse_event(json!({
|
||||
"type": "tool_execution_end",
|
||||
"sessionId": "session-1",
|
||||
"toolCallId": "call-1",
|
||||
"result": { "type": "text", "content": "done" },
|
||||
"isError": false
|
||||
}));
|
||||
let end_events = converter.event_to_universal(&end_event).expect("tool end");
|
||||
assert_eq!(end_events[0].event_type, UniversalEventType::ItemCompleted);
|
||||
if let UniversalEventData::Item(item) = &end_events[0].data {
|
||||
assert_eq!(item.item.kind, ItemKind::ToolResult);
|
||||
assert_eq!(item.item.role, Some(ItemRole::Tool));
|
||||
match &item.item.content[0] {
|
||||
ContentPart::ToolResult { output, .. } => assert_eq!(output, "done"),
|
||||
_ => panic!("expected tool result content"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pi_unknown_event_returns_error() {
|
||||
let mut converter = PiEventConverter::default();
|
||||
let event = parse_event(json!({
|
||||
"type": "unknown_event",
|
||||
"sessionId": "session-1"
|
||||
}));
|
||||
assert!(converter.event_to_universal(&event).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pi_turn_and_agent_end_emit_terminal_status_labels() {
|
||||
let mut converter = PiEventConverter::default();
|
||||
|
||||
let turn_end = parse_event(json!({
|
||||
"type": "turn_end",
|
||||
"sessionId": "session-1"
|
||||
}));
|
||||
let turn_events = converter
|
||||
.event_to_universal(&turn_end)
|
||||
.expect("turn_end conversions");
|
||||
assert_eq!(turn_events[0].event_type, UniversalEventType::ItemCompleted);
|
||||
if let UniversalEventData::Item(item) = &turn_events[0].data {
|
||||
assert_eq!(item.item.kind, ItemKind::Status);
|
||||
assert!(
|
||||
matches!(
|
||||
item.item.content.first(),
|
||||
Some(ContentPart::Status { label, .. }) if label == "turn.completed"
|
||||
),
|
||||
"turn_end should map to turn.completed status"
|
||||
);
|
||||
} else {
|
||||
panic!("expected item event");
|
||||
}
|
||||
|
||||
let agent_end = parse_event(json!({
|
||||
"type": "agent_end",
|
||||
"sessionId": "session-1"
|
||||
}));
|
||||
let agent_events = converter
|
||||
.event_to_universal(&agent_end)
|
||||
.expect("agent_end conversions");
|
||||
assert_eq!(
|
||||
agent_events[0].event_type,
|
||||
UniversalEventType::ItemCompleted
|
||||
);
|
||||
if let UniversalEventData::Item(item) = &agent_events[0].data {
|
||||
assert_eq!(item.item.kind, ItemKind::Status);
|
||||
assert!(
|
||||
matches!(
|
||||
item.item.content.first(),
|
||||
Some(ContentPart::Status { label, .. }) if label == "session.idle"
|
||||
),
|
||||
"agent_end should map to session.idle status"
|
||||
);
|
||||
} else {
|
||||
panic!("expected item event");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pi_message_done_completes_without_message_end() {
|
||||
let mut converter = PiEventConverter::default();
|
||||
|
||||
let start_event = parse_event(json!({
|
||||
"type": "message_start",
|
||||
"sessionId": "session-1",
|
||||
"messageId": "msg-1",
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": [{ "type": "text", "text": "Hello" }]
|
||||
}
|
||||
}));
|
||||
let _start_events = converter
|
||||
.event_to_universal(&start_event)
|
||||
.expect("start conversions");
|
||||
|
||||
let update_event = parse_event(json!({
|
||||
"type": "message_update",
|
||||
"sessionId": "session-1",
|
||||
"messageId": "msg-1",
|
||||
"assistantMessageEvent": { "type": "text_delta", "delta": " world" }
|
||||
}));
|
||||
let _update_events = converter
|
||||
.event_to_universal(&update_event)
|
||||
.expect("update conversions");
|
||||
|
||||
let done_event = parse_event(json!({
|
||||
"type": "message_update",
|
||||
"sessionId": "session-1",
|
||||
"messageId": "msg-1",
|
||||
"assistantMessageEvent": { "type": "done" }
|
||||
}));
|
||||
let done_events = converter
|
||||
.event_to_universal(&done_event)
|
||||
.expect("done conversions");
|
||||
assert_eq!(done_events[0].event_type, UniversalEventType::ItemCompleted);
|
||||
if let UniversalEventData::Item(item) = &done_events[0].data {
|
||||
assert_eq!(item.item.status, ItemStatus::Completed);
|
||||
assert!(
|
||||
matches!(item.item.content.get(0), Some(ContentPart::Text { text }) if text == "Hello world")
|
||||
);
|
||||
} else {
|
||||
panic!("expected item event");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pi_message_done_then_message_end_does_not_double_complete() {
|
||||
let mut converter = PiEventConverter::default();
|
||||
|
||||
let start_event = parse_event(json!({
|
||||
"type": "message_start",
|
||||
"sessionId": "session-1",
|
||||
"messageId": "msg-1",
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": [{ "type": "text", "text": "Hello" }]
|
||||
}
|
||||
}));
|
||||
let _ = converter
|
||||
.event_to_universal(&start_event)
|
||||
.expect("start conversions");
|
||||
|
||||
let update_event = parse_event(json!({
|
||||
"type": "message_update",
|
||||
"sessionId": "session-1",
|
||||
"messageId": "msg-1",
|
||||
"assistantMessageEvent": { "type": "text_delta", "delta": " world" }
|
||||
}));
|
||||
let _ = converter
|
||||
.event_to_universal(&update_event)
|
||||
.expect("update conversions");
|
||||
|
||||
let done_event = parse_event(json!({
|
||||
"type": "message_update",
|
||||
"sessionId": "session-1",
|
||||
"messageId": "msg-1",
|
||||
"assistantMessageEvent": { "type": "done" }
|
||||
}));
|
||||
let done_events = converter
|
||||
.event_to_universal(&done_event)
|
||||
.expect("done conversions");
|
||||
assert_eq!(done_events.len(), 1);
|
||||
assert_eq!(done_events[0].event_type, UniversalEventType::ItemCompleted);
|
||||
|
||||
let end_event = parse_event(json!({
|
||||
"type": "message_end",
|
||||
"sessionId": "session-1",
|
||||
"messageId": "msg-1",
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": [{ "type": "text", "text": "Hello world" }]
|
||||
}
|
||||
}));
|
||||
let end_events = converter
|
||||
.event_to_universal(&end_event)
|
||||
.expect("end conversions");
|
||||
assert!(
|
||||
end_events.is_empty(),
|
||||
"message_end after done should not emit a second completion"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pi_message_end_error_surfaces_failed_status_and_error_text() {
|
||||
let mut converter = PiEventConverter::default();
|
||||
|
||||
let start_event = parse_event(json!({
|
||||
"type": "message_start",
|
||||
"sessionId": "session-1",
|
||||
"messageId": "msg-err",
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": []
|
||||
}
|
||||
}));
|
||||
let _ = converter
|
||||
.event_to_universal(&start_event)
|
||||
.expect("start conversions");
|
||||
|
||||
let end_raw = json!({
|
||||
"type": "message_end",
|
||||
"sessionId": "session-1",
|
||||
"messageId": "msg-err",
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": [],
|
||||
"stopReason": "error",
|
||||
"errorMessage": "Connection error."
|
||||
}
|
||||
});
|
||||
let end_events = converter
|
||||
.event_value_to_universal(&end_raw)
|
||||
.expect("end conversions");
|
||||
|
||||
assert_eq!(end_events[0].event_type, UniversalEventType::ItemCompleted);
|
||||
if let UniversalEventData::Item(item) = &end_events[0].data {
|
||||
assert_eq!(item.item.status, ItemStatus::Failed);
|
||||
assert!(
|
||||
matches!(item.item.content.first(), Some(ContentPart::Text { text }) if text == "Connection error.")
|
||||
);
|
||||
} else {
|
||||
panic!("expected item event");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue