mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 03:00:48 +00:00
Add ACP permission mode support to the SDK (#224)
* chore: recover hamburg workspace state * chore: drop workspace context files * refactor: generalize permissions example * refactor: parse permissions example flags * docs: clarify why fs and terminal stay native * feat: add interactive permission prompt UI to Inspector Add permission request handling to the Inspector UI so users can Allow, Always Allow, or Reject tool calls that require permissions instead of having them auto-cancelled. Wires up SDK onPermissionRequest/respondPermission through App → ChatPanel → ChatMessages with proper toolCallId-to-pendingId mapping. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: prevent permission reply from silently escalating "once" to "always" Remove allow_always from the fallback chain when the user replies "once", aligning with the ACP spec which says "map by option kind first" with no fallback for allow_once. Also fix Inspector to use rawSend, revert hydration guard to accept empty configOptions, and handle respondPermission errors by rejecting the pending promise. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
5d65013aa5
commit
76586f409f
35 changed files with 1786 additions and 472 deletions
26
CLAUDE.md
26
CLAUDE.md
|
|
@ -27,6 +27,8 @@
|
|||
- ACP extensions may be used for gaps (for example `skills`, `models`, and related metadata), but the default is that agent-facing behavior is implemented by the agent through ACP.
|
||||
- Custom HTTP APIs are for non-agent/session platform services (for example filesystem, terminals, and other host/runtime capabilities).
|
||||
- Filesystem and terminal APIs remain Sandbox Agent-specific HTTP contracts and are not ACP.
|
||||
- Do not make Sandbox Agent core flows depend on ACP client implementations of `fs/*` or `terminal/*`; in practice those client-side capabilities are often incomplete or inconsistent.
|
||||
- ACP-native filesystem and terminal methods are also too limited for Sandbox Agent host/runtime needs, so prefer the native HTTP APIs for richer behavior.
|
||||
- Keep `GET /v1/fs/file`, `PUT /v1/fs/file`, and `POST /v1/fs/upload-batch` on HTTP:
|
||||
- These are Sandbox Agent host/runtime operations with cross-agent-consistent behavior.
|
||||
- They may involve very large binary transfers that ACP JSON-RPC envelopes are not suited to stream.
|
||||
|
|
@ -41,6 +43,12 @@
|
|||
- Canonical extension namespace/domain string is `sandboxagent.dev` (no hyphen).
|
||||
- Canonical custom ACP extension method prefix is `_sandboxagent/...` (no hyphen).
|
||||
|
||||
## Docs Terminology
|
||||
|
||||
- Never mention "ACP" in user-facing docs (`docs/**/*.mdx`) except in docs that are specifically about ACP itself (e.g. `docs/acp-http-client.mdx`).
|
||||
- Never expose underlying protocol method names (e.g. `session/request_permission`, `session/create`, `_sandboxagent/session/detach`) in non-ACP docs. Describe the behavior in user-facing terms instead.
|
||||
- Do not describe the underlying protocol implementation in docs. Only document the SDK surface (methods, types, options). ACP protocol details belong exclusively in ACP-specific pages.
|
||||
|
||||
## Architecture (Brief)
|
||||
|
||||
- HTTP contract and problem/error mapping: `server/packages/sandbox-agent/src/router.rs`
|
||||
|
|
@ -54,10 +62,15 @@
|
|||
- `acp-http-client`: protocol-pure ACP-over-HTTP (`/v1/acp`) with no Sandbox-specific HTTP helpers.
|
||||
- `sandbox-agent`: `SandboxAgent` SDK wrapper that combines ACP session operations with Sandbox control-plane and filesystem helpers.
|
||||
- `SandboxAgent` entry points are `SandboxAgent.connect(...)` and `SandboxAgent.start(...)`.
|
||||
- Stable Sandbox session methods are `createSession`, `resumeSession`, `resumeOrCreateSession`, `destroySession`, `sendSessionMethod`, `onSessionEvent`, `setSessionMode`, `setSessionModel`, `setSessionThoughtLevel`, `setSessionConfigOption`, `getSessionConfigOptions`, and `getSessionModes`.
|
||||
- `Session` helpers are `prompt(...)`, `send(...)`, `onEvent(...)`, `setMode(...)`, `setModel(...)`, `setThoughtLevel(...)`, `setConfigOption(...)`, `getConfigOptions()`, and `getModes()`.
|
||||
- Stable Sandbox session methods are `createSession`, `resumeSession`, `resumeOrCreateSession`, `destroySession`, `rawSendSessionMethod`, `onSessionEvent`, `setSessionMode`, `setSessionModel`, `setSessionThoughtLevel`, `setSessionConfigOption`, `getSessionConfigOptions`, `getSessionModes`, `respondPermission`, `rawRespondPermission`, and `onPermissionRequest`.
|
||||
- `Session` helpers are `prompt(...)`, `rawSend(...)`, `onEvent(...)`, `setMode(...)`, `setModel(...)`, `setThoughtLevel(...)`, `setConfigOption(...)`, `getConfigOptions()`, `getModes()`, `respondPermission(...)`, `rawRespondPermission(...)`, and `onPermissionRequest(...)`.
|
||||
- Cleanup is `sdk.dispose()`.
|
||||
|
||||
### TypeScript SDK Naming Conventions
|
||||
|
||||
- Use `respond<Thing>(id, reply)` for SDK methods that reply to an agent-initiated request (e.g. `respondPermission`). This is the standard pattern for answering any inbound JSON-RPC request from the agent.
|
||||
- Prefix raw/low-level escape hatches with `raw` (e.g. `rawRespondPermission`, `rawSend`). These accept protocol-level types directly and bypass SDK abstractions.
|
||||
|
||||
### Docs Source Of Truth
|
||||
|
||||
- For TypeScript docs/examples, source of truth is implementation in:
|
||||
|
|
@ -70,8 +83,17 @@
|
|||
- `server/packages/sandbox-agent/src/cli.rs`
|
||||
- Keep docs aligned to implemented endpoints/commands only (for example ACP under `/v1/acp`, not legacy `/v1/sessions` APIs).
|
||||
|
||||
## ACP Protocol Compliance
|
||||
|
||||
- Before adding any new ACP method, property, or config option category to the SDK, verify it exists in the ACP spec at `https://agentclientprotocol.com/llms-full.txt`.
|
||||
- Valid `SessionConfigOptionCategory` values are: `mode`, `model`, `thought_level`, `other`, or custom categories prefixed with `_` (e.g. `_permission_mode`).
|
||||
- Do not invent ACP properties or categories (e.g. `permission_mode` is not a valid ACP category — use `_permission_mode` if it's a custom extension, or use existing ACP mechanisms like `session/set_mode`).
|
||||
- `NewSessionRequest` only has `_meta`, `cwd`, and `mcpServers`. Do not add non-ACP fields to it.
|
||||
- Sandbox Agent SDK abstractions (like `SessionCreateRequest`) may add convenience properties, but must clearly map to real ACP methods internally and not send fabricated fields over the wire.
|
||||
|
||||
## Source Documents
|
||||
|
||||
- ACP protocol specification (full LLM-readable reference): `https://agentclientprotocol.com/llms-full.txt`
|
||||
- `~/misc/acp-docs/schema/schema.json`
|
||||
- `~/misc/acp-docs/schema/meta.json`
|
||||
- `research/acp/spec.md`
|
||||
|
|
|
|||
|
|
@ -118,7 +118,6 @@ const agents = await client.listAgents();
|
|||
await client.createSession("demo", {
|
||||
agent: "codex",
|
||||
agentMode: "default",
|
||||
permissionMode: "plan",
|
||||
});
|
||||
|
||||
await client.postMessage("demo", { message: "Hello from the SDK." });
|
||||
|
|
@ -128,9 +127,7 @@ for await (const event of client.streamEvents("demo", { offset: 0 })) {
|
|||
}
|
||||
```
|
||||
|
||||
`permissionMode: "acceptEdits"` passes through to Claude, auto-approves file changes for Codex, and is treated as `default` for other agents.
|
||||
|
||||
[SDK documentation](https://sandboxagent.dev/docs/sdks/typescript) — [Building a Chat UI](https://sandboxagent.dev/docs/building-chat-ui) — [Managing Sessions](https://sandboxagent.dev/docs/manage-sessions)
|
||||
[SDK documentation](https://sandboxagent.dev/docs/sdks/typescript) — [Managing Sessions](https://sandboxagent.dev/docs/manage-sessions)
|
||||
|
||||
### HTTP Server
|
||||
|
||||
|
|
|
|||
|
|
@ -125,9 +125,45 @@ for (const opt of options) {
|
|||
await session.setConfigOption("some-agent-option", "value");
|
||||
```
|
||||
|
||||
## Handle permission requests
|
||||
|
||||
For agents that request tool-use permissions, register a permission listener and reply with `once`, `always`, or `reject`:
|
||||
|
||||
```ts
|
||||
const session = await sdk.createSession({
|
||||
agent: "claude",
|
||||
mode: "default",
|
||||
});
|
||||
|
||||
session.onPermissionRequest((request) => {
|
||||
console.log(request.toolCall.title, request.availableReplies);
|
||||
void session.respondPermission(request.id, "once");
|
||||
});
|
||||
|
||||
await session.prompt([
|
||||
{ type: "text", text: "Create ./permission-example.txt with the text hello." },
|
||||
]);
|
||||
```
|
||||
|
||||
|
||||
### Auto-approving permissions
|
||||
|
||||
To auto-approve all permission requests, respond with `"once"` or `"always"` in your listener:
|
||||
|
||||
```ts
|
||||
session.onPermissionRequest((request) => {
|
||||
void session.respondPermission(request.id, "always");
|
||||
});
|
||||
```
|
||||
|
||||
See `examples/permissions/src/index.ts` for a complete permissions example that works with Claude and Codex.
|
||||
|
||||
<Info>
|
||||
Some agents like Claude allow configuring permission behavior through modes (e.g. `bypassPermissions`, `acceptEdits`). We recommend leaving the mode as `default` and handling permission decisions explicitly in `onPermissionRequest` instead.
|
||||
</Info>
|
||||
|
||||
## Destroy a session
|
||||
|
||||
```ts
|
||||
await sdk.destroySession(session.id);
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -58,4 +58,4 @@ Use the filesystem API to upload files, then include file references in prompt c
|
|||
|
||||
- Use absolute file URIs in `resource_link` blocks.
|
||||
- If `mimeType` is omitted, the agent/runtime may infer a default.
|
||||
- Support for non-text resources depends on each agent's ACP prompt capabilities.
|
||||
- Support for non-text resources depends on each agent's prompt capabilities.
|
||||
|
|
|
|||
|
|
@ -1,370 +0,0 @@
|
|||
---
|
||||
title: "Building a Chat UI"
|
||||
description: "Build a chat interface using the universal event stream."
|
||||
icon: "comments"
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
### List agents
|
||||
|
||||
```ts
|
||||
const { agents } = await client.listAgents();
|
||||
|
||||
// Each agent exposes feature coverage via `capabilities` to determine what UI to show
|
||||
const claude = agents.find((a) => a.id === "claude");
|
||||
if (claude?.capabilities.permissions) {
|
||||
// Show permission approval UI
|
||||
}
|
||||
if (claude?.capabilities.questions) {
|
||||
// Show question response UI
|
||||
}
|
||||
```
|
||||
|
||||
### Create a session
|
||||
|
||||
```ts
|
||||
const sessionId = `session-${crypto.randomUUID()}`;
|
||||
|
||||
await client.createSession(sessionId, {
|
||||
agent: "claude",
|
||||
agentMode: "code", // Optional: agent-specific mode
|
||||
permissionMode: "default", // Optional: "default" | "plan" | "bypass" | "acceptEdits" (Claude: accept edits; Codex: auto-approve file changes; others: default)
|
||||
model: "claude-sonnet-4", // Optional: model override
|
||||
});
|
||||
```
|
||||
|
||||
### Send a message
|
||||
|
||||
```ts
|
||||
await client.postMessage(sessionId, { message: "Hello, world!" });
|
||||
```
|
||||
|
||||
### Stream events
|
||||
|
||||
Three options for receiving events:
|
||||
|
||||
```ts
|
||||
// Option 1: SSE (recommended for real-time UI)
|
||||
const stream = client.streamEvents(sessionId, { offset: 0 });
|
||||
for await (const event of stream) {
|
||||
handleEvent(event);
|
||||
}
|
||||
|
||||
// Option 2: Polling
|
||||
const { events, hasMore } = await client.getEvents(sessionId, { offset: 0 });
|
||||
events.forEach(handleEvent);
|
||||
|
||||
// Option 3: Turn streaming (send + stream in one call)
|
||||
const stream = client.streamTurn(sessionId, { message: "Hello" });
|
||||
for await (const event of stream) {
|
||||
handleEvent(event);
|
||||
}
|
||||
```
|
||||
|
||||
Use `offset` to track the last seen `sequence` number and resume from where you left off.
|
||||
|
||||
---
|
||||
|
||||
## Handling Events
|
||||
|
||||
### Bare minimum
|
||||
|
||||
Handle item lifecycle plus turn lifecycle to render a basic chat:
|
||||
|
||||
```ts
|
||||
type ItemState = {
|
||||
item: UniversalItem;
|
||||
deltas: string[];
|
||||
};
|
||||
|
||||
const items = new Map<string, ItemState>();
|
||||
let turnInProgress = false;
|
||||
|
||||
function handleEvent(event: UniversalEvent) {
|
||||
switch (event.type) {
|
||||
case "turn.started": {
|
||||
turnInProgress = true;
|
||||
break;
|
||||
}
|
||||
|
||||
case "turn.ended": {
|
||||
turnInProgress = false;
|
||||
break;
|
||||
}
|
||||
|
||||
case "item.started": {
|
||||
const { item } = event.data as ItemEventData;
|
||||
items.set(item.item_id, { item, deltas: [] });
|
||||
break;
|
||||
}
|
||||
|
||||
case "item.delta": {
|
||||
const { item_id, delta } = event.data as ItemDeltaData;
|
||||
const state = items.get(item_id);
|
||||
if (state) {
|
||||
state.deltas.push(delta);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "item.completed": {
|
||||
const { item } = event.data as ItemEventData;
|
||||
const state = items.get(item.item_id);
|
||||
if (state) {
|
||||
state.item = item;
|
||||
state.deltas = []; // Clear deltas, use final content
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When rendering:
|
||||
- Use `turnInProgress` for turn-level UI state (disable send button, show global "Agent is responding", etc.).
|
||||
- Use `item.status === "in_progress"` for per-item streaming state.
|
||||
|
||||
```ts
|
||||
function renderItem(state: ItemState) {
|
||||
const { item, deltas } = state;
|
||||
const isItemLoading = item.status === "in_progress";
|
||||
|
||||
// For streaming text, combine item content with accumulated deltas
|
||||
const text = item.content
|
||||
.filter((p) => p.type === "text")
|
||||
.map((p) => p.text)
|
||||
.join("");
|
||||
const streamedText = text + deltas.join("");
|
||||
|
||||
return {
|
||||
content: streamedText,
|
||||
isItemLoading,
|
||||
isTurnLoading: turnInProgress,
|
||||
role: item.role,
|
||||
kind: item.kind,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Extra events
|
||||
|
||||
Handle these for a complete implementation:
|
||||
|
||||
```ts
|
||||
function handleEvent(event: UniversalEvent) {
|
||||
switch (event.type) {
|
||||
// ... bare minimum events above ...
|
||||
|
||||
case "session.started": {
|
||||
// Session is ready
|
||||
break;
|
||||
}
|
||||
|
||||
case "session.ended": {
|
||||
const { reason, terminated_by } = event.data as SessionEndedData;
|
||||
// Disable input, show end reason
|
||||
// reason: "completed" | "error" | "terminated"
|
||||
// terminated_by: "agent" | "daemon"
|
||||
break;
|
||||
}
|
||||
|
||||
case "error": {
|
||||
const { message, code } = event.data as ErrorData;
|
||||
// Display error to user
|
||||
break;
|
||||
}
|
||||
|
||||
case "agent.unparsed": {
|
||||
const { error, location } = event.data as AgentUnparsedData;
|
||||
// Parsing failure - treat as bug in development
|
||||
console.error(`Parse error at ${location}: ${error}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Content parts
|
||||
|
||||
Each item has `content` parts. Render based on `type`:
|
||||
|
||||
```ts
|
||||
function renderContentPart(part: ContentPart) {
|
||||
switch (part.type) {
|
||||
case "text":
|
||||
return <Markdown>{part.text}</Markdown>;
|
||||
|
||||
case "tool_call":
|
||||
return <ToolCall name={part.name} args={part.arguments} />;
|
||||
|
||||
case "tool_result":
|
||||
return <ToolResult output={part.output} />;
|
||||
|
||||
case "file_ref":
|
||||
return <FileChange path={part.path} action={part.action} diff={part.diff} />;
|
||||
|
||||
case "reasoning":
|
||||
return <Reasoning>{part.text}</Reasoning>;
|
||||
|
||||
case "status":
|
||||
return <Status label={part.label} detail={part.detail} />;
|
||||
|
||||
case "image":
|
||||
return <Image src={part.path} />;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Handling Permissions
|
||||
|
||||
When `permission.requested` arrives, show an approval UI:
|
||||
|
||||
```ts
|
||||
const pendingPermissions = new Map<string, PermissionEventData>();
|
||||
|
||||
function handleEvent(event: UniversalEvent) {
|
||||
if (event.type === "permission.requested") {
|
||||
const data = event.data as PermissionEventData;
|
||||
pendingPermissions.set(data.permission_id, data);
|
||||
}
|
||||
|
||||
if (event.type === "permission.resolved") {
|
||||
const data = event.data as PermissionEventData;
|
||||
pendingPermissions.delete(data.permission_id);
|
||||
}
|
||||
}
|
||||
|
||||
// User clicks approve/deny
|
||||
async function replyPermission(id: string, reply: "once" | "always" | "reject") {
|
||||
await client.replyPermission(sessionId, id, { reply });
|
||||
pendingPermissions.delete(id);
|
||||
}
|
||||
```
|
||||
|
||||
Render permission requests:
|
||||
|
||||
```ts
|
||||
function PermissionRequest({ data }: { data: PermissionEventData }) {
|
||||
return (
|
||||
<div>
|
||||
<p>Allow: {data.action}</p>
|
||||
<button onClick={() => replyPermission(data.permission_id, "once")}>
|
||||
Allow Once
|
||||
</button>
|
||||
<button onClick={() => replyPermission(data.permission_id, "always")}>
|
||||
Always Allow
|
||||
</button>
|
||||
<button onClick={() => replyPermission(data.permission_id, "reject")}>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Handling Questions
|
||||
|
||||
When `question.requested` arrives, show a selection UI:
|
||||
|
||||
```ts
|
||||
const pendingQuestions = new Map<string, QuestionEventData>();
|
||||
|
||||
function handleEvent(event: UniversalEvent) {
|
||||
if (event.type === "question.requested") {
|
||||
const data = event.data as QuestionEventData;
|
||||
pendingQuestions.set(data.question_id, data);
|
||||
}
|
||||
|
||||
if (event.type === "question.resolved") {
|
||||
const data = event.data as QuestionEventData;
|
||||
pendingQuestions.delete(data.question_id);
|
||||
}
|
||||
}
|
||||
|
||||
// User selects answer(s)
|
||||
async function answerQuestion(id: string, answers: string[][]) {
|
||||
await client.replyQuestion(sessionId, id, { answers });
|
||||
pendingQuestions.delete(id);
|
||||
}
|
||||
|
||||
async function rejectQuestion(id: string) {
|
||||
await client.rejectQuestion(sessionId, id);
|
||||
pendingQuestions.delete(id);
|
||||
}
|
||||
```
|
||||
|
||||
Render question requests:
|
||||
|
||||
```ts
|
||||
function QuestionRequest({ data }: { data: QuestionEventData }) {
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>{data.prompt}</p>
|
||||
{data.options.map((option) => (
|
||||
<label key={option}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(option)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSelected([...selected, option]);
|
||||
} else {
|
||||
setSelected(selected.filter((s) => s !== option));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{option}
|
||||
</label>
|
||||
))}
|
||||
<button onClick={() => answerQuestion(data.question_id, [selected])}>
|
||||
Submit
|
||||
</button>
|
||||
<button onClick={() => rejectQuestion(data.question_id)}>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing with Mock Agent
|
||||
|
||||
The `mock` agent lets you test UI behaviors without external credentials:
|
||||
|
||||
```ts
|
||||
await client.createSession("test-session", { agent: "mock" });
|
||||
```
|
||||
|
||||
Send `help` to see available commands:
|
||||
|
||||
| Command | Tests |
|
||||
|---------|-------|
|
||||
| `help` | Lists all commands |
|
||||
| `demo` | Full UI coverage sequence with markers |
|
||||
| `markdown` | Streaming markdown rendering |
|
||||
| `tool` | Tool call + result with file refs |
|
||||
| `status` | Status item updates |
|
||||
| `image` | Image content part |
|
||||
| `permission` | Permission request flow |
|
||||
| `question` | Question request flow |
|
||||
| `error` | Error + unparsed events |
|
||||
| `end` | Session ended event |
|
||||
| `echo <text>` | Echo text as assistant message |
|
||||
|
||||
Any unrecognized text is echoed back as an assistant message.
|
||||
|
||||
---
|
||||
|
||||
## Reference Implementation
|
||||
|
||||
The [Inspector UI](https://github.com/rivet-dev/sandbox-agent/blob/main/frontend/packages/inspector/src/App.tsx)
|
||||
is a complete reference showing session management, event rendering, and HITL flows.
|
||||
|
|
@ -181,7 +181,7 @@ sandbox-agent api agents list
|
|||
|
||||
#### api agents report
|
||||
|
||||
Emit a JSON report of available models, modes, and thought levels for every agent. Calls `GET /v1/agents?config=true` and groups each agent's config options by category.
|
||||
Emit a JSON report of available models, modes, and thought levels for every agent, grouped by category.
|
||||
|
||||
```bash
|
||||
sandbox-agent api agents report --endpoint http://127.0.0.1:2468 | jq .
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ When prompting, Sandbox Agent does not pre-validate provider credentials. Agent-
|
|||
|
||||
### API
|
||||
|
||||
`GET /v1/agents` includes `credentialsAvailable` per agent.
|
||||
`sdk.listAgents()` includes `credentialsAvailable` per agent.
|
||||
|
||||
```json
|
||||
{
|
||||
|
|
|
|||
|
|
@ -115,8 +115,8 @@ This keeps all Sandbox Agent calls inside the Cloudflare sandbox routing path an
|
|||
## Troubleshooting streaming updates
|
||||
|
||||
If you only receive:
|
||||
- outbound `session/prompt`
|
||||
- final `{ stopReason: "end_turn" }`
|
||||
- the outbound prompt request
|
||||
- the final `{ stopReason: "end_turn" }` response
|
||||
|
||||
then the streamed update channel dropped. In Cloudflare sandbox paths, this is typically caused by forwarding `AbortSignal` from SDK fetch init into `containerFetch(...)`.
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ console.log(url);
|
|||
- Event JSON inspector
|
||||
- Prompt testing
|
||||
- Request/response debugging
|
||||
- Interactive permission prompts (approve, always-allow, or reject tool-use requests)
|
||||
- Process management (create, stop, kill, delete, view logs)
|
||||
- Interactive PTY terminal for tty processes
|
||||
- One-shot command execution
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@ icon: "database"
|
|||
|
||||
Sandbox Agent stores sessions in memory only. When the server restarts or the sandbox is destroyed, all session data is lost. It's your responsibility to persist events to your own database.
|
||||
|
||||
See the [Building a Chat UI](/building-chat-ui) guide for understanding session lifecycle events like `session.started` and `session.ended`.
|
||||
|
||||
## Recommended approach
|
||||
|
||||
1. Store events to your database as they arrive
|
||||
|
|
@ -18,11 +16,11 @@ This prevents duplicate writes and lets you recover from disconnects.
|
|||
|
||||
## Receiving Events
|
||||
|
||||
Two ways to receive events: SSE streaming (recommended) or polling.
|
||||
Two ways to receive events: streaming (recommended) or polling.
|
||||
|
||||
### Streaming
|
||||
|
||||
Use SSE for real-time events with automatic reconnection support.
|
||||
Use streaming for real-time events with automatic reconnection support.
|
||||
|
||||
```typescript
|
||||
import { SandboxAgentClient } from "sandbox-agent";
|
||||
|
|
@ -44,7 +42,7 @@ for await (const event of client.streamEvents("my-session", { offset })) {
|
|||
|
||||
### Polling
|
||||
|
||||
If you can't use SSE streaming, poll the events endpoint:
|
||||
If you can't use streaming, poll the events endpoint:
|
||||
|
||||
```typescript
|
||||
const lastEvent = await db.getLastEvent("my-session");
|
||||
|
|
@ -244,7 +242,7 @@ const events = await redis.lrange(`session:${sessionId}`, offset, -1);
|
|||
|
||||
## Handling disconnects
|
||||
|
||||
The SSE stream may disconnect due to network issues. Handle reconnection gracefully:
|
||||
The event stream may disconnect due to network issues. Handle reconnection gracefully:
|
||||
|
||||
```typescript
|
||||
async function streamWithRetry(sessionId: string) {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ The process API supports:
|
|||
|
||||
- **One-shot execution** — run a command to completion and capture stdout, stderr, and exit code
|
||||
- **Managed processes** — spawn, list, stop, kill, and delete long-lived processes
|
||||
- **Log streaming** — fetch buffered logs or follow live output via SSE
|
||||
- **Log streaming** — fetch buffered logs or follow live output
|
||||
- **Terminals** — full PTY support with bidirectional WebSocket I/O
|
||||
- **Configurable limits** — control concurrency, timeouts, and buffer sizes per runtime
|
||||
|
||||
|
|
@ -155,7 +155,7 @@ curl "http://127.0.0.1:2468/v1/processes/proc_1/logs?tail=50&stream=combined"
|
|||
```
|
||||
</CodeGroup>
|
||||
|
||||
### Follow logs via SSE
|
||||
### Follow logs
|
||||
|
||||
Stream log entries in real time. The subscription replays buffered entries first, then streams new output as it arrives.
|
||||
|
||||
|
|
|
|||
|
|
@ -138,6 +138,19 @@ const options = await session.getConfigOptions();
|
|||
const modes = await session.getModes();
|
||||
```
|
||||
|
||||
Handle permission requests from agents that ask before executing tools:
|
||||
|
||||
```ts
|
||||
const claude = await sdk.createSession({
|
||||
agent: "claude",
|
||||
mode: "default",
|
||||
});
|
||||
|
||||
claude.onPermissionRequest((request) => {
|
||||
void claude.respondPermission(request.id, "once");
|
||||
});
|
||||
```
|
||||
|
||||
See [Agent Sessions](/agent-sessions) for full details on config options and error handling.
|
||||
|
||||
## Events
|
||||
|
|
@ -209,6 +222,6 @@ Parameters:
|
|||
- `baseUrl` (required unless `fetch` is provided): Sandbox Agent server URL
|
||||
- `token` (optional): Bearer token for authenticated servers
|
||||
- `headers` (optional): Additional request headers
|
||||
- `fetch` (optional): Custom fetch implementation used by SDK HTTP and ACP calls
|
||||
- `waitForHealth` (optional, defaults to enabled): waits for `/v1/health` before HTTP helpers and ACP session setup proceed; pass `false` to disable or `{ timeoutMs }` to bound the wait
|
||||
- `fetch` (optional): Custom fetch implementation used by SDK HTTP and session calls
|
||||
- `waitForHealth` (optional, defaults to enabled): waits for `/v1/health` before HTTP helpers and session setup proceed; pass `false` to disable or `{ timeoutMs }` to bound the wait
|
||||
- `signal` (optional): aborts the startup `/v1/health` wait used by `connect()`
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ export function App() {
|
|||
if (event.type === "permission.requested") {
|
||||
const data = event.data as PermissionEventData;
|
||||
log(`[Auto-approved] ${data.action}`);
|
||||
await client.replyPermission(sessionIdRef.current, data.permission_id, { reply: "once" });
|
||||
await client.respondPermission(sessionIdRef.current, data.permission_id, { reply: "once" });
|
||||
}
|
||||
|
||||
// Reject questions (don't support interactive input)
|
||||
|
|
|
|||
18
examples/permissions/package.json
Normal file
18
examples/permissions/package.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"name": "@sandbox-agent/example-permissions",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "tsx src/index.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"commander": "^12.1.0",
|
||||
"sandbox-agent": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "latest",
|
||||
"tsx": "latest",
|
||||
"typescript": "latest"
|
||||
}
|
||||
}
|
||||
192
examples/permissions/src/index.ts
Normal file
192
examples/permissions/src/index.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
import { createInterface } from "node:readline/promises";
|
||||
import { stdin as input, stdout as output } from "node:process";
|
||||
import { Command } from "commander";
|
||||
import {
|
||||
SandboxAgent,
|
||||
type PermissionReply,
|
||||
type SessionPermissionRequest,
|
||||
} from "sandbox-agent";
|
||||
|
||||
const options = parseOptions();
|
||||
const agent = options.agent.trim().toLowerCase();
|
||||
const autoReply = parsePermissionReply(options.reply);
|
||||
const promptText =
|
||||
options.prompt?.trim() ||
|
||||
`Create ./permission-example.txt with the text 'hello from the ${agent} permissions example'.`;
|
||||
|
||||
const sdk = await SandboxAgent.start({
|
||||
spawn: {
|
||||
enabled: true,
|
||||
log: "inherit",
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await sdk.installAgent(agent);
|
||||
|
||||
const agents = await sdk.listAgents({ config: true });
|
||||
const selectedAgent = agents.agents.find((entry) => entry.id === agent);
|
||||
const configOptions = Array.isArray(selectedAgent?.configOptions)
|
||||
? (selectedAgent.configOptions as Array<{ category?: string; currentValue?: string; options?: unknown[] }>)
|
||||
: [];
|
||||
const modeOption = configOptions.find((option) => option.category === "mode");
|
||||
const availableModes = extractOptionValues(modeOption);
|
||||
const mode =
|
||||
options.mode?.trim() ||
|
||||
(typeof modeOption?.currentValue === "string" ? modeOption.currentValue : "") ||
|
||||
availableModes[0] ||
|
||||
"";
|
||||
|
||||
console.log(`Agent: ${agent}`);
|
||||
console.log(`Mode: ${mode || "(default)"}`);
|
||||
if (availableModes.length > 0) {
|
||||
console.log(`Available modes: ${availableModes.join(", ")}`);
|
||||
}
|
||||
console.log(`Working directory: ${process.cwd()}`);
|
||||
console.log(`Prompt: ${promptText}`);
|
||||
if (autoReply) {
|
||||
console.log(`Automatic permission reply: ${autoReply}`);
|
||||
} else {
|
||||
console.log("Interactive permission replies enabled.");
|
||||
}
|
||||
|
||||
const session = await sdk.createSession({
|
||||
agent,
|
||||
...(mode ? { mode } : {}),
|
||||
sessionInit: {
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
},
|
||||
});
|
||||
|
||||
const rl = autoReply
|
||||
? null
|
||||
: createInterface({
|
||||
input,
|
||||
output,
|
||||
});
|
||||
|
||||
session.onPermissionRequest((request: SessionPermissionRequest) => {
|
||||
void handlePermissionRequest(session, request, autoReply, rl);
|
||||
});
|
||||
|
||||
const response = await session.prompt([{ type: "text", text: promptText }]);
|
||||
console.log(`Prompt finished with stopReason=${response.stopReason}`);
|
||||
|
||||
await rl?.close();
|
||||
} finally {
|
||||
await sdk.dispose();
|
||||
}
|
||||
|
||||
async function handlePermissionRequest(
|
||||
session: {
|
||||
respondPermission(permissionId: string, reply: PermissionReply): Promise<void>;
|
||||
},
|
||||
request: SessionPermissionRequest,
|
||||
auto: PermissionReply | null,
|
||||
rl: ReturnType<typeof createInterface> | null,
|
||||
): Promise<void> {
|
||||
const reply = auto ?? (await promptForReply(request, rl));
|
||||
console.log(`Permission ${reply}: ${request.toolCall.title ?? request.toolCall.toolCallId}`);
|
||||
await session.respondPermission(request.id, reply);
|
||||
}
|
||||
|
||||
async function promptForReply(
|
||||
request: SessionPermissionRequest,
|
||||
rl: ReturnType<typeof createInterface> | null,
|
||||
): Promise<PermissionReply> {
|
||||
if (!rl) {
|
||||
return "reject";
|
||||
}
|
||||
|
||||
const title = request.toolCall.title ?? request.toolCall.toolCallId;
|
||||
const available = request.availableReplies;
|
||||
console.log("");
|
||||
console.log(`Permission request: ${title}`);
|
||||
console.log(`Available replies: ${available.join(", ")}`);
|
||||
const answer = (await rl.question("Reply [once|always|reject]: ")).trim().toLowerCase();
|
||||
const parsed = parsePermissionReply(answer);
|
||||
if (parsed && available.includes(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
console.log("Invalid reply, defaulting to reject.");
|
||||
return "reject";
|
||||
}
|
||||
|
||||
function extractOptionValues(option: { options?: unknown[] } | undefined): string[] {
|
||||
if (!option?.options) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const values: string[] = [];
|
||||
for (const entry of option.options) {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
continue;
|
||||
}
|
||||
const value = "value" in entry && typeof entry.value === "string" ? entry.value : null;
|
||||
if (value) {
|
||||
values.push(value);
|
||||
continue;
|
||||
}
|
||||
if (!("options" in entry) || !Array.isArray(entry.options)) {
|
||||
continue;
|
||||
}
|
||||
for (const nested of entry.options) {
|
||||
if (!nested || typeof nested !== "object") {
|
||||
continue;
|
||||
}
|
||||
const nestedValue =
|
||||
"value" in nested && typeof nested.value === "string" ? nested.value : null;
|
||||
if (nestedValue) {
|
||||
values.push(nestedValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(values)];
|
||||
}
|
||||
|
||||
function parsePermissionReply(value: string | undefined): PermissionReply | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (value.trim().toLowerCase()) {
|
||||
case "once":
|
||||
return "once";
|
||||
case "always":
|
||||
return "always";
|
||||
case "reject":
|
||||
case "deny":
|
||||
return "reject";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseOptions(): {
|
||||
agent: string;
|
||||
mode?: string;
|
||||
prompt?: string;
|
||||
reply?: string;
|
||||
} {
|
||||
const argv = process.argv.slice(2);
|
||||
const normalizedArgv = argv[0] === "--" ? argv.slice(1) : argv;
|
||||
const program = new Command();
|
||||
program
|
||||
.name("permissions")
|
||||
.description("Run a permissions example against an agent session.")
|
||||
.requiredOption("--agent <agent>", "Agent to run, for example 'claude' or 'codex'")
|
||||
.option("--mode <mode>", "Mode to configure for the session (uses agent default if omitted)")
|
||||
.option("--prompt <text>", "Prompt to send after the session starts")
|
||||
.option("--reply <reply>", "Automatically answer permission prompts with once, always, or reject");
|
||||
|
||||
program.parse(normalizedArgv, { from: "user" });
|
||||
return program.opts<{
|
||||
agent: string;
|
||||
mode?: string;
|
||||
prompt?: string;
|
||||
reply?: string;
|
||||
}>();
|
||||
}
|
||||
14
examples/permissions/tsconfig.json
Normal file
14
examples/permissions/tsconfig.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
|
|
@ -81,11 +81,12 @@ For all Rivet/RivetKit implementation:
|
|||
- Do not add `workspaceId`/`repoId`/`handoffId` columns just to "namespace" rows for a given actor instance; use actor state and/or the actor key instead.
|
||||
- Example: the `handoff` actor instance already represents `(workspaceId, repoId, handoffId)`, so its SQLite tables should not need those columns for primary keys.
|
||||
3. Do not use backend-global SQLite singletons; database access must go through actor `db` providers (`c.db`).
|
||||
4. Do not use published RivetKit npm packages.
|
||||
5. RivetKit is linked via pnpm `link:` protocol to `../rivet/rivetkit-typescript/packages/rivetkit`. Sub-packages (`@rivetkit/sqlite-vfs`, etc.) resolve transitively from the rivet workspace.
|
||||
4. The default dependency source for RivetKit is the published `rivetkit` package so workspace installs and CI remain self-contained.
|
||||
5. When working on coordinated RivetKit changes, you may temporarily relink to a local checkout instead of the published package.
|
||||
- Dedicated local checkout for this workspace: `/Users/nathan/conductor/workspaces/handoff/rivet-checkout`
|
||||
- Dev worktree note: when working on RivetKit fixes for this repo, prefer the dedicated local checkout above and link to `../rivet-checkout/rivetkit-typescript/packages/rivetkit`.
|
||||
6. Before using, build RivetKit in the rivet repo:
|
||||
- Preferred local link target: `../rivet-checkout/rivetkit-typescript/packages/rivetkit`
|
||||
- Sub-packages (`@rivetkit/sqlite-vfs`, etc.) resolve transitively from the RivetKit workspace when using the local checkout.
|
||||
6. Before using a local checkout, build RivetKit in the rivet repo:
|
||||
```bash
|
||||
cd ../rivet-checkout/rivetkit-typescript
|
||||
pnpm install
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
"drizzle-orm": "^0.44.5",
|
||||
"hono": "^4.11.9",
|
||||
"pino": "^10.3.1",
|
||||
"rivetkit": "link:../../../../../handoff/rivet-checkout/rivetkit-typescript/packages/rivetkit",
|
||||
"rivetkit": "2.1.6",
|
||||
"sandbox-agent": "workspace:*",
|
||||
"uuid": "^13.0.0",
|
||||
"zod": "^4.1.5"
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@openhandoff/shared": "workspace:*",
|
||||
"rivetkit": "link:../../../../../handoff/rivet-checkout/rivetkit-typescript/packages/rivetkit"
|
||||
"rivetkit": "2.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsup": "^8.5.0"
|
||||
|
|
|
|||
|
|
@ -1595,6 +1595,118 @@
|
|||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Permission prompt */
|
||||
.permission-prompt {
|
||||
margin: 8px 0;
|
||||
padding: 12px 14px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.permission-prompt.resolved {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.permission-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.permission-icon {
|
||||
color: var(--warning, #f59e0b);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.permission-prompt.resolved .permission-icon {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.permission-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.permission-description {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8px;
|
||||
padding-left: 22px;
|
||||
}
|
||||
|
||||
.permission-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding-left: 22px;
|
||||
}
|
||||
|
||||
.permission-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition), color var(--transition), border-color var(--transition);
|
||||
}
|
||||
|
||||
.permission-btn:hover:not(:disabled) {
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.permission-btn.allow:hover:not(:disabled) {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
border-color: rgba(34, 197, 94, 0.3);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.permission-btn.reject:hover:not(:disabled) {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.permission-btn:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.permission-btn.selected {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.permission-btn.selected.allow {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
border-color: rgba(34, 197, 94, 0.4);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.permission-btn.selected.reject {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border-color: rgba(239, 68, 68, 0.4);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.permission-btn.dimmed {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.permission-auto-resolved {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.status-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import {
|
|||
type AgentInfo,
|
||||
type SessionEvent,
|
||||
type Session,
|
||||
type SessionPermissionRequest,
|
||||
type PermissionReply,
|
||||
InMemorySessionPersistDriver,
|
||||
type SessionPersistDriver,
|
||||
} from "sandbox-agent";
|
||||
|
|
@ -295,6 +297,11 @@ export default function App() {
|
|||
const clientRef = useRef<SandboxAgent | null>(null);
|
||||
const activeSessionRef = useRef<Session | null>(null);
|
||||
const eventUnsubRef = useRef<(() => void) | null>(null);
|
||||
const permissionUnsubRef = useRef<(() => void) | null>(null);
|
||||
const pendingPermissionsRef = useRef<Map<string, SessionPermissionRequest>>(new Map());
|
||||
const permissionToolCallToIdRef = useRef<Map<string, string>>(new Map());
|
||||
const [pendingPermissionIds, setPendingPermissionIds] = useState<Set<string>>(new Set());
|
||||
const [resolvedPermissions, setResolvedPermissions] = useState<Map<string, string>>(new Map());
|
||||
const sessionEventsCacheRef = useRef<Map<string, SessionEvent[]>>(new Map());
|
||||
const selectedSessionIdRef = useRef(sessionId);
|
||||
const resumeInFlightSessionIdRef = useRef<string | null>(null);
|
||||
|
|
@ -538,8 +545,45 @@ export default function App() {
|
|||
});
|
||||
});
|
||||
eventUnsubRef.current = unsub;
|
||||
|
||||
// Subscribe to permission requests
|
||||
if (permissionUnsubRef.current) {
|
||||
permissionUnsubRef.current();
|
||||
permissionUnsubRef.current = null;
|
||||
}
|
||||
const permUnsub = session.onPermissionRequest((request: SessionPermissionRequest) => {
|
||||
if (!isCurrentSubscription()) return;
|
||||
pendingPermissionsRef.current.set(request.id, request);
|
||||
if (request.toolCall?.toolCallId) {
|
||||
permissionToolCallToIdRef.current.set(request.toolCall.toolCallId, request.id);
|
||||
}
|
||||
setPendingPermissionIds((prev) => new Set([...prev, request.id]));
|
||||
});
|
||||
permissionUnsubRef.current = permUnsub;
|
||||
}, [getClient]);
|
||||
|
||||
const handlePermissionReply = useCallback(async (permissionId: string, reply: PermissionReply) => {
|
||||
const session = activeSessionRef.current;
|
||||
if (!session) return;
|
||||
try {
|
||||
await session.respondPermission(permissionId, reply);
|
||||
const request = pendingPermissionsRef.current.get(permissionId);
|
||||
const selectedOption = request?.options.find((o) =>
|
||||
reply === "always" ? o.kind === "allow_always" :
|
||||
reply === "once" ? o.kind === "allow_once" :
|
||||
o.kind === "reject_once" || o.kind === "reject_always"
|
||||
);
|
||||
setResolvedPermissions((prev) => new Map([...prev, [permissionId, selectedOption?.optionId ?? reply]]));
|
||||
setPendingPermissionIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(permissionId);
|
||||
return next;
|
||||
});
|
||||
} catch (error) {
|
||||
pushErrorToast(error, "Failed to respond to permission request");
|
||||
}
|
||||
}, [pushErrorToast]);
|
||||
|
||||
const connectToDaemon = async (reportError: boolean, overrideEndpoint?: string) => {
|
||||
setConnecting(true);
|
||||
if (reportError) {
|
||||
|
|
@ -551,6 +595,10 @@ export default function App() {
|
|||
eventUnsubRef.current();
|
||||
eventUnsubRef.current = null;
|
||||
}
|
||||
if (permissionUnsubRef.current) {
|
||||
permissionUnsubRef.current();
|
||||
permissionUnsubRef.current = null;
|
||||
}
|
||||
subscriptionGenerationRef.current += 1;
|
||||
activeSessionRef.current = null;
|
||||
if (clientRef.current) {
|
||||
|
|
@ -603,6 +651,10 @@ export default function App() {
|
|||
eventUnsubRef.current();
|
||||
eventUnsubRef.current = null;
|
||||
}
|
||||
if (permissionUnsubRef.current) {
|
||||
permissionUnsubRef.current();
|
||||
permissionUnsubRef.current = null;
|
||||
}
|
||||
subscriptionGenerationRef.current += 1;
|
||||
activeSessionRef.current = null;
|
||||
if (clientRef.current) {
|
||||
|
|
@ -818,7 +870,7 @@ export default function App() {
|
|||
// Apply mode if selected
|
||||
if (!skipPostCreateConfig && config.agentMode) {
|
||||
try {
|
||||
await session.send("session/set_mode", { modeId: config.agentMode });
|
||||
await session.rawSend("session/set_mode", { modeId: config.agentMode });
|
||||
} catch {
|
||||
// Mode application is best-effort
|
||||
}
|
||||
|
|
@ -834,7 +886,7 @@ export default function App() {
|
|||
(opt) => opt.category === "model" && opt.type === "select" && typeof opt.id === "string"
|
||||
);
|
||||
if (modelOption && config.model !== modelOption.currentValue) {
|
||||
await session.send("session/set_config_option", {
|
||||
await session.rawSend("session/set_config_option", {
|
||||
optionId: modelOption.id,
|
||||
value: config.model,
|
||||
});
|
||||
|
|
@ -880,6 +932,10 @@ export default function App() {
|
|||
eventUnsubRef.current();
|
||||
eventUnsubRef.current = null;
|
||||
}
|
||||
if (permissionUnsubRef.current) {
|
||||
permissionUnsubRef.current();
|
||||
permissionUnsubRef.current = null;
|
||||
}
|
||||
activeSessionRef.current = null;
|
||||
await fetchSessions();
|
||||
} catch (error) {
|
||||
|
|
@ -1165,6 +1221,43 @@ export default function App() {
|
|||
continue;
|
||||
}
|
||||
|
||||
if (event.sender === "agent" && method === "session/request_permission") {
|
||||
const params = payload.params as {
|
||||
options?: Array<{ optionId: string; name: string; kind: string }>;
|
||||
toolCall?: { title?: string; toolCallId?: string; description?: string };
|
||||
} | undefined;
|
||||
const toolCallId = params?.toolCall?.toolCallId;
|
||||
const sdkPermissionId = toolCallId
|
||||
? permissionToolCallToIdRef.current.get(toolCallId)
|
||||
: undefined;
|
||||
const permissionId = sdkPermissionId
|
||||
?? (typeof payload.id === "number" || typeof payload.id === "string"
|
||||
? String(payload.id)
|
||||
: event.id);
|
||||
const options = (params?.options ?? []).map((o) => ({
|
||||
optionId: o.optionId,
|
||||
name: o.name,
|
||||
kind: o.kind,
|
||||
}));
|
||||
const title = params?.toolCall?.title ?? params?.toolCall?.toolCallId ?? "Permission request";
|
||||
const resolved = resolvedPermissions.get(permissionId);
|
||||
entries.push({
|
||||
id: event.id,
|
||||
eventId: event.id,
|
||||
kind: "permission",
|
||||
time,
|
||||
permission: {
|
||||
permissionId,
|
||||
title,
|
||||
description: params?.toolCall?.description,
|
||||
options,
|
||||
resolved: resolved != null || sdkPermissionId == null,
|
||||
selectedOptionId: resolved,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.sender === "agent" && method === "_sandboxagent/agent/unparsed") {
|
||||
const params = payload.params as { error?: string; location?: string } | undefined;
|
||||
entries.push({
|
||||
|
|
@ -1194,7 +1287,7 @@ export default function App() {
|
|||
}
|
||||
|
||||
return entries;
|
||||
}, [events]);
|
||||
}, [events, resolvedPermissions]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
|
@ -1202,6 +1295,10 @@ export default function App() {
|
|||
eventUnsubRef.current();
|
||||
eventUnsubRef.current = null;
|
||||
}
|
||||
if (permissionUnsubRef.current) {
|
||||
permissionUnsubRef.current();
|
||||
permissionUnsubRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
|
@ -1684,6 +1781,7 @@ export default function App() {
|
|||
isThinking={isThinking}
|
||||
agentId={agentId}
|
||||
tokenUsage={tokenUsage}
|
||||
onPermissionReply={handlePermissionReply}
|
||||
/>
|
||||
|
||||
<DebugPanel
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useState } from "react";
|
||||
import { getMessageClass } from "./messageUtils";
|
||||
import type { TimelineEntry } from "./types";
|
||||
import { AlertTriangle, ChevronRight, ChevronDown, Wrench, Brain, Info, ExternalLink, PlayCircle } from "lucide-react";
|
||||
import { AlertTriangle, ChevronRight, ChevronDown, Wrench, Brain, Info, ExternalLink, PlayCircle, Shield, Check, X } from "lucide-react";
|
||||
import MarkdownText from "./MarkdownText";
|
||||
|
||||
const ToolItem = ({
|
||||
|
|
@ -170,6 +170,73 @@ const ToolGroup = ({ entries, onEventClick }: { entries: TimelineEntry[]; onEven
|
|||
);
|
||||
};
|
||||
|
||||
const PermissionPrompt = ({
|
||||
entry,
|
||||
onPermissionReply,
|
||||
}: {
|
||||
entry: TimelineEntry;
|
||||
onPermissionReply?: (permissionId: string, reply: "once" | "always" | "reject") => void;
|
||||
}) => {
|
||||
const perm = entry.permission;
|
||||
if (!perm) return null;
|
||||
|
||||
const resolved = perm.resolved;
|
||||
const selectedId = perm.selectedOptionId;
|
||||
|
||||
const replyForOption = (kind: string): "once" | "always" | "reject" => {
|
||||
if (kind === "allow_once") return "once";
|
||||
if (kind === "allow_always") return "always";
|
||||
return "reject";
|
||||
};
|
||||
|
||||
const labelForKind = (kind: string, name: string): string => {
|
||||
if (name) return name;
|
||||
if (kind === "allow_once") return "Allow Once";
|
||||
if (kind === "allow_always") return "Always Allow";
|
||||
if (kind === "reject_once") return "Reject";
|
||||
if (kind === "reject_always") return "Reject Always";
|
||||
return kind;
|
||||
};
|
||||
|
||||
const classForKind = (kind: string): string => {
|
||||
if (kind.startsWith("allow")) return "allow";
|
||||
return "reject";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`permission-prompt ${resolved ? "resolved" : ""}`}>
|
||||
<div className="permission-header">
|
||||
<Shield size={14} className="permission-icon" />
|
||||
<span className="permission-title">{perm.title}</span>
|
||||
</div>
|
||||
{perm.description && (
|
||||
<div className="permission-description">{perm.description}</div>
|
||||
)}
|
||||
<div className="permission-actions">
|
||||
{perm.options.map((opt) => {
|
||||
const isSelected = resolved && selectedId === opt.optionId;
|
||||
const wasRejected = resolved && !isSelected && selectedId != null;
|
||||
return (
|
||||
<button
|
||||
key={opt.optionId}
|
||||
type="button"
|
||||
className={`permission-btn ${classForKind(opt.kind)} ${isSelected ? "selected" : ""} ${wasRejected ? "dimmed" : ""}`}
|
||||
disabled={resolved}
|
||||
onClick={() => onPermissionReply?.(perm.permissionId, replyForOption(opt.kind))}
|
||||
>
|
||||
{isSelected && (opt.kind.startsWith("allow") ? <Check size={12} /> : <X size={12} />)}
|
||||
{labelForKind(opt.kind, opt.name)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{resolved && !selectedId && (
|
||||
<span className="permission-auto-resolved">Auto-resolved</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const agentLogos: Record<string, string> = {
|
||||
claude: `${import.meta.env.BASE_URL}logos/claude.svg`,
|
||||
codex: `${import.meta.env.BASE_URL}logos/openai.svg`,
|
||||
|
|
@ -185,7 +252,8 @@ const ChatMessages = ({
|
|||
messagesEndRef,
|
||||
onEventClick,
|
||||
isThinking,
|
||||
agentId
|
||||
agentId,
|
||||
onPermissionReply,
|
||||
}: {
|
||||
entries: TimelineEntry[];
|
||||
sessionError: string | null;
|
||||
|
|
@ -194,9 +262,10 @@ const ChatMessages = ({
|
|||
onEventClick?: (eventId: string) => void;
|
||||
isThinking?: boolean;
|
||||
agentId?: string;
|
||||
onPermissionReply?: (permissionId: string, reply: "once" | "always" | "reject") => void;
|
||||
}) => {
|
||||
// Group consecutive tool/reasoning/meta entries together
|
||||
const groupedEntries: Array<{ type: "message" | "tool-group" | "divider"; entries: TimelineEntry[] }> = [];
|
||||
const groupedEntries: Array<{ type: "message" | "tool-group" | "divider" | "permission"; entries: TimelineEntry[] }> = [];
|
||||
|
||||
let currentToolGroup: TimelineEntry[] = [];
|
||||
|
||||
|
|
@ -211,7 +280,10 @@ const ChatMessages = ({
|
|||
const isStatusDivider = entry.kind === "meta" &&
|
||||
["Session Started", "Turn Started", "Turn Ended"].includes(entry.meta?.title ?? "");
|
||||
|
||||
if (isStatusDivider) {
|
||||
if (entry.kind === "permission") {
|
||||
flushToolGroup();
|
||||
groupedEntries.push({ type: "permission", entries: [entry] });
|
||||
} else if (isStatusDivider) {
|
||||
flushToolGroup();
|
||||
groupedEntries.push({ type: "divider", entries: [entry] });
|
||||
} else if (entry.kind === "tool" || entry.kind === "reasoning" || (entry.kind === "meta" && entry.meta?.detail)) {
|
||||
|
|
@ -242,6 +314,17 @@ const ChatMessages = ({
|
|||
);
|
||||
}
|
||||
|
||||
if (group.type === "permission") {
|
||||
const entry = group.entries[0];
|
||||
return (
|
||||
<PermissionPrompt
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
onPermissionReply={onPermissionReply}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (group.type === "tool-group") {
|
||||
return <ToolGroup key={`group-${idx}`} entries={group.entries} onEventClick={onEventClick} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ const ChatPanel = ({
|
|||
isThinking,
|
||||
agentId,
|
||||
tokenUsage,
|
||||
onPermissionReply,
|
||||
}: {
|
||||
sessionId: string;
|
||||
transcriptEntries: TimelineEntry[];
|
||||
|
|
@ -87,6 +88,7 @@ const ChatPanel = ({
|
|||
isThinking?: boolean;
|
||||
agentId?: string;
|
||||
tokenUsage?: { used: number; size: number; cost?: number } | null;
|
||||
onPermissionReply?: (permissionId: string, reply: "once" | "always" | "reject") => void;
|
||||
}) => {
|
||||
const [showAgentMenu, setShowAgentMenu] = useState(false);
|
||||
const [copiedSessionId, setCopiedSessionId] = useState(false);
|
||||
|
|
@ -258,6 +260,7 @@ const ChatPanel = ({
|
|||
onEventClick={onEventClick}
|
||||
isThinking={isThinking}
|
||||
agentId={agentId}
|
||||
onPermissionReply={onPermissionReply}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
export type PermissionOption = {
|
||||
optionId: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
};
|
||||
|
||||
export type TimelineEntry = {
|
||||
id: string;
|
||||
eventId?: string; // Links back to the original event for navigation
|
||||
kind: "message" | "tool" | "meta" | "reasoning";
|
||||
kind: "message" | "tool" | "meta" | "reasoning" | "permission";
|
||||
time: string;
|
||||
// For messages:
|
||||
role?: "user" | "assistant";
|
||||
|
|
@ -15,4 +21,13 @@ export type TimelineEntry = {
|
|||
reasoning?: { text: string; visibility?: string };
|
||||
// For meta:
|
||||
meta?: { title: string; detail?: string; severity?: "info" | "error" };
|
||||
// For permission requests:
|
||||
permission?: {
|
||||
permissionId: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
options: PermissionOption[];
|
||||
resolved?: boolean;
|
||||
selectedOptionId?: string;
|
||||
};
|
||||
};
|
||||
|
|
|
|||
577
pnpm-lock.yaml
generated
577
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -11,3 +11,4 @@ packages:
|
|||
- "scripts/release"
|
||||
- "scripts/sandbox-testing"
|
||||
- "examples/*"
|
||||
- "server/packages/sandbox-agent/tests/opencode-compat"
|
||||
|
|
|
|||
|
|
@ -247,3 +247,13 @@ Update this file continuously during the migration.
|
|||
- Owner: Unassigned.
|
||||
- Status: in_progress
|
||||
- Links: `research/acp/simplify-server.md`, `docs/mcp-config.mdx`, `docs/skills-config.mdx`
|
||||
|
||||
- Date: 2026-03-10
|
||||
- Area: ACP HTTP client transport reentrancy for human-in-the-loop requests
|
||||
- Issue: The TypeScript `acp-http-client` serialized the full lifetime of each POST on a single write queue. A long-running `session/prompt` request therefore blocked the client from POSTing a response to an agent-initiated `session/request_permission`, deadlocking permission approval flows.
|
||||
- Impact: Permission requests arrived over SSE, but replying to them never resumed the original prompt turn. This blocked Claude and any other ACP agent using `session/request_permission`.
|
||||
- Proposed direction: Make the HTTP transport fire POSTs asynchronously after preserving outbound ordering at enqueue time, rather than waiting for the entire HTTP response before the next write can begin. Keep response bodies routed back into the readable stream so request promises still resolve normally.
|
||||
- Decision: Accepted and implemented in `acp-http-client`.
|
||||
- Owner: Unassigned.
|
||||
- Status: resolved
|
||||
- Links: `sdks/acp-http-client/src/index.ts`, `sdks/acp-http-client/tests/smoke.test.ts`, `sdks/typescript/tests/integration.test.ts`
|
||||
|
|
|
|||
|
|
@ -378,31 +378,39 @@ class StreamableHttpAcpTransport {
|
|||
});
|
||||
|
||||
const url = this.buildUrl(this.bootstrapQueryIfNeeded());
|
||||
const response = await this.fetcher(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(message),
|
||||
});
|
||||
|
||||
this.postedOnce = true;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new AcpHttpError(response.status, await readProblem(response), response);
|
||||
}
|
||||
|
||||
this.ensureSseLoop();
|
||||
void this.postMessage(url, headers, message);
|
||||
}
|
||||
|
||||
if (response.status === 200) {
|
||||
const text = await response.text();
|
||||
if (text.trim()) {
|
||||
const envelope = JSON.parse(text) as AnyMessage;
|
||||
this.pushInbound(envelope);
|
||||
private async postMessage(url: string, headers: Headers, message: AnyMessage): Promise<void> {
|
||||
try {
|
||||
const response = await this.fetcher(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(message),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new AcpHttpError(response.status, await readProblem(response), response);
|
||||
}
|
||||
} else {
|
||||
|
||||
if (response.status === 200) {
|
||||
const text = await response.text();
|
||||
if (text.trim()) {
|
||||
const envelope = JSON.parse(text) as AnyMessage;
|
||||
this.pushInbound(envelope);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Drain response body so the underlying connection is released back to
|
||||
// the pool. Without this, Node.js undici keeps the socket occupied and
|
||||
// the pool. Without this, Node.js undici keeps the socket occupied and
|
||||
// may stall subsequent requests to the same origin.
|
||||
await response.text().catch(() => {});
|
||||
} catch (error) {
|
||||
console.error("ACP write error:", error);
|
||||
this.failReadable(error);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -140,4 +140,54 @@ describe("AcpHttpClient integration", () => {
|
|||
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
it("answers session/request_permission while session/prompt is still in flight", async () => {
|
||||
const permissionRequests: Array<{ sessionId: string; title?: string | null }> = [];
|
||||
const serverId = `acp-http-client-permissions-${Date.now().toString(36)}`;
|
||||
|
||||
const client = new AcpHttpClient({
|
||||
baseUrl,
|
||||
token,
|
||||
transport: {
|
||||
path: `/v1/acp/${encodeURIComponent(serverId)}`,
|
||||
bootstrapQuery: { agent: "mock" },
|
||||
},
|
||||
client: {
|
||||
requestPermission: async (request) => {
|
||||
permissionRequests.push({
|
||||
sessionId: request.sessionId,
|
||||
title: request.toolCall.title,
|
||||
});
|
||||
return {
|
||||
outcome: {
|
||||
outcome: "selected",
|
||||
optionId: "reject-once",
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await client.initialize();
|
||||
|
||||
const session = await client.newSession({
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
const prompt = await client.prompt({
|
||||
sessionId: session.sessionId,
|
||||
prompt: [{ type: "text", text: "please trigger permission" }],
|
||||
});
|
||||
|
||||
expect(prompt.stopReason).toBe("end_turn");
|
||||
expect(permissionRequests).toEqual([
|
||||
{
|
||||
sessionId: session.sessionId,
|
||||
title: "Write mock.txt",
|
||||
},
|
||||
]);
|
||||
|
||||
await client.disconnect();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,8 +8,12 @@ import {
|
|||
type CancelNotification,
|
||||
type NewSessionRequest,
|
||||
type NewSessionResponse,
|
||||
type PermissionOption,
|
||||
type PermissionOptionKind,
|
||||
type PromptRequest,
|
||||
type PromptResponse,
|
||||
type RequestPermissionRequest,
|
||||
type RequestPermissionResponse,
|
||||
type SessionConfigOption,
|
||||
type SessionNotification,
|
||||
type SessionModeState,
|
||||
|
|
@ -142,9 +146,28 @@ export interface SessionSendOptions {
|
|||
}
|
||||
|
||||
export type SessionEventListener = (event: SessionEvent) => void;
|
||||
export type PermissionReply = "once" | "always" | "reject";
|
||||
export type PermissionRequestListener = (request: SessionPermissionRequest) => void;
|
||||
export type ProcessLogListener = (entry: ProcessLogEntry) => void;
|
||||
export type ProcessLogFollowQuery = Omit<ProcessLogsQuery, "follow">;
|
||||
|
||||
export interface SessionPermissionRequestOption {
|
||||
optionId: string;
|
||||
name: string;
|
||||
kind: PermissionOptionKind;
|
||||
}
|
||||
|
||||
export interface SessionPermissionRequest {
|
||||
id: string;
|
||||
createdAt: number;
|
||||
sessionId: string;
|
||||
agentSessionId: string;
|
||||
availableReplies: PermissionReply[];
|
||||
options: SessionPermissionRequestOption[];
|
||||
toolCall: RequestPermissionRequest["toolCall"];
|
||||
rawRequest: RequestPermissionRequest;
|
||||
}
|
||||
|
||||
export interface AgentQueryOptions {
|
||||
config?: boolean;
|
||||
noCache?: boolean;
|
||||
|
|
@ -238,6 +261,22 @@ export class UnsupportedSessionConfigOptionError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
export class UnsupportedPermissionReplyError extends Error {
|
||||
readonly permissionId: string;
|
||||
readonly requestedReply: PermissionReply;
|
||||
readonly availableReplies: PermissionReply[];
|
||||
|
||||
constructor(permissionId: string, requestedReply: PermissionReply, availableReplies: PermissionReply[]) {
|
||||
super(
|
||||
`Permission '${permissionId}' does not support reply '${requestedReply}'. Available replies: ${availableReplies.join(", ") || "(none)"}`,
|
||||
);
|
||||
this.name = "UnsupportedPermissionReplyError";
|
||||
this.permissionId = permissionId;
|
||||
this.requestedReply = requestedReply;
|
||||
this.availableReplies = availableReplies;
|
||||
}
|
||||
}
|
||||
|
||||
export class Session {
|
||||
private record: SessionRecord;
|
||||
private readonly sandbox: SandboxAgent;
|
||||
|
|
@ -280,14 +319,14 @@ export class Session {
|
|||
return this;
|
||||
}
|
||||
|
||||
async send(method: string, params: Record<string, unknown> = {}, options: SessionSendOptions = {}): Promise<unknown> {
|
||||
const updated = await this.sandbox.sendSessionMethod(this.id, method, params, options);
|
||||
async rawSend(method: string, params: Record<string, unknown> = {}, options: SessionSendOptions = {}): Promise<unknown> {
|
||||
const updated = await this.sandbox.rawSendSessionMethod(this.id, method, params, options);
|
||||
this.apply(updated.session.toRecord());
|
||||
return updated.response;
|
||||
}
|
||||
|
||||
async prompt(prompt: PromptRequest["prompt"]): Promise<PromptResponse> {
|
||||
const response = await this.send("session/prompt", { prompt });
|
||||
const response = await this.rawSend("session/prompt", { prompt });
|
||||
return response as PromptResponse;
|
||||
}
|
||||
|
||||
|
|
@ -327,6 +366,18 @@ export class Session {
|
|||
return this.sandbox.onSessionEvent(this.id, listener);
|
||||
}
|
||||
|
||||
onPermissionRequest(listener: PermissionRequestListener): () => void {
|
||||
return this.sandbox.onPermissionRequest(this.id, listener);
|
||||
}
|
||||
|
||||
async respondPermission(permissionId: string, reply: PermissionReply): Promise<void> {
|
||||
await this.sandbox.respondPermission(permissionId, reply);
|
||||
}
|
||||
|
||||
async rawRespondPermission(permissionId: string, response: RequestPermissionResponse): Promise<void> {
|
||||
await this.sandbox.rawRespondPermission(permissionId, response);
|
||||
}
|
||||
|
||||
toRecord(): SessionRecord {
|
||||
return { ...this.record };
|
||||
}
|
||||
|
|
@ -355,6 +406,12 @@ export class LiveAcpConnection {
|
|||
direction: AcpEnvelopeDirection,
|
||||
localSessionId: string | null,
|
||||
) => void;
|
||||
private readonly onPermissionRequest: (
|
||||
connection: LiveAcpConnection,
|
||||
localSessionId: string,
|
||||
agentSessionId: string,
|
||||
request: RequestPermissionRequest,
|
||||
) => Promise<RequestPermissionResponse>;
|
||||
|
||||
private constructor(
|
||||
agent: string,
|
||||
|
|
@ -366,11 +423,18 @@ export class LiveAcpConnection {
|
|||
direction: AcpEnvelopeDirection,
|
||||
localSessionId: string | null,
|
||||
) => void,
|
||||
onPermissionRequest: (
|
||||
connection: LiveAcpConnection,
|
||||
localSessionId: string,
|
||||
agentSessionId: string,
|
||||
request: RequestPermissionRequest,
|
||||
) => Promise<RequestPermissionResponse>,
|
||||
) {
|
||||
this.agent = agent;
|
||||
this.connectionId = connectionId;
|
||||
this.acp = acp;
|
||||
this.onObservedEnvelope = onObservedEnvelope;
|
||||
this.onPermissionRequest = onPermissionRequest;
|
||||
}
|
||||
|
||||
static async create(options: {
|
||||
|
|
@ -386,6 +450,12 @@ export class LiveAcpConnection {
|
|||
direction: AcpEnvelopeDirection,
|
||||
localSessionId: string | null,
|
||||
) => void;
|
||||
onPermissionRequest: (
|
||||
connection: LiveAcpConnection,
|
||||
localSessionId: string,
|
||||
agentSessionId: string,
|
||||
request: RequestPermissionRequest,
|
||||
) => Promise<RequestPermissionResponse>;
|
||||
}): Promise<LiveAcpConnection> {
|
||||
const connectionId = randomId();
|
||||
|
||||
|
|
@ -400,6 +470,12 @@ export class LiveAcpConnection {
|
|||
bootstrapQuery: { agent: options.agent },
|
||||
},
|
||||
client: {
|
||||
requestPermission: async (request: RequestPermissionRequest) => {
|
||||
if (!live) {
|
||||
return cancelledPermissionResponse();
|
||||
}
|
||||
return live.handlePermissionRequest(request);
|
||||
},
|
||||
sessionUpdate: async (_notification: SessionNotification) => {
|
||||
// Session updates are observed via envelope persistence.
|
||||
},
|
||||
|
|
@ -416,7 +492,13 @@ export class LiveAcpConnection {
|
|||
},
|
||||
});
|
||||
|
||||
live = new LiveAcpConnection(options.agent, connectionId, acp, options.onObservedEnvelope);
|
||||
live = new LiveAcpConnection(
|
||||
options.agent,
|
||||
connectionId,
|
||||
acp,
|
||||
options.onObservedEnvelope,
|
||||
options.onPermissionRequest,
|
||||
);
|
||||
|
||||
const initResult = await acp.initialize({
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
|
|
@ -550,6 +632,23 @@ export class LiveAcpConnection {
|
|||
this.lastAdapterExitAt = Date.now();
|
||||
}
|
||||
|
||||
private async handlePermissionRequest(
|
||||
request: RequestPermissionRequest,
|
||||
): Promise<RequestPermissionResponse> {
|
||||
const agentSessionId = request.sessionId;
|
||||
const localSessionId = this.localByAgentSessionId.get(agentSessionId);
|
||||
if (!localSessionId) {
|
||||
return cancelledPermissionResponse();
|
||||
}
|
||||
|
||||
return this.onPermissionRequest(
|
||||
this,
|
||||
localSessionId,
|
||||
agentSessionId,
|
||||
clonePermissionRequest(request),
|
||||
);
|
||||
}
|
||||
|
||||
private resolveSessionId(envelope: AnyMessage, direction: AcpEnvelopeDirection): string | null {
|
||||
const id = envelopeId(envelope);
|
||||
const method = envelopeMethod(envelope);
|
||||
|
|
@ -782,6 +881,8 @@ export class SandboxAgent {
|
|||
private readonly pendingLiveConnections = new Map<string, Promise<LiveAcpConnection>>();
|
||||
private readonly sessionHandles = new Map<string, Session>();
|
||||
private readonly eventListeners = new Map<string, Set<SessionEventListener>>();
|
||||
private readonly permissionListeners = new Map<string, Set<PermissionRequestListener>>();
|
||||
private readonly pendingPermissionRequests = new Map<string, PendingPermissionRequestState>();
|
||||
private readonly nextSessionEventIndexBySession = new Map<string, number>();
|
||||
private readonly seedSessionEventIndexBySession = new Map<string, Promise<void>>();
|
||||
|
||||
|
|
@ -840,6 +941,11 @@ export class SandboxAgent {
|
|||
this.disposed = true;
|
||||
this.healthWaitAbortController.abort(createAbortError("SandboxAgent was disposed."));
|
||||
|
||||
for (const [permissionId, pending] of this.pendingPermissionRequests) {
|
||||
this.pendingPermissionRequests.delete(permissionId);
|
||||
pending.resolve(cancelledPermissionResponse());
|
||||
}
|
||||
|
||||
const connections = [...this.liveConnections.values()];
|
||||
this.liveConnections.clear();
|
||||
const pending = [...this.pendingLiveConnections.values()];
|
||||
|
|
@ -984,6 +1090,8 @@ export class SandboxAgent {
|
|||
}
|
||||
|
||||
async destroySession(id: string): Promise<Session> {
|
||||
this.cancelPendingPermissionsForSession(id);
|
||||
|
||||
try {
|
||||
await this.sendSessionMethodInternal(id, SESSION_CANCEL_METHOD, {}, {}, true);
|
||||
} catch {
|
||||
|
|
@ -1100,7 +1208,26 @@ export class SandboxAgent {
|
|||
|
||||
async getSessionModes(sessionId: string): Promise<SessionModeState | null> {
|
||||
const record = await this.requireSessionRecord(sessionId);
|
||||
return cloneModes(record.modes);
|
||||
if (record.modes && record.modes.availableModes.length > 0) {
|
||||
return cloneModes(record.modes);
|
||||
}
|
||||
|
||||
const hydrated = await this.hydrateSessionConfigOptions(record.id, record);
|
||||
if (hydrated.modes && hydrated.modes.availableModes.length > 0) {
|
||||
return cloneModes(hydrated.modes);
|
||||
}
|
||||
|
||||
const derived = deriveModesFromConfigOptions(hydrated.configOptions);
|
||||
if (!derived) {
|
||||
return cloneModes(hydrated.modes);
|
||||
}
|
||||
|
||||
const updated: SessionRecord = {
|
||||
...hydrated,
|
||||
modes: derived,
|
||||
};
|
||||
await this.persist.updateSession(updated);
|
||||
return cloneModes(derived);
|
||||
}
|
||||
|
||||
private async setSessionCategoryValue(
|
||||
|
|
@ -1155,7 +1282,7 @@ export class SandboxAgent {
|
|||
return updated;
|
||||
}
|
||||
|
||||
async sendSessionMethod(
|
||||
async rawSendSessionMethod(
|
||||
sessionId: string,
|
||||
method: string,
|
||||
params: Record<string, unknown>,
|
||||
|
|
@ -1290,6 +1417,47 @@ export class SandboxAgent {
|
|||
};
|
||||
}
|
||||
|
||||
onPermissionRequest(sessionId: string, listener: PermissionRequestListener): () => void {
|
||||
const listeners = this.permissionListeners.get(sessionId) ?? new Set<PermissionRequestListener>();
|
||||
listeners.add(listener);
|
||||
this.permissionListeners.set(sessionId, listeners);
|
||||
|
||||
return () => {
|
||||
const set = this.permissionListeners.get(sessionId);
|
||||
if (!set) {
|
||||
return;
|
||||
}
|
||||
set.delete(listener);
|
||||
if (set.size === 0) {
|
||||
this.permissionListeners.delete(sessionId);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async respondPermission(permissionId: string, reply: PermissionReply): Promise<void> {
|
||||
const pending = this.pendingPermissionRequests.get(permissionId);
|
||||
if (!pending) {
|
||||
throw new Error(`permission '${permissionId}' not found`);
|
||||
}
|
||||
|
||||
let response: RequestPermissionResponse;
|
||||
try {
|
||||
response = permissionReplyToResponse(permissionId, pending.request, reply);
|
||||
} catch (error) {
|
||||
pending.reject(error instanceof Error ? error : new Error(String(error)));
|
||||
this.pendingPermissionRequests.delete(permissionId);
|
||||
throw error;
|
||||
}
|
||||
this.resolvePendingPermission(permissionId, response);
|
||||
}
|
||||
|
||||
async rawRespondPermission(permissionId: string, response: RequestPermissionResponse): Promise<void> {
|
||||
if (!this.pendingPermissionRequests.has(permissionId)) {
|
||||
throw new Error(`permission '${permissionId}' not found`);
|
||||
}
|
||||
this.resolvePendingPermission(permissionId, clonePermissionResponse(response));
|
||||
}
|
||||
|
||||
async getHealth(): Promise<HealthResponse> {
|
||||
return this.requestHealth();
|
||||
}
|
||||
|
|
@ -1301,9 +1469,22 @@ export class SandboxAgent {
|
|||
}
|
||||
|
||||
async getAgent(agent: string, options?: AgentQueryOptions): Promise<AgentInfo> {
|
||||
return this.requestJson("GET", `${API_PREFIX}/agents/${encodeURIComponent(agent)}`, {
|
||||
query: toAgentQuery(options),
|
||||
});
|
||||
try {
|
||||
return await this.requestJson("GET", `${API_PREFIX}/agents/${encodeURIComponent(agent)}`, {
|
||||
query: toAgentQuery(options),
|
||||
});
|
||||
} catch (error) {
|
||||
if (!(error instanceof SandboxAgentError) || error.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const listed = await this.listAgents(options);
|
||||
const match = listed.agents.find((entry) => entry.id === agent);
|
||||
if (match) {
|
||||
return match;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async installAgent(agent: string, request: AgentInstallRequest = {}): Promise<AgentInstallResponse> {
|
||||
|
|
@ -1551,6 +1732,8 @@ export class SandboxAgent {
|
|||
onObservedEnvelope: (connection, envelope, direction, localSessionId) => {
|
||||
void this.persistObservedEnvelope(connection, envelope, direction, localSessionId);
|
||||
},
|
||||
onPermissionRequest: async (connection, localSessionId, agentSessionId, request) =>
|
||||
this.enqueuePermissionRequest(connection, localSessionId, agentSessionId, request),
|
||||
});
|
||||
|
||||
const raced = this.liveConnections.get(agent);
|
||||
|
|
@ -1753,6 +1936,69 @@ export class SandboxAgent {
|
|||
return record;
|
||||
}
|
||||
|
||||
private async enqueuePermissionRequest(
|
||||
_connection: LiveAcpConnection,
|
||||
localSessionId: string,
|
||||
agentSessionId: string,
|
||||
request: RequestPermissionRequest,
|
||||
): Promise<RequestPermissionResponse> {
|
||||
const listeners = this.permissionListeners.get(localSessionId);
|
||||
if (!listeners || listeners.size === 0) {
|
||||
return cancelledPermissionResponse();
|
||||
}
|
||||
|
||||
const pendingId = randomId();
|
||||
const permissionRequest: SessionPermissionRequest = {
|
||||
id: pendingId,
|
||||
createdAt: nowMs(),
|
||||
sessionId: localSessionId,
|
||||
agentSessionId,
|
||||
availableReplies: availablePermissionReplies(request.options),
|
||||
options: request.options.map(clonePermissionOption),
|
||||
toolCall: clonePermissionToolCall(request.toolCall),
|
||||
rawRequest: clonePermissionRequest(request),
|
||||
};
|
||||
|
||||
return await new Promise<RequestPermissionResponse>((resolve, reject) => {
|
||||
this.pendingPermissionRequests.set(pendingId, {
|
||||
id: pendingId,
|
||||
sessionId: localSessionId,
|
||||
request: clonePermissionRequest(request),
|
||||
resolve,
|
||||
reject,
|
||||
});
|
||||
|
||||
try {
|
||||
for (const listener of listeners) {
|
||||
listener(permissionRequest);
|
||||
}
|
||||
} catch (error) {
|
||||
this.pendingPermissionRequests.delete(pendingId);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private resolvePendingPermission(permissionId: string, response: RequestPermissionResponse): void {
|
||||
const pending = this.pendingPermissionRequests.get(permissionId);
|
||||
if (!pending) {
|
||||
throw new Error(`permission '${permissionId}' not found`);
|
||||
}
|
||||
|
||||
this.pendingPermissionRequests.delete(permissionId);
|
||||
pending.resolve(response);
|
||||
}
|
||||
|
||||
private cancelPendingPermissionsForSession(sessionId: string): void {
|
||||
for (const [permissionId, pending] of this.pendingPermissionRequests) {
|
||||
if (pending.sessionId !== sessionId) {
|
||||
continue;
|
||||
}
|
||||
this.pendingPermissionRequests.delete(permissionId);
|
||||
pending.resolve(cancelledPermissionResponse());
|
||||
}
|
||||
}
|
||||
|
||||
private async requestJson<T>(method: string, path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const response = await this.requestRaw(method, path, {
|
||||
query: options.query,
|
||||
|
|
@ -1922,6 +2168,14 @@ export class SandboxAgent {
|
|||
}
|
||||
}
|
||||
|
||||
type PendingPermissionRequestState = {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
request: RequestPermissionRequest;
|
||||
resolve: (response: RequestPermissionResponse) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
};
|
||||
|
||||
type QueryValue = string | number | boolean | null | undefined;
|
||||
|
||||
type RequestOptions = {
|
||||
|
|
@ -2166,6 +2420,26 @@ function cloneEnvelope(envelope: AnyMessage): AnyMessage {
|
|||
return JSON.parse(JSON.stringify(envelope)) as AnyMessage;
|
||||
}
|
||||
|
||||
function clonePermissionRequest(request: RequestPermissionRequest): RequestPermissionRequest {
|
||||
return JSON.parse(JSON.stringify(request)) as RequestPermissionRequest;
|
||||
}
|
||||
|
||||
function clonePermissionResponse(response: RequestPermissionResponse): RequestPermissionResponse {
|
||||
return JSON.parse(JSON.stringify(response)) as RequestPermissionResponse;
|
||||
}
|
||||
|
||||
function clonePermissionOption(option: PermissionOption): SessionPermissionRequestOption {
|
||||
return {
|
||||
optionId: option.optionId,
|
||||
name: option.name,
|
||||
kind: option.kind,
|
||||
};
|
||||
}
|
||||
|
||||
function clonePermissionToolCall(toolCall: RequestPermissionRequest["toolCall"]): RequestPermissionRequest["toolCall"] {
|
||||
return JSON.parse(JSON.stringify(toolCall)) as RequestPermissionRequest["toolCall"];
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, any> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
|
@ -2314,6 +2588,35 @@ function extractKnownModeIds(modes: SessionModeState | null | undefined): string
|
|||
.filter((value): value is string => !!value);
|
||||
}
|
||||
|
||||
function deriveModesFromConfigOptions(
|
||||
configOptions: SessionConfigOption[] | undefined,
|
||||
): SessionModeState | null {
|
||||
if (!configOptions || configOptions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const modeOption = findConfigOptionByCategory(configOptions, "mode");
|
||||
if (!modeOption || !Array.isArray(modeOption.options)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const availableModes = modeOption.options
|
||||
.flatMap((entry) => flattenConfigOptions(entry))
|
||||
.map((entry) => ({
|
||||
id: entry.value,
|
||||
name: entry.name,
|
||||
description: entry.description ?? null,
|
||||
}));
|
||||
|
||||
return {
|
||||
currentModeId:
|
||||
typeof modeOption.currentValue === "string" && modeOption.currentValue.length > 0
|
||||
? modeOption.currentValue
|
||||
: availableModes[0]?.id ?? "",
|
||||
availableModes,
|
||||
};
|
||||
}
|
||||
|
||||
function applyCurrentMode(
|
||||
modes: SessionModeState | null | undefined,
|
||||
currentModeId: string,
|
||||
|
|
@ -2344,6 +2647,25 @@ function applyConfigOptionValue(
|
|||
return updated;
|
||||
}
|
||||
|
||||
function flattenConfigOptions(entry: unknown): Array<{ value: string; name: string; description?: string }> {
|
||||
if (!isRecord(entry)) {
|
||||
return [];
|
||||
}
|
||||
if (typeof entry.value === "string" && typeof entry.name === "string") {
|
||||
return [
|
||||
{
|
||||
value: entry.value,
|
||||
name: entry.name,
|
||||
description: typeof entry.description === "string" ? entry.description : undefined,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (!Array.isArray(entry.options)) {
|
||||
return [];
|
||||
}
|
||||
return entry.options.flatMap((nested) => flattenConfigOptions(nested));
|
||||
}
|
||||
|
||||
function envelopeSessionUpdate(message: AnyMessage): Record<string, unknown> | null {
|
||||
if (!isRecord(message) || !("params" in message) || !isRecord(message.params)) {
|
||||
return null;
|
||||
|
|
@ -2368,6 +2690,60 @@ function cloneModes(value: SessionModeState | null | undefined): SessionModeStat
|
|||
return JSON.parse(JSON.stringify(value)) as SessionModeState;
|
||||
}
|
||||
|
||||
function availablePermissionReplies(options: PermissionOption[]): PermissionReply[] {
|
||||
const replies = new Set<PermissionReply>();
|
||||
for (const option of options) {
|
||||
if (option.kind === "allow_once") {
|
||||
replies.add("once");
|
||||
} else if (option.kind === "allow_always") {
|
||||
replies.add("always");
|
||||
} else if (option.kind === "reject_once" || option.kind === "reject_always") {
|
||||
replies.add("reject");
|
||||
}
|
||||
}
|
||||
return [...replies];
|
||||
}
|
||||
|
||||
function permissionReplyToResponse(
|
||||
permissionId: string,
|
||||
request: RequestPermissionRequest,
|
||||
reply: PermissionReply,
|
||||
): RequestPermissionResponse {
|
||||
const preferredKinds: PermissionOptionKind[] =
|
||||
reply === "once"
|
||||
? ["allow_once"]
|
||||
: reply === "always"
|
||||
? ["allow_always", "allow_once"]
|
||||
: ["reject_once", "reject_always"];
|
||||
|
||||
const selected = preferredKinds
|
||||
.map((kind) => request.options.find((option) => option.kind === kind))
|
||||
.find((option): option is PermissionOption => Boolean(option));
|
||||
|
||||
if (!selected) {
|
||||
throw new UnsupportedPermissionReplyError(
|
||||
permissionId,
|
||||
reply,
|
||||
availablePermissionReplies(request.options),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
outcome: {
|
||||
outcome: "selected",
|
||||
optionId: selected.optionId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function cancelledPermissionResponse(): RequestPermissionResponse {
|
||||
return {
|
||||
outcome: {
|
||||
outcome: "cancelled",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isSessionConfigOption(value: unknown): value is SessionConfigOption {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ export {
|
|||
SandboxAgent,
|
||||
SandboxAgentError,
|
||||
Session,
|
||||
UnsupportedPermissionReplyError,
|
||||
UnsupportedSessionCategoryError,
|
||||
UnsupportedSessionConfigOptionError,
|
||||
UnsupportedSessionValueError,
|
||||
|
|
@ -28,6 +29,10 @@ export type {
|
|||
SessionResumeOrCreateRequest,
|
||||
SessionSendOptions,
|
||||
SessionEventListener,
|
||||
PermissionReply,
|
||||
PermissionRequestListener,
|
||||
SessionPermissionRequest,
|
||||
SessionPermissionRequestOption,
|
||||
} from "./client.ts";
|
||||
|
||||
export type { InspectorUrlOptions } from "./inspector.ts";
|
||||
|
|
|
|||
|
|
@ -25,10 +25,12 @@ export function prepareMockAgentDataHome(dataHome: string): Record<string, strin
|
|||
runtimeEnv.XDG_DATA_HOME = dataHome;
|
||||
}
|
||||
|
||||
const nodeScript = String.raw`#!/usr/bin/env node
|
||||
const nodeScript = String.raw`#!/usr/bin/env node
|
||||
const { createInterface } = require("node:readline");
|
||||
|
||||
let nextSession = 0;
|
||||
let nextPermission = 0;
|
||||
const pendingPermissions = new Map();
|
||||
|
||||
function emit(value) {
|
||||
process.stdout.write(JSON.stringify(value) + "\n");
|
||||
|
|
@ -65,6 +67,38 @@ rl.on("line", (line) => {
|
|||
const hasId = Object.prototype.hasOwnProperty.call(msg, "id");
|
||||
const method = hasMethod ? msg.method : undefined;
|
||||
|
||||
if (!hasMethod && hasId) {
|
||||
const pending = pendingPermissions.get(String(msg.id));
|
||||
if (pending) {
|
||||
pendingPermissions.delete(String(msg.id));
|
||||
const outcome = msg?.result?.outcome;
|
||||
const optionId = outcome?.outcome === "selected" ? outcome.optionId : "cancelled";
|
||||
const suffix = optionId === "reject-once" ? "rejected" : "approved";
|
||||
emit({
|
||||
jsonrpc: "2.0",
|
||||
method: "session/update",
|
||||
params: {
|
||||
sessionId: pending.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: {
|
||||
type: "text",
|
||||
text: "mock permission " + suffix + ": " + optionId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
emit({
|
||||
jsonrpc: "2.0",
|
||||
id: pending.promptId,
|
||||
result: {
|
||||
stopReason: "end_turn",
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "session/prompt") {
|
||||
const sessionId = typeof msg?.params?.sessionId === "string" ? msg.params.sessionId : "";
|
||||
const text = firstText(msg?.params?.prompt);
|
||||
|
|
@ -82,6 +116,51 @@ rl.on("line", (line) => {
|
|||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (text.includes("permission")) {
|
||||
nextPermission += 1;
|
||||
const permissionId = "permission-" + nextPermission;
|
||||
pendingPermissions.set(permissionId, {
|
||||
promptId: msg.id,
|
||||
sessionId,
|
||||
});
|
||||
emit({
|
||||
jsonrpc: "2.0",
|
||||
id: permissionId,
|
||||
method: "session/request_permission",
|
||||
params: {
|
||||
sessionId,
|
||||
toolCall: {
|
||||
toolCallId: "tool-call-" + nextPermission,
|
||||
title: "Write mock.txt",
|
||||
kind: "edit",
|
||||
status: "pending",
|
||||
locations: [{ path: "/tmp/mock.txt" }],
|
||||
rawInput: {
|
||||
path: "/tmp/mock.txt",
|
||||
content: "hello",
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
kind: "allow_once",
|
||||
name: "Allow once",
|
||||
optionId: "allow-once",
|
||||
},
|
||||
{
|
||||
kind: "allow_always",
|
||||
name: "Always allow",
|
||||
optionId: "allow-always",
|
||||
},
|
||||
{
|
||||
kind: "reject_once",
|
||||
name: "Reject",
|
||||
optionId: "reject-once",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasMethod || !hasId) {
|
||||
|
|
@ -117,6 +196,10 @@ rl.on("line", (line) => {
|
|||
}
|
||||
|
||||
if (method === "session/prompt") {
|
||||
const text = firstText(msg?.params?.prompt);
|
||||
if (text.includes("permission")) {
|
||||
return;
|
||||
}
|
||||
emit({
|
||||
jsonrpc: "2.0",
|
||||
id: msg.id,
|
||||
|
|
|
|||
|
|
@ -512,10 +512,10 @@ describe("Integration: TypeScript SDK flat session API", () => {
|
|||
|
||||
const session = await sdk.createSession({ agent: "mock" });
|
||||
|
||||
await expect(session.send("session/cancel")).rejects.toThrow(
|
||||
await expect(session.rawSend("session/cancel")).rejects.toThrow(
|
||||
"Use destroySession(sessionId) instead.",
|
||||
);
|
||||
await expect(sdk.sendSessionMethod(session.id, "session/cancel", {})).rejects.toThrow(
|
||||
await expect(sdk.rawSendSessionMethod(session.id, "session/cancel", {})).rejects.toThrow(
|
||||
"Use destroySession(sessionId) instead.",
|
||||
);
|
||||
|
||||
|
|
@ -625,6 +625,43 @@ describe("Integration: TypeScript SDK flat session API", () => {
|
|||
await sdk.dispose();
|
||||
});
|
||||
|
||||
it("surfaces ACP permission requests and maps approve/reject replies", async () => {
|
||||
const sdk = await SandboxAgent.connect({
|
||||
baseUrl,
|
||||
token,
|
||||
});
|
||||
|
||||
const session = await sdk.createSession({ agent: "mock" });
|
||||
const permissionIds: string[] = [];
|
||||
const permissionTexts: string[] = [];
|
||||
|
||||
const offPermissions = session.onPermissionRequest((request) => {
|
||||
permissionIds.push(request.id);
|
||||
const reply = permissionIds.length === 1 ? "reject" : "always";
|
||||
void session.respondPermission(request.id, reply);
|
||||
});
|
||||
|
||||
const offEvents = session.onEvent((event) => {
|
||||
const text = (event.payload as any)?.params?.update?.content?.text;
|
||||
if (typeof text === "string" && text.startsWith("mock permission ")) {
|
||||
permissionTexts.push(text);
|
||||
}
|
||||
});
|
||||
|
||||
await session.prompt([{ type: "text", text: "trigger permission request one" }]);
|
||||
await session.prompt([{ type: "text", text: "trigger permission request two" }]);
|
||||
|
||||
await waitFor(() => (permissionIds.length === 2 ? permissionIds : undefined));
|
||||
await waitFor(() => (permissionTexts.length === 2 ? permissionTexts : undefined));
|
||||
|
||||
expect(permissionTexts[0]).toContain("rejected");
|
||||
expect(permissionTexts[1]).toContain("approved");
|
||||
|
||||
offEvents();
|
||||
offPermissions();
|
||||
await sdk.dispose();
|
||||
});
|
||||
|
||||
it("supports MCP and skills config HTTP helpers", async () => {
|
||||
const sdk = await SandboxAgent.connect({
|
||||
baseUrl,
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest";
|
||||
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v1";
|
||||
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk";
|
||||
import { spawnSandboxAgent, buildSandboxAgent, type SandboxAgentHandle } from "./helpers/spawn";
|
||||
|
||||
describe("OpenCode-compatible Permission API", () => {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest";
|
||||
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v1";
|
||||
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk";
|
||||
import { spawnSandboxAgent, buildSandboxAgent, type SandboxAgentHandle } from "./helpers/spawn";
|
||||
|
||||
describe("OpenCode-compatible Question API", () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue