mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 07:04:48 +00:00
fix: add native turn lifecycle and stabilize opencode session flow
This commit is contained in:
parent
2b0507c3f5
commit
91cac052b8
35 changed files with 1688 additions and 486 deletions
|
|
@ -131,6 +131,8 @@ 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) — [Building a Chat UI](https://sandboxagent.dev/docs/building-chat-ui) — [Managing Sessions](https://sandboxagent.dev/docs/manage-sessions)
|
||||||
|
|
||||||
### HTTP Server
|
### HTTP Server
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ const sessionId = `session-${crypto.randomUUID()}`;
|
||||||
await client.createSession(sessionId, {
|
await client.createSession(sessionId, {
|
||||||
agent: "claude",
|
agent: "claude",
|
||||||
agentMode: "code", // Optional: agent-specific mode
|
agentMode: "code", // Optional: agent-specific mode
|
||||||
permissionMode: "default", // Optional: "default" | "plan" | "bypass"
|
permissionMode: "default", // Optional: "default" | "plan" | "bypass" | "acceptEdits" (Claude: accept edits; Codex: auto-approve file changes; others: default)
|
||||||
model: "claude-sonnet-4", // Optional: model override
|
model: "claude-sonnet-4", // Optional: model override
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
@ -155,6 +155,16 @@ function handleEvent(event: UniversalEvent) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "turn.started": {
|
||||||
|
// Turn began (useful for showing per-turn loading state)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "turn.ended": {
|
||||||
|
// Turn completed (useful for ending per-turn loading state)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case "error": {
|
case "error": {
|
||||||
const { message, code } = event.data as ErrorData;
|
const { message, code } = event.data as ErrorData;
|
||||||
// Display error to user
|
// Display error to user
|
||||||
|
|
|
||||||
|
|
@ -246,7 +246,7 @@ sandbox-agent api sessions create <SESSION_ID> [OPTIONS]
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `-a, --agent <AGENT>` | Agent identifier (required) |
|
| `-a, --agent <AGENT>` | Agent identifier (required) |
|
||||||
| `-g, --agent-mode <MODE>` | Agent mode |
|
| `-g, --agent-mode <MODE>` | Agent mode |
|
||||||
| `-p, --permission-mode <MODE>` | Permission mode (`default`, `plan`, `bypass`) |
|
| `-p, --permission-mode <MODE>` | Permission mode (`default`, `plan`, `bypass`, `acceptEdits`) |
|
||||||
| `-m, --model <MODEL>` | Model override |
|
| `-m, --model <MODEL>` | Model override |
|
||||||
| `-v, --variant <VARIANT>` | Model variant |
|
| `-v, --variant <VARIANT>` | Model variant |
|
||||||
| `-A, --agent-version <VERSION>` | Agent version |
|
| `-A, --agent-version <VERSION>` | Agent version |
|
||||||
|
|
@ -258,6 +258,8 @@ sandbox-agent api sessions create my-session \
|
||||||
--permission-mode default
|
--permission-mode default
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`acceptEdits` passes through to Claude, auto-approves file changes for Codex, and is treated as `default` for other agents.
|
||||||
|
|
||||||
#### Send Message
|
#### Send Message
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -29,9 +29,11 @@ Events / Message Flow
|
||||||
+------------------------+------------------------------+--------------------------------------------+-----------------------------------------+----------------------------------+
|
+------------------------+------------------------------+--------------------------------------------+-----------------------------------------+----------------------------------+
|
||||||
| session.started | none | method=thread/started | type=session.created | none |
|
| session.started | none | method=thread/started | type=session.created | none |
|
||||||
| session.ended | SDKMessage.type=result | no explicit session end (turn/completed) | no explicit session end (session.deleted)| type=done |
|
| session.ended | SDKMessage.type=result | no explicit session end (turn/completed) | no explicit session end (session.deleted)| type=done |
|
||||||
|
| turn.started | synthetic on message send | method=turn/started | type=session.status (busy) | synthetic on message send |
|
||||||
|
| turn.ended | synthetic after result | method=turn/completed | type=session.idle | synthetic on done |
|
||||||
| message (user) | SDKMessage.type=user | item/completed (ThreadItem.type=userMessage)| message.updated (Message.role=user) | type=message |
|
| message (user) | SDKMessage.type=user | item/completed (ThreadItem.type=userMessage)| message.updated (Message.role=user) | type=message |
|
||||||
| message (assistant) | SDKMessage.type=assistant | item/completed (ThreadItem.type=agentMessage)| message.updated (Message.role=assistant)| type=message |
|
| message (assistant) | SDKMessage.type=assistant | item/completed (ThreadItem.type=agentMessage)| message.updated (Message.role=assistant)| type=message |
|
||||||
| message.delta | stream_event (partial) or synthetic | method=item/agentMessage/delta | type=message.part.updated (delta) | synthetic |
|
| message.delta | stream_event (partial) or synthetic | method=item/agentMessage/delta | type=message.part.updated (text-part delta) | synthetic |
|
||||||
| tool call | type=tool_use | method=item/mcpToolCall/progress | message.part.updated (part.type=tool) | type=tool_call |
|
| tool call | type=tool_use | method=item/mcpToolCall/progress | message.part.updated (part.type=tool) | type=tool_call |
|
||||||
| tool result | user.message.content.tool_result | item/completed (tool result ThreadItem variants) | message.part.updated (part.type=tool, state=completed) | type=tool_result |
|
| tool result | user.message.content.tool_result | item/completed (tool result ThreadItem variants) | message.part.updated (part.type=tool, state=completed) | type=tool_result |
|
||||||
| permission.requested | control_request.can_use_tool | none | type=permission.asked | none |
|
| permission.requested | control_request.can_use_tool | none | type=permission.asked | none |
|
||||||
|
|
@ -52,6 +54,8 @@ Synthetics
|
||||||
+------------------------------+------------------------+--------------------------+--------------------------------------------------------------+
|
+------------------------------+------------------------+--------------------------+--------------------------------------------------------------+
|
||||||
| session.started | When agent emits no explicit start | session.started event | Mark source=daemon |
|
| session.started | When agent emits no explicit start | session.started event | Mark source=daemon |
|
||||||
| session.ended | When agent emits no explicit end | session.ended event | Mark source=daemon; reason may be inferred |
|
| session.ended | When agent emits no explicit end | session.ended event | Mark source=daemon; reason may be inferred |
|
||||||
|
| turn.started | When agent emits no explicit turn start | turn.started event | Mark source=daemon |
|
||||||
|
| turn.ended | When agent emits no explicit turn end | turn.ended event | Mark source=daemon |
|
||||||
| item_id (Claude) | Claude provides no item IDs | item_id | Maintain provider_item_id map when possible |
|
| item_id (Claude) | Claude provides no item IDs | item_id | Maintain provider_item_id map when possible |
|
||||||
| user message (Claude) | Claude emits only assistant output | item.completed | Mark source=daemon; preserve raw input in event metadata |
|
| user message (Claude) | Claude emits only assistant output | item.completed | Mark source=daemon; preserve raw input in event metadata |
|
||||||
| question events (Claude) | AskUserQuestion tool usage | question.requested/resolved | Derived from tool_use blocks (source=agent) |
|
| question events (Claude) | AskUserQuestion tool usage | question.requested/resolved | Derived from tool_use blocks (source=agent) |
|
||||||
|
|
@ -60,7 +64,7 @@ Synthetics
|
||||||
| message.delta (Claude) | No native deltas emitted | item.delta | Synthetic delta with full message content; source=daemon |
|
| message.delta (Claude) | No native deltas emitted | item.delta | Synthetic delta with full message content; source=daemon |
|
||||||
| message.delta (Amp) | No native deltas | item.delta | Synthetic delta with full message content; source=daemon |
|
| message.delta (Amp) | No native deltas | item.delta | Synthetic delta with full message content; source=daemon |
|
||||||
+------------------------------+------------------------+--------------------------+--------------------------------------------------------------+
|
+------------------------------+------------------------+--------------------------+--------------------------------------------------------------+
|
||||||
| message.delta (OpenCode) | part delta before message | item.delta | If part arrives first, create item.started stub then delta |
|
| message.delta (OpenCode) | text part delta before message | item.delta | If part arrives first, create item.started stub then delta |
|
||||||
+------------------------------+------------------------+--------------------------+--------------------------------------------------------------+
|
+------------------------------+------------------------+--------------------------+--------------------------------------------------------------+
|
||||||
|
|
||||||
Delta handling
|
Delta handling
|
||||||
|
|
@ -70,10 +74,11 @@ Delta handling
|
||||||
- Claude can emit stream_event deltas when partial streaming is enabled; Amp does not emit deltas.
|
- Claude can emit stream_event deltas when partial streaming is enabled; Amp does not emit deltas.
|
||||||
|
|
||||||
Policy:
|
Policy:
|
||||||
- Always emit item.delta across all providers.
|
- Emit item.delta for streamable text content across providers.
|
||||||
- For providers without native deltas, emit a single synthetic delta containing the full content prior to item.completed.
|
- For providers without native deltas, emit a single synthetic delta containing the full content prior to item.completed.
|
||||||
- For Claude when partial streaming is enabled, forward native deltas and skip the synthetic full-content delta.
|
- For Claude when partial streaming is enabled, forward native deltas and skip the synthetic full-content delta.
|
||||||
- For providers with native deltas, forward as-is; also emit item.completed when final content is known.
|
- For providers with native deltas, forward as-is; also emit item.completed when final content is known.
|
||||||
|
- For OpenCode reasoning part deltas, emit typed reasoning item updates (item.started/item.completed with content.type=reasoning) instead of item.delta.
|
||||||
|
|
||||||
Message normalization notes
|
Message normalization notes
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1157,6 +1157,10 @@
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"nullable": true
|
"nullable": true
|
||||||
},
|
},
|
||||||
|
"directory": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
"model": {
|
"model": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"nullable": true
|
"nullable": true
|
||||||
|
|
@ -1165,6 +1169,10 @@
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"nullable": true
|
"nullable": true
|
||||||
},
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
"variant": {
|
"variant": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"nullable": true
|
"nullable": true
|
||||||
|
|
@ -1595,7 +1603,9 @@
|
||||||
"agentMode",
|
"agentMode",
|
||||||
"permissionMode",
|
"permissionMode",
|
||||||
"ended",
|
"ended",
|
||||||
"eventCount"
|
"eventCount",
|
||||||
|
"createdAt",
|
||||||
|
"updatedAt"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"agent": {
|
"agent": {
|
||||||
|
|
@ -1604,6 +1614,14 @@
|
||||||
"agentMode": {
|
"agentMode": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64"
|
||||||
|
},
|
||||||
|
"directory": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
"ended": {
|
"ended": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
|
@ -1626,6 +1644,14 @@
|
||||||
"sessionId": {
|
"sessionId": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"updatedAt": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64"
|
||||||
|
},
|
||||||
"variant": {
|
"variant": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"nullable": true
|
"nullable": true
|
||||||
|
|
@ -1689,6 +1715,31 @@
|
||||||
"daemon"
|
"daemon"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"TurnEventData": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"phase"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"metadata": {
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"phase": {
|
||||||
|
"$ref": "#/components/schemas/TurnPhase"
|
||||||
|
},
|
||||||
|
"turn_id": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"TurnPhase": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"started",
|
||||||
|
"ended"
|
||||||
|
]
|
||||||
|
},
|
||||||
"TurnStreamQuery": {
|
"TurnStreamQuery": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|
@ -1748,6 +1799,9 @@
|
||||||
},
|
},
|
||||||
"UniversalEventData": {
|
"UniversalEventData": {
|
||||||
"oneOf": [
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/TurnEventData"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/SessionStartedData"
|
"$ref": "#/components/schemas/SessionStartedData"
|
||||||
},
|
},
|
||||||
|
|
@ -1779,6 +1833,8 @@
|
||||||
"enum": [
|
"enum": [
|
||||||
"session.started",
|
"session.started",
|
||||||
"session.ended",
|
"session.ended",
|
||||||
|
"turn.started",
|
||||||
|
"turn.ended",
|
||||||
"item.started",
|
"item.started",
|
||||||
"item.delta",
|
"item.delta",
|
||||||
"item.completed",
|
"item.completed",
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,13 @@ Every event from the API is wrapped in a `UniversalEvent` envelope.
|
||||||
| `session.started` | Session has started | `{ metadata?: any }` |
|
| `session.started` | Session has started | `{ metadata?: any }` |
|
||||||
| `session.ended` | Session has ended | `{ reason, terminated_by, message?, exit_code? }` |
|
| `session.ended` | Session has ended | `{ reason, terminated_by, message?, exit_code? }` |
|
||||||
|
|
||||||
|
### Turn Lifecycle
|
||||||
|
|
||||||
|
| Type | Description | Data |
|
||||||
|
|------|-------------|------|
|
||||||
|
| `turn.started` | Turn has started | `{ phase: "started", turn_id?, metadata? }` |
|
||||||
|
| `turn.ended` | Turn has ended | `{ phase: "ended", turn_id?, metadata? }` |
|
||||||
|
|
||||||
**SessionEndedData**
|
**SessionEndedData**
|
||||||
|
|
||||||
| Field | Type | Values |
|
| Field | Type | Values |
|
||||||
|
|
@ -365,6 +372,8 @@ The daemon emits synthetic events (`synthetic: true`, `source: "daemon"`) to pro
|
||||||
|-----------|------|
|
|-----------|------|
|
||||||
| `session.started` | Agent doesn't emit explicit session start |
|
| `session.started` | Agent doesn't emit explicit session start |
|
||||||
| `session.ended` | Agent doesn't emit explicit session end |
|
| `session.ended` | Agent doesn't emit explicit session end |
|
||||||
|
| `turn.started` | Agent doesn't emit explicit turn start |
|
||||||
|
| `turn.ended` | Agent doesn't emit explicit turn end |
|
||||||
| `item.started` | Agent doesn't emit item start events |
|
| `item.started` | Agent doesn't emit item start events |
|
||||||
| `item.delta` | Agent doesn't stream deltas natively |
|
| `item.delta` | Agent doesn't stream deltas natively |
|
||||||
| `question.*` | Claude Code plan mode (from ExitPlanMode tool) |
|
| `question.*` | Claude Code plan mode (from ExitPlanMode tool) |
|
||||||
|
|
|
||||||
|
|
@ -762,6 +762,30 @@ export default function App() {
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "turn.started": {
|
||||||
|
entries.push({
|
||||||
|
id: event.event_id,
|
||||||
|
kind: "meta",
|
||||||
|
time: event.time,
|
||||||
|
meta: {
|
||||||
|
title: "Turn started",
|
||||||
|
severity: "info"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "turn.ended": {
|
||||||
|
entries.push({
|
||||||
|
id: event.event_id,
|
||||||
|
kind: "meta",
|
||||||
|
time: event.time,
|
||||||
|
meta: {
|
||||||
|
title: "Turn ended",
|
||||||
|
severity: "info"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,10 @@ export const getEventIcon = (type: string) => {
|
||||||
return PlayCircle;
|
return PlayCircle;
|
||||||
case "session.ended":
|
case "session.ended":
|
||||||
return PauseCircle;
|
return PauseCircle;
|
||||||
|
case "turn.started":
|
||||||
|
return PlayCircle;
|
||||||
|
case "turn.ended":
|
||||||
|
return PauseCircle;
|
||||||
case "item.started":
|
case "item.started":
|
||||||
return MessageSquare;
|
return MessageSquare;
|
||||||
case "item.delta":
|
case "item.delta":
|
||||||
|
|
|
||||||
|
|
@ -169,8 +169,10 @@ export interface components {
|
||||||
agent: string;
|
agent: string;
|
||||||
agentMode?: string | null;
|
agentMode?: string | null;
|
||||||
agentVersion?: string | null;
|
agentVersion?: string | null;
|
||||||
|
directory?: string | null;
|
||||||
model?: string | null;
|
model?: string | null;
|
||||||
permissionMode?: string | null;
|
permissionMode?: string | null;
|
||||||
|
title?: string | null;
|
||||||
variant?: string | null;
|
variant?: string | null;
|
||||||
};
|
};
|
||||||
CreateSessionResponse: {
|
CreateSessionResponse: {
|
||||||
|
|
@ -287,6 +289,9 @@ export interface components {
|
||||||
SessionInfo: {
|
SessionInfo: {
|
||||||
agent: string;
|
agent: string;
|
||||||
agentMode: string;
|
agentMode: string;
|
||||||
|
/** Format: int64 */
|
||||||
|
createdAt: number;
|
||||||
|
directory?: string | null;
|
||||||
ended: boolean;
|
ended: boolean;
|
||||||
/** Format: int64 */
|
/** Format: int64 */
|
||||||
eventCount: number;
|
eventCount: number;
|
||||||
|
|
@ -294,6 +299,9 @@ export interface components {
|
||||||
nativeSessionId?: string | null;
|
nativeSessionId?: string | null;
|
||||||
permissionMode: string;
|
permissionMode: string;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
|
title?: string | null;
|
||||||
|
/** Format: int64 */
|
||||||
|
updatedAt: number;
|
||||||
variant?: string | null;
|
variant?: string | null;
|
||||||
};
|
};
|
||||||
SessionListResponse: {
|
SessionListResponse: {
|
||||||
|
|
@ -314,6 +322,13 @@ export interface components {
|
||||||
};
|
};
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
TerminatedBy: "agent" | "daemon";
|
TerminatedBy: "agent" | "daemon";
|
||||||
|
TurnEventData: {
|
||||||
|
metadata?: unknown;
|
||||||
|
phase: components["schemas"]["TurnPhase"];
|
||||||
|
turn_id?: string | null;
|
||||||
|
};
|
||||||
|
/** @enum {string} */
|
||||||
|
TurnPhase: "started" | "ended";
|
||||||
TurnStreamQuery: {
|
TurnStreamQuery: {
|
||||||
includeRaw?: boolean | null;
|
includeRaw?: boolean | null;
|
||||||
};
|
};
|
||||||
|
|
@ -330,9 +345,9 @@ export interface components {
|
||||||
time: string;
|
time: string;
|
||||||
type: components["schemas"]["UniversalEventType"];
|
type: components["schemas"]["UniversalEventType"];
|
||||||
};
|
};
|
||||||
UniversalEventData: components["schemas"]["SessionStartedData"] | components["schemas"]["SessionEndedData"] | components["schemas"]["ItemEventData"] | components["schemas"]["ItemDeltaData"] | components["schemas"]["ErrorData"] | components["schemas"]["PermissionEventData"] | components["schemas"]["QuestionEventData"] | components["schemas"]["AgentUnparsedData"];
|
UniversalEventData: components["schemas"]["TurnEventData"] | components["schemas"]["SessionStartedData"] | components["schemas"]["SessionEndedData"] | components["schemas"]["ItemEventData"] | components["schemas"]["ItemDeltaData"] | components["schemas"]["ErrorData"] | components["schemas"]["PermissionEventData"] | components["schemas"]["QuestionEventData"] | components["schemas"]["AgentUnparsedData"];
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
UniversalEventType: "session.started" | "session.ended" | "item.started" | "item.delta" | "item.completed" | "error" | "permission.requested" | "permission.resolved" | "question.requested" | "question.resolved" | "agent.unparsed";
|
UniversalEventType: "session.started" | "session.ended" | "turn.started" | "turn.ended" | "item.started" | "item.delta" | "item.completed" | "error" | "permission.requested" | "permission.resolved" | "question.requested" | "question.resolved" | "agent.unparsed";
|
||||||
UniversalItem: {
|
UniversalItem: {
|
||||||
content: components["schemas"]["ContentPart"][];
|
content: components["schemas"]["ContentPart"][];
|
||||||
item_id: string;
|
item_id: string;
|
||||||
|
|
|
||||||
|
|
@ -605,8 +605,22 @@ fn run_opencode(cli: &CliConfig, args: &OpencodeArgs) -> Result<(), CliError> {
|
||||||
let token = cli.token.clone();
|
let token = cli.token.clone();
|
||||||
|
|
||||||
let base_url = format!("http://{}:{}", args.host, args.port);
|
let base_url = format!("http://{}:{}", args.host, args.port);
|
||||||
|
let has_proxy_env = std::env::var_os("HTTP_PROXY").is_some()
|
||||||
|
|| std::env::var_os("http_proxy").is_some()
|
||||||
|
|| std::env::var_os("HTTPS_PROXY").is_some()
|
||||||
|
|| std::env::var_os("https_proxy").is_some();
|
||||||
|
let has_no_proxy_env =
|
||||||
|
std::env::var_os("NO_PROXY").is_some() || std::env::var_os("no_proxy").is_some();
|
||||||
|
write_stderr_line(&format!(
|
||||||
|
"gigacode startup: ensuring daemon at {base_url} (token: {}, proxy env: {}, no_proxy env: {})",
|
||||||
|
if token.is_some() { "set" } else { "unset" },
|
||||||
|
if has_proxy_env { "set" } else { "unset" },
|
||||||
|
if has_no_proxy_env { "set" } else { "unset" }
|
||||||
|
))?;
|
||||||
crate::daemon::ensure_running(cli, &args.host, args.port, token.as_deref())?;
|
crate::daemon::ensure_running(cli, &args.host, args.port, token.as_deref())?;
|
||||||
|
write_stderr_line("gigacode startup: daemon is healthy")?;
|
||||||
|
|
||||||
|
write_stderr_line("gigacode startup: creating OpenCode session via /opencode/session")?;
|
||||||
let session_id = create_opencode_session(
|
let session_id = create_opencode_session(
|
||||||
&base_url,
|
&base_url,
|
||||||
token.as_deref(),
|
token.as_deref(),
|
||||||
|
|
@ -616,7 +630,12 @@ fn run_opencode(cli: &CliConfig, args: &OpencodeArgs) -> Result<(), CliError> {
|
||||||
write_stdout_line(&format!("OpenCode session: {session_id}"))?;
|
write_stdout_line(&format!("OpenCode session: {session_id}"))?;
|
||||||
|
|
||||||
let attach_url = format!("{base_url}/opencode");
|
let attach_url = format!("{base_url}/opencode");
|
||||||
|
write_stderr_line("gigacode startup: resolving OpenCode binary (installing if needed)")?;
|
||||||
let opencode_bin = resolve_opencode_bin()?;
|
let opencode_bin = resolve_opencode_bin()?;
|
||||||
|
write_stderr_line(&format!(
|
||||||
|
"gigacode startup: launching OpenCode attach using {}",
|
||||||
|
opencode_bin.display()
|
||||||
|
))?;
|
||||||
let mut opencode_cmd = ProcessCommand::new(opencode_bin);
|
let mut opencode_cmd = ProcessCommand::new(opencode_bin);
|
||||||
opencode_cmd
|
opencode_cmd
|
||||||
.arg("attach")
|
.arg("attach")
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ mod build_id {
|
||||||
pub use build_id::BUILD_ID;
|
pub use build_id::BUILD_ID;
|
||||||
|
|
||||||
const DAEMON_HEALTH_TIMEOUT: Duration = Duration::from_secs(30);
|
const DAEMON_HEALTH_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
const HEALTH_CHECK_CONNECT_TIMEOUT: Duration = Duration::from_secs(2);
|
||||||
|
const HEALTH_CHECK_REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Paths
|
// Paths
|
||||||
|
|
@ -143,16 +145,40 @@ pub fn is_process_running(pid: u32) -> bool {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
pub fn check_health(base_url: &str, token: Option<&str>) -> Result<bool, CliError> {
|
pub fn check_health(base_url: &str, token: Option<&str>) -> Result<bool, CliError> {
|
||||||
let client = HttpClient::builder().build()?;
|
|
||||||
let url = format!("{base_url}/v1/health");
|
let url = format!("{base_url}/v1/health");
|
||||||
|
let started_at = Instant::now();
|
||||||
|
let client = HttpClient::builder()
|
||||||
|
.connect_timeout(HEALTH_CHECK_CONNECT_TIMEOUT)
|
||||||
|
.timeout(HEALTH_CHECK_REQUEST_TIMEOUT)
|
||||||
|
.build()?;
|
||||||
let mut request = client.get(url);
|
let mut request = client.get(url);
|
||||||
if let Some(token) = token {
|
if let Some(token) = token {
|
||||||
request = request.bearer_auth(token);
|
request = request.bearer_auth(token);
|
||||||
}
|
}
|
||||||
match request.send() {
|
match request.send() {
|
||||||
Ok(response) if response.status().is_success() => Ok(true),
|
Ok(response) if response.status().is_success() => {
|
||||||
Ok(_) => Ok(false),
|
tracing::info!(
|
||||||
Err(_) => Ok(false),
|
elapsed_ms = started_at.elapsed().as_millis(),
|
||||||
|
"daemon health check succeeded"
|
||||||
|
);
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
Ok(response) => {
|
||||||
|
tracing::warn!(
|
||||||
|
status = %response.status(),
|
||||||
|
elapsed_ms = started_at.elapsed().as_millis(),
|
||||||
|
"daemon health check returned non-success status"
|
||||||
|
);
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::warn!(
|
||||||
|
error = %err,
|
||||||
|
elapsed_ms = started_at.elapsed().as_millis(),
|
||||||
|
"daemon health check request failed"
|
||||||
|
);
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -162,10 +188,15 @@ pub fn wait_for_health(
|
||||||
token: Option<&str>,
|
token: Option<&str>,
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
) -> Result<(), CliError> {
|
) -> Result<(), CliError> {
|
||||||
let client = HttpClient::builder().build()?;
|
let client = HttpClient::builder()
|
||||||
|
.connect_timeout(HEALTH_CHECK_CONNECT_TIMEOUT)
|
||||||
|
.timeout(HEALTH_CHECK_REQUEST_TIMEOUT)
|
||||||
|
.build()?;
|
||||||
let deadline = Instant::now() + timeout;
|
let deadline = Instant::now() + timeout;
|
||||||
|
let mut attempts: u32 = 0;
|
||||||
|
|
||||||
while Instant::now() < deadline {
|
while Instant::now() < deadline {
|
||||||
|
attempts += 1;
|
||||||
if let Some(child) = server_child.as_mut() {
|
if let Some(child) = server_child.as_mut() {
|
||||||
if let Some(status) = child.try_wait()? {
|
if let Some(status) = child.try_wait()? {
|
||||||
return Err(CliError::Server(format!(
|
return Err(CliError::Server(format!(
|
||||||
|
|
@ -180,13 +211,43 @@ pub fn wait_for_health(
|
||||||
request = request.bearer_auth(token);
|
request = request.bearer_auth(token);
|
||||||
}
|
}
|
||||||
match request.send() {
|
match request.send() {
|
||||||
Ok(response) if response.status().is_success() => return Ok(()),
|
Ok(response) if response.status().is_success() => {
|
||||||
_ => {
|
tracing::info!(
|
||||||
|
attempts,
|
||||||
|
elapsed_ms =
|
||||||
|
(timeout - deadline.saturating_duration_since(Instant::now())).as_millis(),
|
||||||
|
"daemon became healthy while waiting"
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Ok(response) => {
|
||||||
|
if attempts % 10 == 0 {
|
||||||
|
tracing::info!(
|
||||||
|
attempts,
|
||||||
|
status = %response.status(),
|
||||||
|
"daemon still not healthy; waiting"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(200));
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
if attempts % 10 == 0 {
|
||||||
|
tracing::warn!(
|
||||||
|
attempts,
|
||||||
|
error = %err,
|
||||||
|
"daemon health poll request failed; still waiting"
|
||||||
|
);
|
||||||
|
}
|
||||||
std::thread::sleep(Duration::from_millis(200));
|
std::thread::sleep(Duration::from_millis(200));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tracing::error!(
|
||||||
|
attempts,
|
||||||
|
timeout_ms = timeout.as_millis(),
|
||||||
|
"timed out waiting for daemon health"
|
||||||
|
);
|
||||||
Err(CliError::Server(
|
Err(CliError::Server(
|
||||||
"timed out waiting for sandbox-agent health".to_string(),
|
"timed out waiting for sandbox-agent health".to_string(),
|
||||||
))
|
))
|
||||||
|
|
@ -197,7 +258,7 @@ pub fn wait_for_health(
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
pub fn spawn_sandbox_agent_daemon(
|
pub fn spawn_sandbox_agent_daemon(
|
||||||
cli: &CliConfig,
|
_cli: &CliConfig,
|
||||||
host: &str,
|
host: &str,
|
||||||
port: u16,
|
port: u16,
|
||||||
token: Option<&str>,
|
token: Option<&str>,
|
||||||
|
|
@ -478,6 +539,10 @@ pub fn ensure_running(
|
||||||
) -> Result<(), CliError> {
|
) -> Result<(), CliError> {
|
||||||
let base_url = format!("http://{host}:{port}");
|
let base_url = format!("http://{host}:{port}");
|
||||||
let pid_path = daemon_pid_path(host, port);
|
let pid_path = daemon_pid_path(host, port);
|
||||||
|
eprintln!(
|
||||||
|
"checking daemon health at {base_url} (token: {})...",
|
||||||
|
if token.is_some() { "set" } else { "unset" }
|
||||||
|
);
|
||||||
|
|
||||||
// Check if daemon is already healthy
|
// Check if daemon is already healthy
|
||||||
if check_health(&base_url, token)? {
|
if check_health(&base_url, token)? {
|
||||||
|
|
|
||||||
|
|
@ -256,6 +256,7 @@ impl OpenCodeQuestionRecord {
|
||||||
|
|
||||||
#[derive(Default, Clone)]
|
#[derive(Default, Clone)]
|
||||||
struct OpenCodeSessionRuntime {
|
struct OpenCodeSessionRuntime {
|
||||||
|
turn_in_progress: bool,
|
||||||
last_user_message_id: Option<String>,
|
last_user_message_id: Option<String>,
|
||||||
active_assistant_message_id: Option<String>,
|
active_assistant_message_id: Option<String>,
|
||||||
last_agent: Option<String>,
|
last_agent: Option<String>,
|
||||||
|
|
@ -277,6 +278,10 @@ struct OpenCodeSessionRuntime {
|
||||||
open_tool_calls: HashSet<String>,
|
open_tool_calls: HashSet<String>,
|
||||||
/// Assistant messages that have streamed text deltas.
|
/// Assistant messages that have streamed text deltas.
|
||||||
messages_with_text_deltas: HashSet<String>,
|
messages_with_text_deltas: HashSet<String>,
|
||||||
|
/// Item IDs (native and normalized) known to be user messages.
|
||||||
|
user_item_ids: HashSet<String>,
|
||||||
|
/// Item IDs (native and normalized) that should not emit text deltas.
|
||||||
|
non_text_item_ids: HashSet<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
|
|
@ -512,29 +517,83 @@ async fn ensure_backing_session(
|
||||||
let request = CreateSessionRequest {
|
let request = CreateSessionRequest {
|
||||||
agent: agent.to_string(),
|
agent: agent.to_string(),
|
||||||
agent_mode: None,
|
agent_mode: None,
|
||||||
permission_mode,
|
permission_mode: permission_mode.clone(),
|
||||||
model: model.clone(),
|
model: model.clone(),
|
||||||
variant: variant.clone(),
|
variant: variant.clone(),
|
||||||
agent_version: None,
|
agent_version: None,
|
||||||
directory,
|
directory,
|
||||||
title,
|
title,
|
||||||
};
|
};
|
||||||
match state
|
let manager = state.inner.session_manager();
|
||||||
.inner
|
match manager
|
||||||
.session_manager()
|
.create_session(session_id.to_string(), request.clone())
|
||||||
.create_session(session_id.to_string(), request)
|
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(_) => Ok(()),
|
Ok(_) => Ok(()),
|
||||||
Err(SandboxError::SessionAlreadyExists { .. }) => state
|
Err(SandboxError::SessionAlreadyExists { .. }) => {
|
||||||
.inner
|
let should_recreate = manager
|
||||||
.session_manager()
|
.get_session_info(session_id)
|
||||||
.set_session_overrides(session_id, model, variant)
|
.await
|
||||||
.await
|
.map(|info| info.agent != agent && info.event_count <= 1)
|
||||||
.or_else(|err| match err {
|
.unwrap_or(false);
|
||||||
SandboxError::SessionNotFound { .. } => Ok(()),
|
if should_recreate {
|
||||||
other => Err(other),
|
manager.delete_session(session_id).await?;
|
||||||
}),
|
match manager
|
||||||
|
.create_session(session_id.to_string(), request.clone())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => Ok(()),
|
||||||
|
Err(SandboxError::SessionAlreadyExists { .. }) => {
|
||||||
|
match manager
|
||||||
|
.set_session_overrides(session_id, model.clone(), variant.clone())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(SandboxError::SessionNotFound { .. }) => {
|
||||||
|
tracing::warn!(
|
||||||
|
target = "sandbox_agent::opencode",
|
||||||
|
session_id,
|
||||||
|
"backing session vanished while applying overrides; retrying create_session"
|
||||||
|
);
|
||||||
|
match manager
|
||||||
|
.create_session(session_id.to_string(), request.clone())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) | Err(SandboxError::SessionAlreadyExists { .. }) => {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(err) => Err(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(other) => Err(other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => Err(err),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match manager
|
||||||
|
.set_session_overrides(session_id, model.clone(), variant.clone())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(SandboxError::SessionNotFound { .. }) => {
|
||||||
|
tracing::warn!(
|
||||||
|
target = "sandbox_agent::opencode",
|
||||||
|
session_id,
|
||||||
|
"backing session missing while setting overrides; retrying create_session"
|
||||||
|
);
|
||||||
|
match manager
|
||||||
|
.create_session(session_id.to_string(), request.clone())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) | Err(SandboxError::SessionAlreadyExists { .. }) => Ok(()),
|
||||||
|
Err(err) => Err(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(other) => Err(other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Err(err) => Err(err),
|
Err(err) => Err(err),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -596,6 +655,13 @@ struct OpenCodeCreateSessionRequest {
|
||||||
permission: Option<Value>,
|
permission: Option<Value>,
|
||||||
#[serde(alias = "permission_mode")]
|
#[serde(alias = "permission_mode")]
|
||||||
permission_mode: Option<String>,
|
permission_mode: Option<String>,
|
||||||
|
#[schema(value_type = String)]
|
||||||
|
model: Option<Value>,
|
||||||
|
#[serde(rename = "providerID")]
|
||||||
|
provider_id: Option<String>,
|
||||||
|
#[serde(rename = "modelID")]
|
||||||
|
model_id: Option<String>,
|
||||||
|
variant: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||||
|
|
@ -687,6 +753,17 @@ struct SessionSummarizeRequest {
|
||||||
auto: Option<bool>,
|
auto: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct SessionInitRequest {
|
||||||
|
#[serde(rename = "providerID")]
|
||||||
|
provider_id: Option<String>,
|
||||||
|
#[serde(rename = "modelID")]
|
||||||
|
model_id: Option<String>,
|
||||||
|
#[serde(rename = "messageID")]
|
||||||
|
message_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||||
struct PermissionReplyRequest {
|
struct PermissionReplyRequest {
|
||||||
response: Option<String>,
|
response: Option<String>,
|
||||||
|
|
@ -1002,13 +1079,16 @@ async fn resolve_session_agent(
|
||||||
) -> (String, String, String) {
|
) -> (String, String, String) {
|
||||||
let cache = opencode_model_cache(state).await;
|
let cache = opencode_model_cache(state).await;
|
||||||
let default_model_id = cache.default_model.clone();
|
let default_model_id = cache.default_model.clone();
|
||||||
let mut provider_id = requested_provider
|
let requested_provider = requested_provider
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
.filter(|value| *value != "sandbox-agent")
|
.filter(|value| *value != "sandbox-agent")
|
||||||
.map(|value| value.to_string());
|
.map(|value| value.to_string());
|
||||||
let model_id = requested_model
|
let requested_model = requested_model
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
.map(|value| value.to_string());
|
.map(|value| value.to_string());
|
||||||
|
let explicit_selection = requested_provider.is_some() || requested_model.is_some();
|
||||||
|
let mut provider_id = requested_provider.clone();
|
||||||
|
let model_id = requested_model.clone();
|
||||||
if provider_id.is_none() {
|
if provider_id.is_none() {
|
||||||
if let Some(model_value) = model_id.as_deref() {
|
if let Some(model_value) = model_id.as_deref() {
|
||||||
if let Some(entry) = cache
|
if let Some(entry) = cache
|
||||||
|
|
@ -1041,7 +1121,7 @@ async fn resolve_session_agent(
|
||||||
state
|
state
|
||||||
.opencode
|
.opencode
|
||||||
.update_runtime(session_id, |runtime| {
|
.update_runtime(session_id, |runtime| {
|
||||||
if runtime.session_agent_id.is_none() {
|
if runtime.session_agent_id.is_none() || explicit_selection {
|
||||||
let agent = resolved_agent.unwrap_or_else(default_agent_id);
|
let agent = resolved_agent.unwrap_or_else(default_agent_id);
|
||||||
runtime.session_agent_id = Some(agent.as_str().to_string());
|
runtime.session_agent_id = Some(agent.as_str().to_string());
|
||||||
runtime.session_provider_id = Some(provider_id.clone());
|
runtime.session_provider_id = Some(provider_id.clone());
|
||||||
|
|
@ -1527,6 +1607,61 @@ fn unique_assistant_message_id(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn set_item_text_delta_capability(
|
||||||
|
runtime: &mut OpenCodeSessionRuntime,
|
||||||
|
item_id: Option<&str>,
|
||||||
|
native_item_id: Option<&str>,
|
||||||
|
supports_text_deltas: bool,
|
||||||
|
) {
|
||||||
|
for key in [item_id, native_item_id].into_iter().flatten() {
|
||||||
|
if supports_text_deltas {
|
||||||
|
runtime.non_text_item_ids.remove(key);
|
||||||
|
} else {
|
||||||
|
runtime.non_text_item_ids.insert(key.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn item_delta_is_non_text(
|
||||||
|
runtime: &OpenCodeSessionRuntime,
|
||||||
|
item_id: Option<&str>,
|
||||||
|
native_item_id: Option<&str>,
|
||||||
|
) -> bool {
|
||||||
|
[item_id, native_item_id]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.any(|key| runtime.non_text_item_ids.contains(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn item_supports_text_deltas(item: &UniversalItem) -> bool {
|
||||||
|
if item.kind != ItemKind::Message {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if !matches!(item.role.as_ref(), Some(ItemRole::Assistant)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if item.content.is_empty() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
item.content
|
||||||
|
.iter()
|
||||||
|
.any(|part| matches!(part, ContentPart::Text { .. }))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_message_text_from_content(parts: &[ContentPart]) -> Option<String> {
|
||||||
|
let mut text = String::new();
|
||||||
|
for part in parts {
|
||||||
|
if let ContentPart::Text { text: chunk } = part {
|
||||||
|
text.push_str(chunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if text.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn extract_text_from_content(parts: &[ContentPart]) -> Option<String> {
|
fn extract_text_from_content(parts: &[ContentPart]) -> Option<String> {
|
||||||
let mut text = String::new();
|
let mut text = String::new();
|
||||||
for part in parts {
|
for part in parts {
|
||||||
|
|
@ -1890,43 +2025,77 @@ fn patterns_from_metadata(metadata: &Option<Value>) -> Vec<String> {
|
||||||
patterns
|
patterns
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn turn_error_from_metadata(metadata: &Option<Value>) -> Option<(String, Option<Value>)> {
|
||||||
|
let error = metadata.as_ref()?.get("error")?;
|
||||||
|
let message = error
|
||||||
|
.get("message")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("Turn failed")
|
||||||
|
.to_string();
|
||||||
|
Some((message, Some(error.clone())))
|
||||||
|
}
|
||||||
|
|
||||||
async fn apply_universal_event(state: Arc<OpenCodeAppState>, event: UniversalEvent) {
|
async fn apply_universal_event(state: Arc<OpenCodeAppState>, event: UniversalEvent) {
|
||||||
match event.event_type {
|
match event.event_type {
|
||||||
UniversalEventType::ItemStarted | UniversalEventType::ItemCompleted => {
|
UniversalEventType::ItemStarted | UniversalEventType::ItemCompleted => {
|
||||||
if let UniversalEventData::Item(ItemEventData { item }) = &event.data {
|
if let UniversalEventData::Item(ItemEventData { item }) = &event.data {
|
||||||
// turn.completed or session.idle status → emit session.idle
|
|
||||||
if event.event_type == UniversalEventType::ItemCompleted
|
|
||||||
&& item.kind == ItemKind::Status
|
|
||||||
{
|
|
||||||
if let Some(ContentPart::Status { label, .. }) = item.content.first() {
|
|
||||||
if label == "turn.completed" || label == "session.idle" {
|
|
||||||
let runtime = state
|
|
||||||
.opencode
|
|
||||||
.update_runtime(&event.session_id, |runtime| {
|
|
||||||
if runtime.open_tool_calls.is_empty() {
|
|
||||||
runtime.active_assistant_message_id = None;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
if !runtime.open_tool_calls.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let session_id = event.session_id.clone();
|
|
||||||
state.opencode.emit_event(json!({
|
|
||||||
"type": "session.status",
|
|
||||||
"properties": {"sessionID": session_id, "status": {"type": "idle"}}
|
|
||||||
}));
|
|
||||||
state.opencode.emit_event(json!({
|
|
||||||
"type": "session.idle",
|
|
||||||
"properties": {"sessionID": session_id}
|
|
||||||
}));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
apply_item_event(state, event.clone(), item.clone()).await;
|
apply_item_event(state, event.clone(), item.clone()).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
UniversalEventType::TurnStarted => {
|
||||||
|
state
|
||||||
|
.opencode
|
||||||
|
.update_runtime(&event.session_id, |runtime| {
|
||||||
|
runtime.turn_in_progress = true;
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let session_id = event.session_id.clone();
|
||||||
|
state.opencode.emit_event(json!({
|
||||||
|
"type": "session.status",
|
||||||
|
"properties": {"sessionID": session_id, "status": {"type": "busy"}}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
UniversalEventType::TurnEnded => {
|
||||||
|
let turn_data = match &event.data {
|
||||||
|
UniversalEventData::Turn(data) => Some(data.clone()),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let mut should_emit_idle = false;
|
||||||
|
let runtime = state
|
||||||
|
.opencode
|
||||||
|
.update_runtime(&event.session_id, |runtime| {
|
||||||
|
let was_turn_in_progress = runtime.turn_in_progress;
|
||||||
|
if runtime.open_tool_calls.is_empty() {
|
||||||
|
runtime.active_assistant_message_id = None;
|
||||||
|
runtime.turn_in_progress = false;
|
||||||
|
should_emit_idle = was_turn_in_progress;
|
||||||
|
} else {
|
||||||
|
runtime.turn_in_progress = true;
|
||||||
|
should_emit_idle = false;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
if !runtime.open_tool_calls.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(turn_data) = turn_data {
|
||||||
|
if let Some((message, details)) = turn_error_from_metadata(&turn_data.metadata) {
|
||||||
|
emit_session_error(&state.opencode, &event.session_id, &message, None, details);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !should_emit_idle {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let session_id = event.session_id.clone();
|
||||||
|
state.opencode.emit_event(json!({
|
||||||
|
"type": "session.status",
|
||||||
|
"properties": {"sessionID": session_id, "status": {"type": "idle"}}
|
||||||
|
}));
|
||||||
|
state.opencode.emit_event(json!({
|
||||||
|
"type": "session.idle",
|
||||||
|
"properties": {"sessionID": session_id}
|
||||||
|
}));
|
||||||
|
}
|
||||||
UniversalEventType::ItemDelta => {
|
UniversalEventType::ItemDelta => {
|
||||||
if let UniversalEventData::ItemDelta(ItemDeltaData {
|
if let UniversalEventData::ItemDelta(ItemDeltaData {
|
||||||
item_id,
|
item_id,
|
||||||
|
|
@ -1945,6 +2114,13 @@ async fn apply_universal_event(state: Arc<OpenCodeAppState>, event: UniversalEve
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
UniversalEventType::SessionEnded => {
|
UniversalEventType::SessionEnded => {
|
||||||
|
state
|
||||||
|
.opencode
|
||||||
|
.update_runtime(&event.session_id, |runtime| {
|
||||||
|
runtime.turn_in_progress = false;
|
||||||
|
runtime.active_assistant_message_id = None;
|
||||||
|
})
|
||||||
|
.await;
|
||||||
let session_id = event.session_id.clone();
|
let session_id = event.session_id.clone();
|
||||||
state.opencode.emit_event(json!({
|
state.opencode.emit_event(json!({
|
||||||
"type": "session.status",
|
"type": "session.status",
|
||||||
|
|
@ -1968,6 +2144,16 @@ async fn apply_universal_event(state: Arc<OpenCodeAppState>, event: UniversalEve
|
||||||
UniversalEventType::Error => {
|
UniversalEventType::Error => {
|
||||||
if let UniversalEventData::Error(error) = &event.data {
|
if let UniversalEventData::Error(error) = &event.data {
|
||||||
let session_id = event.session_id.clone();
|
let session_id = event.session_id.clone();
|
||||||
|
let mut should_emit_idle = false;
|
||||||
|
state
|
||||||
|
.opencode
|
||||||
|
.update_runtime(&session_id, |runtime| {
|
||||||
|
let was_turn_in_progress = runtime.turn_in_progress;
|
||||||
|
runtime.turn_in_progress = false;
|
||||||
|
runtime.active_assistant_message_id = None;
|
||||||
|
should_emit_idle = was_turn_in_progress;
|
||||||
|
})
|
||||||
|
.await;
|
||||||
emit_session_error(
|
emit_session_error(
|
||||||
&state.opencode,
|
&state.opencode,
|
||||||
&session_id,
|
&session_id,
|
||||||
|
|
@ -1975,7 +2161,9 @@ async fn apply_universal_event(state: Arc<OpenCodeAppState>, event: UniversalEve
|
||||||
error.code.as_deref(),
|
error.code.as_deref(),
|
||||||
error.details.clone(),
|
error.details.clone(),
|
||||||
);
|
);
|
||||||
emit_session_idle(&state.opencode, &session_id);
|
if should_emit_idle {
|
||||||
|
emit_session_idle(&state.opencode, &session_id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|
@ -2111,16 +2299,6 @@ async fn apply_item_event(
|
||||||
event: UniversalEvent,
|
event: UniversalEvent,
|
||||||
item: UniversalItem,
|
item: UniversalItem,
|
||||||
) {
|
) {
|
||||||
if matches!(item.kind, ItemKind::ToolCall | ItemKind::ToolResult) {
|
|
||||||
apply_tool_item_event(state, event, item).await;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if item.kind != ItemKind::Message {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if matches!(item.role, Some(ItemRole::User)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let session_id = event.session_id.clone();
|
let session_id = event.session_id.clone();
|
||||||
let item_id_key = if item.item_id.is_empty() {
|
let item_id_key = if item.item_id.is_empty() {
|
||||||
None
|
None
|
||||||
|
|
@ -2128,6 +2306,38 @@ async fn apply_item_event(
|
||||||
Some(item.item_id.clone())
|
Some(item.item_id.clone())
|
||||||
};
|
};
|
||||||
let native_id_key = item.native_item_id.clone();
|
let native_id_key = item.native_item_id.clone();
|
||||||
|
let supports_text_deltas = item_supports_text_deltas(&item);
|
||||||
|
let is_user_item = matches!(item.role.as_ref(), Some(ItemRole::User));
|
||||||
|
let _ = state
|
||||||
|
.opencode
|
||||||
|
.update_runtime(&session_id, |runtime| {
|
||||||
|
set_item_text_delta_capability(
|
||||||
|
runtime,
|
||||||
|
item_id_key.as_deref(),
|
||||||
|
native_id_key.as_deref(),
|
||||||
|
supports_text_deltas,
|
||||||
|
);
|
||||||
|
if is_user_item {
|
||||||
|
if let Some(item_key) = item_id_key.as_ref() {
|
||||||
|
runtime.user_item_ids.insert(item_key.clone());
|
||||||
|
}
|
||||||
|
if let Some(native_key) = native_id_key.as_ref() {
|
||||||
|
runtime.user_item_ids.insert(native_key.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if matches!(item.kind, ItemKind::ToolCall | ItemKind::ToolResult) {
|
||||||
|
apply_tool_item_event(state, event, item).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if item.kind != ItemKind::Message {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if is_user_item {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let mut message_id: Option<String> = None;
|
let mut message_id: Option<String> = None;
|
||||||
let mut parent_id: Option<String> = None;
|
let mut parent_id: Option<String> = None;
|
||||||
let runtime = state
|
let runtime = state
|
||||||
|
|
@ -2146,6 +2356,7 @@ async fn apply_item_event(
|
||||||
.clone()
|
.clone()
|
||||||
.and_then(|key| runtime.message_id_for_item.get(&key).cloned())
|
.and_then(|key| runtime.message_id_for_item.get(&key).cloned())
|
||||||
})
|
})
|
||||||
|
.or_else(|| runtime.active_assistant_message_id.clone())
|
||||||
{
|
{
|
||||||
message_id = Some(existing);
|
message_id = Some(existing);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -2216,7 +2427,7 @@ async fn apply_item_event(
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
if let Some(text) = extract_text_from_content(&item.content) {
|
if let Some(text) = extract_message_text_from_content(&item.content) {
|
||||||
if event.event_type == UniversalEventType::ItemStarted {
|
if event.event_type == UniversalEventType::ItemStarted {
|
||||||
// Reset streaming text state for a new assistant item.
|
// Reset streaming text state for a new assistant item.
|
||||||
let _ = state
|
let _ = state
|
||||||
|
|
@ -2677,22 +2888,35 @@ async fn apply_item_delta(
|
||||||
Some(item_id)
|
Some(item_id)
|
||||||
};
|
};
|
||||||
let native_id_key = native_item_id;
|
let native_id_key = native_item_id;
|
||||||
let is_user_delta = item_id_key
|
|
||||||
.as_ref()
|
|
||||||
.map(|value| value.starts_with("user_"))
|
|
||||||
.unwrap_or(false)
|
|
||||||
|| native_id_key
|
|
||||||
.as_ref()
|
|
||||||
.map(|value| value.starts_with("user_"))
|
|
||||||
.unwrap_or(false);
|
|
||||||
if is_user_delta {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let mut message_id: Option<String> = None;
|
let mut message_id: Option<String> = None;
|
||||||
let mut parent_id: Option<String> = None;
|
let mut parent_id: Option<String> = None;
|
||||||
|
let mut is_user_delta = false;
|
||||||
|
let mut suppress_non_text_delta = false;
|
||||||
let runtime = state
|
let runtime = state
|
||||||
.opencode
|
.opencode
|
||||||
.update_runtime(&session_id, |runtime| {
|
.update_runtime(&session_id, |runtime| {
|
||||||
|
if item_delta_is_non_text(runtime, item_id_key.as_deref(), native_id_key.as_deref()) {
|
||||||
|
suppress_non_text_delta = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let is_user_from_runtime = item_id_key
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|value| runtime.user_item_ids.contains(value))
|
||||||
|
|| native_id_key
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|value| runtime.user_item_ids.contains(value));
|
||||||
|
let is_user_from_prefix = item_id_key
|
||||||
|
.as_ref()
|
||||||
|
.map(|value| value.starts_with("user_"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
|| native_id_key
|
||||||
|
.as_ref()
|
||||||
|
.map(|value| value.starts_with("user_"))
|
||||||
|
.unwrap_or(false);
|
||||||
|
if is_user_from_runtime || is_user_from_prefix {
|
||||||
|
is_user_delta = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
parent_id = runtime.last_user_message_id.clone();
|
parent_id = runtime.last_user_message_id.clone();
|
||||||
if let Some(existing) = item_id_key
|
if let Some(existing) = item_id_key
|
||||||
.clone()
|
.clone()
|
||||||
|
|
@ -2720,6 +2944,9 @@ async fn apply_item_delta(
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
if is_user_delta || suppress_non_text_delta {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let message_id = message_id.unwrap_or_else(|| {
|
let message_id = message_id.unwrap_or_else(|| {
|
||||||
unique_assistant_message_id(&runtime, parent_id.as_ref(), event.sequence)
|
unique_assistant_message_id(&runtime, parent_id.as_ref(), event.sequence)
|
||||||
});
|
});
|
||||||
|
|
@ -3494,6 +3721,10 @@ async fn oc_session_create(
|
||||||
parent_id: None,
|
parent_id: None,
|
||||||
permission: None,
|
permission: None,
|
||||||
permission_mode: None,
|
permission_mode: None,
|
||||||
|
model: None,
|
||||||
|
provider_id: None,
|
||||||
|
model_id: None,
|
||||||
|
variant: None,
|
||||||
});
|
});
|
||||||
let directory = state
|
let directory = state
|
||||||
.opencode
|
.opencode
|
||||||
|
|
@ -3502,7 +3733,19 @@ async fn oc_session_create(
|
||||||
let id = next_id("ses_", &SESSION_COUNTER);
|
let id = next_id("ses_", &SESSION_COUNTER);
|
||||||
let slug = format!("session-{}", id);
|
let slug = format!("session-{}", id);
|
||||||
let title = body.title.unwrap_or_else(|| format!("Session {}", id));
|
let title = body.title.unwrap_or_else(|| format!("Session {}", id));
|
||||||
let permission_mode = body.permission_mode;
|
let permission_mode = body.permission_mode.clone();
|
||||||
|
let requested_provider = body
|
||||||
|
.model
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|v| v.get("providerID"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.or(body.provider_id.as_deref());
|
||||||
|
let requested_model = body
|
||||||
|
.model
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|v| v.get("modelID"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.or(body.model_id.as_deref());
|
||||||
let record = OpenCodeSessionRecord {
|
let record = OpenCodeSessionRecord {
|
||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
slug,
|
slug,
|
||||||
|
|
@ -3514,7 +3757,7 @@ async fn oc_session_create(
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
share_url: None,
|
share_url: None,
|
||||||
permission_mode,
|
permission_mode: permission_mode.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let session_value = record.to_value();
|
let session_value = record.to_value();
|
||||||
|
|
@ -3523,11 +3766,32 @@ async fn oc_session_create(
|
||||||
sessions.insert(id.clone(), record);
|
sessions.insert(id.clone(), record);
|
||||||
drop(sessions);
|
drop(sessions);
|
||||||
|
|
||||||
|
let (session_agent, provider_id, model_id) =
|
||||||
|
resolve_session_agent(&state, &id, requested_provider, requested_model).await;
|
||||||
|
let session_agent_id = AgentId::parse(&session_agent).unwrap_or_else(default_agent_id);
|
||||||
|
let backing_model = backing_model_for_agent(session_agent_id, &provider_id, &model_id);
|
||||||
|
let backing_variant = body.variant.clone();
|
||||||
|
if let Err(err) = ensure_backing_session(
|
||||||
|
&state,
|
||||||
|
&id,
|
||||||
|
&session_agent,
|
||||||
|
backing_model,
|
||||||
|
backing_variant,
|
||||||
|
permission_mode,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
let mut sessions = state.opencode.sessions.lock().await;
|
||||||
|
sessions.remove(&id);
|
||||||
|
drop(sessions);
|
||||||
|
return sandbox_error_response(err).into_response();
|
||||||
|
}
|
||||||
|
|
||||||
state
|
state
|
||||||
.opencode
|
.opencode
|
||||||
.emit_event(session_event("session.created", &session_value));
|
.emit_event(session_event("session.created", &session_value));
|
||||||
|
|
||||||
(StatusCode::OK, Json(session_value))
|
(StatusCode::OK, Json(session_value)).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
|
|
@ -3591,6 +3855,14 @@ async fn oc_session_update(
|
||||||
let mut sessions = state.opencode.sessions.lock().await;
|
let mut sessions = state.opencode.sessions.lock().await;
|
||||||
if let Some(session) = sessions.get_mut(&session_id) {
|
if let Some(session) = sessions.get_mut(&session_id) {
|
||||||
if let Some(title) = body.title {
|
if let Some(title) = body.title {
|
||||||
|
if let Err(err) = state
|
||||||
|
.inner
|
||||||
|
.session_manager()
|
||||||
|
.set_session_title(&session_id, title.clone())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
return sandbox_error_response(err).into_response();
|
||||||
|
}
|
||||||
session.title = title;
|
session.title = title;
|
||||||
session.updated_at = state.opencode.now_ms();
|
session.updated_at = state.opencode.now_ms();
|
||||||
}
|
}
|
||||||
|
|
@ -3616,6 +3888,15 @@ async fn oc_session_delete(
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let mut sessions = state.opencode.sessions.lock().await;
|
let mut sessions = state.opencode.sessions.lock().await;
|
||||||
if let Some(session) = sessions.remove(&session_id) {
|
if let Some(session) = sessions.remove(&session_id) {
|
||||||
|
drop(sessions);
|
||||||
|
if let Err(err) = state
|
||||||
|
.inner
|
||||||
|
.session_manager()
|
||||||
|
.delete_session(&session_id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
return sandbox_error_response(err).into_response();
|
||||||
|
}
|
||||||
state
|
state
|
||||||
.opencode
|
.opencode
|
||||||
.emit_event(session_event("session.deleted", &session.to_value()));
|
.emit_event(session_event("session.deleted", &session.to_value()));
|
||||||
|
|
@ -3632,9 +3913,18 @@ async fn oc_session_delete(
|
||||||
)]
|
)]
|
||||||
async fn oc_session_status(State(state): State<Arc<OpenCodeAppState>>) -> impl IntoResponse {
|
async fn oc_session_status(State(state): State<Arc<OpenCodeAppState>>) -> impl IntoResponse {
|
||||||
let sessions = state.inner.session_manager().list_sessions().await;
|
let sessions = state.inner.session_manager().list_sessions().await;
|
||||||
|
let runtimes = state.opencode.session_runtime.lock().await;
|
||||||
let mut status_map = serde_json::Map::new();
|
let mut status_map = serde_json::Map::new();
|
||||||
for s in &sessions {
|
for s in &sessions {
|
||||||
let status = if s.ended { "idle" } else { "busy" };
|
let status = if runtimes
|
||||||
|
.get(&s.session_id)
|
||||||
|
.map(|runtime| runtime.turn_in_progress)
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
"busy"
|
||||||
|
} else {
|
||||||
|
"idle"
|
||||||
|
};
|
||||||
status_map.insert(s.session_id.clone(), json!({"type": status}));
|
status_map.insert(s.session_id.clone(), json!({"type": status}));
|
||||||
}
|
}
|
||||||
(StatusCode::OK, Json(Value::Object(status_map)))
|
(StatusCode::OK, Json(Value::Object(status_map)))
|
||||||
|
|
@ -3669,11 +3959,61 @@ async fn oc_session_children() -> impl IntoResponse {
|
||||||
post,
|
post,
|
||||||
path = "/session/{sessionID}/init",
|
path = "/session/{sessionID}/init",
|
||||||
params(("sessionID" = String, Path, description = "Session ID")),
|
params(("sessionID" = String, Path, description = "Session ID")),
|
||||||
|
request_body = SessionInitRequest,
|
||||||
responses((status = 200)),
|
responses((status = 200)),
|
||||||
tag = "opencode"
|
tag = "opencode"
|
||||||
)]
|
)]
|
||||||
async fn oc_session_init() -> impl IntoResponse {
|
async fn oc_session_init(
|
||||||
bool_ok(true)
|
State(state): State<Arc<OpenCodeAppState>>,
|
||||||
|
Path(session_id): Path<String>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Query(query): Query<DirectoryQuery>,
|
||||||
|
body: Option<Json<SessionInitRequest>>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let directory = state
|
||||||
|
.opencode
|
||||||
|
.directory_for(&headers, query.directory.as_ref());
|
||||||
|
let _ = state.opencode.ensure_session(&session_id, directory).await;
|
||||||
|
let body = body.map(|json| json.0).unwrap_or(SessionInitRequest {
|
||||||
|
provider_id: None,
|
||||||
|
model_id: None,
|
||||||
|
message_id: None,
|
||||||
|
});
|
||||||
|
let requested_provider = body
|
||||||
|
.provider_id
|
||||||
|
.as_deref()
|
||||||
|
.filter(|value| !value.is_empty());
|
||||||
|
let requested_model = body.model_id.as_deref().filter(|value| !value.is_empty());
|
||||||
|
if requested_provider.is_none() && requested_model.is_none() {
|
||||||
|
return bool_ok(true).into_response();
|
||||||
|
}
|
||||||
|
if requested_provider.is_none() || requested_model.is_none() {
|
||||||
|
return bad_request("providerID and modelID are required when selecting a model")
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
let (session_agent, provider_id, model_id) =
|
||||||
|
resolve_session_agent(&state, &session_id, requested_provider, requested_model).await;
|
||||||
|
let session_agent_id = AgentId::parse(&session_agent).unwrap_or_else(default_agent_id);
|
||||||
|
let backing_model = backing_model_for_agent(session_agent_id, &provider_id, &model_id);
|
||||||
|
let session_permission_mode = {
|
||||||
|
let sessions = state.opencode.sessions.lock().await;
|
||||||
|
sessions
|
||||||
|
.get(&session_id)
|
||||||
|
.and_then(|s| s.permission_mode.clone())
|
||||||
|
};
|
||||||
|
if let Err(err) = ensure_backing_session(
|
||||||
|
&state,
|
||||||
|
&session_id,
|
||||||
|
&session_agent,
|
||||||
|
backing_model,
|
||||||
|
None,
|
||||||
|
session_permission_mode,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
return sandbox_error_response(err).into_response();
|
||||||
|
}
|
||||||
|
bool_ok(true).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
|
|
@ -3877,6 +4217,7 @@ async fn oc_session_message_create(
|
||||||
let _ = state
|
let _ = state
|
||||||
.opencode
|
.opencode
|
||||||
.update_runtime(&session_id, |runtime| {
|
.update_runtime(&session_id, |runtime| {
|
||||||
|
runtime.turn_in_progress = true;
|
||||||
runtime.last_user_message_id = Some(user_message_id.clone());
|
runtime.last_user_message_id = Some(user_message_id.clone());
|
||||||
runtime.active_assistant_message_id = None;
|
runtime.active_assistant_message_id = None;
|
||||||
runtime.last_agent = Some(agent_mode.clone());
|
runtime.last_agent = Some(agent_mode.clone());
|
||||||
|
|
@ -3902,6 +4243,13 @@ async fn oc_session_message_create(
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
|
let _ = state
|
||||||
|
.opencode
|
||||||
|
.update_runtime(&session_id, |runtime| {
|
||||||
|
runtime.turn_in_progress = false;
|
||||||
|
runtime.active_assistant_message_id = None;
|
||||||
|
})
|
||||||
|
.await;
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
target = "sandbox_agent::opencode",
|
target = "sandbox_agent::opencode",
|
||||||
?err,
|
?err,
|
||||||
|
|
@ -3926,6 +4274,13 @@ async fn oc_session_message_create(
|
||||||
.send_message(session_id.clone(), prompt_text)
|
.send_message(session_id.clone(), prompt_text)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
|
let _ = state
|
||||||
|
.opencode
|
||||||
|
.update_runtime(&session_id, |runtime| {
|
||||||
|
runtime.turn_in_progress = false;
|
||||||
|
runtime.active_assistant_message_id = None;
|
||||||
|
})
|
||||||
|
.await;
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
target = "sandbox_agent::opencode",
|
target = "sandbox_agent::opencode",
|
||||||
?err,
|
?err,
|
||||||
|
|
@ -5421,3 +5776,107 @@ async fn oc_tui_select_session(
|
||||||
tags((name = "opencode", description = "OpenCode compatibility API"))
|
tags((name = "opencode", description = "OpenCode compatibility API"))
|
||||||
)]
|
)]
|
||||||
pub struct OpenCodeApiDoc;
|
pub struct OpenCodeApiDoc;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use sandbox_agent_universal_agent_schema::ReasoningVisibility;
|
||||||
|
|
||||||
|
fn assistant_item(content: Vec<ContentPart>) -> UniversalItem {
|
||||||
|
UniversalItem {
|
||||||
|
item_id: "itm_assistant".to_string(),
|
||||||
|
native_item_id: Some("native_assistant".to_string()),
|
||||||
|
parent_id: None,
|
||||||
|
kind: ItemKind::Message,
|
||||||
|
role: Some(ItemRole::Assistant),
|
||||||
|
content,
|
||||||
|
status: ItemStatus::InProgress,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_message_text_ignores_non_text_parts() {
|
||||||
|
let parts = vec![
|
||||||
|
ContentPart::Status {
|
||||||
|
label: "Thinking".to_string(),
|
||||||
|
detail: Some("Preparing friendly brief response".to_string()),
|
||||||
|
},
|
||||||
|
ContentPart::Reasoning {
|
||||||
|
text: "Preparing friendly brief response".to_string(),
|
||||||
|
visibility: ReasoningVisibility::Public,
|
||||||
|
},
|
||||||
|
ContentPart::Text {
|
||||||
|
text: "Hey! How can I help?".to_string(),
|
||||||
|
},
|
||||||
|
ContentPart::Json {
|
||||||
|
json: serde_json::json!({"ignored": true}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
extract_message_text_from_content(&parts),
|
||||||
|
Some("Hey! How can I help?".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn item_supports_text_deltas_only_for_assistant_text_messages() {
|
||||||
|
assert!(item_supports_text_deltas(&assistant_item(Vec::new())));
|
||||||
|
assert!(item_supports_text_deltas(&assistant_item(vec![
|
||||||
|
ContentPart::Text {
|
||||||
|
text: "hello".to_string(),
|
||||||
|
}
|
||||||
|
])));
|
||||||
|
assert!(!item_supports_text_deltas(&assistant_item(vec![
|
||||||
|
ContentPart::Reasoning {
|
||||||
|
text: "internal".to_string(),
|
||||||
|
visibility: ReasoningVisibility::Private,
|
||||||
|
}
|
||||||
|
])));
|
||||||
|
|
||||||
|
let user = UniversalItem {
|
||||||
|
item_id: "itm_user".to_string(),
|
||||||
|
native_item_id: Some("native_user".to_string()),
|
||||||
|
parent_id: None,
|
||||||
|
kind: ItemKind::Message,
|
||||||
|
role: Some(ItemRole::User),
|
||||||
|
content: vec![ContentPart::Text {
|
||||||
|
text: "hello".to_string(),
|
||||||
|
}],
|
||||||
|
status: ItemStatus::InProgress,
|
||||||
|
};
|
||||||
|
assert!(!item_supports_text_deltas(&user));
|
||||||
|
|
||||||
|
let status = UniversalItem {
|
||||||
|
item_id: "itm_status".to_string(),
|
||||||
|
native_item_id: Some("native_status".to_string()),
|
||||||
|
parent_id: None,
|
||||||
|
kind: ItemKind::Status,
|
||||||
|
role: Some(ItemRole::Assistant),
|
||||||
|
content: vec![ContentPart::Status {
|
||||||
|
label: "thinking".to_string(),
|
||||||
|
detail: None,
|
||||||
|
}],
|
||||||
|
status: ItemStatus::InProgress,
|
||||||
|
};
|
||||||
|
assert!(!item_supports_text_deltas(&status));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn text_delta_capability_blocks_non_text_item_ids() {
|
||||||
|
let mut runtime = OpenCodeSessionRuntime::default();
|
||||||
|
set_item_text_delta_capability(&mut runtime, Some("itm_1"), Some("native_1"), false);
|
||||||
|
assert!(item_delta_is_non_text(
|
||||||
|
&runtime,
|
||||||
|
Some("itm_1"),
|
||||||
|
Some("native_1")
|
||||||
|
));
|
||||||
|
|
||||||
|
set_item_text_delta_capability(&mut runtime, Some("itm_1"), Some("native_1"), true);
|
||||||
|
assert!(!item_delta_is_non_text(
|
||||||
|
&runtime,
|
||||||
|
Some("itm_1"),
|
||||||
|
Some("native_1")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,11 +22,12 @@ use reqwest::Client;
|
||||||
use sandbox_agent_error::{AgentError, ErrorType, ProblemDetails, SandboxError};
|
use sandbox_agent_error::{AgentError, ErrorType, ProblemDetails, SandboxError};
|
||||||
use sandbox_agent_universal_agent_schema::{
|
use sandbox_agent_universal_agent_schema::{
|
||||||
codex as codex_schema, convert_amp, convert_claude, convert_codex, convert_opencode,
|
codex as codex_schema, convert_amp, convert_claude, convert_codex, convert_opencode,
|
||||||
turn_completed_event, AgentUnparsedData, ContentPart, ErrorData, EventConversion, EventSource,
|
turn_ended_event, turn_started_event, AgentUnparsedData, ContentPart, ErrorData,
|
||||||
FileAction, ItemDeltaData, ItemEventData, ItemKind, ItemRole, ItemStatus, PermissionEventData,
|
EventConversion, EventSource, FileAction, ItemDeltaData, ItemEventData, ItemKind, ItemRole,
|
||||||
PermissionStatus, QuestionEventData, QuestionStatus, ReasoningVisibility, SessionEndReason,
|
ItemStatus, PermissionEventData, PermissionStatus, QuestionEventData, QuestionStatus,
|
||||||
SessionEndedData, SessionStartedData, StderrOutput, TerminatedBy, UniversalEvent,
|
ReasoningVisibility, SessionEndReason, SessionEndedData, SessionStartedData, StderrOutput,
|
||||||
UniversalEventData, UniversalEventType, UniversalItem,
|
TerminatedBy, TurnEventData, TurnPhase, UniversalEvent, UniversalEventData, UniversalEventType,
|
||||||
|
UniversalItem,
|
||||||
};
|
};
|
||||||
use schemars::JsonSchema;
|
use schemars::JsonSchema;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
@ -336,6 +337,8 @@ pub async fn shutdown_servers(state: &Arc<AppState>) {
|
||||||
EventSource,
|
EventSource,
|
||||||
SessionStartedData,
|
SessionStartedData,
|
||||||
SessionEndedData,
|
SessionEndedData,
|
||||||
|
TurnEventData,
|
||||||
|
TurnPhase,
|
||||||
SessionEndReason,
|
SessionEndReason,
|
||||||
TerminatedBy,
|
TerminatedBy,
|
||||||
StderrOutput,
|
StderrOutput,
|
||||||
|
|
@ -648,6 +651,7 @@ impl SessionState {
|
||||||
}
|
}
|
||||||
if conversion.event_type == UniversalEventType::ItemCompleted
|
if conversion.event_type == UniversalEventType::ItemCompleted
|
||||||
&& data.item.kind == ItemKind::Message
|
&& data.item.kind == ItemKind::Message
|
||||||
|
&& !matches!(data.item.role, Some(ItemRole::User))
|
||||||
&& !self.item_delta_seen.contains(&data.item.item_id)
|
&& !self.item_delta_seen.contains(&data.item.item_id)
|
||||||
{
|
{
|
||||||
if let Some(delta) = text_delta_from_parts(&data.item.content) {
|
if let Some(delta) = text_delta_from_parts(&data.item.content) {
|
||||||
|
|
@ -736,6 +740,15 @@ impl SessionState {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if event.event_type == UniversalEventType::PermissionRequested
|
||||||
|
&& self.permission_mode == "acceptEdits"
|
||||||
|
{
|
||||||
|
if let UniversalEventData::Permission(ref data) = event.data {
|
||||||
|
if is_file_change_action(&data.action) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
self.events.push(event.clone());
|
self.events.push(event.clone());
|
||||||
let _ = self.broadcaster.send(event.clone());
|
let _ = self.broadcaster.send(event.clone());
|
||||||
|
|
@ -1853,6 +1866,49 @@ impl SessionManager {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn set_session_title(
|
||||||
|
&self,
|
||||||
|
session_id: &str,
|
||||||
|
title: String,
|
||||||
|
) -> Result<(), SandboxError> {
|
||||||
|
let mut sessions = self.sessions.lock().await;
|
||||||
|
let Some(session) = SessionManager::session_mut(&mut sessions, session_id) else {
|
||||||
|
return Err(SandboxError::SessionNotFound {
|
||||||
|
session_id: session_id.to_string(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
session.title = Some(title);
|
||||||
|
session.updated_at = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_millis() as i64)
|
||||||
|
.unwrap_or(session.updated_at);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn delete_session(&self, session_id: &str) -> Result<(), SandboxError> {
|
||||||
|
let (agent, native_session_id) = {
|
||||||
|
let mut sessions = self.sessions.lock().await;
|
||||||
|
let Some(index) = sessions
|
||||||
|
.iter()
|
||||||
|
.position(|session| session.session_id == session_id)
|
||||||
|
else {
|
||||||
|
return Err(SandboxError::SessionNotFound {
|
||||||
|
session_id: session_id.to_string(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
let session = sessions.remove(index);
|
||||||
|
(session.agent, session.native_session_id)
|
||||||
|
};
|
||||||
|
|
||||||
|
if agent == AgentId::Opencode || agent == AgentId::Codex {
|
||||||
|
self.server_manager
|
||||||
|
.unregister_session(agent, session_id, native_session_id.as_deref())
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn agent_modes(&self, agent: AgentId) -> Result<Vec<AgentModeInfo>, SandboxError> {
|
async fn agent_modes(&self, agent: AgentId) -> Result<Vec<AgentModeInfo>, SandboxError> {
|
||||||
if agent != AgentId::Opencode {
|
if agent != AgentId::Opencode {
|
||||||
return Ok(agent_modes_for(agent));
|
return Ok(agent_modes_for(agent));
|
||||||
|
|
@ -1946,6 +2002,14 @@ impl SessionManager {
|
||||||
) -> Result<(), SandboxError> {
|
) -> Result<(), SandboxError> {
|
||||||
// Use allow_ended=true and do explicit check to allow resumable agents
|
// Use allow_ended=true and do explicit check to allow resumable agents
|
||||||
let session_snapshot = self.session_snapshot_for_message(&session_id).await?;
|
let session_snapshot = self.session_snapshot_for_message(&session_id).await?;
|
||||||
|
if !agent_emits_turn_started(session_snapshot.agent) {
|
||||||
|
let _ = self
|
||||||
|
.record_conversions(
|
||||||
|
&session_id,
|
||||||
|
vec![turn_started_event(None, None).synthetic()],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
if session_snapshot.agent == AgentId::Mock {
|
if session_snapshot.agent == AgentId::Mock {
|
||||||
self.send_mock_message(session_id, message).await?;
|
self.send_mock_message(session_id, message).await?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
|
|
@ -2568,46 +2632,7 @@ impl SessionManager {
|
||||||
.ok_or_else(|| SandboxError::InvalidRequest {
|
.ok_or_else(|| SandboxError::InvalidRequest {
|
||||||
message: "missing codex permission metadata".to_string(),
|
message: "missing codex permission metadata".to_string(),
|
||||||
})?;
|
})?;
|
||||||
let metadata = pending.metadata.clone().unwrap_or(Value::Null);
|
let line = codex_permission_response_line(permission_id, &pending, reply.clone())?;
|
||||||
let request_id = codex_request_id_from_metadata(&metadata)
|
|
||||||
.or_else(|| codex_request_id_from_string(permission_id))
|
|
||||||
.ok_or_else(|| SandboxError::InvalidRequest {
|
|
||||||
message: "invalid codex permission request id".to_string(),
|
|
||||||
})?;
|
|
||||||
let request_kind = metadata
|
|
||||||
.get("codexRequestKind")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or("");
|
|
||||||
let response_value = match request_kind {
|
|
||||||
"commandExecution" => {
|
|
||||||
let decision = codex_command_decision_for_reply(reply.clone());
|
|
||||||
let response =
|
|
||||||
codex_schema::CommandExecutionRequestApprovalResponse { decision };
|
|
||||||
serde_json::to_value(response).map_err(|err| SandboxError::InvalidRequest {
|
|
||||||
message: err.to_string(),
|
|
||||||
})?
|
|
||||||
}
|
|
||||||
"fileChange" => {
|
|
||||||
let decision = codex_file_change_decision_for_reply(reply.clone());
|
|
||||||
let response = codex_schema::FileChangeRequestApprovalResponse { decision };
|
|
||||||
serde_json::to_value(response).map_err(|err| SandboxError::InvalidRequest {
|
|
||||||
message: err.to_string(),
|
|
||||||
})?
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
return Err(SandboxError::InvalidRequest {
|
|
||||||
message: "unsupported codex permission request".to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let response = codex_schema::JsonrpcResponse {
|
|
||||||
id: request_id,
|
|
||||||
result: response_value,
|
|
||||||
};
|
|
||||||
let line =
|
|
||||||
serde_json::to_string(&response).map_err(|err| SandboxError::InvalidRequest {
|
|
||||||
message: err.to_string(),
|
|
||||||
})?;
|
|
||||||
server
|
server
|
||||||
.stdin_sender
|
.stdin_sender
|
||||||
.send(line)
|
.send(line)
|
||||||
|
|
@ -2977,8 +3002,23 @@ impl SessionManager {
|
||||||
session_id: session_id.to_string(),
|
session_id: session_id.to_string(),
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
|
let mut accept_edits_permission_ids = Vec::new();
|
||||||
|
if session.agent == AgentId::Codex && session.permission_mode == "acceptEdits" {
|
||||||
|
for conversion in &conversions {
|
||||||
|
if conversion.event_type != UniversalEventType::PermissionRequested {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let UniversalEventData::Permission(data) = &conversion.data else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if is_file_change_action(&data.action) {
|
||||||
|
accept_edits_permission_ids.push(data.permission_id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let events = session.record_conversions(conversions);
|
let events = session.record_conversions(conversions);
|
||||||
let mut auto_approvals = Vec::new();
|
let mut auto_approvals = Vec::new();
|
||||||
|
let mut seen = HashSet::new();
|
||||||
for event in &events {
|
for event in &events {
|
||||||
if event.event_type != UniversalEventType::PermissionRequested {
|
if event.event_type != UniversalEventType::PermissionRequested {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -2987,10 +3027,7 @@ impl SessionManager {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let cached = session.should_auto_approve_permission(&data.action, &data.metadata);
|
let cached = session.should_auto_approve_permission(&data.action, &data.metadata);
|
||||||
if session.agent == AgentId::Codex
|
if is_question_tool_action(&data.action) || !cached {
|
||||||
|| is_question_tool_action(&data.action)
|
|
||||||
|| !cached
|
|
||||||
{
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Some(pending) = session.take_permission(&data.permission_id) {
|
if let Some(pending) = session.take_permission(&data.permission_id) {
|
||||||
|
|
@ -3000,14 +3037,49 @@ impl SessionManager {
|
||||||
session.claude_sender(),
|
session.claude_sender(),
|
||||||
data.permission_id.clone(),
|
data.permission_id.clone(),
|
||||||
pending,
|
pending,
|
||||||
|
PermissionReply::Always,
|
||||||
));
|
));
|
||||||
|
seen.insert(data.permission_id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for permission_id in accept_edits_permission_ids {
|
||||||
|
if seen.contains(&permission_id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(pending) = session.take_permission(&permission_id) {
|
||||||
|
auto_approvals.push((
|
||||||
|
session.agent,
|
||||||
|
session.native_session_id.clone(),
|
||||||
|
session.claude_sender(),
|
||||||
|
permission_id.clone(),
|
||||||
|
pending,
|
||||||
|
PermissionReply::Always,
|
||||||
|
));
|
||||||
|
seen.insert(permission_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(events, auto_approvals)
|
(events, auto_approvals)
|
||||||
};
|
};
|
||||||
|
|
||||||
for (agent, native_session_id, claude_sender, permission_id, pending) in auto_approvals {
|
for (agent, native_session_id, claude_sender, permission_id, pending, reply) in
|
||||||
|
auto_approvals
|
||||||
|
{
|
||||||
|
let reply_for_status = reply.clone();
|
||||||
let reply_result = match agent {
|
let reply_result = match agent {
|
||||||
|
AgentId::Codex => {
|
||||||
|
let (server, _) = self
|
||||||
|
.server_manager
|
||||||
|
.ensure_stdio_server(AgentId::Codex)
|
||||||
|
.await?;
|
||||||
|
let line =
|
||||||
|
codex_permission_response_line(&permission_id, &pending, reply.clone())?;
|
||||||
|
server
|
||||||
|
.stdin_sender
|
||||||
|
.send(line)
|
||||||
|
.map_err(|_| SandboxError::InvalidRequest {
|
||||||
|
message: "codex server not active".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
AgentId::Opencode => {
|
AgentId::Opencode => {
|
||||||
let agent_session_id =
|
let agent_session_id =
|
||||||
native_session_id
|
native_session_id
|
||||||
|
|
@ -3020,7 +3092,7 @@ impl SessionManager {
|
||||||
self.opencode_permission_reply(
|
self.opencode_permission_reply(
|
||||||
&agent_session_id,
|
&agent_session_id,
|
||||||
&permission_id,
|
&permission_id,
|
||||||
PermissionReply::Always,
|
reply.clone(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
@ -3039,12 +3111,27 @@ impl SessionManager {
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or(Value::Null);
|
.unwrap_or(Value::Null);
|
||||||
let mut response_map = serde_json::Map::new();
|
let mut response_map = serde_json::Map::new();
|
||||||
if !updated_input.is_null() {
|
match reply.clone() {
|
||||||
response_map.insert("updatedInput".to_string(), updated_input);
|
PermissionReply::Reject => {
|
||||||
|
response_map.insert(
|
||||||
|
"message".to_string(),
|
||||||
|
Value::String("Permission denied.".to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
PermissionReply::Once | PermissionReply::Always => {
|
||||||
|
if !updated_input.is_null() {
|
||||||
|
response_map
|
||||||
|
.insert("updatedInput".to_string(), updated_input);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
let behavior = match reply.clone() {
|
||||||
|
PermissionReply::Reject => "deny",
|
||||||
|
PermissionReply::Once | PermissionReply::Always => "allow",
|
||||||
|
};
|
||||||
let line = claude_control_response_line(
|
let line = claude_control_response_line(
|
||||||
&permission_id,
|
&permission_id,
|
||||||
"allow",
|
behavior,
|
||||||
Value::Object(response_map),
|
Value::Object(response_map),
|
||||||
);
|
);
|
||||||
sender.send(line).map_err(|_| SandboxError::InvalidRequest {
|
sender.send(line).map_err(|_| SandboxError::InvalidRequest {
|
||||||
|
|
@ -3078,7 +3165,11 @@ impl SessionManager {
|
||||||
UniversalEventData::Permission(PermissionEventData {
|
UniversalEventData::Permission(PermissionEventData {
|
||||||
permission_id: permission_id.clone(),
|
permission_id: permission_id.clone(),
|
||||||
action: pending.action,
|
action: pending.action,
|
||||||
status: PermissionStatus::AcceptForSession,
|
status: match reply_for_status {
|
||||||
|
PermissionReply::Reject => PermissionStatus::Reject,
|
||||||
|
PermissionReply::Once => PermissionStatus::Accept,
|
||||||
|
PermissionReply::Always => PermissionStatus::AcceptForSession,
|
||||||
|
},
|
||||||
metadata: pending.metadata,
|
metadata: pending.metadata,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
@ -5007,6 +5098,10 @@ fn agent_supports_item_started(agent: AgentId) -> bool {
|
||||||
agent_capabilities_for(agent).item_started
|
agent_capabilities_for(agent).item_started
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn agent_emits_turn_started(agent: AgentId) -> bool {
|
||||||
|
matches!(agent, AgentId::Codex | AgentId::Opencode)
|
||||||
|
}
|
||||||
|
|
||||||
fn agent_capabilities_for(agent: AgentId) -> AgentCapabilities {
|
fn agent_capabilities_for(agent: AgentId) -> AgentCapabilities {
|
||||||
match agent {
|
match agent {
|
||||||
// Claude CLI supports tool calls/results and permission prompts via the SDK control protocol,
|
// Claude CLI supports tool calls/results and permission prompts via the SDK control protocol,
|
||||||
|
|
@ -5375,7 +5470,7 @@ fn normalize_permission_mode(
|
||||||
agent: AgentId,
|
agent: AgentId,
|
||||||
permission_mode: Option<&str>,
|
permission_mode: Option<&str>,
|
||||||
) -> Result<String, SandboxError> {
|
) -> Result<String, SandboxError> {
|
||||||
let mode = match permission_mode.unwrap_or("default") {
|
let mut mode = match permission_mode.unwrap_or("default") {
|
||||||
"default" | "plan" | "bypass" | "acceptEdits" => permission_mode.unwrap_or("default"),
|
"default" | "plan" | "bypass" | "acceptEdits" => permission_mode.unwrap_or("default"),
|
||||||
value => {
|
value => {
|
||||||
return Err(SandboxError::InvalidRequest {
|
return Err(SandboxError::InvalidRequest {
|
||||||
|
|
@ -5384,6 +5479,10 @@ fn normalize_permission_mode(
|
||||||
.into())
|
.into())
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
if agent != AgentId::Claude && mode == "acceptEdits" && agent != AgentId::Codex {
|
||||||
|
// acceptEdits is Claude-only unless explicitly handled; treat it as a no-op for other agents.
|
||||||
|
mode = "default";
|
||||||
|
}
|
||||||
if agent == AgentId::Claude {
|
if agent == AgentId::Claude {
|
||||||
// Claude refuses --dangerously-skip-permissions when running as root,
|
// Claude refuses --dangerously-skip-permissions when running as root,
|
||||||
// which is common in container environments (Docker, Daytona, E2B).
|
// which is common in container environments (Docker, Daytona, E2B).
|
||||||
|
|
@ -5402,7 +5501,7 @@ fn normalize_permission_mode(
|
||||||
}
|
}
|
||||||
let supported = match agent {
|
let supported = match agent {
|
||||||
AgentId::Claude => false,
|
AgentId::Claude => false,
|
||||||
AgentId::Codex => matches!(mode, "default" | "plan" | "bypass"),
|
AgentId::Codex => matches!(mode, "default" | "plan" | "bypass" | "acceptEdits"),
|
||||||
AgentId::Amp => matches!(mode, "default" | "bypass"),
|
AgentId::Amp => matches!(mode, "default" | "bypass"),
|
||||||
AgentId::Opencode => matches!(mode, "default"),
|
AgentId::Opencode => matches!(mode, "default"),
|
||||||
AgentId::Mock => matches!(mode, "default" | "plan" | "bypass"),
|
AgentId::Mock => matches!(mode, "default" | "plan" | "bypass"),
|
||||||
|
|
@ -5482,14 +5581,30 @@ fn build_spawn_options(
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if let Some(anthropic) = credentials.anthropic {
|
if let Some(anthropic) = credentials.anthropic {
|
||||||
options
|
let should_inject_claude_env = !(session.agent == AgentId::Claude
|
||||||
.env
|
&& anthropic.source == "claude-code"
|
||||||
.entry("ANTHROPIC_API_KEY".to_string())
|
&& anthropic.provider == "anthropic");
|
||||||
.or_insert(anthropic.api_key.clone());
|
if should_inject_claude_env {
|
||||||
options
|
if session.agent == AgentId::Claude && anthropic.auth_type == AuthType::Oauth {
|
||||||
.env
|
options
|
||||||
.entry("CLAUDE_API_KEY".to_string())
|
.env
|
||||||
.or_insert(anthropic.api_key);
|
.entry("CLAUDE_CODE_OAUTH_TOKEN".to_string())
|
||||||
|
.or_insert(anthropic.api_key.clone());
|
||||||
|
options
|
||||||
|
.env
|
||||||
|
.entry("ANTHROPIC_AUTH_TOKEN".to_string())
|
||||||
|
.or_insert(anthropic.api_key);
|
||||||
|
} else {
|
||||||
|
options
|
||||||
|
.env
|
||||||
|
.entry("ANTHROPIC_API_KEY".to_string())
|
||||||
|
.or_insert(anthropic.api_key.clone());
|
||||||
|
options
|
||||||
|
.env
|
||||||
|
.entry("CLAUDE_API_KEY".to_string())
|
||||||
|
.or_insert(anthropic.api_key);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if let Some(openai) = credentials.openai {
|
if let Some(openai) = credentials.openai {
|
||||||
options
|
options
|
||||||
|
|
@ -5504,6 +5619,102 @@ fn build_spawn_options(
|
||||||
options
|
options
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn test_snapshot(agent: AgentId) -> SessionSnapshot {
|
||||||
|
SessionSnapshot {
|
||||||
|
session_id: "test-session".to_string(),
|
||||||
|
agent,
|
||||||
|
agent_mode: "build".to_string(),
|
||||||
|
permission_mode: "default".to_string(),
|
||||||
|
model: None,
|
||||||
|
variant: None,
|
||||||
|
native_session_id: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn claude_code_api_key_credentials() -> ExtractedCredentials {
|
||||||
|
ExtractedCredentials {
|
||||||
|
anthropic: Some(ProviderCredentials {
|
||||||
|
api_key: "sk-ant-test".to_string(),
|
||||||
|
source: "claude-code".to_string(),
|
||||||
|
auth_type: AuthType::ApiKey,
|
||||||
|
provider: "anthropic".to_string(),
|
||||||
|
}),
|
||||||
|
openai: None,
|
||||||
|
other: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn environment_oauth_credentials() -> ExtractedCredentials {
|
||||||
|
ExtractedCredentials {
|
||||||
|
anthropic: Some(ProviderCredentials {
|
||||||
|
api_key: "oauth-token".to_string(),
|
||||||
|
source: "environment".to_string(),
|
||||||
|
auth_type: AuthType::Oauth,
|
||||||
|
provider: "anthropic".to_string(),
|
||||||
|
}),
|
||||||
|
openai: None,
|
||||||
|
other: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_spawn_options_skips_claude_env_for_claude_code_source() {
|
||||||
|
let options = build_spawn_options(
|
||||||
|
&test_snapshot(AgentId::Claude),
|
||||||
|
"hello".to_string(),
|
||||||
|
claude_code_api_key_credentials(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(!options.env.contains_key("ANTHROPIC_API_KEY"));
|
||||||
|
assert!(!options.env.contains_key("CLAUDE_API_KEY"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_spawn_options_keeps_anthropic_env_for_non_claude_agent() {
|
||||||
|
let options = build_spawn_options(
|
||||||
|
&test_snapshot(AgentId::Amp),
|
||||||
|
"hello".to_string(),
|
||||||
|
claude_code_api_key_credentials(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
options.env.get("ANTHROPIC_API_KEY").map(String::as_str),
|
||||||
|
Some("sk-ant-test")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
options.env.get("CLAUDE_API_KEY").map(String::as_str),
|
||||||
|
Some("sk-ant-test")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_spawn_options_uses_oauth_env_for_claude_oauth_credentials() {
|
||||||
|
let options = build_spawn_options(
|
||||||
|
&test_snapshot(AgentId::Claude),
|
||||||
|
"hello".to_string(),
|
||||||
|
environment_oauth_credentials(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
options
|
||||||
|
.env
|
||||||
|
.get("CLAUDE_CODE_OAUTH_TOKEN")
|
||||||
|
.map(String::as_str),
|
||||||
|
Some("oauth-token")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
options.env.get("ANTHROPIC_AUTH_TOKEN").map(String::as_str),
|
||||||
|
Some("oauth-token")
|
||||||
|
);
|
||||||
|
assert!(!options.env.contains_key("ANTHROPIC_API_KEY"));
|
||||||
|
assert!(!options.env.contains_key("CLAUDE_API_KEY"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn claude_input_session_id(session: &SessionSnapshot) -> String {
|
fn claude_input_session_id(session: &SessionSnapshot) -> String {
|
||||||
session
|
session
|
||||||
.native_session_id
|
.native_session_id
|
||||||
|
|
@ -5594,6 +5805,11 @@ pub(crate) fn is_question_tool_action(action: &str) -> bool {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_file_change_action(action: &str) -> bool {
|
||||||
|
matches!(action, "fileChange" | "file_change" | "file-change")
|
||||||
|
|| action.eq_ignore_ascii_case("filechange")
|
||||||
|
}
|
||||||
|
|
||||||
fn permission_cache_keys(action: &str, metadata: &Option<Value>) -> Vec<String> {
|
fn permission_cache_keys(action: &str, metadata: &Option<Value>) -> Vec<String> {
|
||||||
let mut keys = Vec::new();
|
let mut keys = Vec::new();
|
||||||
push_permission_cache_key(&mut keys, action);
|
push_permission_cache_key(&mut keys, action);
|
||||||
|
|
@ -6187,6 +6403,51 @@ fn codex_rpc_error_to_universal(error: &codex_schema::JsonrpcError) -> EventConv
|
||||||
EventConversion::new(UniversalEventType::Error, UniversalEventData::Error(data))
|
EventConversion::new(UniversalEventType::Error, UniversalEventData::Error(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn codex_permission_response_line(
|
||||||
|
permission_id: &str,
|
||||||
|
pending: &PendingPermission,
|
||||||
|
reply: PermissionReply,
|
||||||
|
) -> Result<String, SandboxError> {
|
||||||
|
let metadata = pending.metadata.clone().unwrap_or(Value::Null);
|
||||||
|
let request_id = codex_request_id_from_metadata(&metadata)
|
||||||
|
.or_else(|| codex_request_id_from_string(permission_id))
|
||||||
|
.ok_or_else(|| SandboxError::InvalidRequest {
|
||||||
|
message: "invalid codex permission request id".to_string(),
|
||||||
|
})?;
|
||||||
|
let request_kind = metadata
|
||||||
|
.get("codexRequestKind")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("");
|
||||||
|
let response_value = match request_kind {
|
||||||
|
"commandExecution" => {
|
||||||
|
let decision = codex_command_decision_for_reply(reply);
|
||||||
|
let response = codex_schema::CommandExecutionRequestApprovalResponse { decision };
|
||||||
|
serde_json::to_value(response).map_err(|err| SandboxError::InvalidRequest {
|
||||||
|
message: err.to_string(),
|
||||||
|
})?
|
||||||
|
}
|
||||||
|
"fileChange" => {
|
||||||
|
let decision = codex_file_change_decision_for_reply(reply);
|
||||||
|
let response = codex_schema::FileChangeRequestApprovalResponse { decision };
|
||||||
|
serde_json::to_value(response).map_err(|err| SandboxError::InvalidRequest {
|
||||||
|
message: err.to_string(),
|
||||||
|
})?
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(SandboxError::InvalidRequest {
|
||||||
|
message: "unsupported codex permission request".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let response = codex_schema::JsonrpcResponse {
|
||||||
|
id: request_id,
|
||||||
|
result: response_value,
|
||||||
|
};
|
||||||
|
serde_json::to_string(&response).map_err(|err| SandboxError::InvalidRequest {
|
||||||
|
message: err.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn codex_request_id_from_metadata(metadata: &Value) -> Option<codex_schema::RequestId> {
|
fn codex_request_id_from_metadata(metadata: &Value) -> Option<codex_schema::RequestId> {
|
||||||
let metadata = metadata.as_object()?;
|
let metadata = metadata.as_object()?;
|
||||||
let value = metadata.get("codexRequestId")?;
|
let value = metadata.get("codexRequestId")?;
|
||||||
|
|
@ -6704,13 +6965,13 @@ fn mock_command_conversions(prefix: &str, input: &str) -> Vec<EventConversion> {
|
||||||
return vec![];
|
return vec![];
|
||||||
}
|
}
|
||||||
let mut events = mock_command_events(prefix, trimmed);
|
let mut events = mock_command_events(prefix, trimmed);
|
||||||
if should_append_turn_completed(&events) {
|
if should_append_turn_ended(&events) {
|
||||||
events.push(turn_completed_event());
|
events.push(turn_ended_event(None, None).synthetic());
|
||||||
}
|
}
|
||||||
events
|
events
|
||||||
}
|
}
|
||||||
|
|
||||||
fn should_append_turn_completed(events: &[EventConversion]) -> bool {
|
fn should_append_turn_ended(events: &[EventConversion]) -> bool {
|
||||||
let Some(last) = events.last() else {
|
let Some(last) = events.last() else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
@ -7559,34 +7820,16 @@ fn stream_turn_events(
|
||||||
|
|
||||||
fn is_turn_terminal(event: &UniversalEvent, _agent: AgentId) -> bool {
|
fn is_turn_terminal(event: &UniversalEvent, _agent: AgentId) -> bool {
|
||||||
match event.event_type {
|
match event.event_type {
|
||||||
UniversalEventType::SessionEnded
|
UniversalEventType::TurnEnded
|
||||||
|
| UniversalEventType::SessionEnded
|
||||||
| UniversalEventType::Error
|
| UniversalEventType::Error
|
||||||
| UniversalEventType::AgentUnparsed
|
| UniversalEventType::AgentUnparsed
|
||||||
| UniversalEventType::PermissionRequested
|
| UniversalEventType::PermissionRequested
|
||||||
| UniversalEventType::QuestionRequested => true,
|
| UniversalEventType::QuestionRequested => true,
|
||||||
UniversalEventType::ItemCompleted => {
|
|
||||||
let UniversalEventData::Item(ItemEventData { item }) = &event.data else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
matches!(status_label(item), Some("turn.completed" | "session.idle"))
|
|
||||||
}
|
|
||||||
_ => false,
|
_ => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn status_label(item: &UniversalItem) -> Option<&str> {
|
|
||||||
if item.kind != ItemKind::Status {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
item.content.iter().find_map(|part| {
|
|
||||||
if let ContentPart::Status { label, .. } = part {
|
|
||||||
Some(label.as_str())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn to_sse_event(event: UniversalEvent) -> Event {
|
fn to_sse_event(event: UniversalEvent) -> Event {
|
||||||
Event::default()
|
Event::default()
|
||||||
.json_data(&event)
|
.json_data(&event)
|
||||||
|
|
|
||||||
|
|
@ -1048,6 +1048,13 @@ async fn run_turn_stream_check(app: &Router, config: &TestAgentConfig) {
|
||||||
create_session(app, config.agent, &session_id, test_permission_mode(config.agent)).await;
|
create_session(app, config.agent, &session_id, test_permission_mode(config.agent)).await;
|
||||||
|
|
||||||
let events = read_turn_stream_events(app, &session_id, Duration::from_secs(120)).await;
|
let events = read_turn_stream_events(app, &session_id, Duration::from_secs(120)).await;
|
||||||
|
assert!(
|
||||||
|
events
|
||||||
|
.iter()
|
||||||
|
.any(|event| event.get("type").and_then(Value::as_str) == Some("turn.ended")),
|
||||||
|
"turn stream did not include turn.ended for {}",
|
||||||
|
config.agent
|
||||||
|
);
|
||||||
let events = truncate_after_first_stop(&events);
|
let events = truncate_after_first_stop(&events);
|
||||||
assert!(
|
assert!(
|
||||||
!events.is_empty(),
|
!events.is_empty(),
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,25 @@ describe("OpenCode-compatible Event Streaming", () => {
|
||||||
let handle: SandboxAgentHandle;
|
let handle: SandboxAgentHandle;
|
||||||
let client: OpencodeClient;
|
let client: OpencodeClient;
|
||||||
|
|
||||||
|
function uniqueSessionId(prefix: string): string {
|
||||||
|
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initSessionViaHttp(
|
||||||
|
sessionId: string,
|
||||||
|
body: Record<string, unknown>
|
||||||
|
): Promise<void> {
|
||||||
|
const response = await fetch(`${handle.baseUrl}/opencode/session/${sessionId}/init`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${handle.token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
expect(response.ok).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await buildSandboxAgent();
|
await buildSandboxAgent();
|
||||||
});
|
});
|
||||||
|
|
@ -144,6 +163,129 @@ describe("OpenCode-compatible Event Streaming", () => {
|
||||||
|
|
||||||
expect(response.data).toBeDefined();
|
expect(response.data).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should be idle before first prompt and return to idle after prompt completion", async () => {
|
||||||
|
const sessionId = uniqueSessionId("status-idle");
|
||||||
|
await initSessionViaHttp(sessionId, { providerID: "mock", modelID: "mock" });
|
||||||
|
|
||||||
|
const initial = await client.session.status();
|
||||||
|
expect(initial.data?.[sessionId]?.type).toBe("idle");
|
||||||
|
|
||||||
|
const eventStream = await client.event.subscribe();
|
||||||
|
const statuses: string[] = [];
|
||||||
|
|
||||||
|
const collectIdle = new Promise<void>((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(
|
||||||
|
() => reject(new Error("Timed out waiting for session.idle")),
|
||||||
|
15_000
|
||||||
|
);
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
for await (const event of (eventStream as any).stream) {
|
||||||
|
if (event?.properties?.sessionID !== sessionId) continue;
|
||||||
|
if (event.type === "session.status") {
|
||||||
|
const statusType = event?.properties?.status?.type;
|
||||||
|
if (typeof statusType === "string") statuses.push(statusType);
|
||||||
|
}
|
||||||
|
if (event.type === "session.idle") {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Stream ended
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.session.prompt({
|
||||||
|
path: { id: sessionId },
|
||||||
|
body: {
|
||||||
|
model: { providerID: "mock", modelID: "mock" },
|
||||||
|
parts: [{ type: "text", text: "Say hello" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await collectIdle;
|
||||||
|
|
||||||
|
expect(statuses).toContain("busy");
|
||||||
|
const finalStatus = await client.session.status();
|
||||||
|
expect(finalStatus.data?.[sessionId]?.type).toBe("idle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should emit session.error and return idle for failed turns", async () => {
|
||||||
|
const sessionId = uniqueSessionId("status-error");
|
||||||
|
await initSessionViaHttp(sessionId, { providerID: "mock", modelID: "mock" });
|
||||||
|
|
||||||
|
const eventStream = await client.event.subscribe();
|
||||||
|
const errors: any[] = [];
|
||||||
|
const idles: any[] = [];
|
||||||
|
|
||||||
|
const collectErrorAndIdle = new Promise<void>((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(
|
||||||
|
() => reject(new Error("Timed out waiting for session.error + session.idle")),
|
||||||
|
15_000
|
||||||
|
);
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
for await (const event of (eventStream as any).stream) {
|
||||||
|
if (event?.properties?.sessionID !== sessionId) continue;
|
||||||
|
if (event.type === "session.error") {
|
||||||
|
errors.push(event);
|
||||||
|
}
|
||||||
|
if (event.type === "session.idle") {
|
||||||
|
idles.push(event);
|
||||||
|
}
|
||||||
|
if (errors.length > 0 && idles.length > 0) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Stream ended
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.session.prompt({
|
||||||
|
path: { id: sessionId },
|
||||||
|
body: {
|
||||||
|
model: { providerID: "mock", modelID: "mock" },
|
||||||
|
parts: [{ type: "text", text: "error" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await collectErrorAndIdle;
|
||||||
|
|
||||||
|
expect(errors.length).toBeGreaterThan(0);
|
||||||
|
const finalStatus = await client.session.status();
|
||||||
|
expect(finalStatus.data?.[sessionId]?.type).toBe("idle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should report idle for newly initialized sessions across connected providers", async () => {
|
||||||
|
const providersResponse = await fetch(`${handle.baseUrl}/opencode/provider`, {
|
||||||
|
headers: { Authorization: `Bearer ${handle.token}` },
|
||||||
|
});
|
||||||
|
expect(providersResponse.ok).toBe(true);
|
||||||
|
const providersData = await providersResponse.json();
|
||||||
|
|
||||||
|
const connected: string[] = providersData.connected ?? [];
|
||||||
|
const defaults: Record<string, string> = providersData.default ?? {};
|
||||||
|
|
||||||
|
for (const providerID of connected) {
|
||||||
|
const modelID = defaults[providerID];
|
||||||
|
if (!modelID) continue;
|
||||||
|
|
||||||
|
const sessionId = uniqueSessionId(`status-${providerID.replace(/[^a-zA-Z0-9_-]/g, "_")}`);
|
||||||
|
|
||||||
|
await initSessionViaHttp(sessionId, { providerID, modelID });
|
||||||
|
|
||||||
|
const status = await client.session.status();
|
||||||
|
expect(status.data?.[sessionId]?.type).toBe("idle");
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("session.idle count", () => {
|
describe("session.idle count", () => {
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,67 @@ describe("OpenCode-compatible Session API", () => {
|
||||||
return session?.permissionMode;
|
return session?.permissionMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getBackingSession(sessionId: string) {
|
||||||
|
const response = await fetch(`${handle.baseUrl}/v1/sessions`, {
|
||||||
|
headers: { Authorization: `Bearer ${handle.token}` },
|
||||||
|
});
|
||||||
|
expect(response.ok).toBe(true);
|
||||||
|
const data = await response.json();
|
||||||
|
return (data.sessions ?? []).find((item: any) => item.sessionId === sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initSessionViaHttp(
|
||||||
|
sessionId: string,
|
||||||
|
body: Record<string, unknown> = {}
|
||||||
|
): Promise<{ response: Response; data: any }> {
|
||||||
|
const response = await fetch(`${handle.baseUrl}/opencode/session/${sessionId}/init`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${handle.token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
return { response, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listMessagesViaHttp(sessionId: string): Promise<any[]> {
|
||||||
|
const response = await fetch(`${handle.baseUrl}/opencode/session/${sessionId}/message`, {
|
||||||
|
headers: { Authorization: `Bearer ${handle.token}` },
|
||||||
|
});
|
||||||
|
expect(response.ok).toBe(true);
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getProvidersViaHttp(): Promise<{
|
||||||
|
connected: string[];
|
||||||
|
default: Record<string, string>;
|
||||||
|
}> {
|
||||||
|
const response = await fetch(`${handle.baseUrl}/opencode/provider`, {
|
||||||
|
headers: { Authorization: `Bearer ${handle.token}` },
|
||||||
|
});
|
||||||
|
expect(response.ok).toBe(true);
|
||||||
|
const data = await response.json();
|
||||||
|
return {
|
||||||
|
connected: data.connected ?? [],
|
||||||
|
default: data.default ?? {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForAssistantMessage(sessionId: string, timeoutMs = 10_000): Promise<any> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const messages = await listMessagesViaHttp(sessionId);
|
||||||
|
const assistant = messages.find((message) => message?.info?.role === "assistant");
|
||||||
|
if (assistant) {
|
||||||
|
return assistant;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
throw new Error("Timed out waiting for assistant message");
|
||||||
|
}
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
// Build the binary if needed
|
// Build the binary if needed
|
||||||
await buildSandboxAgent();
|
await buildSandboxAgent();
|
||||||
|
|
@ -145,6 +206,78 @@ describe("OpenCode-compatible Session API", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("session.init", () => {
|
||||||
|
it("should accept empty init body and keep message flow working", async () => {
|
||||||
|
const session = await client.session.create();
|
||||||
|
const sessionId = session.data?.id!;
|
||||||
|
expect(sessionId).toBeDefined();
|
||||||
|
|
||||||
|
const initialized = await initSessionViaHttp(sessionId, {});
|
||||||
|
expect(initialized.response.ok).toBe(true);
|
||||||
|
expect(initialized.data).toBe(true);
|
||||||
|
|
||||||
|
const prompt = await client.session.prompt({
|
||||||
|
path: { id: sessionId },
|
||||||
|
body: {
|
||||||
|
parts: [{ type: "text", text: "hello after init" }],
|
||||||
|
} as any,
|
||||||
|
});
|
||||||
|
expect(prompt.error).toBeUndefined();
|
||||||
|
|
||||||
|
const assistant = await waitForAssistantMessage(sessionId);
|
||||||
|
expect(assistant?.info?.role).toBe("assistant");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should apply explicit init model selection to the backing session", async () => {
|
||||||
|
const session = await client.session.create();
|
||||||
|
const sessionId = session.data?.id!;
|
||||||
|
expect(sessionId).toBeDefined();
|
||||||
|
|
||||||
|
const initialized = await initSessionViaHttp(sessionId, {
|
||||||
|
providerID: "codex",
|
||||||
|
modelID: "gpt-5",
|
||||||
|
messageID: "msg_init",
|
||||||
|
});
|
||||||
|
expect(initialized.response.ok).toBe(true);
|
||||||
|
expect(initialized.data).toBe(true);
|
||||||
|
|
||||||
|
const backingSession = await getBackingSession(sessionId);
|
||||||
|
expect(backingSession?.agent).toBe("codex");
|
||||||
|
expect(backingSession?.model).toBe("gpt-5");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should accept first prompt after codex init without session-not-found", async () => {
|
||||||
|
const providers = await getProvidersViaHttp();
|
||||||
|
if (!providers.connected.includes("codex")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const codexDefaultModel = providers.default?.codex;
|
||||||
|
if (!codexDefaultModel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await client.session.create();
|
||||||
|
const sessionId = session.data?.id!;
|
||||||
|
expect(sessionId).toBeDefined();
|
||||||
|
|
||||||
|
const initialized = await initSessionViaHttp(sessionId, {
|
||||||
|
providerID: "codex",
|
||||||
|
modelID: codexDefaultModel,
|
||||||
|
});
|
||||||
|
expect(initialized.response.ok).toBe(true);
|
||||||
|
expect(initialized.data).toBe(true);
|
||||||
|
|
||||||
|
const prompt = await client.session.prompt({
|
||||||
|
path: { id: sessionId },
|
||||||
|
body: {
|
||||||
|
model: { providerID: "codex", modelID: codexDefaultModel },
|
||||||
|
parts: [{ type: "text", text: "hello after codex init" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(prompt.error).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("session.get", () => {
|
describe("session.get", () => {
|
||||||
it("should retrieve session by ID", async () => {
|
it("should retrieve session by ID", async () => {
|
||||||
const created = await client.session.create({ body: { title: "Test" } });
|
const created = await client.session.create({ body: { title: "Test" } });
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,46 @@ async fn http_events_snapshots() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn accept_edits_noop_for_non_claude() {
|
||||||
|
let app = TestApp::new();
|
||||||
|
let session_id = "accept-edits-noop";
|
||||||
|
|
||||||
|
let (status, _) = send_json(
|
||||||
|
&app.app,
|
||||||
|
Method::POST,
|
||||||
|
&format!("/v1/sessions/{session_id}"),
|
||||||
|
Some(json!({
|
||||||
|
"agent": AgentId::Mock.as_str(),
|
||||||
|
"permissionMode": "acceptEdits"
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK, "create session with acceptEdits");
|
||||||
|
|
||||||
|
let (status, sessions) = send_json(&app.app, Method::GET, "/v1/sessions", None).await;
|
||||||
|
assert_eq!(status, StatusCode::OK, "list sessions");
|
||||||
|
|
||||||
|
let sessions = sessions
|
||||||
|
.get("sessions")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.expect("sessions list");
|
||||||
|
let session = sessions
|
||||||
|
.iter()
|
||||||
|
.find(|entry| {
|
||||||
|
entry
|
||||||
|
.get("sessionId")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.is_some_and(|id| id == session_id)
|
||||||
|
})
|
||||||
|
.expect("created session");
|
||||||
|
let permission_mode = session
|
||||||
|
.get("permissionMode")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.expect("permissionMode");
|
||||||
|
assert_eq!(permission_mode, "default");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
async fn sse_events_snapshots() {
|
async fn sse_events_snapshots() {
|
||||||
let configs = test_agents_from_env().expect("configure SANDBOX_TEST_AGENTS or install agents");
|
let configs = test_agents_from_env().expect("configure SANDBOX_TEST_AGENTS or install agents");
|
||||||
|
|
@ -125,6 +165,11 @@ async fn turn_stream_route() {
|
||||||
let configs = test_agents_from_env().expect("configure SANDBOX_TEST_AGENTS or install agents");
|
let configs = test_agents_from_env().expect("configure SANDBOX_TEST_AGENTS or install agents");
|
||||||
|
|
||||||
for config in &configs {
|
for config in &configs {
|
||||||
|
// OpenCode's embedded bun can hang while installing plugins, which blocks turn streaming.
|
||||||
|
// OpenCode turn behavior is covered by the dedicated opencode-compat suite.
|
||||||
|
if config.agent == AgentId::Opencode {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let app = TestApp::new();
|
let app = TestApp::new();
|
||||||
let capabilities = fetch_capabilities(&app.app).await;
|
let capabilities = fetch_capabilities(&app.app).await;
|
||||||
let caps = capabilities
|
let caps = capabilities
|
||||||
|
|
@ -137,6 +182,34 @@ async fn turn_stream_route() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn turn_stream_emits_turn_lifecycle_for_mock() {
|
||||||
|
let app = TestApp::new();
|
||||||
|
install_agent(&app.app, AgentId::Mock).await;
|
||||||
|
|
||||||
|
let session_id = "turn-lifecycle-mock";
|
||||||
|
create_session(
|
||||||
|
&app.app,
|
||||||
|
AgentId::Mock,
|
||||||
|
session_id,
|
||||||
|
test_permission_mode(AgentId::Mock),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let events = read_turn_stream_events(&app.app, session_id, Duration::from_secs(30)).await;
|
||||||
|
let started_count = events
|
||||||
|
.iter()
|
||||||
|
.filter(|event| event.get("type").and_then(Value::as_str) == Some("turn.started"))
|
||||||
|
.count();
|
||||||
|
let ended_count = events
|
||||||
|
.iter()
|
||||||
|
.filter(|event| event.get("type").and_then(Value::as_str) == Some("turn.ended"))
|
||||||
|
.count();
|
||||||
|
|
||||||
|
assert_eq!(started_count, 1, "expected exactly one turn.started event");
|
||||||
|
assert_eq!(ended_count, 1, "expected exactly one turn.ended event");
|
||||||
|
}
|
||||||
|
|
||||||
async fn run_concurrency_snapshot(app: &Router, config: &TestAgentConfig) {
|
async fn run_concurrency_snapshot(app: &Router, config: &TestAgentConfig) {
|
||||||
let _guard = apply_credentials(&config.credentials);
|
let _guard = apply_credentials(&config.credentials);
|
||||||
install_agent(app, config.agent).await;
|
install_agent(app, config.agent).await;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
---
|
---
|
||||||
source: server/packages/sandbox-agent/tests/sessions/multi_turn.rs
|
source: server/packages/sandbox-agent/tests/sessions/multi_turn.rs
|
||||||
|
assertion_line: 15
|
||||||
expression: value
|
expression: value
|
||||||
---
|
---
|
||||||
first:
|
first:
|
||||||
|
|
@ -15,19 +16,13 @@ first:
|
||||||
status: in_progress
|
status: in_progress
|
||||||
seq: 2
|
seq: 2
|
||||||
type: item.started
|
type: item.started
|
||||||
- delta:
|
|
||||||
delta: "<redacted>"
|
|
||||||
item_id: "<redacted>"
|
|
||||||
native_item_id: "<redacted>"
|
|
||||||
seq: 3
|
|
||||||
type: item.delta
|
|
||||||
- item:
|
- item:
|
||||||
content_types:
|
content_types:
|
||||||
- text
|
- text
|
||||||
kind: message
|
kind: message
|
||||||
role: user
|
role: user
|
||||||
status: completed
|
status: completed
|
||||||
seq: 4
|
seq: 3
|
||||||
type: item.completed
|
type: item.completed
|
||||||
- item:
|
- item:
|
||||||
content_types:
|
content_types:
|
||||||
|
|
@ -35,13 +30,13 @@ first:
|
||||||
kind: message
|
kind: message
|
||||||
role: assistant
|
role: assistant
|
||||||
status: in_progress
|
status: in_progress
|
||||||
seq: 5
|
seq: 4
|
||||||
type: item.started
|
type: item.started
|
||||||
- delta:
|
- delta:
|
||||||
delta: "<redacted>"
|
delta: "<redacted>"
|
||||||
item_id: "<redacted>"
|
item_id: "<redacted>"
|
||||||
native_item_id: "<redacted>"
|
native_item_id: "<redacted>"
|
||||||
seq: 6
|
seq: 5
|
||||||
type: item.delta
|
type: item.delta
|
||||||
- item:
|
- item:
|
||||||
content_types:
|
content_types:
|
||||||
|
|
@ -49,7 +44,7 @@ first:
|
||||||
kind: message
|
kind: message
|
||||||
role: assistant
|
role: assistant
|
||||||
status: completed
|
status: completed
|
||||||
seq: 7
|
seq: 6
|
||||||
type: item.completed
|
type: item.completed
|
||||||
second:
|
second:
|
||||||
- item:
|
- item:
|
||||||
|
|
@ -60,19 +55,13 @@ second:
|
||||||
status: in_progress
|
status: in_progress
|
||||||
seq: 1
|
seq: 1
|
||||||
type: item.started
|
type: item.started
|
||||||
- delta:
|
|
||||||
delta: "<redacted>"
|
|
||||||
item_id: "<redacted>"
|
|
||||||
native_item_id: "<redacted>"
|
|
||||||
seq: 2
|
|
||||||
type: item.delta
|
|
||||||
- item:
|
- item:
|
||||||
content_types:
|
content_types:
|
||||||
- text
|
- text
|
||||||
kind: message
|
kind: message
|
||||||
role: user
|
role: user
|
||||||
status: completed
|
status: completed
|
||||||
seq: 3
|
seq: 2
|
||||||
type: item.completed
|
type: item.completed
|
||||||
- item:
|
- item:
|
||||||
content_types:
|
content_types:
|
||||||
|
|
@ -80,13 +69,13 @@ second:
|
||||||
kind: message
|
kind: message
|
||||||
role: assistant
|
role: assistant
|
||||||
status: in_progress
|
status: in_progress
|
||||||
seq: 4
|
seq: 3
|
||||||
type: item.started
|
type: item.started
|
||||||
- delta:
|
- delta:
|
||||||
delta: "<redacted>"
|
delta: "<redacted>"
|
||||||
item_id: "<redacted>"
|
item_id: "<redacted>"
|
||||||
native_item_id: "<redacted>"
|
native_item_id: "<redacted>"
|
||||||
seq: 5
|
seq: 4
|
||||||
type: item.delta
|
type: item.delta
|
||||||
- item:
|
- item:
|
||||||
content_types:
|
content_types:
|
||||||
|
|
@ -94,5 +83,5 @@ second:
|
||||||
kind: message
|
kind: message
|
||||||
role: assistant
|
role: assistant
|
||||||
status: completed
|
status: completed
|
||||||
seq: 6
|
seq: 5
|
||||||
type: item.completed
|
type: item.completed
|
||||||
|
|
|
||||||
|
|
@ -8,20 +8,16 @@ first:
|
||||||
seq: 1
|
seq: 1
|
||||||
session: started
|
session: started
|
||||||
type: session.started
|
type: session.started
|
||||||
|
- seq: 2
|
||||||
|
type: turn.started
|
||||||
- item:
|
- item:
|
||||||
content_types:
|
content_types:
|
||||||
- text
|
- text
|
||||||
kind: message
|
kind: message
|
||||||
role: user
|
role: user
|
||||||
status: in_progress
|
status: in_progress
|
||||||
seq: 2
|
|
||||||
type: item.started
|
|
||||||
- delta:
|
|
||||||
delta: "<redacted>"
|
|
||||||
item_id: "<redacted>"
|
|
||||||
native_item_id: "<redacted>"
|
|
||||||
seq: 3
|
seq: 3
|
||||||
type: item.delta
|
type: item.started
|
||||||
- item:
|
- item:
|
||||||
content_types:
|
content_types:
|
||||||
- text
|
- text
|
||||||
|
|
@ -69,47 +65,13 @@ first:
|
||||||
seq: 10
|
seq: 10
|
||||||
type: item.delta
|
type: item.delta
|
||||||
second:
|
second:
|
||||||
|
- seq: 1
|
||||||
|
type: turn.started
|
||||||
- item:
|
- item:
|
||||||
content_types:
|
content_types:
|
||||||
- text
|
- text
|
||||||
kind: message
|
kind: message
|
||||||
role: user
|
role: assistant
|
||||||
status: in_progress
|
status: completed
|
||||||
seq: 1
|
|
||||||
type: item.started
|
|
||||||
- delta:
|
|
||||||
delta: "<redacted>"
|
|
||||||
item_id: "<redacted>"
|
|
||||||
native_item_id: "<redacted>"
|
|
||||||
seq: 2
|
seq: 2
|
||||||
type: item.delta
|
|
||||||
- item:
|
|
||||||
content_types:
|
|
||||||
- text
|
|
||||||
kind: message
|
|
||||||
role: user
|
|
||||||
status: completed
|
|
||||||
seq: 3
|
|
||||||
type: item.completed
|
|
||||||
- item:
|
|
||||||
content_types:
|
|
||||||
- text
|
|
||||||
kind: message
|
|
||||||
role: assistant
|
|
||||||
status: in_progress
|
|
||||||
seq: 4
|
|
||||||
type: item.started
|
|
||||||
- delta:
|
|
||||||
delta: "<redacted>"
|
|
||||||
item_id: "<redacted>"
|
|
||||||
native_item_id: "<redacted>"
|
|
||||||
seq: 5
|
|
||||||
type: item.delta
|
|
||||||
- item:
|
|
||||||
content_types:
|
|
||||||
- text
|
|
||||||
kind: message
|
|
||||||
role: assistant
|
|
||||||
status: completed
|
|
||||||
seq: 6
|
|
||||||
type: item.completed
|
type: item.completed
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
---
|
---
|
||||||
source: server/packages/sandbox-agent/tests/sessions/permissions.rs
|
source: server/packages/sandbox-agent/tests/sessions/permissions.rs
|
||||||
|
assertion_line: 12
|
||||||
expression: value
|
expression: value
|
||||||
---
|
---
|
||||||
- metadata: true
|
- metadata: true
|
||||||
|
|
@ -14,23 +15,17 @@ expression: value
|
||||||
status: in_progress
|
status: in_progress
|
||||||
seq: 2
|
seq: 2
|
||||||
type: item.started
|
type: item.started
|
||||||
- delta:
|
|
||||||
delta: "<redacted>"
|
|
||||||
item_id: "<redacted>"
|
|
||||||
native_item_id: "<redacted>"
|
|
||||||
seq: 3
|
|
||||||
type: item.delta
|
|
||||||
- item:
|
- item:
|
||||||
content_types:
|
content_types:
|
||||||
- text
|
- text
|
||||||
kind: message
|
kind: message
|
||||||
role: user
|
role: user
|
||||||
status: completed
|
status: completed
|
||||||
seq: 4
|
seq: 3
|
||||||
type: item.completed
|
type: item.completed
|
||||||
- permission:
|
- permission:
|
||||||
action: command_execution
|
action: command_execution
|
||||||
id: "<redacted>"
|
id: "<redacted>"
|
||||||
status: requested
|
status: requested
|
||||||
seq: 5
|
seq: 4
|
||||||
type: permission.requested
|
type: permission.requested
|
||||||
|
|
|
||||||
|
|
@ -7,20 +7,16 @@ expression: value
|
||||||
seq: 1
|
seq: 1
|
||||||
session: started
|
session: started
|
||||||
type: session.started
|
type: session.started
|
||||||
|
- seq: 2
|
||||||
|
type: turn.started
|
||||||
- item:
|
- item:
|
||||||
content_types:
|
content_types:
|
||||||
- text
|
- text
|
||||||
kind: message
|
kind: message
|
||||||
role: user
|
role: user
|
||||||
status: in_progress
|
status: in_progress
|
||||||
seq: 2
|
|
||||||
type: item.started
|
|
||||||
- delta:
|
|
||||||
delta: "<redacted>"
|
|
||||||
item_id: "<redacted>"
|
|
||||||
native_item_id: "<redacted>"
|
|
||||||
seq: 3
|
seq: 3
|
||||||
type: item.delta
|
type: item.started
|
||||||
- item:
|
- item:
|
||||||
content_types:
|
content_types:
|
||||||
- text
|
- text
|
||||||
|
|
@ -61,3 +57,9 @@ expression: value
|
||||||
native_item_id: "<redacted>"
|
native_item_id: "<redacted>"
|
||||||
seq: 9
|
seq: 9
|
||||||
type: item.delta
|
type: item.delta
|
||||||
|
- delta:
|
||||||
|
delta: "<redacted>"
|
||||||
|
item_id: "<redacted>"
|
||||||
|
native_item_id: "<redacted>"
|
||||||
|
seq: 10
|
||||||
|
type: item.delta
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
---
|
---
|
||||||
source: server/packages/sandbox-agent/tests/sessions/questions.rs
|
source: server/packages/sandbox-agent/tests/sessions/questions.rs
|
||||||
|
assertion_line: 12
|
||||||
expression: value
|
expression: value
|
||||||
---
|
---
|
||||||
- metadata: true
|
- metadata: true
|
||||||
|
|
@ -14,23 +15,17 @@ expression: value
|
||||||
status: in_progress
|
status: in_progress
|
||||||
seq: 2
|
seq: 2
|
||||||
type: item.started
|
type: item.started
|
||||||
- delta:
|
|
||||||
delta: "<redacted>"
|
|
||||||
item_id: "<redacted>"
|
|
||||||
native_item_id: "<redacted>"
|
|
||||||
seq: 3
|
|
||||||
type: item.delta
|
|
||||||
- item:
|
- item:
|
||||||
content_types:
|
content_types:
|
||||||
- text
|
- text
|
||||||
kind: message
|
kind: message
|
||||||
role: user
|
role: user
|
||||||
status: completed
|
status: completed
|
||||||
seq: 4
|
seq: 3
|
||||||
type: item.completed
|
type: item.completed
|
||||||
- question:
|
- question:
|
||||||
id: "<redacted>"
|
id: "<redacted>"
|
||||||
options: 2
|
options: 2
|
||||||
status: requested
|
status: requested
|
||||||
seq: 5
|
seq: 4
|
||||||
type: question.requested
|
type: question.requested
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
---
|
---
|
||||||
source: server/packages/sandbox-agent/tests/sessions/questions.rs
|
source: server/packages/sandbox-agent/tests/sessions/questions.rs
|
||||||
|
assertion_line: 12
|
||||||
expression: value
|
expression: value
|
||||||
---
|
---
|
||||||
- metadata: true
|
- metadata: true
|
||||||
|
|
@ -14,23 +15,17 @@ expression: value
|
||||||
status: in_progress
|
status: in_progress
|
||||||
seq: 2
|
seq: 2
|
||||||
type: item.started
|
type: item.started
|
||||||
- delta:
|
|
||||||
delta: "<redacted>"
|
|
||||||
item_id: "<redacted>"
|
|
||||||
native_item_id: "<redacted>"
|
|
||||||
seq: 3
|
|
||||||
type: item.delta
|
|
||||||
- item:
|
- item:
|
||||||
content_types:
|
content_types:
|
||||||
- text
|
- text
|
||||||
kind: message
|
kind: message
|
||||||
role: user
|
role: user
|
||||||
status: completed
|
status: completed
|
||||||
seq: 4
|
seq: 3
|
||||||
type: item.completed
|
type: item.completed
|
||||||
- question:
|
- question:
|
||||||
id: "<redacted>"
|
id: "<redacted>"
|
||||||
options: 2
|
options: 2
|
||||||
status: requested
|
status: requested
|
||||||
seq: 5
|
seq: 4
|
||||||
type: question.requested
|
type: question.requested
|
||||||
|
|
|
||||||
|
|
@ -7,20 +7,16 @@ expression: value
|
||||||
seq: 1
|
seq: 1
|
||||||
session: started
|
session: started
|
||||||
type: session.started
|
type: session.started
|
||||||
|
- seq: 2
|
||||||
|
type: turn.started
|
||||||
- item:
|
- item:
|
||||||
content_types:
|
content_types:
|
||||||
- text
|
- text
|
||||||
kind: message
|
kind: message
|
||||||
role: user
|
role: user
|
||||||
status: in_progress
|
status: in_progress
|
||||||
seq: 2
|
|
||||||
type: item.started
|
|
||||||
- delta:
|
|
||||||
delta: "<redacted>"
|
|
||||||
item_id: "<redacted>"
|
|
||||||
native_item_id: "<redacted>"
|
|
||||||
seq: 3
|
seq: 3
|
||||||
type: item.delta
|
type: item.started
|
||||||
- item:
|
- item:
|
||||||
content_types:
|
content_types:
|
||||||
- text
|
- text
|
||||||
|
|
@ -43,95 +39,11 @@ expression: value
|
||||||
native_item_id: "<redacted>"
|
native_item_id: "<redacted>"
|
||||||
seq: 6
|
seq: 6
|
||||||
type: item.delta
|
type: item.delta
|
||||||
- delta:
|
|
||||||
delta: "<redacted>"
|
|
||||||
item_id: "<redacted>"
|
|
||||||
native_item_id: "<redacted>"
|
|
||||||
seq: 7
|
|
||||||
type: item.delta
|
|
||||||
- delta:
|
|
||||||
delta: "<redacted>"
|
|
||||||
item_id: "<redacted>"
|
|
||||||
native_item_id: "<redacted>"
|
|
||||||
seq: 8
|
|
||||||
type: item.delta
|
|
||||||
- delta:
|
|
||||||
delta: "<redacted>"
|
|
||||||
item_id: "<redacted>"
|
|
||||||
native_item_id: "<redacted>"
|
|
||||||
seq: 9
|
|
||||||
type: item.delta
|
|
||||||
- delta:
|
|
||||||
delta: "<redacted>"
|
|
||||||
item_id: "<redacted>"
|
|
||||||
native_item_id: "<redacted>"
|
|
||||||
seq: 10
|
|
||||||
type: item.delta
|
|
||||||
- delta:
|
|
||||||
delta: "<redacted>"
|
|
||||||
item_id: "<redacted>"
|
|
||||||
native_item_id: "<redacted>"
|
|
||||||
seq: 11
|
|
||||||
type: item.delta
|
|
||||||
- delta:
|
|
||||||
delta: "<redacted>"
|
|
||||||
item_id: "<redacted>"
|
|
||||||
native_item_id: "<redacted>"
|
|
||||||
seq: 12
|
|
||||||
type: item.delta
|
|
||||||
- delta:
|
|
||||||
delta: "<redacted>"
|
|
||||||
item_id: "<redacted>"
|
|
||||||
native_item_id: "<redacted>"
|
|
||||||
seq: 13
|
|
||||||
type: item.delta
|
|
||||||
- delta:
|
|
||||||
delta: "<redacted>"
|
|
||||||
item_id: "<redacted>"
|
|
||||||
native_item_id: "<redacted>"
|
|
||||||
seq: 14
|
|
||||||
type: item.delta
|
|
||||||
- delta:
|
|
||||||
delta: "<redacted>"
|
|
||||||
item_id: "<redacted>"
|
|
||||||
native_item_id: "<redacted>"
|
|
||||||
seq: 15
|
|
||||||
type: item.delta
|
|
||||||
- delta:
|
|
||||||
delta: "<redacted>"
|
|
||||||
item_id: "<redacted>"
|
|
||||||
native_item_id: "<redacted>"
|
|
||||||
seq: 16
|
|
||||||
type: item.delta
|
|
||||||
- delta:
|
|
||||||
delta: "<redacted>"
|
|
||||||
item_id: "<redacted>"
|
|
||||||
native_item_id: "<redacted>"
|
|
||||||
seq: 17
|
|
||||||
type: item.delta
|
|
||||||
- delta:
|
|
||||||
delta: "<redacted>"
|
|
||||||
item_id: "<redacted>"
|
|
||||||
native_item_id: "<redacted>"
|
|
||||||
seq: 18
|
|
||||||
type: item.delta
|
|
||||||
- delta:
|
|
||||||
delta: "<redacted>"
|
|
||||||
item_id: "<redacted>"
|
|
||||||
native_item_id: "<redacted>"
|
|
||||||
seq: 19
|
|
||||||
type: item.delta
|
|
||||||
- delta:
|
|
||||||
delta: "<redacted>"
|
|
||||||
item_id: "<redacted>"
|
|
||||||
native_item_id: "<redacted>"
|
|
||||||
seq: 20
|
|
||||||
type: item.delta
|
|
||||||
- item:
|
- item:
|
||||||
content_types:
|
content_types:
|
||||||
- text
|
- text
|
||||||
kind: message
|
kind: message
|
||||||
role: assistant
|
role: assistant
|
||||||
status: completed
|
status: completed
|
||||||
seq: 21
|
seq: 7
|
||||||
type: item.completed
|
type: item.completed
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
---
|
---
|
||||||
source: server/packages/sandbox-agent/tests/sessions/session_lifecycle.rs
|
source: server/packages/sandbox-agent/tests/sessions/session_lifecycle.rs
|
||||||
|
assertion_line: 12
|
||||||
expression: value
|
expression: value
|
||||||
---
|
---
|
||||||
session_a:
|
session_a:
|
||||||
|
|
@ -15,19 +16,13 @@ session_a:
|
||||||
status: in_progress
|
status: in_progress
|
||||||
seq: 2
|
seq: 2
|
||||||
type: item.started
|
type: item.started
|
||||||
- delta:
|
|
||||||
delta: "<redacted>"
|
|
||||||
item_id: "<redacted>"
|
|
||||||
native_item_id: "<redacted>"
|
|
||||||
seq: 3
|
|
||||||
type: item.delta
|
|
||||||
- item:
|
- item:
|
||||||
content_types:
|
content_types:
|
||||||
- text
|
- text
|
||||||
kind: message
|
kind: message
|
||||||
role: user
|
role: user
|
||||||
status: completed
|
status: completed
|
||||||
seq: 4
|
seq: 3
|
||||||
type: item.completed
|
type: item.completed
|
||||||
- item:
|
- item:
|
||||||
content_types:
|
content_types:
|
||||||
|
|
@ -35,13 +30,13 @@ session_a:
|
||||||
kind: message
|
kind: message
|
||||||
role: assistant
|
role: assistant
|
||||||
status: in_progress
|
status: in_progress
|
||||||
seq: 5
|
seq: 4
|
||||||
type: item.started
|
type: item.started
|
||||||
- delta:
|
- delta:
|
||||||
delta: "<redacted>"
|
delta: "<redacted>"
|
||||||
item_id: "<redacted>"
|
item_id: "<redacted>"
|
||||||
native_item_id: "<redacted>"
|
native_item_id: "<redacted>"
|
||||||
seq: 6
|
seq: 5
|
||||||
type: item.delta
|
type: item.delta
|
||||||
- item:
|
- item:
|
||||||
content_types:
|
content_types:
|
||||||
|
|
@ -49,7 +44,7 @@ session_a:
|
||||||
kind: message
|
kind: message
|
||||||
role: assistant
|
role: assistant
|
||||||
status: completed
|
status: completed
|
||||||
seq: 7
|
seq: 6
|
||||||
type: item.completed
|
type: item.completed
|
||||||
session_b:
|
session_b:
|
||||||
- metadata: true
|
- metadata: true
|
||||||
|
|
@ -64,19 +59,13 @@ session_b:
|
||||||
status: in_progress
|
status: in_progress
|
||||||
seq: 2
|
seq: 2
|
||||||
type: item.started
|
type: item.started
|
||||||
- delta:
|
|
||||||
delta: "<redacted>"
|
|
||||||
item_id: "<redacted>"
|
|
||||||
native_item_id: "<redacted>"
|
|
||||||
seq: 3
|
|
||||||
type: item.delta
|
|
||||||
- item:
|
- item:
|
||||||
content_types:
|
content_types:
|
||||||
- text
|
- text
|
||||||
kind: message
|
kind: message
|
||||||
role: user
|
role: user
|
||||||
status: completed
|
status: completed
|
||||||
seq: 4
|
seq: 3
|
||||||
type: item.completed
|
type: item.completed
|
||||||
- item:
|
- item:
|
||||||
content_types:
|
content_types:
|
||||||
|
|
@ -84,13 +73,13 @@ session_b:
|
||||||
kind: message
|
kind: message
|
||||||
role: assistant
|
role: assistant
|
||||||
status: in_progress
|
status: in_progress
|
||||||
seq: 5
|
seq: 4
|
||||||
type: item.started
|
type: item.started
|
||||||
- delta:
|
- delta:
|
||||||
delta: "<redacted>"
|
delta: "<redacted>"
|
||||||
item_id: "<redacted>"
|
item_id: "<redacted>"
|
||||||
native_item_id: "<redacted>"
|
native_item_id: "<redacted>"
|
||||||
seq: 6
|
seq: 5
|
||||||
type: item.delta
|
type: item.delta
|
||||||
- item:
|
- item:
|
||||||
content_types:
|
content_types:
|
||||||
|
|
@ -98,5 +87,5 @@ session_b:
|
||||||
kind: message
|
kind: message
|
||||||
role: assistant
|
role: assistant
|
||||||
status: completed
|
status: completed
|
||||||
seq: 7
|
seq: 6
|
||||||
type: item.completed
|
type: item.completed
|
||||||
|
|
|
||||||
|
|
@ -8,20 +8,16 @@ session_a:
|
||||||
seq: 1
|
seq: 1
|
||||||
session: started
|
session: started
|
||||||
type: session.started
|
type: session.started
|
||||||
|
- seq: 2
|
||||||
|
type: turn.started
|
||||||
- item:
|
- item:
|
||||||
content_types:
|
content_types:
|
||||||
- text
|
- text
|
||||||
kind: message
|
kind: message
|
||||||
role: user
|
role: user
|
||||||
status: in_progress
|
status: in_progress
|
||||||
seq: 2
|
|
||||||
type: item.started
|
|
||||||
- delta:
|
|
||||||
delta: "<redacted>"
|
|
||||||
item_id: "<redacted>"
|
|
||||||
native_item_id: "<redacted>"
|
|
||||||
seq: 3
|
seq: 3
|
||||||
type: item.delta
|
type: item.started
|
||||||
- item:
|
- item:
|
||||||
content_types:
|
content_types:
|
||||||
- text
|
- text
|
||||||
|
|
@ -49,20 +45,16 @@ session_b:
|
||||||
seq: 1
|
seq: 1
|
||||||
session: started
|
session: started
|
||||||
type: session.started
|
type: session.started
|
||||||
|
- seq: 2
|
||||||
|
type: turn.started
|
||||||
- item:
|
- item:
|
||||||
content_types:
|
content_types:
|
||||||
- text
|
- text
|
||||||
kind: message
|
kind: message
|
||||||
role: user
|
role: user
|
||||||
status: in_progress
|
status: in_progress
|
||||||
seq: 2
|
|
||||||
type: item.started
|
|
||||||
- delta:
|
|
||||||
delta: "<redacted>"
|
|
||||||
item_id: "<redacted>"
|
|
||||||
native_item_id: "<redacted>"
|
|
||||||
seq: 3
|
seq: 3
|
||||||
type: item.delta
|
type: item.started
|
||||||
- item:
|
- item:
|
||||||
content_types:
|
content_types:
|
||||||
- text
|
- text
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
---
|
---
|
||||||
source: server/packages/sandbox-agent/tests/sessions/../common/http.rs
|
source: server/packages/sandbox-agent/tests/sessions/../common/http.rs
|
||||||
|
assertion_line: 1001
|
||||||
expression: normalized
|
expression: normalized
|
||||||
---
|
---
|
||||||
- metadata: true
|
- metadata: true
|
||||||
|
|
@ -14,19 +15,13 @@ expression: normalized
|
||||||
status: in_progress
|
status: in_progress
|
||||||
seq: 2
|
seq: 2
|
||||||
type: item.started
|
type: item.started
|
||||||
- delta:
|
|
||||||
delta: "<redacted>"
|
|
||||||
item_id: "<redacted>"
|
|
||||||
native_item_id: "<redacted>"
|
|
||||||
seq: 3
|
|
||||||
type: item.delta
|
|
||||||
- item:
|
- item:
|
||||||
content_types:
|
content_types:
|
||||||
- text
|
- text
|
||||||
kind: message
|
kind: message
|
||||||
role: user
|
role: user
|
||||||
status: completed
|
status: completed
|
||||||
seq: 4
|
seq: 3
|
||||||
type: item.completed
|
type: item.completed
|
||||||
- item:
|
- item:
|
||||||
content_types:
|
content_types:
|
||||||
|
|
@ -34,13 +29,13 @@ expression: normalized
|
||||||
kind: message
|
kind: message
|
||||||
role: assistant
|
role: assistant
|
||||||
status: in_progress
|
status: in_progress
|
||||||
seq: 5
|
seq: 4
|
||||||
type: item.started
|
type: item.started
|
||||||
- delta:
|
- delta:
|
||||||
delta: "<redacted>"
|
delta: "<redacted>"
|
||||||
item_id: "<redacted>"
|
item_id: "<redacted>"
|
||||||
native_item_id: "<redacted>"
|
native_item_id: "<redacted>"
|
||||||
seq: 6
|
seq: 5
|
||||||
type: item.delta
|
type: item.delta
|
||||||
- item:
|
- item:
|
||||||
content_types:
|
content_types:
|
||||||
|
|
@ -48,5 +43,5 @@ expression: normalized
|
||||||
kind: message
|
kind: message
|
||||||
role: assistant
|
role: assistant
|
||||||
status: completed
|
status: completed
|
||||||
seq: 7
|
seq: 6
|
||||||
type: item.completed
|
type: item.completed
|
||||||
|
|
|
||||||
|
|
@ -7,20 +7,16 @@ expression: normalized
|
||||||
seq: 1
|
seq: 1
|
||||||
session: started
|
session: started
|
||||||
type: session.started
|
type: session.started
|
||||||
|
- seq: 2
|
||||||
|
type: turn.started
|
||||||
- item:
|
- item:
|
||||||
content_types:
|
content_types:
|
||||||
- text
|
- text
|
||||||
kind: message
|
kind: message
|
||||||
role: user
|
role: user
|
||||||
status: in_progress
|
status: in_progress
|
||||||
seq: 2
|
|
||||||
type: item.started
|
|
||||||
- delta:
|
|
||||||
delta: "<redacted>"
|
|
||||||
item_id: "<redacted>"
|
|
||||||
native_item_id: "<redacted>"
|
|
||||||
seq: 3
|
seq: 3
|
||||||
type: item.delta
|
type: item.started
|
||||||
- item:
|
- item:
|
||||||
content_types:
|
content_types:
|
||||||
- text
|
- text
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
---
|
---
|
||||||
source: server/packages/sandbox-agent/tests/sessions/../common/http.rs
|
source: server/packages/sandbox-agent/tests/sessions/../common/http.rs
|
||||||
|
assertion_line: 1039
|
||||||
expression: normalized
|
expression: normalized
|
||||||
---
|
---
|
||||||
- metadata: true
|
- metadata: true
|
||||||
|
|
@ -14,19 +15,13 @@ expression: normalized
|
||||||
status: in_progress
|
status: in_progress
|
||||||
seq: 2
|
seq: 2
|
||||||
type: item.started
|
type: item.started
|
||||||
- delta:
|
|
||||||
delta: "<redacted>"
|
|
||||||
item_id: "<redacted>"
|
|
||||||
native_item_id: "<redacted>"
|
|
||||||
seq: 3
|
|
||||||
type: item.delta
|
|
||||||
- item:
|
- item:
|
||||||
content_types:
|
content_types:
|
||||||
- text
|
- text
|
||||||
kind: message
|
kind: message
|
||||||
role: user
|
role: user
|
||||||
status: completed
|
status: completed
|
||||||
seq: 4
|
seq: 3
|
||||||
type: item.completed
|
type: item.completed
|
||||||
- item:
|
- item:
|
||||||
content_types:
|
content_types:
|
||||||
|
|
@ -34,13 +29,13 @@ expression: normalized
|
||||||
kind: message
|
kind: message
|
||||||
role: assistant
|
role: assistant
|
||||||
status: in_progress
|
status: in_progress
|
||||||
seq: 5
|
seq: 4
|
||||||
type: item.started
|
type: item.started
|
||||||
- delta:
|
- delta:
|
||||||
delta: "<redacted>"
|
delta: "<redacted>"
|
||||||
item_id: "<redacted>"
|
item_id: "<redacted>"
|
||||||
native_item_id: "<redacted>"
|
native_item_id: "<redacted>"
|
||||||
seq: 6
|
seq: 5
|
||||||
type: item.delta
|
type: item.delta
|
||||||
- item:
|
- item:
|
||||||
content_types:
|
content_types:
|
||||||
|
|
@ -48,5 +43,5 @@ expression: normalized
|
||||||
kind: message
|
kind: message
|
||||||
role: assistant
|
role: assistant
|
||||||
status: completed
|
status: completed
|
||||||
seq: 7
|
seq: 6
|
||||||
type: item.completed
|
type: item.completed
|
||||||
|
|
|
||||||
|
|
@ -7,20 +7,16 @@ expression: normalized
|
||||||
seq: 1
|
seq: 1
|
||||||
session: started
|
session: started
|
||||||
type: session.started
|
type: session.started
|
||||||
|
- seq: 2
|
||||||
|
type: turn.started
|
||||||
- item:
|
- item:
|
||||||
content_types:
|
content_types:
|
||||||
- text
|
- text
|
||||||
kind: message
|
kind: message
|
||||||
role: user
|
role: user
|
||||||
status: in_progress
|
status: in_progress
|
||||||
seq: 2
|
|
||||||
type: item.started
|
|
||||||
- delta:
|
|
||||||
delta: "<redacted>"
|
|
||||||
item_id: "<redacted>"
|
|
||||||
native_item_id: "<redacted>"
|
|
||||||
seq: 3
|
seq: 3
|
||||||
type: item.delta
|
type: item.started
|
||||||
- item:
|
- item:
|
||||||
content_types:
|
content_types:
|
||||||
- text
|
- text
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ use serde_json::Value;
|
||||||
|
|
||||||
use crate::amp as schema;
|
use crate::amp as schema;
|
||||||
use crate::{
|
use crate::{
|
||||||
turn_completed_event, ContentPart, ErrorData, EventConversion, ItemDeltaData, ItemEventData,
|
turn_ended_event, ContentPart, ErrorData, EventConversion, ItemDeltaData, ItemEventData,
|
||||||
ItemKind, ItemRole, ItemStatus, SessionEndReason, SessionEndedData, TerminatedBy,
|
ItemKind, ItemRole, ItemStatus, SessionEndReason, SessionEndedData, TerminatedBy,
|
||||||
UniversalEventData, UniversalEventType, UniversalItem,
|
UniversalEventData, UniversalEventType, UniversalItem,
|
||||||
};
|
};
|
||||||
|
|
@ -99,7 +99,7 @@ pub fn event_to_universal(
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
schema::StreamJsonMessageType::Done => {
|
schema::StreamJsonMessageType::Done => {
|
||||||
events.push(turn_completed_event());
|
events.push(turn_ended_event(None, None).synthetic());
|
||||||
events.push(
|
events.push(
|
||||||
EventConversion::new(
|
EventConversion::new(
|
||||||
UniversalEventType::SessionEnded,
|
UniversalEventType::SessionEnded,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
turn_completed_event, ContentPart, EventConversion, ItemDeltaData, ItemEventData, ItemKind,
|
turn_ended_event, ContentPart, EventConversion, ItemDeltaData, ItemEventData, ItemKind,
|
||||||
ItemRole, ItemStatus, PermissionEventData, PermissionStatus, QuestionEventData, QuestionStatus,
|
ItemRole, ItemStatus, PermissionEventData, PermissionStatus, QuestionEventData, QuestionStatus,
|
||||||
SessionStartedData, UniversalEventData, UniversalEventType, UniversalItem,
|
SessionStartedData, UniversalEventData, UniversalEventType, UniversalItem,
|
||||||
};
|
};
|
||||||
|
|
@ -425,7 +425,7 @@ fn result_event_to_universal(event: &Value, session_id: &str) -> Vec<EventConver
|
||||||
UniversalEventType::ItemCompleted,
|
UniversalEventType::ItemCompleted,
|
||||||
UniversalEventData::Item(ItemEventData { item: message_item }),
|
UniversalEventData::Item(ItemEventData { item: message_item }),
|
||||||
),
|
),
|
||||||
turn_completed_event(),
|
turn_ended_event(None, None).synthetic(),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ use crate::codex as schema;
|
||||||
use crate::{
|
use crate::{
|
||||||
ContentPart, ErrorData, EventConversion, ItemDeltaData, ItemEventData, ItemKind, ItemRole,
|
ContentPart, ErrorData, EventConversion, ItemDeltaData, ItemEventData, ItemKind, ItemRole,
|
||||||
ItemStatus, ReasoningVisibility, SessionEndReason, SessionEndedData, SessionStartedData,
|
ItemStatus, ReasoningVisibility, SessionEndReason, SessionEndedData, SessionStartedData,
|
||||||
TerminatedBy, UniversalEventData, UniversalEventType, UniversalItem,
|
TerminatedBy, TurnEventData, TurnPhase, UniversalEventData, UniversalEventType, UniversalItem,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Convert a Codex ServerNotification to universal events.
|
/// Convert a Codex ServerNotification to universal events.
|
||||||
|
|
@ -36,18 +36,26 @@ pub fn notification_to_universal(
|
||||||
Some(params.thread_id.clone()),
|
Some(params.thread_id.clone()),
|
||||||
raw,
|
raw,
|
||||||
)]),
|
)]),
|
||||||
schema::ServerNotification::TurnStarted(params) => Ok(vec![status_event(
|
schema::ServerNotification::TurnStarted(params) => Ok(vec![EventConversion::new(
|
||||||
"turn.started",
|
UniversalEventType::TurnStarted,
|
||||||
serde_json::to_string(¶ms.turn).ok(),
|
UniversalEventData::Turn(TurnEventData {
|
||||||
Some(params.thread_id.clone()),
|
phase: TurnPhase::Started,
|
||||||
raw,
|
turn_id: Some(params.turn.id.clone()),
|
||||||
)]),
|
metadata: serde_json::to_value(¶ms.turn).ok(),
|
||||||
schema::ServerNotification::TurnCompleted(params) => Ok(vec![status_event(
|
}),
|
||||||
"turn.completed",
|
)
|
||||||
serde_json::to_string(¶ms.turn).ok(),
|
.with_native_session(Some(params.thread_id.clone()))
|
||||||
Some(params.thread_id.clone()),
|
.with_raw(raw)]),
|
||||||
raw,
|
schema::ServerNotification::TurnCompleted(params) => Ok(vec![EventConversion::new(
|
||||||
)]),
|
UniversalEventType::TurnEnded,
|
||||||
|
UniversalEventData::Turn(TurnEventData {
|
||||||
|
phase: TurnPhase::Ended,
|
||||||
|
turn_id: Some(params.turn.id.clone()),
|
||||||
|
metadata: serde_json::to_value(¶ms.turn).ok(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.with_native_session(Some(params.thread_id.clone()))
|
||||||
|
.with_raw(raw)]),
|
||||||
schema::ServerNotification::TurnDiffUpdated(params) => Ok(vec![status_event(
|
schema::ServerNotification::TurnDiffUpdated(params) => Ok(vec![status_event(
|
||||||
"turn.diff.updated",
|
"turn.diff.updated",
|
||||||
serde_json::to_string(params).ok(),
|
serde_json::to_string(params).ok(),
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,9 @@ use serde_json::Value;
|
||||||
use crate::opencode as schema;
|
use crate::opencode as schema;
|
||||||
use crate::{
|
use crate::{
|
||||||
ContentPart, EventConversion, ItemDeltaData, ItemEventData, ItemKind, ItemRole, ItemStatus,
|
ContentPart, EventConversion, ItemDeltaData, ItemEventData, ItemKind, ItemRole, ItemStatus,
|
||||||
PermissionEventData, PermissionStatus, QuestionEventData, QuestionStatus, SessionStartedData,
|
PermissionEventData, PermissionStatus, QuestionEventData, QuestionStatus, ReasoningVisibility,
|
||||||
UniversalEventData, UniversalEventType, UniversalItem,
|
SessionStartedData, TurnEventData, TurnPhase, UniversalEventData, UniversalEventType,
|
||||||
|
UniversalItem,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn event_to_universal(event: &schema::Event) -> Result<Vec<EventConversion>, String> {
|
pub fn event_to_universal(event: &schema::Event) -> Result<Vec<EventConversion>, String> {
|
||||||
|
|
@ -69,27 +70,37 @@ pub fn event_to_universal(event: &schema::Event) -> Result<Vec<EventConversion>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
schema::Part::ReasoningPart(reasoning_part) => {
|
schema::Part::ReasoningPart(reasoning_part) => {
|
||||||
let delta_text = delta
|
let reasoning_text = delta
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_else(|| reasoning_part.text.clone());
|
.unwrap_or_else(|| reasoning_part.text.clone());
|
||||||
let stub = stub_message_item(&message_id, ItemRole::Assistant);
|
let reasoning_id = reasoning_part.id.clone();
|
||||||
|
let mut started = stub_message_item(&reasoning_id, ItemRole::Assistant);
|
||||||
|
started.parent_id = Some(message_id.clone());
|
||||||
|
let completed = UniversalItem {
|
||||||
|
item_id: String::new(),
|
||||||
|
native_item_id: Some(reasoning_id),
|
||||||
|
parent_id: Some(message_id.clone()),
|
||||||
|
kind: ItemKind::Message,
|
||||||
|
role: Some(ItemRole::Assistant),
|
||||||
|
content: vec![ContentPart::Reasoning {
|
||||||
|
text: reasoning_text,
|
||||||
|
visibility: ReasoningVisibility::Public,
|
||||||
|
}],
|
||||||
|
status: ItemStatus::Completed,
|
||||||
|
};
|
||||||
events.push(
|
events.push(
|
||||||
EventConversion::new(
|
EventConversion::new(
|
||||||
UniversalEventType::ItemStarted,
|
UniversalEventType::ItemStarted,
|
||||||
UniversalEventData::Item(ItemEventData { item: stub }),
|
UniversalEventData::Item(ItemEventData { item: started }),
|
||||||
)
|
)
|
||||||
.synthetic()
|
.synthetic()
|
||||||
.with_raw(raw.clone()),
|
.with_raw(raw.clone()),
|
||||||
);
|
);
|
||||||
events.push(
|
events.push(
|
||||||
EventConversion::new(
|
EventConversion::new(
|
||||||
UniversalEventType::ItemDelta,
|
UniversalEventType::ItemCompleted,
|
||||||
UniversalEventData::ItemDelta(ItemDeltaData {
|
UniversalEventData::Item(ItemEventData { item: completed }),
|
||||||
item_id: String::new(),
|
|
||||||
native_item_id: Some(message_id.clone()),
|
|
||||||
delta: delta_text,
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
.with_native_session(session_id.clone())
|
.with_native_session(session_id.clone())
|
||||||
.with_raw(raw.clone()),
|
.with_raw(raw.clone()),
|
||||||
|
|
@ -207,26 +218,59 @@ pub fn event_to_universal(event: &schema::Event) -> Result<Vec<EventConversion>,
|
||||||
properties,
|
properties,
|
||||||
type_: _,
|
type_: _,
|
||||||
} = status;
|
} = status;
|
||||||
|
let status_type = serde_json::to_value(&properties.status)
|
||||||
|
.ok()
|
||||||
|
.and_then(|value| {
|
||||||
|
value
|
||||||
|
.get("type")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::to_string)
|
||||||
|
});
|
||||||
let detail =
|
let detail =
|
||||||
serde_json::to_string(&properties.status).unwrap_or_else(|_| "status".to_string());
|
serde_json::to_string(&properties.status).unwrap_or_else(|_| "status".to_string());
|
||||||
let item = status_item("session.status", Some(detail));
|
let item = status_item("session.status", Some(detail));
|
||||||
let conversion = EventConversion::new(
|
let mut events = vec![EventConversion::new(
|
||||||
UniversalEventType::ItemCompleted,
|
UniversalEventType::ItemCompleted,
|
||||||
UniversalEventData::Item(ItemEventData { item }),
|
UniversalEventData::Item(ItemEventData { item }),
|
||||||
)
|
)
|
||||||
.with_native_session(Some(properties.session_id.clone()))
|
.with_native_session(Some(properties.session_id.clone()))
|
||||||
.with_raw(raw);
|
.with_raw(raw.clone())];
|
||||||
Ok(vec![conversion])
|
|
||||||
|
if matches!(status_type.as_deref(), Some("busy" | "idle")) {
|
||||||
|
let (event_type, phase) = if status_type.as_deref() == Some("busy") {
|
||||||
|
(UniversalEventType::TurnStarted, TurnPhase::Started)
|
||||||
|
} else {
|
||||||
|
(UniversalEventType::TurnEnded, TurnPhase::Ended)
|
||||||
|
};
|
||||||
|
events.push(
|
||||||
|
EventConversion::new(
|
||||||
|
event_type,
|
||||||
|
UniversalEventData::Turn(TurnEventData {
|
||||||
|
phase,
|
||||||
|
turn_id: None,
|
||||||
|
metadata: Some(
|
||||||
|
serde_json::to_value(&properties.status).unwrap_or(Value::Null),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.with_native_session(Some(properties.session_id.clone()))
|
||||||
|
.with_raw(raw),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(events)
|
||||||
}
|
}
|
||||||
schema::Event::SessionIdle(idle) => {
|
schema::Event::SessionIdle(idle) => {
|
||||||
let schema::EventSessionIdle {
|
let schema::EventSessionIdle {
|
||||||
properties,
|
properties,
|
||||||
type_: _,
|
type_: _,
|
||||||
} = idle;
|
} = idle;
|
||||||
let item = status_item("session.idle", None);
|
|
||||||
let conversion = EventConversion::new(
|
let conversion = EventConversion::new(
|
||||||
UniversalEventType::ItemCompleted,
|
UniversalEventType::TurnEnded,
|
||||||
UniversalEventData::Item(ItemEventData { item }),
|
UniversalEventData::Turn(TurnEventData {
|
||||||
|
phase: TurnPhase::Ended,
|
||||||
|
turn_id: None,
|
||||||
|
metadata: None,
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
.with_native_session(Some(properties.session_id.clone()))
|
.with_native_session(Some(properties.session_id.clone()))
|
||||||
.with_raw(raw);
|
.with_raw(raw);
|
||||||
|
|
@ -528,3 +572,50 @@ fn permission_from_opencode(request: &schema::PermissionRequest) -> PermissionEv
|
||||||
metadata: serde_json::to_value(request).ok(),
|
metadata: serde_json::to_value(request).ok(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reasoning_part_updates_stay_typed_not_text_delta() {
|
||||||
|
let event = schema::Event::MessagePartUpdated(schema::EventMessagePartUpdated {
|
||||||
|
properties: schema::EventMessagePartUpdatedProperties {
|
||||||
|
delta: Some("Preparing friendly brief response".to_string()),
|
||||||
|
part: schema::Part::ReasoningPart(schema::ReasoningPart {
|
||||||
|
id: "part_reason_1".to_string(),
|
||||||
|
message_id: "msg_1".to_string(),
|
||||||
|
metadata: serde_json::Map::new(),
|
||||||
|
session_id: "ses_1".to_string(),
|
||||||
|
text: "Preparing".to_string(),
|
||||||
|
time: schema::ReasoningPartTime {
|
||||||
|
end: None,
|
||||||
|
start: 0.0,
|
||||||
|
},
|
||||||
|
type_: "reasoning".to_string(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
type_: "message.part.updated".to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let converted = event_to_universal(&event).expect("conversion succeeds");
|
||||||
|
assert_eq!(converted.len(), 2);
|
||||||
|
assert!(converted
|
||||||
|
.iter()
|
||||||
|
.all(|entry| entry.event_type != UniversalEventType::ItemDelta));
|
||||||
|
|
||||||
|
let completed = converted
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry.event_type == UniversalEventType::ItemCompleted)
|
||||||
|
.expect("item.completed exists");
|
||||||
|
let UniversalEventData::Item(ItemEventData { item }) = &completed.data else {
|
||||||
|
panic!("expected item payload");
|
||||||
|
};
|
||||||
|
assert_eq!(item.native_item_id.as_deref(), Some("part_reason_1"));
|
||||||
|
assert!(matches!(
|
||||||
|
item.content.first(),
|
||||||
|
Some(ContentPart::Reasoning { text, .. })
|
||||||
|
if text == "Preparing friendly brief response"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,10 @@ pub enum UniversalEventType {
|
||||||
SessionStarted,
|
SessionStarted,
|
||||||
#[serde(rename = "session.ended")]
|
#[serde(rename = "session.ended")]
|
||||||
SessionEnded,
|
SessionEnded,
|
||||||
|
#[serde(rename = "turn.started")]
|
||||||
|
TurnStarted,
|
||||||
|
#[serde(rename = "turn.ended")]
|
||||||
|
TurnEnded,
|
||||||
#[serde(rename = "item.started")]
|
#[serde(rename = "item.started")]
|
||||||
ItemStarted,
|
ItemStarted,
|
||||||
#[serde(rename = "item.delta")]
|
#[serde(rename = "item.delta")]
|
||||||
|
|
@ -63,6 +67,7 @@ pub enum UniversalEventType {
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||||
#[serde(untagged)]
|
#[serde(untagged)]
|
||||||
pub enum UniversalEventData {
|
pub enum UniversalEventData {
|
||||||
|
Turn(TurnEventData),
|
||||||
SessionStarted(SessionStartedData),
|
SessionStarted(SessionStartedData),
|
||||||
SessionEnded(SessionEndedData),
|
SessionEnded(SessionEndedData),
|
||||||
Item(ItemEventData),
|
Item(ItemEventData),
|
||||||
|
|
@ -93,6 +98,22 @@ pub struct SessionEndedData {
|
||||||
pub stderr: Option<StderrOutput>,
|
pub stderr: Option<StderrOutput>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||||
|
pub struct TurnEventData {
|
||||||
|
pub phase: TurnPhase,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub turn_id: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub metadata: Option<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum TurnPhase {
|
||||||
|
Started,
|
||||||
|
Ended,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||||
pub struct StderrOutput {
|
pub struct StderrOutput {
|
||||||
/// First N lines of stderr (if truncated) or full stderr (if not truncated)
|
/// First N lines of stderr (if truncated) or full stderr (if not truncated)
|
||||||
|
|
@ -318,25 +339,26 @@ impl EventConversion {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn turn_completed_event() -> EventConversion {
|
pub fn turn_started_event(turn_id: Option<String>, metadata: Option<Value>) -> EventConversion {
|
||||||
EventConversion::new(
|
EventConversion::new(
|
||||||
UniversalEventType::ItemCompleted,
|
UniversalEventType::TurnStarted,
|
||||||
UniversalEventData::Item(ItemEventData {
|
UniversalEventData::Turn(TurnEventData {
|
||||||
item: UniversalItem {
|
phase: TurnPhase::Started,
|
||||||
item_id: String::new(),
|
turn_id,
|
||||||
native_item_id: None,
|
metadata,
|
||||||
parent_id: None,
|
}),
|
||||||
kind: ItemKind::Status,
|
)
|
||||||
role: Some(ItemRole::System),
|
}
|
||||||
content: vec![ContentPart::Status {
|
|
||||||
label: "turn.completed".to_string(),
|
pub fn turn_ended_event(turn_id: Option<String>, metadata: Option<Value>) -> EventConversion {
|
||||||
detail: None,
|
EventConversion::new(
|
||||||
}],
|
UniversalEventType::TurnEnded,
|
||||||
status: ItemStatus::Completed,
|
UniversalEventData::Turn(TurnEventData {
|
||||||
},
|
phase: TurnPhase::Ended,
|
||||||
|
turn_id,
|
||||||
|
metadata,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.synthetic()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn item_from_text(role: ItemRole, text: String) -> UniversalItem {
|
pub fn item_from_text(role: ItemRole, text: String) -> UniversalItem {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue