mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 10:05:18 +00:00
feat: add opencode compatibility layer
This commit is contained in:
parent
cc5a9e0d73
commit
86bbb28cd2
32 changed files with 18163 additions and 310 deletions
12
CLAUDE.md
12
CLAUDE.md
|
|
@ -64,10 +64,22 @@ Universal schema guidance:
|
|||
- `sandbox-agent api sessions reject-question` ↔ `POST /v1/sessions/{sessionId}/questions/{questionId}/reject`
|
||||
- `sandbox-agent api sessions reply-permission` ↔ `POST /v1/sessions/{sessionId}/permissions/{permissionId}/reply`
|
||||
|
||||
## OpenCode CLI (Experimental)
|
||||
|
||||
`sandbox-agent opencode` starts a sandbox-agent server and attaches an OpenCode session (uses `/opencode`).
|
||||
|
||||
## Post-Release Testing
|
||||
|
||||
After cutting a release, verify the release works correctly. Run `/project:post-release-testing` to execute the testing agent.
|
||||
|
||||
## OpenCode Compatibility Tests
|
||||
|
||||
The OpenCode compatibility suite lives at `server/packages/sandbox-agent/tests/opencode-compat` and validates the `@opencode-ai/sdk` against the `/opencode` API. Run it with:
|
||||
|
||||
```bash
|
||||
SANDBOX_AGENT_SKIP_INSPECTOR=1 pnpm --filter @sandbox-agent/opencode-compat-tests test
|
||||
```
|
||||
|
||||
## Git Commits
|
||||
|
||||
- Do not include any co-authors in commit messages (no `Co-Authored-By` lines)
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ zip = { version = "0.6", default-features = false, features = ["deflate"] }
|
|||
url = "2.5"
|
||||
regress = "0.10"
|
||||
include_dir = "0.7"
|
||||
base64 = "0.22"
|
||||
|
||||
# Code generation (build deps)
|
||||
typify = "0.4"
|
||||
|
|
|
|||
31
docs/cli.mdx
31
docs/cli.mdx
|
|
@ -29,6 +29,12 @@ sandbox-agent server [OPTIONS]
|
|||
sandbox-agent server --token "$TOKEN" --port 3000
|
||||
```
|
||||
|
||||
Server logs are redirected to a daily log file under the sandbox-agent data directory (for example, `~/.local/share/sandbox-agent/logs`). Override with `SANDBOX_AGENT_LOG_DIR`, or set `SANDBOX_AGENT_LOG_STDOUT=1` to keep logs on stdout/stderr.
|
||||
|
||||
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)
|
||||
|
||||
---
|
||||
|
||||
## Install Agent (Local)
|
||||
|
|
@ -49,6 +55,31 @@ sandbox-agent install-agent claude --reinstall
|
|||
|
||||
---
|
||||
|
||||
## OpenCode (Experimental)
|
||||
|
||||
Start a sandbox-agent server and attach an OpenCode session (uses `opencode attach`):
|
||||
|
||||
```bash
|
||||
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 |
|
||||
| `--opencode-bin <PATH>` | - | Override `opencode` binary path |
|
||||
|
||||
```bash
|
||||
sandbox-agent opencode --token "$TOKEN"
|
||||
```
|
||||
|
||||
Requires the `opencode` binary to be installed (or set `OPENCODE_BIN` / `--opencode-bin`).
|
||||
|
||||
---
|
||||
|
||||
## Credentials
|
||||
|
||||
### Extract
|
||||
|
|
|
|||
|
|
@ -61,11 +61,12 @@
|
|||
},
|
||||
{
|
||||
"group": "Reference",
|
||||
"pages": [
|
||||
"cli",
|
||||
"inspector",
|
||||
"session-transcript-schema",
|
||||
"cors",
|
||||
"pages": [
|
||||
"cli",
|
||||
"opencode-compatibility",
|
||||
"inspector",
|
||||
"session-transcript-schema",
|
||||
"cors",
|
||||
{
|
||||
"group": "AI",
|
||||
"pages": ["ai/skill", "ai/llms-txt"]
|
||||
|
|
|
|||
108
docs/opencode-compatibility.mdx
Normal file
108
docs/opencode-compatibility.mdx
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
---
|
||||
title: "OpenCode Compatibility"
|
||||
description: "OpenCode endpoint coverage for /opencode."
|
||||
---
|
||||
|
||||
Sandbox Agent exposes OpenCode-compatible endpoints under `/opencode`.
|
||||
Authentication matches `/v1`: if a token is configured, requests must include `Authorization: Bearer <token>`.
|
||||
|
||||
| Method | Path | Status | Notes | Tests |
|
||||
|---|---|---|---|---|
|
||||
| GET | /agent | Stubbed | Single stub agent entry. | E2E: openapi-coverage |
|
||||
| DELETE | /auth/{providerID} | Stubbed | | E2E: openapi-coverage |
|
||||
| PUT | /auth/{providerID} | Stubbed | | E2E: openapi-coverage |
|
||||
| GET | /command | Stubbed | | E2E: openapi-coverage |
|
||||
| GET | /config | Stubbed | Returns/echoes config payloads. | E2E: openapi-coverage |
|
||||
| PATCH | /config | Stubbed | Returns/echoes config payloads. | E2E: openapi-coverage |
|
||||
| GET | /config/providers | Stubbed | Returns/echoes config payloads. | E2E: openapi-coverage |
|
||||
| GET | /event | SSE stub | Emits compat events for session/message/pty updates only. | E2E: openapi-coverage, events |
|
||||
| GET | /experimental/resource | Stubbed | Experimental endpoints return empty stubs. | E2E: openapi-coverage |
|
||||
| GET | /experimental/tool | Stubbed | Experimental endpoints return empty stubs. | E2E: openapi-coverage |
|
||||
| GET | /experimental/tool/ids | Stubbed | Experimental endpoints return empty stubs. | E2E: openapi-coverage |
|
||||
| DELETE | /experimental/worktree | Stubbed | Experimental endpoints return empty stubs. | E2E: openapi-coverage |
|
||||
| GET | /experimental/worktree | Stubbed | Experimental endpoints return empty stubs. | E2E: openapi-coverage |
|
||||
| POST | /experimental/worktree | Stubbed | Experimental endpoints return empty stubs. | E2E: openapi-coverage |
|
||||
| POST | /experimental/worktree/reset | Stubbed | Experimental endpoints return empty stubs. | E2E: openapi-coverage |
|
||||
| GET | /file | Stubbed | Returns empty lists/content; no filesystem access. | E2E: openapi-coverage |
|
||||
| GET | /file/content | Stubbed | Returns empty lists/content; no filesystem access. | E2E: openapi-coverage |
|
||||
| GET | /file/status | Stubbed | Returns empty lists/content; no filesystem access. | E2E: openapi-coverage |
|
||||
| GET | /find | Stubbed | Returns empty results. | E2E: openapi-coverage |
|
||||
| GET | /find/file | Stubbed | Returns empty results. | E2E: openapi-coverage |
|
||||
| GET | /find/symbol | Stubbed | Returns empty results. | E2E: openapi-coverage |
|
||||
| GET | /formatter | Stubbed | | E2E: openapi-coverage |
|
||||
| GET | /global/config | Stubbed | | E2E: openapi-coverage |
|
||||
| PATCH | /global/config | Stubbed | | E2E: openapi-coverage |
|
||||
| POST | /global/dispose | Stubbed | | E2E: openapi-coverage |
|
||||
| GET | /global/event | SSE stub | Wraps compat events in GlobalEvent; no external sources. | E2E: openapi-coverage, events |
|
||||
| GET | /global/health | Stubbed | | E2E: openapi-coverage |
|
||||
| POST | /instance/dispose | Stubbed | | E2E: openapi-coverage |
|
||||
| POST | /log | Stubbed | | E2E: openapi-coverage |
|
||||
| GET | /lsp | Stubbed | | E2E: openapi-coverage |
|
||||
| GET | /mcp | Stubbed | Returns disabled/needs_auth stubs. | E2E: openapi-coverage |
|
||||
| POST | /mcp | Stubbed | Returns disabled/needs_auth stubs. | E2E: openapi-coverage |
|
||||
| DELETE | /mcp/{name}/auth | Stubbed | Returns disabled/needs_auth stubs. | E2E: openapi-coverage |
|
||||
| POST | /mcp/{name}/auth | Stubbed | Returns disabled/needs_auth stubs. | E2E: openapi-coverage |
|
||||
| POST | /mcp/{name}/auth/authenticate | Stubbed | Returns disabled/needs_auth stubs. | E2E: openapi-coverage |
|
||||
| POST | /mcp/{name}/auth/callback | Stubbed | Returns disabled/needs_auth stubs. | E2E: openapi-coverage |
|
||||
| POST | /mcp/{name}/connect | Stubbed | Returns disabled/needs_auth stubs. | E2E: openapi-coverage |
|
||||
| POST | /mcp/{name}/disconnect | Stubbed | Returns disabled/needs_auth stubs. | E2E: openapi-coverage |
|
||||
| GET | /path | Derived stub | | E2E: openapi-coverage |
|
||||
| GET | /permission | Stubbed | | E2E: openapi-coverage, permissions |
|
||||
| POST | /permission/{requestID}/reply | Stubbed | | E2E: openapi-coverage, permissions |
|
||||
| GET | /project | Derived stub | | E2E: openapi-coverage |
|
||||
| PATCH | /project/{projectID} | Derived stub | | E2E: openapi-coverage |
|
||||
| GET | /project/current | Derived stub | | E2E: openapi-coverage |
|
||||
| GET | /provider | Stubbed | Returns empty provider metadata. | E2E: openapi-coverage |
|
||||
| POST | /provider/{providerID}/oauth/authorize | Stubbed | Returns empty provider metadata. | E2E: openapi-coverage |
|
||||
| POST | /provider/{providerID}/oauth/callback | Stubbed | Returns empty provider metadata. | E2E: openapi-coverage |
|
||||
| GET | /provider/auth | Stubbed | Returns empty provider metadata. | E2E: openapi-coverage |
|
||||
| GET | /pty | Stateful stub | In-memory PTY records; no real PTY process. | E2E: openapi-coverage |
|
||||
| POST | /pty | Stateful stub | In-memory PTY records; no real PTY process. | E2E: openapi-coverage |
|
||||
| DELETE | /pty/{ptyID} | Stateful stub | In-memory PTY records; no real PTY process. | E2E: openapi-coverage |
|
||||
| GET | /pty/{ptyID} | Stateful stub | In-memory PTY records; no real PTY process. | E2E: openapi-coverage |
|
||||
| PUT | /pty/{ptyID} | Stateful stub | In-memory PTY records; no real PTY process. | E2E: openapi-coverage |
|
||||
| GET | /pty/{ptyID}/connect | Stateful stub | In-memory PTY records; no real PTY process. | E2E: openapi-coverage |
|
||||
| GET | /question | Stubbed | | E2E: openapi-coverage, permissions |
|
||||
| POST | /question/{requestID}/reject | Stubbed | | E2E: openapi-coverage, permissions |
|
||||
| POST | /question/{requestID}/reply | Stubbed | | E2E: openapi-coverage, permissions |
|
||||
| GET | /session | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage, session |
|
||||
| POST | /session | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage, session |
|
||||
| DELETE | /session/{sessionID} | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage, session |
|
||||
| GET | /session/{sessionID} | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage, session |
|
||||
| PATCH | /session/{sessionID} | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage, session |
|
||||
| POST | /session/{sessionID}/abort | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage, messaging |
|
||||
| GET | /session/{sessionID}/children | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage |
|
||||
| POST | /session/{sessionID}/command | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage |
|
||||
| GET | /session/{sessionID}/diff | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage |
|
||||
| POST | /session/{sessionID}/fork | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage |
|
||||
| POST | /session/{sessionID}/init | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage |
|
||||
| GET | /session/{sessionID}/message | Stateful stub | In-memory messages; assistant replies are stubbed. | E2E: openapi-coverage, messaging, events |
|
||||
| POST | /session/{sessionID}/message | Stateful stub | In-memory messages; assistant replies are stubbed. | E2E: openapi-coverage, messaging, events |
|
||||
| GET | /session/{sessionID}/message/{messageID} | Stateful stub | In-memory messages; assistant replies are stubbed. | E2E: openapi-coverage, messaging |
|
||||
| DELETE | /session/{sessionID}/message/{messageID}/part/{partID} | Stateful stub | In-memory messages; assistant replies are stubbed. | E2E: openapi-coverage, messaging |
|
||||
| PATCH | /session/{sessionID}/message/{messageID}/part/{partID} | Stateful stub | In-memory messages; assistant replies are stubbed. | E2E: openapi-coverage, messaging |
|
||||
| POST | /session/{sessionID}/permissions/{permissionID} | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage |
|
||||
| POST | /session/{sessionID}/prompt_async | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage, messaging |
|
||||
| POST | /session/{sessionID}/revert | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage |
|
||||
| DELETE | /session/{sessionID}/share | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage |
|
||||
| POST | /session/{sessionID}/share | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage |
|
||||
| POST | /session/{sessionID}/shell | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage |
|
||||
| POST | /session/{sessionID}/summarize | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage |
|
||||
| GET | /session/{sessionID}/todo | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage |
|
||||
| POST | /session/{sessionID}/unrevert | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage |
|
||||
| GET | /session/status | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage, events |
|
||||
| GET | /skill | Stubbed | Returns empty skills list. | E2E: openapi-coverage |
|
||||
| POST | /tui/append-prompt | Stubbed | Returns true/empty control payloads. | E2E: openapi-coverage |
|
||||
| POST | /tui/clear-prompt | Stubbed | Returns true/empty control payloads. | E2E: openapi-coverage |
|
||||
| GET | /tui/control/next | Stubbed | Returns true/empty control payloads. | E2E: openapi-coverage |
|
||||
| POST | /tui/control/response | Stubbed | Returns true/empty control payloads. | E2E: openapi-coverage |
|
||||
| POST | /tui/execute-command | Stubbed | Returns true/empty control payloads. | E2E: openapi-coverage |
|
||||
| POST | /tui/open-help | Stubbed | Returns true/empty control payloads. | E2E: openapi-coverage |
|
||||
| POST | /tui/open-models | Stubbed | Returns true/empty control payloads. | E2E: openapi-coverage |
|
||||
| POST | /tui/open-sessions | Stubbed | Returns true/empty control payloads. | E2E: openapi-coverage |
|
||||
| POST | /tui/open-themes | Stubbed | Returns true/empty control payloads. | E2E: openapi-coverage |
|
||||
| POST | /tui/publish | Stubbed | Returns true/empty control payloads. | E2E: openapi-coverage |
|
||||
| POST | /tui/select-session | Stubbed | Returns true/empty control payloads. | E2E: openapi-coverage |
|
||||
| POST | /tui/show-toast | Stubbed | Returns true/empty control payloads. | E2E: openapi-coverage |
|
||||
| POST | /tui/submit-prompt | Stubbed | Returns true/empty control payloads. | E2E: openapi-coverage |
|
||||
| GET | /vcs | Derived stub | | E2E: openapi-coverage |
|
||||
553
research/agents/openclaw.md
Normal file
553
research/agents/openclaw.md
Normal file
|
|
@ -0,0 +1,553 @@
|
|||
# OpenClaw (formerly Clawdbot) Research
|
||||
|
||||
Research notes on OpenClaw's architecture, API, and automation patterns for integration with sandbox-agent.
|
||||
|
||||
## Overview
|
||||
|
||||
- **Provider**: Multi-provider (Anthropic, OpenAI, etc. via Pi agent)
|
||||
- **Execution Method**: WebSocket Gateway + HTTP APIs
|
||||
- **Session Persistence**: Session Key (string) + Session ID (UUID)
|
||||
- **SDK**: No official SDK - uses WebSocket/HTTP protocol directly
|
||||
- **Binary**: `clawdbot` (npm global install or local)
|
||||
- **Default Port**: 18789 (WebSocket + HTTP multiplex)
|
||||
|
||||
## Architecture
|
||||
|
||||
OpenClaw is architected differently from other coding agents (Claude Code, Codex, OpenCode, Amp):
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Gateway Service │ ws://127.0.0.1:18789
|
||||
│ (long-running daemon) │ http://127.0.0.1:18789
|
||||
│ │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ Pi Agent (embedded RPC) │ │
|
||||
│ │ - Tool execution │ │
|
||||
│ │ - Model routing │ │
|
||||
│ │ - Session management │ │
|
||||
│ └─────────────────────────────┘ │
|
||||
└─────────────────────────────────────┘
|
||||
│
|
||||
├── WebSocket (full control plane)
|
||||
├── HTTP /v1/chat/completions (OpenAI-compatible)
|
||||
├── HTTP /v1/responses (OpenResponses-compatible)
|
||||
├── HTTP /tools/invoke (single tool invocation)
|
||||
└── HTTP /hooks/agent (webhook triggers)
|
||||
```
|
||||
|
||||
**Key Difference**: OpenClaw runs as a **daemon** that owns the agent runtime. Other agents (Claude, Codex, Amp) spawn a subprocess per turn. OpenClaw is more similar to OpenCode's server model but with a persistent gateway.
|
||||
|
||||
## Automation Methods (Priority Order)
|
||||
|
||||
### 1. WebSocket Gateway Protocol (Recommended)
|
||||
|
||||
Full-featured bidirectional control with streaming events.
|
||||
|
||||
#### Connection Handshake
|
||||
|
||||
```typescript
|
||||
// Connect to Gateway
|
||||
const ws = new WebSocket("ws://127.0.0.1:18789");
|
||||
|
||||
// First frame MUST be connect request
|
||||
ws.send(JSON.stringify({
|
||||
type: "req",
|
||||
id: "1",
|
||||
method: "connect",
|
||||
params: {
|
||||
minProtocol: 3,
|
||||
maxProtocol: 3,
|
||||
client: {
|
||||
id: "gateway-client", // or custom client id
|
||||
version: "1.0.0",
|
||||
platform: "linux",
|
||||
mode: "backend"
|
||||
},
|
||||
role: "operator",
|
||||
scopes: ["operator.admin"],
|
||||
caps: [],
|
||||
auth: { token: "YOUR_GATEWAY_TOKEN" }
|
||||
}
|
||||
}));
|
||||
|
||||
// Expect hello-ok response
|
||||
// { type: "res", id: "1", ok: true, payload: { type: "hello-ok", ... } }
|
||||
```
|
||||
|
||||
#### Agent Request
|
||||
|
||||
```typescript
|
||||
// Send agent turn request
|
||||
const runId = crypto.randomUUID();
|
||||
ws.send(JSON.stringify({
|
||||
type: "req",
|
||||
id: runId,
|
||||
method: "agent",
|
||||
params: {
|
||||
message: "Your prompt here",
|
||||
idempotencyKey: runId,
|
||||
sessionKey: "agent:main:main", // or custom session key
|
||||
thinking: "low", // optional: low|medium|high
|
||||
deliver: false, // don't send to messaging channel
|
||||
timeout: 300000 // 5 minute timeout
|
||||
}
|
||||
}));
|
||||
```
|
||||
|
||||
#### Response Flow (Two-Stage)
|
||||
|
||||
```typescript
|
||||
// Stage 1: Immediate ack
|
||||
// { type: "res", id: "...", ok: true, payload: { runId, status: "accepted", acceptedAt: 1234567890 } }
|
||||
|
||||
// Stage 2: Streaming events
|
||||
// { type: "event", event: "agent", payload: { runId, seq: 1, stream: "output", data: {...} } }
|
||||
// { type: "event", event: "agent", payload: { runId, seq: 2, stream: "tool", data: {...} } }
|
||||
// ...
|
||||
|
||||
// Stage 3: Final response (same id as request)
|
||||
// { type: "res", id: "...", ok: true, payload: { runId, status: "ok", summary: "completed", result: {...} } }
|
||||
```
|
||||
|
||||
### 2. OpenAI-Compatible HTTP API
|
||||
|
||||
For simple integration with tools expecting OpenAI Chat Completions.
|
||||
|
||||
**Enable in config:**
|
||||
```json5
|
||||
{
|
||||
gateway: {
|
||||
http: {
|
||||
endpoints: {
|
||||
chatCompletions: { enabled: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Request:**
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:18789/v1/chat/completions \
|
||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "clawdbot:main",
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
"stream": true
|
||||
}'
|
||||
```
|
||||
|
||||
**Model Format:**
|
||||
- `model: "clawdbot:<agentId>"` (e.g., `"clawdbot:main"`)
|
||||
- `model: "agent:<agentId>"` (alias)
|
||||
|
||||
### 3. OpenResponses HTTP API
|
||||
|
||||
For clients that speak OpenResponses (item-based input, function tools).
|
||||
|
||||
**Enable in config:**
|
||||
```json5
|
||||
{
|
||||
gateway: {
|
||||
http: {
|
||||
endpoints: {
|
||||
responses: { enabled: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Request:**
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:18789/v1/responses \
|
||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "clawdbot:main",
|
||||
"input": "Hello",
|
||||
"stream": true
|
||||
}'
|
||||
```
|
||||
|
||||
### 4. Webhooks (Fire-and-Forget)
|
||||
|
||||
For event-driven automation without maintaining a connection.
|
||||
|
||||
**Enable in config:**
|
||||
```json5
|
||||
{
|
||||
hooks: {
|
||||
enabled: true,
|
||||
token: "webhook-secret",
|
||||
path: "/hooks"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Request:**
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:18789/hooks/agent \
|
||||
-H "Authorization: Bearer webhook-secret" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"message": "Run this task",
|
||||
"name": "Automation",
|
||||
"sessionKey": "hook:automation:task-123",
|
||||
"deliver": false,
|
||||
"timeoutSeconds": 120
|
||||
}'
|
||||
```
|
||||
|
||||
**Response:** `202 Accepted` (async run started)
|
||||
|
||||
### 5. CLI Subprocess
|
||||
|
||||
For simple one-off automation (similar to Claude Code pattern).
|
||||
|
||||
```bash
|
||||
clawdbot agent --message "Your prompt" --session-key "automation:task"
|
||||
```
|
||||
|
||||
## Session Management
|
||||
|
||||
### Session Key Format
|
||||
|
||||
```
|
||||
agent:<agentId>:<sessionType>
|
||||
agent:main:main # Main agent, main session
|
||||
agent:main:subagent:abc # Subagent session
|
||||
agent:beta:main # Beta agent, main session
|
||||
hook:email:msg-123 # Webhook-spawned session
|
||||
global # Legacy global session
|
||||
```
|
||||
|
||||
### Session Operations (WebSocket)
|
||||
|
||||
```typescript
|
||||
// List sessions
|
||||
{ type: "req", id: "...", method: "sessions.list", params: { limit: 50, activeMinutes: 120 } }
|
||||
|
||||
// Resolve session info
|
||||
{ type: "req", id: "...", method: "sessions.resolve", params: { key: "agent:main:main" } }
|
||||
|
||||
// Patch session settings
|
||||
{ type: "req", id: "...", method: "sessions.patch", params: {
|
||||
key: "agent:main:main",
|
||||
thinkingLevel: "medium",
|
||||
model: "anthropic/claude-sonnet-4-20250514"
|
||||
}}
|
||||
|
||||
// Reset session (clear history)
|
||||
{ type: "req", id: "...", method: "sessions.reset", params: { key: "agent:main:main" } }
|
||||
|
||||
// Delete session
|
||||
{ type: "req", id: "...", method: "sessions.delete", params: { key: "agent:main:main" } }
|
||||
|
||||
// Compact session (summarize history)
|
||||
{ type: "req", id: "...", method: "sessions.compact", params: { key: "agent:main:main" } }
|
||||
```
|
||||
|
||||
### Session CLI
|
||||
|
||||
```bash
|
||||
clawdbot sessions # List sessions
|
||||
clawdbot sessions --active 120 # Active in last 2 hours
|
||||
clawdbot sessions --json # JSON output
|
||||
```
|
||||
|
||||
## Streaming Events
|
||||
|
||||
### Event Format
|
||||
|
||||
```typescript
|
||||
interface AgentEvent {
|
||||
runId: string; // Correlates to request
|
||||
seq: number; // Monotonically increasing per run
|
||||
stream: string; // Event category
|
||||
ts: number; // Unix timestamp (ms)
|
||||
data: Record<string, unknown>; // Event-specific payload
|
||||
}
|
||||
```
|
||||
|
||||
### Stream Types
|
||||
|
||||
| Stream | Description |
|
||||
|--------|-------------|
|
||||
| `output` | Text output chunks |
|
||||
| `tool` | Tool invocation/result |
|
||||
| `thinking` | Extended thinking content |
|
||||
| `status` | Run status changes |
|
||||
| `error` | Error information |
|
||||
|
||||
### Event Categories
|
||||
|
||||
| Event Type | Payload |
|
||||
|------------|---------|
|
||||
| `assistant.delta` | `{ text: "..." }` |
|
||||
| `tool.start` | `{ name: "Read", input: {...} }` |
|
||||
| `tool.result` | `{ name: "Read", result: "..." }` |
|
||||
| `thinking.delta` | `{ text: "..." }` |
|
||||
| `run.complete` | `{ summary: "..." }` |
|
||||
| `run.error` | `{ error: "..." }` |
|
||||
|
||||
## Token Usage / Cost Tracking
|
||||
|
||||
OpenClaw tracks tokens per response and supports cost estimation.
|
||||
|
||||
### In-Chat Commands
|
||||
|
||||
```
|
||||
/status # Session model, context usage, last response tokens, estimated cost
|
||||
/usage off|tokens|full # Toggle per-response usage footer
|
||||
/usage cost # Local cost summary from session logs
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
Token costs are configured per model:
|
||||
```json5
|
||||
{
|
||||
models: {
|
||||
providers: {
|
||||
anthropic: {
|
||||
models: [{
|
||||
id: "claude-sonnet-4-20250514",
|
||||
cost: {
|
||||
input: 3.00, // USD per 1M tokens
|
||||
output: 15.00,
|
||||
cacheRead: 0.30,
|
||||
cacheWrite: 3.75
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Programmatic Access
|
||||
|
||||
Token usage is included in agent response payloads:
|
||||
```typescript
|
||||
// In final response or streaming events
|
||||
{
|
||||
usage: {
|
||||
inputTokens: 1234,
|
||||
outputTokens: 567,
|
||||
cacheReadTokens: 890,
|
||||
cacheWriteTokens: 123
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
### Gateway Token
|
||||
|
||||
```bash
|
||||
# Environment variable
|
||||
CLAWDBOT_GATEWAY_TOKEN=your-secret-token
|
||||
|
||||
# Or config file
|
||||
{
|
||||
gateway: {
|
||||
auth: {
|
||||
mode: "token",
|
||||
token: "your-secret-token"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### HTTP Requests
|
||||
|
||||
```
|
||||
Authorization: Bearer YOUR_TOKEN
|
||||
```
|
||||
|
||||
### WebSocket Connect
|
||||
|
||||
```typescript
|
||||
{
|
||||
params: {
|
||||
auth: { token: "YOUR_TOKEN" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Status Sync
|
||||
|
||||
### Health Check
|
||||
|
||||
```typescript
|
||||
// WebSocket
|
||||
{ type: "req", id: "...", method: "health", params: {} }
|
||||
|
||||
// HTTP
|
||||
curl http://127.0.0.1:18789/health # Basic health
|
||||
clawdbot health --json # Detailed health
|
||||
```
|
||||
|
||||
### Status Response
|
||||
|
||||
```typescript
|
||||
{
|
||||
ok: boolean;
|
||||
linkedChannel?: string;
|
||||
models?: { available: string[] };
|
||||
agents?: { configured: string[] };
|
||||
presence?: PresenceEntry[];
|
||||
uptimeMs?: number;
|
||||
}
|
||||
```
|
||||
|
||||
### Presence Events
|
||||
|
||||
The gateway pushes presence updates to all connected clients:
|
||||
```typescript
|
||||
// Event
|
||||
{ type: "event", event: "presence", payload: { entries: [...], stateVersion: {...} } }
|
||||
```
|
||||
|
||||
## Comparison with Other Agents
|
||||
|
||||
| Aspect | OpenClaw | Claude Code | Codex | OpenCode | Amp |
|
||||
|--------|----------|-------------|-------|----------|-----|
|
||||
| **Process Model** | Daemon | Subprocess | Server | Server | Subprocess |
|
||||
| **Protocol** | WebSocket + HTTP | CLI JSONL | JSON-RPC stdio | HTTP + SSE | CLI JSONL |
|
||||
| **Session Resume** | Session Key | `--resume` | Thread ID | Session ID | `--continue` |
|
||||
| **Multi-Turn** | Same session key | Same session ID | Same thread | Same session | Same session |
|
||||
| **Streaming** | WS events + SSE | JSONL | Notifications | SSE | JSONL |
|
||||
| **HITL** | No | No (headless) | No (SDK) | Yes (SSE) | No |
|
||||
| **SDK** | None (protocol) | None (CLI) | Yes | Yes | Closed |
|
||||
|
||||
### Key Differences
|
||||
|
||||
1. **Daemon Model**: OpenClaw runs as a persistent gateway service, not a per-turn subprocess
|
||||
2. **Multi-Protocol**: Supports WebSocket, OpenAI-compatible HTTP, OpenResponses, and webhooks
|
||||
3. **Channel Integration**: Built-in WhatsApp/Telegram/Discord/iMessage support
|
||||
4. **Node System**: Mobile/desktop nodes can connect for camera, canvas, location, etc.
|
||||
5. **No HITL**: Like Claude/Codex/Amp, permissions are configured upfront, not interactive
|
||||
|
||||
## Integration Patterns for sandbox-agent
|
||||
|
||||
### Recommended: Persistent WebSocket Connection
|
||||
|
||||
```typescript
|
||||
class OpenClawDriver {
|
||||
private ws: WebSocket;
|
||||
private pending = new Map<string, { resolve, reject }>();
|
||||
|
||||
async connect(url: string, token: string) {
|
||||
this.ws = new WebSocket(url);
|
||||
await this.handshake(token);
|
||||
this.ws.on("message", (data) => this.handleMessage(JSON.parse(data)));
|
||||
}
|
||||
|
||||
async runAgent(params: {
|
||||
message: string;
|
||||
sessionKey?: string;
|
||||
thinking?: string;
|
||||
}): Promise<AgentResult> {
|
||||
const runId = crypto.randomUUID();
|
||||
const events: AgentEvent[] = [];
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pending.set(runId, { resolve, reject, events });
|
||||
this.ws.send(JSON.stringify({
|
||||
type: "req",
|
||||
id: runId,
|
||||
method: "agent",
|
||||
params: {
|
||||
message: params.message,
|
||||
sessionKey: params.sessionKey ?? "agent:main:main",
|
||||
thinking: params.thinking,
|
||||
deliver: false,
|
||||
idempotencyKey: runId
|
||||
}
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
private handleMessage(frame: GatewayFrame) {
|
||||
if (frame.type === "event" && frame.event === "agent") {
|
||||
const pending = this.pending.get(frame.payload.runId);
|
||||
if (pending) pending.events.push(frame.payload);
|
||||
} else if (frame.type === "res") {
|
||||
const pending = this.pending.get(frame.id);
|
||||
if (pending && frame.payload?.status === "ok") {
|
||||
pending.resolve({ result: frame.payload, events: pending.events });
|
||||
this.pending.delete(frame.id);
|
||||
} else if (pending && frame.payload?.status === "error") {
|
||||
pending.reject(new Error(frame.payload.summary));
|
||||
this.pending.delete(frame.id);
|
||||
}
|
||||
// Ignore "accepted" acks
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Alternative: HTTP API (Simpler)
|
||||
|
||||
```typescript
|
||||
async function runOpenClawPrompt(prompt: string, sessionKey?: string) {
|
||||
const response = await fetch("http://127.0.0.1:18789/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${process.env.CLAWDBOT_GATEWAY_TOKEN}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-clawdbot-session-key": sessionKey ?? "automation:sandbox"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "clawdbot:main",
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
stream: false
|
||||
})
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration for sandbox-agent Integration
|
||||
|
||||
Recommended config for automated use:
|
||||
|
||||
```json5
|
||||
{
|
||||
gateway: {
|
||||
port: 18789,
|
||||
auth: {
|
||||
mode: "token",
|
||||
token: "${CLAWDBOT_GATEWAY_TOKEN}"
|
||||
},
|
||||
http: {
|
||||
endpoints: {
|
||||
chatCompletions: { enabled: true },
|
||||
responses: { enabled: true }
|
||||
}
|
||||
}
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "anthropic/claude-sonnet-4-20250514"
|
||||
},
|
||||
thinking: { level: "low" },
|
||||
workspace: "${HOME}/workspace"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- OpenClaw is significantly more complex than other agents due to its gateway architecture
|
||||
- The multi-protocol support (WS, OpenAI, OpenResponses, webhooks) provides flexibility
|
||||
- Session management is richer (labels, spawn tracking, model/thinking overrides)
|
||||
- No SDK means direct protocol implementation is required
|
||||
- The daemon model means connection lifecycle management is important (reconnects, etc.)
|
||||
- Agent responses are two-stage: immediate ack + final result (handle both)
|
||||
- Tool policy filtering is configurable per agent/session/group
|
||||
54
research/opencode-tmux-test.md
Normal file
54
research/opencode-tmux-test.md
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# OpenCode TUI Test Plan (Tmux)
|
||||
|
||||
This plan captures OpenCode TUI output and sends input via tmux so we can validate `/opencode` end-to-end.
|
||||
|
||||
## Prereqs
|
||||
- `opencode` installed and on PATH.
|
||||
- `tmux` installed (e.g., `/home/linuxbrew/.linuxbrew/bin/tmux`).
|
||||
- Local sandbox-agent binary built.
|
||||
|
||||
## Environment
|
||||
- `SANDBOX_AGENT_LOG_DIR=/path` to set server log dir
|
||||
- `SANDBOX_AGENT_LOG_STDOUT=1` to keep logs on stdout/stderr
|
||||
- `SANDBOX_AGENT_LOG_HTTP=0` to disable request logs
|
||||
- `SANDBOX_AGENT_LOG_HTTP_HEADERS=1` to include request headers (Authorization redacted)
|
||||
- `RUST_LOG=...` for trace filtering
|
||||
|
||||
## Steps
|
||||
1. Build and run the server using the local binary:
|
||||
```bash
|
||||
SANDBOX_AGENT_SKIP_INSPECTOR=1 cargo build -p sandbox-agent
|
||||
SANDBOX_AGENT_LOG_HTTP_HEADERS=1 ./target/debug/sandbox-agent server \
|
||||
--host 127.0.0.1 --port 2468 --token "$TOKEN"
|
||||
```
|
||||
2. Create a session via the OpenCode API:
|
||||
```bash
|
||||
SESSION_JSON=$(curl -sS -H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{}' \
|
||||
http://127.0.0.1:2468/opencode/session)
|
||||
SESSION_ID=$(node -e "const v=JSON.parse(process.env.SESSION_JSON||'{}');process.stdout.write(v.id||'');")
|
||||
```
|
||||
3. Start the OpenCode TUI in tmux:
|
||||
```bash
|
||||
tmux new-session -d -s opencode \
|
||||
"opencode attach http://127.0.0.1:2468/opencode --session $SESSION_ID --password $TOKEN"
|
||||
```
|
||||
4. Send a prompt:
|
||||
```bash
|
||||
tmux send-keys -t opencode:0.0 "hello" C-m
|
||||
```
|
||||
5. Capture output:
|
||||
```bash
|
||||
tmux capture-pane -pt opencode:0.0 -S -200 > /tmp/opencode-screen.txt
|
||||
```
|
||||
6. Inspect server logs for requests:
|
||||
```bash
|
||||
tail -n 200 ~/.local/share/sandbox-agent/logs/log-$(date +%m-%d-%y)
|
||||
```
|
||||
7. Repeat after adjusting `/opencode` stubs until the TUI displays responses.
|
||||
|
||||
## Notes
|
||||
- Tmux captures terminal output only. GUI outputs require screenshots or logs.
|
||||
- If OpenCode connects to another host/port, logs will show no requests.
|
||||
- If the prompt stays in the input box, use `C-m` to submit (plain `Enter` may not trigger send in tmux).
|
||||
82
research/opencode-web-customization.md
Normal file
82
research/opencode-web-customization.md
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
# OpenCode Web Customization & Local Run Notes
|
||||
|
||||
## Local Web UI (pointing at `/opencode`)
|
||||
|
||||
This uses the OpenCode web app from `~/misc/opencode/packages/app` and points it at the
|
||||
Sandbox Agent OpenCode-compatible API. The OpenCode JS SDK emits **absolute** paths
|
||||
(`"/global/event"`, `"/session/:id/message"`, etc.), so any base URL path is discarded.
|
||||
To keep the UI working, sandbox-agent now exposes the OpenCode router at both
|
||||
`/opencode/*` and the root (`/global/*`, `/session/*`, ...).
|
||||
|
||||
### 1) Start sandbox-agent (OpenCode compat)
|
||||
|
||||
```bash
|
||||
cd /home/nathan/sandbox-agent.feat-opencode-compat
|
||||
SANDBOX_AGENT_SKIP_INSPECTOR=1 SANDBOX_AGENT_LOG_STDOUT=1 \
|
||||
./target/debug/sandbox-agent server --no-token --host 127.0.0.1 --port 2468 \
|
||||
--cors-allow-origin http://127.0.0.1:5173 \
|
||||
> /tmp/sandbox-agent-opencode.log 2>&1 &
|
||||
```
|
||||
|
||||
Logs:
|
||||
|
||||
```bash
|
||||
tail -f /tmp/sandbox-agent-opencode.log
|
||||
```
|
||||
|
||||
### 2) Start OpenCode web app (dev)
|
||||
|
||||
```bash
|
||||
cd /home/nathan/misc/opencode/packages/app
|
||||
VITE_OPENCODE_SERVER_HOST=127.0.0.1 VITE_OPENCODE_SERVER_PORT=2468 \
|
||||
/home/nathan/.bun/bin/bun run dev -- --host 127.0.0.1 --port 5173 \
|
||||
> /tmp/opencode-web.log 2>&1 &
|
||||
```
|
||||
|
||||
Logs:
|
||||
|
||||
```bash
|
||||
tail -f /tmp/opencode-web.log
|
||||
```
|
||||
|
||||
### 3) Open the UI
|
||||
|
||||
```
|
||||
http://127.0.0.1:5173/
|
||||
```
|
||||
|
||||
The app should connect to `http://127.0.0.1:2468` by default in dev (via
|
||||
`VITE_OPENCODE_SERVER_HOST/PORT`). If you see a “Could not connect to server”
|
||||
error, verify the sandbox-agent process is running and reachable on port 2468.
|
||||
|
||||
### Notes
|
||||
|
||||
- The web UI uses `VITE_OPENCODE_SERVER_HOST` and `VITE_OPENCODE_SERVER_PORT` to
|
||||
pick the OpenCode server in dev mode (see `packages/app/src/app.tsx`).
|
||||
- When running in production, the app defaults to `window.location.origin` for
|
||||
the server URL. If you need a different target, you must configure it via the
|
||||
in-app “Switch server” dialog or change the build config.
|
||||
- If you see a connect error in the web app, check CORS. By default, sandbox-agent
|
||||
allows no origins. You must pass `--cors-allow-origin` for the dev server URL.
|
||||
- The OpenCode provider list now exposes a `sandbox-agent` provider with models
|
||||
for each agent (defaulting to `mock`). Use the provider/model selector in the UI
|
||||
to pick the backing agent instead of environment variables.
|
||||
|
||||
## Dev Server Learnings (Feb 4, 2026)
|
||||
|
||||
- The browser **cannot** reach `http://127.0.0.1:2468` unless the web UI is on the
|
||||
same machine. If the UI is loaded from `http://100.94.102.49:5173`, the server
|
||||
must be reachable at `http://100.94.102.49:2468`.
|
||||
- The OpenCode JS SDK uses absolute paths, so a base URL path (like
|
||||
`http://host:port/opencode`) is ignored. This means the server must expose
|
||||
OpenCode routes at the **root** (`/global/*`, `/session/*`, ...), even if it
|
||||
also exposes them under `/opencode/*`.
|
||||
- CORS must allow the UI origin. Example:
|
||||
```bash
|
||||
./target/debug/sandbox-agent server --no-token --host 0.0.0.0 --port 2468 \
|
||||
--cors-allow-origin http://100.94.102.49:5173
|
||||
```
|
||||
- Binding the dev servers to `0.0.0.0` is required for remote access. Verify
|
||||
`ss -ltnp | grep ':2468'` and `ss -ltnp | grep ':5173'`.
|
||||
- If the UI throws “No default model found”, it usually means the `/provider`
|
||||
response lacks a providerID → modelID default mapping for a connected provider.
|
||||
|
|
@ -282,6 +282,14 @@
|
|||
},
|
||||
"deletions": {
|
||||
"type": "number"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"added",
|
||||
"deleted",
|
||||
"modified"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
|
@ -777,6 +785,60 @@
|
|||
"text"
|
||||
]
|
||||
},
|
||||
"SubtaskPart": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"sessionID": {
|
||||
"type": "string"
|
||||
},
|
||||
"messageID": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "subtask"
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"agent": {
|
||||
"type": "string"
|
||||
},
|
||||
"model": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"providerID": {
|
||||
"type": "string"
|
||||
},
|
||||
"modelID": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"providerID",
|
||||
"modelID"
|
||||
]
|
||||
},
|
||||
"command": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"sessionID",
|
||||
"messageID",
|
||||
"type",
|
||||
"prompt",
|
||||
"description",
|
||||
"agent"
|
||||
]
|
||||
},
|
||||
"ReasoningPart": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -1541,58 +1603,7 @@
|
|||
"$ref": "#/definitions/TextPart"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"sessionID": {
|
||||
"type": "string"
|
||||
},
|
||||
"messageID": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "subtask"
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"agent": {
|
||||
"type": "string"
|
||||
},
|
||||
"model": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"providerID": {
|
||||
"type": "string"
|
||||
},
|
||||
"modelID": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"providerID",
|
||||
"modelID"
|
||||
]
|
||||
},
|
||||
"command": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"sessionID",
|
||||
"messageID",
|
||||
"type",
|
||||
"prompt",
|
||||
"description",
|
||||
"agent"
|
||||
]
|
||||
"$ref": "#/definitions/SubtaskPart"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/ReasoningPart"
|
||||
|
|
@ -3085,55 +3096,6 @@
|
|||
"payload"
|
||||
]
|
||||
},
|
||||
"BadRequestError": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {},
|
||||
"errors": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
}
|
||||
},
|
||||
"success": {
|
||||
"type": "boolean",
|
||||
"const": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data",
|
||||
"errors",
|
||||
"success"
|
||||
]
|
||||
},
|
||||
"NotFoundError": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"const": "NotFoundError"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"message"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"data"
|
||||
]
|
||||
},
|
||||
"KeybindsConfig": {
|
||||
"description": "Custom keybind configurations",
|
||||
"type": "object",
|
||||
|
|
@ -3634,6 +3596,10 @@
|
|||
"description": "Enable mDNS service discovery",
|
||||
"type": "boolean"
|
||||
},
|
||||
"mdnsDomain": {
|
||||
"description": "Custom domain name for mDNS service (default: opencode.local)",
|
||||
"type": "string"
|
||||
},
|
||||
"cors": {
|
||||
"description": "Additional domains to allow for CORS",
|
||||
"type": "array",
|
||||
|
|
@ -3729,6 +3695,9 @@
|
|||
},
|
||||
"doom_loop": {
|
||||
"$ref": "#/definitions/PermissionActionConfig"
|
||||
},
|
||||
"skill": {
|
||||
"$ref": "#/definitions/PermissionRuleConfig"
|
||||
}
|
||||
},
|
||||
"additionalProperties": {
|
||||
|
|
@ -3746,6 +3715,10 @@
|
|||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"description": "Default model variant for this agent (applies only when using the agent's configured model).",
|
||||
"type": "string"
|
||||
},
|
||||
"temperature": {
|
||||
"type": "number"
|
||||
},
|
||||
|
|
@ -3792,9 +3765,25 @@
|
|||
"additionalProperties": {}
|
||||
},
|
||||
"color": {
|
||||
"description": "Hex color code for the agent (e.g., #FF5733)",
|
||||
"type": "string",
|
||||
"pattern": "^#[0-9a-fA-F]{6}$"
|
||||
"description": "Hex color code (e.g., #FF5733) or theme color (e.g., primary)",
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^#[0-9a-fA-F]{6}$"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"primary",
|
||||
"secondary",
|
||||
"accent",
|
||||
"success",
|
||||
"warning",
|
||||
"error",
|
||||
"info"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"steps": {
|
||||
"description": "Maximum number of agentic iterations before forcing text-only response",
|
||||
|
|
@ -4296,6 +4285,19 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"skills": {
|
||||
"description": "Additional skill folder paths",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"paths": {
|
||||
"description": "Additional paths to skill folders",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"watcher": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -4618,73 +4620,6 @@
|
|||
"experimental": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"hook": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file_edited": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"environment": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"session_completed": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"environment": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"chatMaxRetries": {
|
||||
"description": "Number of retries for chat completions on failure",
|
||||
"type": "number"
|
||||
},
|
||||
"disable_paste_summary": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
|
@ -4718,6 +4653,134 @@
|
|||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"BadRequestError": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {},
|
||||
"errors": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
}
|
||||
},
|
||||
"success": {
|
||||
"type": "boolean",
|
||||
"const": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data",
|
||||
"errors",
|
||||
"success"
|
||||
]
|
||||
},
|
||||
"OAuth": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "oauth"
|
||||
},
|
||||
"refresh": {
|
||||
"type": "string"
|
||||
},
|
||||
"access": {
|
||||
"type": "string"
|
||||
},
|
||||
"expires": {
|
||||
"type": "number"
|
||||
},
|
||||
"accountId": {
|
||||
"type": "string"
|
||||
},
|
||||
"enterpriseUrl": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"refresh",
|
||||
"access",
|
||||
"expires"
|
||||
]
|
||||
},
|
||||
"ApiAuth": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "api"
|
||||
},
|
||||
"key": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"key"
|
||||
]
|
||||
},
|
||||
"WellKnownAuth": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "wellknown"
|
||||
},
|
||||
"key": {
|
||||
"type": "string"
|
||||
},
|
||||
"token": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"key",
|
||||
"token"
|
||||
]
|
||||
},
|
||||
"Auth": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/OAuth"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/ApiAuth"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/WellKnownAuth"
|
||||
}
|
||||
]
|
||||
},
|
||||
"NotFoundError": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"const": "NotFoundError"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"message"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"data"
|
||||
]
|
||||
},
|
||||
"Model": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -5431,7 +5494,10 @@
|
|||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "text"
|
||||
"enum": [
|
||||
"text",
|
||||
"binary"
|
||||
]
|
||||
},
|
||||
"content": {
|
||||
"type": "string"
|
||||
|
|
@ -5682,8 +5748,13 @@
|
|||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"mcp": {
|
||||
"type": "boolean"
|
||||
"source": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"command",
|
||||
"mcp",
|
||||
"skill"
|
||||
]
|
||||
},
|
||||
"template": {
|
||||
"anyOf": [
|
||||
|
|
@ -5761,6 +5832,9 @@
|
|||
"providerID"
|
||||
]
|
||||
},
|
||||
"variant": {
|
||||
"type": "string"
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string"
|
||||
},
|
||||
|
|
@ -5837,85 +5911,6 @@
|
|||
"extensions",
|
||||
"enabled"
|
||||
]
|
||||
},
|
||||
"OAuth": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "oauth"
|
||||
},
|
||||
"refresh": {
|
||||
"type": "string"
|
||||
},
|
||||
"access": {
|
||||
"type": "string"
|
||||
},
|
||||
"expires": {
|
||||
"type": "number"
|
||||
},
|
||||
"accountId": {
|
||||
"type": "string"
|
||||
},
|
||||
"enterpriseUrl": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"refresh",
|
||||
"access",
|
||||
"expires"
|
||||
]
|
||||
},
|
||||
"ApiAuth": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "api"
|
||||
},
|
||||
"key": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"key"
|
||||
]
|
||||
},
|
||||
"WellKnownAuth": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "wellknown"
|
||||
},
|
||||
"key": {
|
||||
"type": "string"
|
||||
},
|
||||
"token": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"key",
|
||||
"token"
|
||||
]
|
||||
},
|
||||
"Auth": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/OAuth"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/ApiAuth"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/WellKnownAuth"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
10933
resources/agent-schemas/artifacts/openapi/opencode.json
Normal file
10933
resources/agent-schemas/artifacts/openapi/opencode.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,9 +1,13 @@
|
|||
import { existsSync, mkdirSync, writeFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { fetchWithCache } from "./cache.js";
|
||||
import { createNormalizedSchema, openApiToJsonSchema, type NormalizedSchema } from "./normalize.js";
|
||||
import type { JSONSchema7 } from "json-schema";
|
||||
|
||||
const OPENAPI_URL =
|
||||
"https://raw.githubusercontent.com/sst/opencode/dev/packages/sdk/openapi.json";
|
||||
const OPENAPI_URLS = [
|
||||
"https://raw.githubusercontent.com/anomalyco/opencode/dev/packages/sdk/openapi.json",
|
||||
"https://raw.githubusercontent.com/sst/opencode/dev/packages/sdk/openapi.json",
|
||||
];
|
||||
|
||||
// Key schemas we want to extract
|
||||
const TARGET_SCHEMAS = [
|
||||
|
|
@ -19,16 +23,40 @@ const TARGET_SCHEMAS = [
|
|||
"ErrorPart",
|
||||
];
|
||||
|
||||
const OPENAPI_ARTIFACT_DIR = join(import.meta.dirname, "..", "artifacts", "openapi");
|
||||
const OPENAPI_ARTIFACT_PATH = join(OPENAPI_ARTIFACT_DIR, "opencode.json");
|
||||
|
||||
interface OpenAPISpec {
|
||||
components?: {
|
||||
schemas?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
function writeOpenApiArtifact(specText: string): void {
|
||||
if (!existsSync(OPENAPI_ARTIFACT_DIR)) {
|
||||
mkdirSync(OPENAPI_ARTIFACT_DIR, { recursive: true });
|
||||
}
|
||||
writeFileSync(OPENAPI_ARTIFACT_PATH, specText);
|
||||
console.log(` [wrote] ${OPENAPI_ARTIFACT_PATH}`);
|
||||
}
|
||||
|
||||
export async function extractOpenCodeSchema(): Promise<NormalizedSchema> {
|
||||
console.log("Extracting OpenCode schema from OpenAPI spec...");
|
||||
|
||||
const specText = await fetchWithCache(OPENAPI_URL);
|
||||
let specText: string | null = null;
|
||||
let lastError: Error | null = null;
|
||||
for (const url of OPENAPI_URLS) {
|
||||
try {
|
||||
specText = await fetchWithCache(url);
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
}
|
||||
}
|
||||
if (!specText) {
|
||||
throw lastError ?? new Error("Failed to fetch OpenCode OpenAPI spec");
|
||||
}
|
||||
writeOpenApiArtifact(specText);
|
||||
const spec: OpenAPISpec = JSON.parse(specText);
|
||||
|
||||
if (!spec.components?.schemas) {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ futures.workspace = true
|
|||
reqwest.workspace = true
|
||||
dirs.workspace = true
|
||||
time.workspace = true
|
||||
chrono.workspace = true
|
||||
tokio.workspace = true
|
||||
tokio-stream.workspace = true
|
||||
tower-http.workspace = true
|
||||
|
|
@ -34,11 +35,15 @@ tracing.workspace = true
|
|||
tracing-logfmt.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
include_dir.workspace = true
|
||||
base64.workspace = true
|
||||
tempfile = { workspace = true, optional = true }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.52", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Console"] }
|
||||
|
||||
[dev-dependencies]
|
||||
http-body-util.workspace = true
|
||||
insta.workspace = true
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
mod agent_server_logs;
|
||||
pub mod credentials;
|
||||
pub mod opencode_compat;
|
||||
pub mod router;
|
||||
pub mod server_logs;
|
||||
pub mod telemetry;
|
||||
pub mod ui;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command as ProcessCommand, Stdio};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use clap::{Args, Parser, Subcommand};
|
||||
|
||||
|
|
@ -20,6 +22,7 @@ use sandbox_agent::router::{
|
|||
AgentListResponse, AgentModesResponse, CreateSessionResponse, EventsResponse,
|
||||
SessionListResponse,
|
||||
};
|
||||
use sandbox_agent::server_logs::ServerLogs;
|
||||
use sandbox_agent::telemetry;
|
||||
use sandbox_agent::ui;
|
||||
use sandbox_agent_agent_management::agents::{AgentId, AgentManager, InstallOptions};
|
||||
|
|
@ -28,7 +31,7 @@ use sandbox_agent_agent_management::credentials::{
|
|||
ProviderCredentials,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use serde_json::{json, Value};
|
||||
use thiserror::Error;
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
|
||||
|
|
@ -36,6 +39,7 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilte
|
|||
const API_PREFIX: &str = "/v1";
|
||||
const DEFAULT_HOST: &str = "127.0.0.1";
|
||||
const DEFAULT_PORT: u16 = 2468;
|
||||
const LOGS_RETENTION: Duration = Duration::from_secs(7 * 24 * 60 * 60);
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "sandbox-agent", bin_name = "sandbox-agent")]
|
||||
|
|
@ -58,6 +62,8 @@ enum Command {
|
|||
Server(ServerArgs),
|
||||
/// Call the HTTP API without writing client code.
|
||||
Api(ApiArgs),
|
||||
/// EXPERIMENTAL: Start a sandbox-agent server and attach an OpenCode session.
|
||||
Opencode(OpencodeArgs),
|
||||
/// Install or reinstall an agent without running the server.
|
||||
InstallAgent(InstallAgentArgs),
|
||||
/// Inspect locally discovered credentials.
|
||||
|
|
@ -94,6 +100,21 @@ struct ApiArgs {
|
|||
command: ApiCommand,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct OpencodeArgs {
|
||||
#[arg(long, short = 'H', default_value = DEFAULT_HOST)]
|
||||
host: String,
|
||||
|
||||
#[arg(long, short = 'p', default_value_t = DEFAULT_PORT)]
|
||||
port: u16,
|
||||
|
||||
#[arg(long)]
|
||||
session_title: Option<String>,
|
||||
|
||||
#[arg(long)]
|
||||
opencode_bin: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct CredentialsArgs {
|
||||
#[command(subcommand)]
|
||||
|
|
@ -349,8 +370,11 @@ enum CliError {
|
|||
}
|
||||
|
||||
fn main() {
|
||||
init_logging();
|
||||
let cli = Cli::parse();
|
||||
if let Err(err) = init_logging(&cli) {
|
||||
eprintln!("failed to init logging: {err}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let result = match &cli.command {
|
||||
Command::Server(args) => run_server(&cli, args),
|
||||
|
|
@ -363,7 +387,11 @@ fn main() {
|
|||
}
|
||||
}
|
||||
|
||||
fn init_logging() {
|
||||
fn init_logging(cli: &Cli) -> Result<(), CliError> {
|
||||
if matches!(cli.command, Command::Server(_)) {
|
||||
maybe_redirect_server_logs();
|
||||
}
|
||||
|
||||
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
|
||||
tracing_subscriber::registry()
|
||||
.with(filter)
|
||||
|
|
@ -373,6 +401,7 @@ fn init_logging() {
|
|||
.with_writer(std::io::stderr),
|
||||
)
|
||||
.init();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_server(cli: &Cli, server: &ServerArgs) -> Result<(), CliError> {
|
||||
|
|
@ -434,12 +463,33 @@ fn default_install_dir() -> PathBuf {
|
|||
.unwrap_or_else(|| PathBuf::from(".").join(".sandbox-agent").join("bin"))
|
||||
}
|
||||
|
||||
fn default_server_log_dir() -> PathBuf {
|
||||
if let Ok(dir) = std::env::var("SANDBOX_AGENT_LOG_DIR") {
|
||||
return PathBuf::from(dir);
|
||||
}
|
||||
dirs::data_dir()
|
||||
.map(|dir| dir.join("sandbox-agent").join("logs"))
|
||||
.unwrap_or_else(|| PathBuf::from(".").join(".sandbox-agent").join("logs"))
|
||||
}
|
||||
|
||||
fn maybe_redirect_server_logs() {
|
||||
if std::env::var("SANDBOX_AGENT_LOG_STDOUT").is_ok() {
|
||||
return;
|
||||
}
|
||||
|
||||
let log_dir = default_server_log_dir();
|
||||
if let Err(err) = ServerLogs::new(log_dir, LOGS_RETENTION).start_sync() {
|
||||
eprintln!("failed to redirect logs: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
fn run_client(command: &Command, cli: &Cli) -> Result<(), CliError> {
|
||||
match command {
|
||||
Command::Server(_) => Err(CliError::Server(
|
||||
"server subcommand must be invoked as `sandbox-agent server`".to_string(),
|
||||
)),
|
||||
Command::Api(subcommand) => run_api(&subcommand.command, cli),
|
||||
Command::Opencode(args) => run_opencode(cli, args),
|
||||
Command::InstallAgent(args) => install_agent_local(args),
|
||||
Command::Credentials(subcommand) => run_credentials(&subcommand.command),
|
||||
}
|
||||
|
|
@ -452,6 +502,53 @@ fn run_api(command: &ApiCommand, cli: &Cli) -> Result<(), CliError> {
|
|||
}
|
||||
}
|
||||
|
||||
fn run_opencode(cli: &Cli, args: &OpencodeArgs) -> Result<(), CliError> {
|
||||
write_stderr_line("experimental: opencode subcommand may change without notice")?;
|
||||
|
||||
let token = if cli.no_token {
|
||||
None
|
||||
} else {
|
||||
Some(cli.token.clone().ok_or(CliError::MissingToken)?)
|
||||
};
|
||||
|
||||
let mut server_child = spawn_sandbox_agent_server(cli, args, token.as_deref())?;
|
||||
let base_url = format!("http://{}:{}", args.host, args.port);
|
||||
wait_for_health(&mut server_child, &base_url, token.as_deref())?;
|
||||
|
||||
let session_id = create_opencode_session(&base_url, token.as_deref(), args.session_title.as_deref())?;
|
||||
write_stdout_line(&format!("OpenCode session: {session_id}"))?;
|
||||
|
||||
let attach_url = format!("{base_url}/opencode");
|
||||
let opencode_bin = resolve_opencode_bin(args.opencode_bin.as_ref());
|
||||
let mut opencode_cmd = ProcessCommand::new(opencode_bin);
|
||||
opencode_cmd
|
||||
.arg("attach")
|
||||
.arg(&attach_url)
|
||||
.arg("--session")
|
||||
.arg(&session_id)
|
||||
.stdin(Stdio::inherit())
|
||||
.stdout(Stdio::inherit())
|
||||
.stderr(Stdio::inherit());
|
||||
if let Some(token) = token.as_deref() {
|
||||
opencode_cmd.arg("--password").arg(token);
|
||||
}
|
||||
|
||||
let status = opencode_cmd.status().map_err(|err| {
|
||||
terminate_child(&mut server_child);
|
||||
CliError::Server(format!("failed to start opencode: {err}"))
|
||||
})?;
|
||||
|
||||
terminate_child(&mut server_child);
|
||||
|
||||
if !status.success() {
|
||||
return Err(CliError::Server(format!(
|
||||
"opencode exited with status {status}"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_agents(command: &AgentsCommand, cli: &Cli) -> Result<(), CliError> {
|
||||
match command {
|
||||
AgentsCommand::List(args) => {
|
||||
|
|
@ -607,6 +704,113 @@ fn run_sessions(command: &SessionsCommand, cli: &Cli) -> Result<(), CliError> {
|
|||
}
|
||||
}
|
||||
|
||||
fn spawn_sandbox_agent_server(
|
||||
cli: &Cli,
|
||||
args: &OpencodeArgs,
|
||||
token: Option<&str>,
|
||||
) -> Result<Child, CliError> {
|
||||
let exe = std::env::current_exe()?;
|
||||
let mut cmd = ProcessCommand::new(exe);
|
||||
cmd.arg("server")
|
||||
.arg("--host")
|
||||
.arg(&args.host)
|
||||
.arg("--port")
|
||||
.arg(args.port.to_string())
|
||||
.stdin(Stdio::inherit())
|
||||
.stdout(Stdio::inherit())
|
||||
.stderr(Stdio::inherit());
|
||||
|
||||
if cli.no_token {
|
||||
cmd.arg("--no-token");
|
||||
} else if let Some(token) = token {
|
||||
cmd.arg("--token").arg(token);
|
||||
}
|
||||
|
||||
cmd.spawn().map_err(CliError::from)
|
||||
}
|
||||
|
||||
fn wait_for_health(
|
||||
server_child: &mut Child,
|
||||
base_url: &str,
|
||||
token: Option<&str>,
|
||||
) -> Result<(), CliError> {
|
||||
let client = HttpClient::builder().build()?;
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
|
||||
while Instant::now() < deadline {
|
||||
if let Some(status) = server_child.try_wait()? {
|
||||
return Err(CliError::Server(format!(
|
||||
"sandbox-agent exited before becoming healthy ({status})"
|
||||
)));
|
||||
}
|
||||
|
||||
let url = format!("{base_url}/v1/health");
|
||||
let mut request = client.get(&url);
|
||||
if let Some(token) = token {
|
||||
request = request.bearer_auth(token);
|
||||
}
|
||||
match request.send() {
|
||||
Ok(response) if response.status().is_success() => return Ok(()),
|
||||
_ => {
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(CliError::Server(
|
||||
"timed out waiting for sandbox-agent health".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn create_opencode_session(
|
||||
base_url: &str,
|
||||
token: Option<&str>,
|
||||
title: Option<&str>,
|
||||
) -> Result<String, CliError> {
|
||||
let client = HttpClient::builder().build()?;
|
||||
let url = format!("{base_url}/opencode/session");
|
||||
let body = if let Some(title) = title {
|
||||
json!({ "title": title })
|
||||
} else {
|
||||
json!({})
|
||||
};
|
||||
let mut request = client.post(&url).json(&body);
|
||||
if let Ok(directory) = std::env::current_dir() {
|
||||
request = request.header("x-opencode-directory", directory.to_string_lossy().to_string());
|
||||
}
|
||||
if let Some(token) = token {
|
||||
request = request.bearer_auth(token);
|
||||
}
|
||||
let response = request.send()?;
|
||||
let status = response.status();
|
||||
let text = response.text()?;
|
||||
if !status.is_success() {
|
||||
print_error_body(&text)?;
|
||||
return Err(CliError::HttpStatus(status));
|
||||
}
|
||||
let body: Value = serde_json::from_str(&text)?;
|
||||
let session_id = body
|
||||
.get("id")
|
||||
.and_then(|value| value.as_str())
|
||||
.ok_or_else(|| CliError::Server("opencode session missing id".to_string()))?;
|
||||
Ok(session_id.to_string())
|
||||
}
|
||||
|
||||
fn resolve_opencode_bin(explicit: Option<&PathBuf>) -> PathBuf {
|
||||
if let Some(path) = explicit {
|
||||
return path.clone();
|
||||
}
|
||||
if let Ok(path) = std::env::var("OPENCODE_BIN") {
|
||||
return PathBuf::from(path);
|
||||
}
|
||||
PathBuf::from("opencode")
|
||||
}
|
||||
|
||||
fn terminate_child(child: &mut Child) {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
fn run_credentials(command: &CredentialsCommand) -> Result<(), CliError> {
|
||||
match command {
|
||||
CredentialsCommand::Extract(args) => {
|
||||
|
|
|
|||
4274
server/packages/sandbox-agent/src/opencode_compat.rs
Normal file
4274
server/packages/sandbox-agent/src/opencode_compat.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -34,9 +34,12 @@ use tokio::sync::{broadcast, mpsc, oneshot, Mutex};
|
|||
use tokio::time::sleep;
|
||||
use tokio_stream::wrappers::BroadcastStream;
|
||||
use tower_http::trace::TraceLayer;
|
||||
use base64::Engine;
|
||||
use tracing::Span;
|
||||
use utoipa::{Modify, OpenApi, ToSchema};
|
||||
|
||||
use crate::agent_server_logs::AgentServerLogs;
|
||||
use crate::opencode_compat::{build_opencode_router, OpenCodeAppState};
|
||||
use crate::ui;
|
||||
use sandbox_agent_agent_management::agents::{
|
||||
AgentError as ManagerError, AgentId, AgentManager, InstallOptions, SpawnOptions, StreamingSpawn,
|
||||
|
|
@ -68,6 +71,10 @@ impl AppState {
|
|||
session_manager,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn session_manager(&self) -> Arc<SessionManager> {
|
||||
self.session_manager.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
|
@ -126,16 +133,78 @@ pub fn build_router_with_state(shared: Arc<AppState>) -> (Router, Arc<AppState>)
|
|||
));
|
||||
}
|
||||
|
||||
let opencode_state = OpenCodeAppState::new(shared.clone());
|
||||
let mut opencode_router = build_opencode_router(opencode_state.clone());
|
||||
let mut opencode_root_router = build_opencode_router(opencode_state);
|
||||
if shared.auth.token.is_some() {
|
||||
opencode_router = opencode_router.layer(axum::middleware::from_fn_with_state(
|
||||
shared.clone(),
|
||||
require_token,
|
||||
));
|
||||
opencode_root_router = opencode_root_router.layer(axum::middleware::from_fn_with_state(
|
||||
shared.clone(),
|
||||
require_token,
|
||||
));
|
||||
}
|
||||
|
||||
let mut router = Router::new()
|
||||
.route("/", get(get_root))
|
||||
.nest("/v1", v1_router)
|
||||
.nest("/opencode", opencode_router)
|
||||
.merge(opencode_root_router)
|
||||
.fallback(not_found);
|
||||
|
||||
if ui::is_enabled() {
|
||||
router = router.merge(ui::router());
|
||||
}
|
||||
|
||||
(router.layer(TraceLayer::new_for_http()), shared)
|
||||
let http_logging = match std::env::var("SANDBOX_AGENT_LOG_HTTP") {
|
||||
Ok(value) if value == "0" || value.eq_ignore_ascii_case("false") => false,
|
||||
_ => true,
|
||||
};
|
||||
if http_logging {
|
||||
let include_headers = std::env::var("SANDBOX_AGENT_LOG_HTTP_HEADERS").is_ok();
|
||||
let trace_layer = TraceLayer::new_for_http()
|
||||
.make_span_with(move |req: &Request<_>| {
|
||||
if include_headers {
|
||||
let mut headers = Vec::new();
|
||||
for (name, value) in req.headers().iter() {
|
||||
let name_str = name.as_str();
|
||||
let display_value = if name_str.eq_ignore_ascii_case("authorization") {
|
||||
"<redacted>".to_string()
|
||||
} else {
|
||||
value.to_str().unwrap_or("<binary>").to_string()
|
||||
};
|
||||
headers.push((name_str.to_string(), display_value));
|
||||
}
|
||||
tracing::info_span!(
|
||||
"http.request",
|
||||
method = %req.method(),
|
||||
uri = %req.uri(),
|
||||
headers = ?headers
|
||||
)
|
||||
} else {
|
||||
tracing::info_span!(
|
||||
"http.request",
|
||||
method = %req.method(),
|
||||
uri = %req.uri()
|
||||
)
|
||||
}
|
||||
})
|
||||
.on_request(|_req: &Request<_>, span: &Span| {
|
||||
tracing::info!(parent: span, "request");
|
||||
})
|
||||
.on_response(|res: &Response<_>, latency: Duration, span: &Span| {
|
||||
tracing::info!(
|
||||
parent: span,
|
||||
status = %res.status(),
|
||||
latency_ms = latency.as_millis()
|
||||
);
|
||||
});
|
||||
router = router.layer(trace_layer);
|
||||
}
|
||||
|
||||
(router, shared)
|
||||
}
|
||||
|
||||
pub async fn shutdown_servers(state: &Arc<AppState>) {
|
||||
|
|
@ -744,7 +813,7 @@ struct AgentServerManager {
|
|||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct SessionManager {
|
||||
pub(crate) struct SessionManager {
|
||||
agent_manager: Arc<AgentManager>,
|
||||
sessions: Mutex<Vec<SessionState>>,
|
||||
server_manager: Arc<AgentServerManager>,
|
||||
|
|
@ -847,9 +916,25 @@ impl CodexServer {
|
|||
}
|
||||
}
|
||||
|
||||
struct SessionSubscription {
|
||||
initial_events: Vec<UniversalEvent>,
|
||||
receiver: broadcast::Receiver<UniversalEvent>,
|
||||
pub(crate) struct SessionSubscription {
|
||||
pub(crate) initial_events: Vec<UniversalEvent>,
|
||||
pub(crate) receiver: broadcast::Receiver<UniversalEvent>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct PendingPermissionInfo {
|
||||
pub session_id: String,
|
||||
pub permission_id: String,
|
||||
pub action: String,
|
||||
pub metadata: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct PendingQuestionInfo {
|
||||
pub session_id: String,
|
||||
pub question_id: String,
|
||||
pub prompt: String,
|
||||
pub options: Vec<String>,
|
||||
}
|
||||
|
||||
impl ManagedServer {
|
||||
|
|
@ -1477,7 +1562,7 @@ impl SessionManager {
|
|||
logs.read_stderr()
|
||||
}
|
||||
|
||||
async fn create_session(
|
||||
pub(crate) async fn create_session(
|
||||
self: &Arc<Self>,
|
||||
session_id: String,
|
||||
request: CreateSessionRequest,
|
||||
|
|
@ -1604,7 +1689,7 @@ impl SessionManager {
|
|||
}
|
||||
}
|
||||
|
||||
async fn send_message(
|
||||
pub(crate) async fn send_message(
|
||||
self: &Arc<Self>,
|
||||
session_id: String,
|
||||
message: String,
|
||||
|
|
@ -1819,7 +1904,7 @@ impl SessionManager {
|
|||
.collect()
|
||||
}
|
||||
|
||||
async fn subscribe(
|
||||
pub(crate) async fn subscribe(
|
||||
&self,
|
||||
session_id: &str,
|
||||
offset: u64,
|
||||
|
|
@ -1843,6 +1928,38 @@ impl SessionManager {
|
|||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn list_pending_permissions(&self) -> Vec<PendingPermissionInfo> {
|
||||
let sessions = self.sessions.lock().await;
|
||||
let mut items = Vec::new();
|
||||
for session in sessions.iter() {
|
||||
for (permission_id, pending) in session.pending_permissions.iter() {
|
||||
items.push(PendingPermissionInfo {
|
||||
session_id: session.session_id.clone(),
|
||||
permission_id: permission_id.clone(),
|
||||
action: pending.action.clone(),
|
||||
metadata: pending.metadata.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
pub(crate) async fn list_pending_questions(&self) -> Vec<PendingQuestionInfo> {
|
||||
let sessions = self.sessions.lock().await;
|
||||
let mut items = Vec::new();
|
||||
for session in sessions.iter() {
|
||||
for (question_id, pending) in session.pending_questions.iter() {
|
||||
items.push(PendingQuestionInfo {
|
||||
session_id: session.session_id.clone(),
|
||||
question_id: question_id.clone(),
|
||||
prompt: pending.prompt.clone(),
|
||||
options: pending.options.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
async fn subscribe_for_turn(
|
||||
&self,
|
||||
session_id: &str,
|
||||
|
|
@ -1871,7 +1988,7 @@ impl SessionManager {
|
|||
Ok((SessionSnapshot::from(session), subscription))
|
||||
}
|
||||
|
||||
async fn reply_question(
|
||||
pub(crate) async fn reply_question(
|
||||
&self,
|
||||
session_id: &str,
|
||||
question_id: &str,
|
||||
|
|
@ -1949,7 +2066,7 @@ impl SessionManager {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
async fn reject_question(
|
||||
pub(crate) async fn reject_question(
|
||||
&self,
|
||||
session_id: &str,
|
||||
question_id: &str,
|
||||
|
|
@ -2028,7 +2145,7 @@ impl SessionManager {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
async fn reply_permission(
|
||||
pub(crate) async fn reply_permission(
|
||||
self: &Arc<Self>,
|
||||
session_id: &str,
|
||||
permission_id: &str,
|
||||
|
|
@ -3291,11 +3408,35 @@ fn extract_token(headers: &HeaderMap) -> Option<String> {
|
|||
if let Some(value) = headers.get(axum::http::header::AUTHORIZATION) {
|
||||
if let Ok(value) = value.to_str() {
|
||||
let value = value.trim();
|
||||
if let Some(stripped) = value.strip_prefix("Bearer ") {
|
||||
return Some(stripped.to_string());
|
||||
}
|
||||
if let Some(stripped) = value.strip_prefix("Token ") {
|
||||
return Some(stripped.to_string());
|
||||
if let Some((scheme, rest)) = value.split_once(' ') {
|
||||
let scheme_lower = scheme.to_ascii_lowercase();
|
||||
let rest = rest.trim();
|
||||
match scheme_lower.as_str() {
|
||||
"bearer" | "token" => {
|
||||
return Some(rest.to_string());
|
||||
}
|
||||
"basic" => {
|
||||
let engines = [
|
||||
base64::engine::general_purpose::STANDARD,
|
||||
base64::engine::general_purpose::STANDARD_NO_PAD,
|
||||
base64::engine::general_purpose::URL_SAFE,
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD,
|
||||
];
|
||||
for engine in engines {
|
||||
if let Ok(decoded) = engine.decode(rest) {
|
||||
if let Ok(decoded_str) = String::from_utf8(decoded) {
|
||||
if let Some((_, password)) = decoded_str.split_once(':') {
|
||||
return Some(password.to_string());
|
||||
}
|
||||
if !decoded_str.is_empty() {
|
||||
return Some(decoded_str);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
9
server/packages/sandbox-agent/src/server_logs/mod.rs
Normal file
9
server/packages/sandbox-agent/src/server_logs/mod.rs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
#[cfg(unix)]
|
||||
mod unix;
|
||||
#[cfg(windows)]
|
||||
mod windows;
|
||||
|
||||
#[cfg(unix)]
|
||||
pub use unix::ServerLogs;
|
||||
#[cfg(windows)]
|
||||
pub use windows::ServerLogs;
|
||||
103
server/packages/sandbox-agent/src/server_logs/unix.rs
Normal file
103
server/packages/sandbox-agent/src/server_logs/unix.rs
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
use std::os::unix::io::AsRawFd;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use chrono::{Datelike, Duration, TimeDelta, TimeZone, Utc};
|
||||
|
||||
pub struct ServerLogs {
|
||||
path: PathBuf,
|
||||
retention: Duration,
|
||||
|
||||
last_rotation: chrono::DateTime<Utc>,
|
||||
next_rotation: chrono::DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl ServerLogs {
|
||||
pub fn new(path: PathBuf, retention: std::time::Duration) -> Self {
|
||||
Self {
|
||||
path,
|
||||
retention: chrono::Duration::from_std(retention).expect("invalid retention duration"),
|
||||
last_rotation: Utc.timestamp_opt(0, 0).unwrap(),
|
||||
next_rotation: Utc.timestamp_opt(0, 0).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_sync(mut self) -> Result<std::thread::JoinHandle<()>, std::io::Error> {
|
||||
std::fs::create_dir_all(&self.path)?;
|
||||
self.rotate_sync()?;
|
||||
|
||||
Ok(std::thread::spawn(|| self.run_sync()))
|
||||
}
|
||||
|
||||
fn run_sync(mut self) {
|
||||
loop {
|
||||
let now = Utc::now();
|
||||
|
||||
if self.next_rotation - now > Duration::seconds(5) {
|
||||
std::thread::sleep(
|
||||
(self.next_rotation - now - Duration::seconds(5))
|
||||
.max(TimeDelta::default())
|
||||
.to_std()
|
||||
.expect("bad duration"),
|
||||
);
|
||||
} else if now.ordinal() != self.last_rotation.ordinal() {
|
||||
if let Err(err) = self.rotate_sync() {
|
||||
tracing::error!(?err, "failed logs rotation");
|
||||
}
|
||||
} else {
|
||||
std::thread::sleep(std::time::Duration::from_millis(250));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rotate_sync(&mut self) -> Result<(), std::io::Error> {
|
||||
self.last_rotation = Utc::now();
|
||||
self.next_rotation = Utc.from_utc_datetime(
|
||||
&(self
|
||||
.last_rotation
|
||||
.date_naive()
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.ok_or_else(|| {
|
||||
std::io::Error::new(std::io::ErrorKind::Other, "invalid date")
|
||||
})?
|
||||
+ Duration::days(1)),
|
||||
);
|
||||
|
||||
let file_name = format!("log-{}", self.last_rotation.format("%m-%d-%y"));
|
||||
let path = self.path.join(file_name);
|
||||
|
||||
let log_file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)?;
|
||||
let log_fd = log_file.as_raw_fd();
|
||||
|
||||
unsafe {
|
||||
libc::dup2(log_fd, libc::STDOUT_FILENO);
|
||||
libc::dup2(log_fd, libc::STDERR_FILENO);
|
||||
}
|
||||
|
||||
self.prune_sync()
|
||||
}
|
||||
|
||||
fn prune_sync(&self) -> Result<(), std::io::Error> {
|
||||
let mut entries = std::fs::read_dir(&self.path)?;
|
||||
let mut pruned = 0;
|
||||
|
||||
while let Some(entry) = entries.next() {
|
||||
let entry = entry?;
|
||||
let metadata = entry.metadata()?;
|
||||
let modified = chrono::DateTime::<Utc>::from(metadata.modified()?);
|
||||
if modified < Utc::now() - self.retention {
|
||||
pruned += 1;
|
||||
let _ = std::fs::remove_file(entry.path());
|
||||
}
|
||||
}
|
||||
|
||||
if pruned != 0 {
|
||||
tracing::debug!("pruned {pruned} log files");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
131
server/packages/sandbox-agent/src/server_logs/windows.rs
Normal file
131
server/packages/sandbox-agent/src/server_logs/windows.rs
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
use chrono::{Datelike, Duration, TimeDelta, TimeZone, Utc};
|
||||
use windows::{
|
||||
core::PCSTR,
|
||||
Win32::{
|
||||
Foundation::{HANDLE, INVALID_HANDLE_VALUE},
|
||||
Storage::FileSystem::{
|
||||
CreateFileA, FILE_ATTRIBUTE_NORMAL, FILE_GENERIC_WRITE, FILE_SHARE_READ, OPEN_ALWAYS,
|
||||
},
|
||||
System::Console::{SetStdHandle, STD_ERROR_HANDLE, STD_OUTPUT_HANDLE},
|
||||
},
|
||||
};
|
||||
|
||||
pub struct ServerLogs {
|
||||
path: PathBuf,
|
||||
retention: Duration,
|
||||
|
||||
last_rotation: chrono::DateTime<Utc>,
|
||||
next_rotation: chrono::DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl ServerLogs {
|
||||
pub fn new(path: PathBuf, retention: std::time::Duration) -> Self {
|
||||
Self {
|
||||
path,
|
||||
retention: chrono::Duration::from_std(retention).expect("invalid retention duration"),
|
||||
last_rotation: Utc.timestamp_opt(0, 0).unwrap(),
|
||||
next_rotation: Utc.timestamp_opt(0, 0).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_sync(mut self) -> Result<std::thread::JoinHandle<()>, std::io::Error> {
|
||||
std::fs::create_dir_all(&self.path)?;
|
||||
self.rotate_sync()?;
|
||||
|
||||
Ok(std::thread::spawn(|| self.run_sync()))
|
||||
}
|
||||
|
||||
fn run_sync(mut self) {
|
||||
loop {
|
||||
let now = Utc::now();
|
||||
|
||||
if self.next_rotation - now > Duration::seconds(5) {
|
||||
std::thread::sleep(
|
||||
(self.next_rotation - now - Duration::seconds(5))
|
||||
.max(TimeDelta::default())
|
||||
.to_std()
|
||||
.expect("bad duration"),
|
||||
);
|
||||
} else if now.ordinal() != self.last_rotation.ordinal() {
|
||||
if let Err(err) = self.rotate_sync() {
|
||||
tracing::error!(?err, "failed logs rotation");
|
||||
}
|
||||
} else {
|
||||
std::thread::sleep(std::time::Duration::from_millis(250));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rotate_sync(&mut self) -> Result<(), std::io::Error> {
|
||||
self.last_rotation = Utc::now();
|
||||
self.next_rotation = Utc.from_utc_datetime(
|
||||
&(self
|
||||
.last_rotation
|
||||
.date_naive()
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.ok_or_else(|| {
|
||||
std::io::Error::new(std::io::ErrorKind::Other, "invalid date")
|
||||
})?
|
||||
+ Duration::days(1)),
|
||||
);
|
||||
|
||||
let file_name = format!("log-{}", self.last_rotation.format("%m-%d-%y"));
|
||||
let path = self.path.join(file_name);
|
||||
|
||||
let path_str = path
|
||||
.to_str()
|
||||
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::Other, "invalid path"))?;
|
||||
let path_cstr = std::ffi::CString::new(path_str)
|
||||
.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err.to_string()))?;
|
||||
|
||||
unsafe {
|
||||
let file_handle = CreateFileA(
|
||||
PCSTR(path_cstr.as_ptr() as *const u8),
|
||||
FILE_GENERIC_WRITE.0,
|
||||
FILE_SHARE_READ,
|
||||
None,
|
||||
OPEN_ALWAYS,
|
||||
FILE_ATTRIBUTE_NORMAL,
|
||||
HANDLE(std::ptr::null_mut()),
|
||||
)
|
||||
.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err.to_string()))?;
|
||||
|
||||
if file_handle == INVALID_HANDLE_VALUE {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
"failed to create log file",
|
||||
));
|
||||
}
|
||||
|
||||
SetStdHandle(STD_OUTPUT_HANDLE, file_handle)
|
||||
.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err.to_string()))?;
|
||||
SetStdHandle(STD_ERROR_HANDLE, file_handle)
|
||||
.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err.to_string()))?;
|
||||
}
|
||||
|
||||
self.prune_sync()
|
||||
}
|
||||
|
||||
fn prune_sync(&self) -> Result<(), std::io::Error> {
|
||||
let mut entries = std::fs::read_dir(&self.path)?;
|
||||
let mut pruned = 0;
|
||||
|
||||
while let Some(entry) = entries.next() {
|
||||
let entry = entry?;
|
||||
let metadata = entry.metadata()?;
|
||||
let modified = chrono::DateTime::<Utc>::from(metadata.modified()?);
|
||||
if modified < Utc::now() - self.retention {
|
||||
pruned += 1;
|
||||
let _ = std::fs::remove_file(entry.path());
|
||||
}
|
||||
}
|
||||
|
||||
if pruned != 0 {
|
||||
tracing::debug!("pruned {pruned} log files");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
# OpenCode Compatibility Tests
|
||||
|
||||
These tests verify that sandbox-agent exposes OpenCode-compatible API endpoints under `/opencode` and that they are usable with the official [`@opencode-ai/sdk`](https://www.npmjs.com/package/@opencode-ai/sdk) TypeScript SDK.
|
||||
|
||||
## Purpose
|
||||
|
||||
The goal is to enable sandbox-agent to be used as a drop-in replacement for OpenCode's server, allowing tools and integrations built for OpenCode to work seamlessly with sandbox-agent.
|
||||
|
||||
## Test Coverage
|
||||
|
||||
The tests cover the following OpenCode API surfaces:
|
||||
|
||||
### Session Management (`session.test.ts`)
|
||||
- `POST /session` - Create a new session
|
||||
- `GET /session` - List all sessions
|
||||
- `GET /session/{id}` - Get session details
|
||||
- `PATCH /session/{id}` - Update session properties
|
||||
- `DELETE /session/{id}` - Delete a session
|
||||
|
||||
### Messaging (`messaging.test.ts`)
|
||||
- `POST /session/{id}/message` - Send a prompt to the session
|
||||
- `POST /session/{id}/prompt_async` - Send async prompt
|
||||
- `GET /session/{id}/message` - List messages
|
||||
- `GET /session/{id}/message/{messageID}` - Get specific message
|
||||
- `POST /session/{id}/abort` - Abort session
|
||||
|
||||
### Event Streaming (`events.test.ts`)
|
||||
- `GET /event` - Subscribe to all events (SSE)
|
||||
- `GET /global/event` - Subscribe to global events (SSE)
|
||||
- `GET /session/status` - Get session status
|
||||
|
||||
### Permissions (`permissions.test.ts`)
|
||||
- `POST /session/{id}/permissions/{permissionID}` - Respond to permission request
|
||||
|
||||
### OpenAPI Coverage (Rust)
|
||||
- `cargo test -p sandbox-agent --test opencode_openapi`
|
||||
- Compares the Utoipa-generated OpenCode spec against `resources/agent-schemas/artifacts/openapi/opencode.json`
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# From this directory
|
||||
pnpm test
|
||||
|
||||
# Or from the workspace root
|
||||
pnpm --filter @sandbox-agent/opencode-compat-tests test
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Build the sandbox-agent binary:
|
||||
```bash
|
||||
cargo build -p sandbox-agent
|
||||
```
|
||||
|
||||
2. Or set `SANDBOX_AGENT_BIN` environment variable to point to a pre-built binary.
|
||||
|
||||
## Test Approach
|
||||
|
||||
Each test:
|
||||
1. Spawns a fresh sandbox-agent instance on a unique port
|
||||
2. Uses `createOpencodeClient` from `@opencode-ai/sdk` to connect
|
||||
3. Tests the OpenCode-compatible endpoints
|
||||
4. Cleans up the server instance
|
||||
|
||||
This ensures tests are isolated and can run in parallel.
|
||||
|
||||
## Current Status
|
||||
|
||||
These tests validate the `/opencode` compatibility layer and should pass when the endpoints are mounted and responding with OpenCode-compatible shapes.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
To make sandbox-agent OpenCode-compatible, the following needs to be implemented:
|
||||
|
||||
1. **OpenCode API Routes** - Exposed under `/opencode`
|
||||
2. **Request/Response Mapping** - OpenCode response shapes with stubbed data where needed
|
||||
3. **SSE Event Streaming** - OpenCode event format for SSE
|
||||
4. **Permission Handling** - Accepts OpenCode permission replies
|
||||
|
||||
See the OpenCode SDK types at `/home/nathan/misc/opencode/packages/sdk/js/src/gen/types.gen.ts` for the expected API shapes.
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
/**
|
||||
* Tests for OpenCode-compatible event streaming endpoints.
|
||||
*
|
||||
* These tests verify that sandbox-agent exposes OpenCode-compatible SSE event
|
||||
* endpoints that can be used with the official OpenCode SDK.
|
||||
*
|
||||
* Expected endpoints:
|
||||
* - GET /event - Subscribe to all events (SSE)
|
||||
* - GET /global/event - Subscribe to global events (SSE)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest";
|
||||
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk";
|
||||
import { spawnSandboxAgent, buildSandboxAgent, type SandboxAgentHandle } from "./helpers/spawn";
|
||||
|
||||
describe("OpenCode-compatible Event Streaming", () => {
|
||||
let handle: SandboxAgentHandle;
|
||||
let client: OpencodeClient;
|
||||
|
||||
beforeAll(async () => {
|
||||
await buildSandboxAgent();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
handle = await spawnSandboxAgent({ opencodeCompat: true });
|
||||
client = createOpencodeClient({
|
||||
baseUrl: `${handle.baseUrl}/opencode`,
|
||||
headers: { Authorization: `Bearer ${handle.token}` },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await handle?.dispose();
|
||||
});
|
||||
|
||||
describe("event.subscribe", () => {
|
||||
it("should connect to SSE endpoint", async () => {
|
||||
// The event.subscribe returns an SSE stream
|
||||
const response = await client.event.subscribe();
|
||||
|
||||
expect(response).toBeDefined();
|
||||
expect((response as any).stream).toBeDefined();
|
||||
});
|
||||
|
||||
it("should receive session.created event when session is created", async () => {
|
||||
const events: any[] = [];
|
||||
|
||||
// Start listening for events
|
||||
const eventStream = await client.event.subscribe();
|
||||
|
||||
// Set up event collection with timeout
|
||||
const collectEvents = new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(resolve, 5000);
|
||||
(async () => {
|
||||
try {
|
||||
for await (const event of (eventStream as any).stream) {
|
||||
events.push(event);
|
||||
if (event.type === "session.created") {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Stream ended or errored
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
// Create a session
|
||||
await client.session.create({ body: { title: "Event Test" } });
|
||||
|
||||
await collectEvents;
|
||||
|
||||
// Should have received at least one session.created event
|
||||
const sessionCreatedEvent = events.find((e) => e.type === "session.created");
|
||||
expect(sessionCreatedEvent).toBeDefined();
|
||||
});
|
||||
|
||||
it("should receive message.part.updated events during prompt", async () => {
|
||||
const events: any[] = [];
|
||||
|
||||
// Create a session first
|
||||
const session = await client.session.create();
|
||||
const sessionId = session.data?.id!;
|
||||
|
||||
// Start listening for events
|
||||
const eventStream = await client.event.subscribe();
|
||||
|
||||
const collectEvents = new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(resolve, 10000);
|
||||
(async () => {
|
||||
try {
|
||||
for await (const event of (eventStream as any).stream) {
|
||||
events.push(event);
|
||||
// Look for message part updates or completion
|
||||
if (
|
||||
event.type === "message.part.updated" ||
|
||||
event.type === "session.idle"
|
||||
) {
|
||||
if (events.length >= 3) {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Stream ended
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
// Send a prompt
|
||||
await client.session.prompt({
|
||||
path: { id: sessionId },
|
||||
body: {
|
||||
model: { providerID: "sandbox-agent", modelID: "mock" },
|
||||
parts: [{ type: "text", text: "Say hello" }],
|
||||
},
|
||||
});
|
||||
|
||||
await collectEvents;
|
||||
|
||||
// Should have received some events
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("global.event", () => {
|
||||
it("should connect to global SSE endpoint", async () => {
|
||||
const response = await client.global.event();
|
||||
|
||||
expect(response).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("session.status", () => {
|
||||
it("should return session status", async () => {
|
||||
const session = await client.session.create();
|
||||
const sessionId = session.data?.id!;
|
||||
|
||||
const response = await client.session.status();
|
||||
|
||||
expect(response.data).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,237 @@
|
|||
/**
|
||||
* Utilities for spawning sandbox-agent for OpenCode compatibility testing.
|
||||
* Mirrors the patterns from sdks/typescript/src/spawn.ts
|
||||
*/
|
||||
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { createServer, type AddressInfo, type Server } from "node:net";
|
||||
import { existsSync } from "node:fs";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export interface SandboxAgentHandle {
|
||||
baseUrl: string;
|
||||
token: string;
|
||||
child: ChildProcess;
|
||||
dispose: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the sandbox-agent binary in common locations
|
||||
*/
|
||||
function findBinary(): string | null {
|
||||
// Check environment variable first
|
||||
if (process.env.SANDBOX_AGENT_BIN) {
|
||||
const path = process.env.SANDBOX_AGENT_BIN;
|
||||
if (existsSync(path)) {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
// Check cargo build outputs (relative to tests/opencode-compat/helpers)
|
||||
const cargoPaths = [
|
||||
resolve(__dirname, "../../../../../../target/debug/sandbox-agent"),
|
||||
resolve(__dirname, "../../../../../../target/release/sandbox-agent"),
|
||||
];
|
||||
|
||||
for (const p of cargoPaths) {
|
||||
if (existsSync(p)) {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a free port on the given host
|
||||
*/
|
||||
async function getFreePort(host: string): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = createServer();
|
||||
server.unref();
|
||||
server.on("error", reject);
|
||||
server.listen(0, host, () => {
|
||||
const address = server.address() as AddressInfo;
|
||||
server.close(() => resolve(address.port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the server to become healthy
|
||||
*/
|
||||
async function waitForHealth(
|
||||
baseUrl: string,
|
||||
token: string,
|
||||
timeoutMs: number,
|
||||
child: ChildProcess
|
||||
): Promise<void> {
|
||||
const start = Date.now();
|
||||
let lastError: string | undefined;
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (child.exitCode !== null) {
|
||||
throw new Error("sandbox-agent exited before becoming healthy");
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/v1/health`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
lastError = `status ${response.status}`;
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for sandbox-agent health (${lastError ?? "unknown"})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for child process to exit
|
||||
*/
|
||||
async function waitForExit(child: ChildProcess, timeoutMs: number): Promise<boolean> {
|
||||
if (child.exitCode !== null) {
|
||||
return true;
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => resolve(false), timeoutMs);
|
||||
child.once("exit", () => {
|
||||
clearTimeout(timer);
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export interface SpawnOptions {
|
||||
host?: string;
|
||||
port?: number;
|
||||
token?: string;
|
||||
timeoutMs?: number;
|
||||
env?: Record<string, string>;
|
||||
/** Enable OpenCode compatibility mode */
|
||||
opencodeCompat?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a sandbox-agent instance for testing.
|
||||
* Each test should spawn its own instance on a unique port.
|
||||
*/
|
||||
export async function spawnSandboxAgent(options: SpawnOptions = {}): Promise<SandboxAgentHandle> {
|
||||
const binaryPath = findBinary();
|
||||
if (!binaryPath) {
|
||||
throw new Error(
|
||||
"sandbox-agent binary not found. Run 'cargo build -p sandbox-agent' first or set SANDBOX_AGENT_BIN."
|
||||
);
|
||||
}
|
||||
|
||||
const host = options.host ?? "127.0.0.1";
|
||||
const port = options.port ?? (await getFreePort(host));
|
||||
const token = options.token ?? randomBytes(24).toString("hex");
|
||||
const timeoutMs = options.timeoutMs ?? 30_000;
|
||||
|
||||
const args = ["server", "--host", host, "--port", String(port), "--token", token];
|
||||
|
||||
const compatEnv = {
|
||||
OPENCODE_COMPAT_FIXED_TIME_MS: "1700000000000",
|
||||
OPENCODE_COMPAT_DIRECTORY: "/workspace",
|
||||
OPENCODE_COMPAT_WORKTREE: "/workspace",
|
||||
OPENCODE_COMPAT_HOME: "/home/opencode",
|
||||
OPENCODE_COMPAT_STATE: "/state/opencode",
|
||||
OPENCODE_COMPAT_CONFIG: "/config/opencode",
|
||||
OPENCODE_COMPAT_BRANCH: "main",
|
||||
};
|
||||
|
||||
const child = spawn(binaryPath, args, {
|
||||
stdio: "pipe",
|
||||
env: {
|
||||
...process.env,
|
||||
...compatEnv,
|
||||
...(options.env ?? {}),
|
||||
},
|
||||
});
|
||||
|
||||
// Collect stderr for debugging
|
||||
let stderr = "";
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
const text = chunk.toString();
|
||||
stderr += text;
|
||||
if (process.env.SANDBOX_AGENT_TEST_LOGS) {
|
||||
process.stderr.write(text);
|
||||
}
|
||||
});
|
||||
if (process.env.SANDBOX_AGENT_TEST_LOGS) {
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
process.stderr.write(chunk.toString());
|
||||
});
|
||||
}
|
||||
|
||||
const baseUrl = `http://${host}:${port}`;
|
||||
|
||||
try {
|
||||
await waitForHealth(baseUrl, token, timeoutMs, child);
|
||||
} catch (err) {
|
||||
child.kill("SIGKILL");
|
||||
if (stderr) {
|
||||
throw new Error(`${err}. Stderr: ${stderr}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const dispose = async () => {
|
||||
if (child.exitCode !== null) {
|
||||
return;
|
||||
}
|
||||
child.kill("SIGTERM");
|
||||
const exited = await waitForExit(child, 5_000);
|
||||
if (!exited) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
};
|
||||
|
||||
return { baseUrl, token, child, dispose };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the sandbox-agent binary if it doesn't exist
|
||||
*/
|
||||
export async function buildSandboxAgent(): Promise<void> {
|
||||
const binaryPath = findBinary();
|
||||
if (binaryPath) {
|
||||
console.log(`sandbox-agent binary found at: ${binaryPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Building sandbox-agent...");
|
||||
const projectRoot = resolve(__dirname, "../../../../../..");
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn("cargo", ["build", "-p", "sandbox-agent"], {
|
||||
cwd: projectRoot,
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
SANDBOX_AGENT_SKIP_INSPECTOR: "1",
|
||||
},
|
||||
});
|
||||
|
||||
proc.on("exit", (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`cargo build failed with code ${code}`));
|
||||
}
|
||||
});
|
||||
|
||||
proc.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
/**
|
||||
* Tests for OpenCode-compatible messaging endpoints.
|
||||
*
|
||||
* These tests verify that sandbox-agent exposes OpenCode-compatible message/prompt
|
||||
* endpoints that can be used with the official OpenCode SDK.
|
||||
*
|
||||
* Expected endpoints:
|
||||
* - POST /session/{id}/message - Send a prompt to the session
|
||||
* - GET /session/{id}/message - List messages in a session
|
||||
* - GET /session/{id}/message/{messageID} - Get a specific message
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest";
|
||||
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk";
|
||||
import { spawnSandboxAgent, buildSandboxAgent, type SandboxAgentHandle } from "./helpers/spawn";
|
||||
|
||||
describe("OpenCode-compatible Messaging API", () => {
|
||||
let handle: SandboxAgentHandle;
|
||||
let client: OpencodeClient;
|
||||
let sessionId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await buildSandboxAgent();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
handle = await spawnSandboxAgent({ opencodeCompat: true });
|
||||
client = createOpencodeClient({
|
||||
baseUrl: `${handle.baseUrl}/opencode`,
|
||||
headers: { Authorization: `Bearer ${handle.token}` },
|
||||
});
|
||||
|
||||
// Create a session for messaging tests
|
||||
const session = await client.session.create();
|
||||
sessionId = session.data?.id!;
|
||||
expect(sessionId).toBeDefined();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await handle?.dispose();
|
||||
});
|
||||
|
||||
describe("session.prompt", () => {
|
||||
it("should send a message to the session", async () => {
|
||||
const response = await client.session.prompt({
|
||||
path: { id: sessionId },
|
||||
body: {
|
||||
model: { providerID: "sandbox-agent", modelID: "mock" },
|
||||
parts: [{ type: "text", text: "Hello, world!" }],
|
||||
},
|
||||
});
|
||||
|
||||
// The response should return a message or acknowledgement
|
||||
expect(response.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should accept text-only prompt", async () => {
|
||||
const response = await client.session.prompt({
|
||||
path: { id: sessionId },
|
||||
body: {
|
||||
model: { providerID: "sandbox-agent", modelID: "mock" },
|
||||
parts: [{ type: "text", text: "Say hello" }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("session.promptAsync", () => {
|
||||
it("should send async prompt and return immediately", async () => {
|
||||
const response = await client.session.promptAsync({
|
||||
path: { id: sessionId },
|
||||
body: {
|
||||
model: { providerID: "sandbox-agent", modelID: "mock" },
|
||||
parts: [{ type: "text", text: "Process this asynchronously" }],
|
||||
},
|
||||
});
|
||||
|
||||
// Should return quickly without waiting for completion
|
||||
expect(response.error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("session.messages", () => {
|
||||
it("should return empty list for new session", async () => {
|
||||
const response = await client.session.messages({
|
||||
path: { id: sessionId },
|
||||
});
|
||||
|
||||
expect(response.data).toBeDefined();
|
||||
expect(Array.isArray(response.data)).toBe(true);
|
||||
});
|
||||
|
||||
it("should list messages after sending a prompt", async () => {
|
||||
await client.session.prompt({
|
||||
path: { id: sessionId },
|
||||
body: {
|
||||
model: { providerID: "sandbox-agent", modelID: "mock" },
|
||||
parts: [{ type: "text", text: "Test message" }],
|
||||
},
|
||||
});
|
||||
|
||||
const response = await client.session.messages({
|
||||
path: { id: sessionId },
|
||||
});
|
||||
|
||||
expect(response.data).toBeDefined();
|
||||
expect(response.data?.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("session.message (get specific)", () => {
|
||||
it("should retrieve a specific message by ID", async () => {
|
||||
// Send a prompt first
|
||||
await client.session.prompt({
|
||||
path: { id: sessionId },
|
||||
body: {
|
||||
model: { providerID: "sandbox-agent", modelID: "mock" },
|
||||
parts: [{ type: "text", text: "Test" }],
|
||||
},
|
||||
});
|
||||
|
||||
// Get messages to find a message ID
|
||||
const messagesResponse = await client.session.messages({
|
||||
path: { id: sessionId },
|
||||
});
|
||||
const messageId = messagesResponse.data?.[0]?.id;
|
||||
|
||||
if (messageId) {
|
||||
const response = await client.session.message({
|
||||
path: { id: sessionId, messageID: messageId },
|
||||
});
|
||||
|
||||
expect(response.data).toBeDefined();
|
||||
expect(response.data?.id).toBe(messageId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("session.abort", () => {
|
||||
it("should abort an in-progress session", async () => {
|
||||
// Start an async prompt
|
||||
await client.session.promptAsync({
|
||||
path: { id: sessionId },
|
||||
body: {
|
||||
model: { providerID: "sandbox-agent", modelID: "mock" },
|
||||
parts: [{ type: "text", text: "Long running task" }],
|
||||
},
|
||||
});
|
||||
|
||||
// Abort the session
|
||||
const response = await client.session.abort({
|
||||
path: { id: sessionId },
|
||||
});
|
||||
|
||||
expect(response.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should handle abort on idle session gracefully", async () => {
|
||||
// Abort without starting any work
|
||||
const response = await client.session.abort({
|
||||
path: { id: sessionId },
|
||||
});
|
||||
|
||||
// Should not error, even if there's nothing to abort
|
||||
expect(response.error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"name": "@sandbox-agent/opencode-compat-tests",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "^1.1.21"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
/**
|
||||
* Tests for OpenCode-compatible permission endpoints.
|
||||
*
|
||||
* These tests verify that sandbox-agent exposes OpenCode-compatible permission
|
||||
* handling endpoints that can be used with the official OpenCode SDK.
|
||||
*
|
||||
* Expected endpoints:
|
||||
* - POST /session/{id}/permissions/{permissionID} - Respond to a permission request
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest";
|
||||
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2";
|
||||
import { spawnSandboxAgent, buildSandboxAgent, type SandboxAgentHandle } from "./helpers/spawn";
|
||||
|
||||
describe("OpenCode-compatible Permission API", () => {
|
||||
let handle: SandboxAgentHandle;
|
||||
let client: OpencodeClient;
|
||||
let sessionId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await buildSandboxAgent();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
handle = await spawnSandboxAgent({ opencodeCompat: true });
|
||||
client = createOpencodeClient({
|
||||
baseUrl: `${handle.baseUrl}/opencode`,
|
||||
headers: { Authorization: `Bearer ${handle.token}` },
|
||||
});
|
||||
|
||||
// Create a session
|
||||
const session = await client.session.create();
|
||||
sessionId = session.data?.id!;
|
||||
expect(sessionId).toBeDefined();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await handle?.dispose();
|
||||
});
|
||||
|
||||
const permissionPrompt = "permission";
|
||||
|
||||
async function waitForPermissionRequest(timeoutMs = 10_000) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const list = await client.permission.list();
|
||||
const request = list.data?.[0];
|
||||
if (request) {
|
||||
return request;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
}
|
||||
throw new Error("Timed out waiting for permission request");
|
||||
}
|
||||
|
||||
describe("permission.reply (global)", () => {
|
||||
it("should receive permission.asked and reply via global endpoint", async () => {
|
||||
await client.session.prompt({
|
||||
sessionID: sessionId,
|
||||
model: { providerID: "sandbox-agent", modelID: "mock" },
|
||||
parts: [{ type: "text", text: permissionPrompt }],
|
||||
});
|
||||
|
||||
const asked = await waitForPermissionRequest();
|
||||
const requestId = asked?.id;
|
||||
expect(requestId).toBeDefined();
|
||||
|
||||
const response = await client.permission.reply({
|
||||
requestID: requestId,
|
||||
reply: "once",
|
||||
});
|
||||
expect(response.error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("postSessionIdPermissionsPermissionId (session)", () => {
|
||||
it("should accept permission response for a session", async () => {
|
||||
await client.session.prompt({
|
||||
sessionID: sessionId,
|
||||
model: { providerID: "sandbox-agent", modelID: "mock" },
|
||||
parts: [{ type: "text", text: permissionPrompt }],
|
||||
});
|
||||
|
||||
const asked = await waitForPermissionRequest();
|
||||
const requestId = asked?.id;
|
||||
expect(requestId).toBeDefined();
|
||||
|
||||
const response = await client.permission.respond({
|
||||
sessionID: sessionId,
|
||||
permissionID: requestId,
|
||||
response: "allow",
|
||||
});
|
||||
|
||||
expect(response.error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
/**
|
||||
* Tests for OpenCode-compatible question endpoints.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest";
|
||||
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2";
|
||||
import { spawnSandboxAgent, buildSandboxAgent, type SandboxAgentHandle } from "./helpers/spawn";
|
||||
|
||||
describe("OpenCode-compatible Question API", () => {
|
||||
let handle: SandboxAgentHandle;
|
||||
let client: OpencodeClient;
|
||||
let sessionId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await buildSandboxAgent();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
handle = await spawnSandboxAgent({ opencodeCompat: true });
|
||||
client = createOpencodeClient({
|
||||
baseUrl: `${handle.baseUrl}/opencode`,
|
||||
headers: { Authorization: `Bearer ${handle.token}` },
|
||||
});
|
||||
|
||||
const session = await client.session.create();
|
||||
sessionId = session.data?.id!;
|
||||
expect(sessionId).toBeDefined();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await handle?.dispose();
|
||||
});
|
||||
|
||||
const questionPrompt = "question";
|
||||
|
||||
async function waitForQuestionRequest(timeoutMs = 10_000) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const list = await client.question.list();
|
||||
const request = list.data?.[0];
|
||||
if (request) {
|
||||
return request;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
}
|
||||
throw new Error("Timed out waiting for question request");
|
||||
}
|
||||
|
||||
it("should ask a question and accept a reply", async () => {
|
||||
await client.session.prompt({
|
||||
sessionID: sessionId,
|
||||
model: { providerID: "sandbox-agent", modelID: "mock" },
|
||||
parts: [{ type: "text", text: questionPrompt }],
|
||||
});
|
||||
|
||||
const asked = await waitForQuestionRequest();
|
||||
const requestId = asked?.id;
|
||||
expect(requestId).toBeDefined();
|
||||
|
||||
const replyResponse = await client.question.reply({
|
||||
requestID: requestId,
|
||||
answers: [["Yes"]],
|
||||
});
|
||||
expect(replyResponse.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should allow rejecting a question", async () => {
|
||||
await client.session.prompt({
|
||||
sessionID: sessionId,
|
||||
model: { providerID: "sandbox-agent", modelID: "mock" },
|
||||
parts: [{ type: "text", text: questionPrompt }],
|
||||
});
|
||||
|
||||
const asked = await waitForQuestionRequest();
|
||||
const requestId = asked?.id;
|
||||
expect(requestId).toBeDefined();
|
||||
|
||||
const rejectResponse = await client.question.reject({
|
||||
requestID: requestId,
|
||||
});
|
||||
expect(rejectResponse.error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
/**
|
||||
* Tests for OpenCode-compatible session management endpoints.
|
||||
*
|
||||
* These tests verify that sandbox-agent exposes OpenCode-compatible API endpoints
|
||||
* that can be used with the official OpenCode SDK.
|
||||
*
|
||||
* Expected endpoints:
|
||||
* - POST /session - Create a new session
|
||||
* - GET /session - List all sessions
|
||||
* - GET /session/{id} - Get session details
|
||||
* - PATCH /session/{id} - Update session properties
|
||||
* - DELETE /session/{id} - Delete a session
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from "vitest";
|
||||
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk";
|
||||
import { spawnSandboxAgent, buildSandboxAgent, type SandboxAgentHandle } from "./helpers/spawn";
|
||||
|
||||
describe("OpenCode-compatible Session API", () => {
|
||||
let handle: SandboxAgentHandle;
|
||||
let client: OpencodeClient;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Build the binary if needed
|
||||
await buildSandboxAgent();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Spawn a fresh sandbox-agent instance for each test
|
||||
handle = await spawnSandboxAgent({ opencodeCompat: true });
|
||||
client = createOpencodeClient({
|
||||
baseUrl: `${handle.baseUrl}/opencode`,
|
||||
headers: { Authorization: `Bearer ${handle.token}` },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await handle?.dispose();
|
||||
});
|
||||
|
||||
describe("session.create", () => {
|
||||
it("should create a new session", async () => {
|
||||
const response = await client.session.create();
|
||||
|
||||
expect(response.data).toBeDefined();
|
||||
expect(response.data?.id).toBeDefined();
|
||||
expect(typeof response.data?.id).toBe("string");
|
||||
expect(response.data?.id.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should create session with custom title", async () => {
|
||||
const response = await client.session.create({
|
||||
body: { title: "Test Session" },
|
||||
});
|
||||
|
||||
expect(response.data).toBeDefined();
|
||||
expect(response.data?.title).toBe("Test Session");
|
||||
});
|
||||
|
||||
it("should assign unique IDs to each session", async () => {
|
||||
const session1 = await client.session.create();
|
||||
const session2 = await client.session.create();
|
||||
|
||||
expect(session1.data?.id).not.toBe(session2.data?.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("session.list", () => {
|
||||
it("should return empty list when no sessions exist", async () => {
|
||||
const response = await client.session.list();
|
||||
|
||||
expect(response.data).toBeDefined();
|
||||
expect(Array.isArray(response.data)).toBe(true);
|
||||
expect(response.data?.length).toBe(0);
|
||||
});
|
||||
|
||||
it("should list created sessions", async () => {
|
||||
// Create some sessions
|
||||
await client.session.create({ body: { title: "Session 1" } });
|
||||
await client.session.create({ body: { title: "Session 2" } });
|
||||
|
||||
const response = await client.session.list();
|
||||
|
||||
expect(response.data).toBeDefined();
|
||||
expect(response.data?.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("session.get", () => {
|
||||
it("should retrieve session by ID", async () => {
|
||||
const created = await client.session.create({ body: { title: "Test" } });
|
||||
const sessionId = created.data?.id;
|
||||
expect(sessionId).toBeDefined();
|
||||
|
||||
const response = await client.session.get({ path: { id: sessionId! } });
|
||||
|
||||
expect(response.data).toBeDefined();
|
||||
expect(response.data?.id).toBe(sessionId);
|
||||
expect(response.data?.title).toBe("Test");
|
||||
});
|
||||
|
||||
it("should return error for non-existent session", async () => {
|
||||
const response = await client.session.get({
|
||||
path: { id: "non-existent-session-id" },
|
||||
});
|
||||
|
||||
expect(response.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("session.update", () => {
|
||||
it("should update session title", async () => {
|
||||
const created = await client.session.create({ body: { title: "Original" } });
|
||||
const sessionId = created.data?.id!;
|
||||
|
||||
await client.session.update({
|
||||
path: { id: sessionId },
|
||||
body: { title: "Updated" },
|
||||
});
|
||||
|
||||
const response = await client.session.get({ path: { id: sessionId } });
|
||||
expect(response.data?.title).toBe("Updated");
|
||||
});
|
||||
});
|
||||
|
||||
describe("session.delete", () => {
|
||||
it("should delete a session", async () => {
|
||||
const created = await client.session.create();
|
||||
const sessionId = created.data?.id!;
|
||||
|
||||
await client.session.delete({ path: { id: sessionId } });
|
||||
|
||||
const response = await client.session.get({ path: { id: sessionId } });
|
||||
expect(response.error).toBeDefined();
|
||||
});
|
||||
|
||||
it("should not affect other sessions when one is deleted", async () => {
|
||||
const session1 = await client.session.create({ body: { title: "Keep" } });
|
||||
const session2 = await client.session.create({ body: { title: "Delete" } });
|
||||
|
||||
await client.session.delete({ path: { id: session2.data?.id! } });
|
||||
|
||||
const response = await client.session.get({ path: { id: session1.data?.id! } });
|
||||
expect(response.data).toBeDefined();
|
||||
expect(response.data?.title).toBe("Keep");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
/**
|
||||
* Tests for OpenCode-compatible tool calls and file actions.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest";
|
||||
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk";
|
||||
import { spawnSandboxAgent, buildSandboxAgent, type SandboxAgentHandle } from "./helpers/spawn";
|
||||
|
||||
describe("OpenCode-compatible Tool + File Actions", () => {
|
||||
let handle: SandboxAgentHandle;
|
||||
let client: OpencodeClient;
|
||||
let sessionId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await buildSandboxAgent();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
handle = await spawnSandboxAgent({ opencodeCompat: true });
|
||||
client = createOpencodeClient({
|
||||
baseUrl: `${handle.baseUrl}/opencode`,
|
||||
headers: { Authorization: `Bearer ${handle.token}` },
|
||||
});
|
||||
|
||||
const session = await client.session.create();
|
||||
sessionId = session.data?.id!;
|
||||
expect(sessionId).toBeDefined();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await handle?.dispose();
|
||||
});
|
||||
|
||||
it("should emit tool and file parts plus file.edited events", async () => {
|
||||
const eventStream = await client.event.subscribe();
|
||||
const tracker = {
|
||||
tool: false,
|
||||
file: false,
|
||||
edited: false,
|
||||
};
|
||||
|
||||
const waiter = new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error("Timed out waiting for tool events")), 15_000);
|
||||
(async () => {
|
||||
try {
|
||||
for await (const event of (eventStream as any).stream) {
|
||||
if (event.type === "message.part.updated") {
|
||||
const part = event.properties?.part;
|
||||
if (part?.type === "tool") {
|
||||
tracker.tool = true;
|
||||
}
|
||||
if (part?.type === "file") {
|
||||
tracker.file = true;
|
||||
}
|
||||
}
|
||||
if (event.type === "file.edited") {
|
||||
tracker.edited = true;
|
||||
}
|
||||
if (tracker.tool && tracker.file && tracker.edited) {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
await client.session.prompt({
|
||||
path: { id: sessionId },
|
||||
body: {
|
||||
model: { providerID: "sandbox-agent", modelID: "mock" },
|
||||
parts: [{ type: "text", text: "tool" }],
|
||||
},
|
||||
});
|
||||
|
||||
await waiter;
|
||||
expect(tracker.tool).toBe(true);
|
||||
expect(tracker.file).toBe(true);
|
||||
expect(tracker.edited).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": false,
|
||||
"noEmit": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
import { resolve } from "node:path";
|
||||
import { realpathSync } from "node:fs";
|
||||
|
||||
// Resolve the actual SDK path through pnpm's symlink structure
|
||||
function resolveSdkPath(): string {
|
||||
try {
|
||||
// Try to resolve through the local node_modules symlink
|
||||
const localPath = resolve(__dirname, "node_modules/@opencode-ai/sdk");
|
||||
const realPath = realpathSync(localPath);
|
||||
return resolve(realPath, "dist");
|
||||
} catch {
|
||||
// Fallback to root node_modules
|
||||
return resolve(__dirname, "../../../../../node_modules/@opencode-ai/sdk/dist");
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["**/*.test.ts"],
|
||||
testTimeout: 60_000,
|
||||
hookTimeout: 60_000,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
// Work around SDK publishing issue where exports point to src/ instead of dist/
|
||||
"@opencode-ai/sdk": resolveSdkPath(),
|
||||
},
|
||||
},
|
||||
});
|
||||
73
server/packages/sandbox-agent/tests/opencode_openapi.rs
Normal file
73
server/packages/sandbox-agent/tests/opencode_openapi.rs
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
use std::collections::BTreeSet;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use sandbox_agent::opencode_compat::OpenCodeApiDoc;
|
||||
use serde_json::Value;
|
||||
use utoipa::OpenApi;
|
||||
|
||||
fn collect_path_methods(spec: &Value) -> BTreeSet<String> {
|
||||
let mut methods = BTreeSet::new();
|
||||
let Some(paths) = spec.get("paths").and_then(|value| value.as_object()) else {
|
||||
return methods;
|
||||
};
|
||||
for (path, item) in paths {
|
||||
let Some(item) = item.as_object() else {
|
||||
continue;
|
||||
};
|
||||
for method in [
|
||||
"get", "post", "put", "patch", "delete", "options", "head", "trace",
|
||||
] {
|
||||
if item.contains_key(method) {
|
||||
methods.insert(format!("{} {}", method.to_uppercase(), path));
|
||||
}
|
||||
}
|
||||
}
|
||||
methods
|
||||
}
|
||||
|
||||
fn official_spec_path() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../../resources/agent-schemas/artifacts/openapi/opencode.json")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opencode_openapi_matches_official_paths() {
|
||||
let official_path = official_spec_path();
|
||||
let official_json = fs::read_to_string(&official_path)
|
||||
.unwrap_or_else(|err| panic!("failed to read official OpenCode spec at {official_path:?}: {err}"));
|
||||
let official: Value =
|
||||
serde_json::from_str(&official_json).expect("official OpenCode spec is not valid JSON");
|
||||
|
||||
let ours = OpenCodeApiDoc::openapi();
|
||||
let ours_value = serde_json::to_value(&ours).expect("failed to serialize OpenCode OpenAPI");
|
||||
|
||||
let official_methods = collect_path_methods(&official);
|
||||
let our_methods = collect_path_methods(&ours_value);
|
||||
|
||||
let missing: Vec<_> = official_methods
|
||||
.difference(&our_methods)
|
||||
.cloned()
|
||||
.collect();
|
||||
let extra: Vec<_> = our_methods
|
||||
.difference(&official_methods)
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
if !missing.is_empty() || !extra.is_empty() {
|
||||
let mut message = String::new();
|
||||
if !missing.is_empty() {
|
||||
message.push_str("Missing endpoints (present in official spec, absent in ours):\n");
|
||||
for endpoint in &missing {
|
||||
message.push_str(&format!("- {endpoint}\n"));
|
||||
}
|
||||
}
|
||||
if !extra.is_empty() {
|
||||
message.push_str("Extra endpoints (present in ours, absent in official spec):\n");
|
||||
for endpoint in &extra {
|
||||
message.push_str(&format!("- {endpoint}\n"));
|
||||
}
|
||||
}
|
||||
panic!("{message}");
|
||||
}
|
||||
}
|
||||
|
|
@ -43,7 +43,7 @@ pub fn event_to_universal(event: &schema::Event) -> Result<Vec<EventConversion>,
|
|||
let (session_id, message_id) = part_session_message(part);
|
||||
|
||||
match part {
|
||||
schema::Part::Variant0(text_part) => {
|
||||
schema::Part::TextPart(text_part) => {
|
||||
let schema::TextPart { text, .. } = text_part;
|
||||
let delta_text = delta.as_ref().unwrap_or(&text).clone();
|
||||
let stub = stub_message_item(&message_id, ItemRole::Assistant);
|
||||
|
|
@ -68,7 +68,7 @@ pub fn event_to_universal(event: &schema::Event) -> Result<Vec<EventConversion>,
|
|||
.with_raw(raw.clone()),
|
||||
);
|
||||
}
|
||||
schema::Part::Variant2(reasoning_part) => {
|
||||
schema::Part::ReasoningPart(reasoning_part) => {
|
||||
let delta_text = delta
|
||||
.as_ref()
|
||||
.cloned()
|
||||
|
|
@ -95,7 +95,7 @@ pub fn event_to_universal(event: &schema::Event) -> Result<Vec<EventConversion>,
|
|||
.with_raw(raw.clone()),
|
||||
);
|
||||
}
|
||||
schema::Part::Variant3(file_part) => {
|
||||
schema::Part::FilePart(file_part) => {
|
||||
let file_content = file_part_to_content(file_part);
|
||||
let item = UniversalItem {
|
||||
item_id: String::new(),
|
||||
|
|
@ -115,7 +115,7 @@ pub fn event_to_universal(event: &schema::Event) -> Result<Vec<EventConversion>,
|
|||
.with_raw(raw.clone()),
|
||||
);
|
||||
}
|
||||
schema::Part::Variant4(tool_part) => {
|
||||
schema::Part::ToolPart(tool_part) => {
|
||||
let tool_events = tool_part_to_events(&tool_part, &message_id);
|
||||
for event in tool_events {
|
||||
events.push(
|
||||
|
|
@ -125,9 +125,9 @@ pub fn event_to_universal(event: &schema::Event) -> Result<Vec<EventConversion>,
|
|||
);
|
||||
}
|
||||
}
|
||||
schema::Part::Variant1 { .. } => {
|
||||
let detail =
|
||||
serde_json::to_string(part).unwrap_or_else(|_| "subtask".to_string());
|
||||
schema::Part::SubtaskPart(subtask_part) => {
|
||||
let detail = serde_json::to_string(subtask_part)
|
||||
.unwrap_or_else(|_| "subtask".to_string());
|
||||
let item = status_item("subtask", Some(detail));
|
||||
events.push(
|
||||
EventConversion::new(
|
||||
|
|
@ -138,13 +138,13 @@ pub fn event_to_universal(event: &schema::Event) -> Result<Vec<EventConversion>,
|
|||
.with_raw(raw.clone()),
|
||||
);
|
||||
}
|
||||
schema::Part::Variant5(_)
|
||||
| schema::Part::Variant6(_)
|
||||
| schema::Part::Variant7(_)
|
||||
| schema::Part::Variant8(_)
|
||||
| schema::Part::Variant9(_)
|
||||
| schema::Part::Variant10(_)
|
||||
| schema::Part::Variant11(_) => {
|
||||
schema::Part::StepStartPart(_)
|
||||
| schema::Part::StepFinishPart(_)
|
||||
| schema::Part::SnapshotPart(_)
|
||||
| schema::Part::PatchPart(_)
|
||||
| schema::Part::AgentPart(_)
|
||||
| schema::Part::RetryPart(_)
|
||||
| schema::Part::CompactionPart(_) => {
|
||||
let detail = serde_json::to_string(part).unwrap_or_else(|_| "part".to_string());
|
||||
let item = status_item("part.updated", Some(detail));
|
||||
events.push(
|
||||
|
|
@ -306,52 +306,51 @@ fn message_to_item(message: &schema::Message) -> (UniversalItem, bool, Option<St
|
|||
|
||||
fn part_session_message(part: &schema::Part) -> (Option<String>, String) {
|
||||
match part {
|
||||
schema::Part::Variant0(text_part) => (
|
||||
schema::Part::TextPart(text_part) => (
|
||||
Some(text_part.session_id.clone()),
|
||||
text_part.message_id.clone(),
|
||||
),
|
||||
schema::Part::Variant1 {
|
||||
session_id,
|
||||
message_id,
|
||||
..
|
||||
} => (Some(session_id.clone()), message_id.clone()),
|
||||
schema::Part::Variant2(reasoning_part) => (
|
||||
schema::Part::SubtaskPart(subtask_part) => (
|
||||
Some(subtask_part.session_id.clone()),
|
||||
subtask_part.message_id.clone(),
|
||||
),
|
||||
schema::Part::ReasoningPart(reasoning_part) => (
|
||||
Some(reasoning_part.session_id.clone()),
|
||||
reasoning_part.message_id.clone(),
|
||||
),
|
||||
schema::Part::Variant3(file_part) => (
|
||||
schema::Part::FilePart(file_part) => (
|
||||
Some(file_part.session_id.clone()),
|
||||
file_part.message_id.clone(),
|
||||
),
|
||||
schema::Part::Variant4(tool_part) => (
|
||||
schema::Part::ToolPart(tool_part) => (
|
||||
Some(tool_part.session_id.clone()),
|
||||
tool_part.message_id.clone(),
|
||||
),
|
||||
schema::Part::Variant5(step_part) => (
|
||||
schema::Part::StepStartPart(step_part) => (
|
||||
Some(step_part.session_id.clone()),
|
||||
step_part.message_id.clone(),
|
||||
),
|
||||
schema::Part::Variant6(step_part) => (
|
||||
schema::Part::StepFinishPart(step_part) => (
|
||||
Some(step_part.session_id.clone()),
|
||||
step_part.message_id.clone(),
|
||||
),
|
||||
schema::Part::Variant7(snapshot_part) => (
|
||||
schema::Part::SnapshotPart(snapshot_part) => (
|
||||
Some(snapshot_part.session_id.clone()),
|
||||
snapshot_part.message_id.clone(),
|
||||
),
|
||||
schema::Part::Variant8(patch_part) => (
|
||||
schema::Part::PatchPart(patch_part) => (
|
||||
Some(patch_part.session_id.clone()),
|
||||
patch_part.message_id.clone(),
|
||||
),
|
||||
schema::Part::Variant9(agent_part) => (
|
||||
schema::Part::AgentPart(agent_part) => (
|
||||
Some(agent_part.session_id.clone()),
|
||||
agent_part.message_id.clone(),
|
||||
),
|
||||
schema::Part::Variant10(retry_part) => (
|
||||
schema::Part::RetryPart(retry_part) => (
|
||||
Some(retry_part.session_id.clone()),
|
||||
retry_part.message_id.clone(),
|
||||
),
|
||||
schema::Part::Variant11(compaction_part) => (
|
||||
schema::Part::CompactionPart(compaction_part) => (
|
||||
Some(compaction_part.session_id.clone()),
|
||||
compaction_part.message_id.clone(),
|
||||
),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue