feat: opencode runner (#22)
Co-authored-by: banteg <4562643+banteg@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
1204524bef
commit
7c30674e53
@@ -0,0 +1,46 @@
|
||||
# OpenCode Runner
|
||||
|
||||
This runner integrates with the [OpenCode CLI](https://github.com/sst/opencode).
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm i -g opencode-ai@latest
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Add to your `takopi.toml`:
|
||||
|
||||
```toml
|
||||
[opencode]
|
||||
model = "claude-sonnet" # optional
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
takopi opencode
|
||||
```
|
||||
|
||||
## Resume Format
|
||||
|
||||
Resume line format: `` `opencode --session ses_XXX` ``
|
||||
|
||||
The runner recognizes both `--session` and `-s` flags (with or without `run`).
|
||||
|
||||
Note: The resume line is meant to reopen the interactive TUI session. `opencode run` is headless and requires a message or command, so it is not the canonical resume command.
|
||||
|
||||
## JSON Event Format
|
||||
|
||||
OpenCode outputs JSON events with the following types:
|
||||
|
||||
| Event Type | Description |
|
||||
|------------|-------------|
|
||||
| `step_start` | Beginning of a processing step |
|
||||
| `tool_use` | Tool invocation with input/output |
|
||||
| `text` | Text output from the model |
|
||||
| `step_finish` | End of a step (reason: "stop" or "tool-calls" when present) |
|
||||
| `error` | Error event |
|
||||
|
||||
See [opencode-stream-json-cheatsheet.md](./opencode-stream-json-cheatsheet.md) for detailed event format documentation.
|
||||
@@ -0,0 +1,145 @@
|
||||
# OpenCode `run --format json` Event Cheatsheet
|
||||
|
||||
`opencode run --format json` writes one JSON object per line (JSONL) to stdout.
|
||||
Each line has a `type` field indicating the event type.
|
||||
|
||||
## Event Types
|
||||
|
||||
### `step_start`
|
||||
|
||||
Marks the beginning of a processing step.
|
||||
|
||||
Fields:
|
||||
- `type`: `"step_start"`
|
||||
- `timestamp`: Unix timestamp in milliseconds
|
||||
- `sessionID`: Session identifier (format: `ses_XXX`)
|
||||
- `part.id`: Part identifier
|
||||
- `part.sessionID`: Session ID (duplicated)
|
||||
- `part.messageID`: Message ID
|
||||
- `part.type`: `"step-start"`
|
||||
- `part.snapshot`: Git snapshot hash
|
||||
|
||||
Example:
|
||||
```json
|
||||
{"type":"step_start","timestamp":1767036059338,"sessionID":"ses_494719016ffe85dkDMj0FPRbHK","part":{"id":"prt_b6b8e7ec7001qAZUB7eTENxPpI","sessionID":"ses_494719016ffe85dkDMj0FPRbHK","messageID":"msg_b6b8e702b0012XuEC4bGe0XhKa","type":"step-start","snapshot":"71db24a798b347669c0ebadb2dfad238f991753d"}}
|
||||
```
|
||||
|
||||
### `tool_use`
|
||||
|
||||
Tool invocation event. Emitted when a tool finishes (`status == "completed"`).
|
||||
|
||||
Fields:
|
||||
- `type`: `"tool_use"`
|
||||
- `timestamp`: Unix timestamp in milliseconds
|
||||
- `sessionID`: Session identifier
|
||||
- `part.id`: Part identifier
|
||||
- `part.callID`: Unique call ID for this tool invocation
|
||||
- `part.tool`: Tool name (e.g., "bash", "read", "write", "grep")
|
||||
- `part.state.status`: `"completed"` (the CLI JSON output does not emit pending/running tool states)
|
||||
- `part.state.input`: Tool input parameters
|
||||
- `part.state.output`: Tool output (when completed)
|
||||
- `part.state.title`: Human-readable description
|
||||
- `part.state.metadata`: Additional metadata (exit codes, etc.)
|
||||
- `part.state.time.start`: Start timestamp
|
||||
- `part.state.time.end`: End timestamp
|
||||
|
||||
Example:
|
||||
```json
|
||||
{"type":"tool_use","timestamp":1767036061199,"sessionID":"ses_494719016ffe85dkDMj0FPRbHK","part":{"id":"prt_b6b8e85bb001CzBoN2dDlEZJnP","sessionID":"ses_494719016ffe85dkDMj0FPRbHK","messageID":"msg_b6b8e702b0012XuEC4bGe0XhKa","type":"tool","callID":"r9bQWsNLvOrJGIOz","tool":"bash","state":{"status":"completed","input":{"command":"echo hello","description":"Print hello to stdout"},"output":"hello\n","title":"Print hello to stdout","metadata":{"output":"hello\n","exit":0,"description":"Print hello to stdout"},"time":{"start":1767036061123,"end":1767036061173}}}}
|
||||
```
|
||||
|
||||
### `text`
|
||||
|
||||
Text output from the model.
|
||||
|
||||
Fields:
|
||||
- `type`: `"text"`
|
||||
- `timestamp`: Unix timestamp in milliseconds
|
||||
- `sessionID`: Session identifier
|
||||
- `part.id`: Part identifier
|
||||
- `part.type`: `"text"`
|
||||
- `part.text`: The actual text content
|
||||
- `part.time.start`: Start timestamp
|
||||
- `part.time.end`: End timestamp
|
||||
|
||||
Example:
|
||||
```json
|
||||
{"type":"text","timestamp":1767036064268,"sessionID":"ses_494719016ffe85dkDMj0FPRbHK","part":{"id":"prt_b6b8e8ff2002mxSx9LtvAlf8Ng","sessionID":"ses_494719016ffe85dkDMj0FPRbHK","messageID":"msg_b6b8e8627001yM4qKJCXdC7W1L","type":"text","text":"```\nhello\n```","time":{"start":1767036064265,"end":1767036064265}}}
|
||||
```
|
||||
|
||||
### `step_finish`
|
||||
|
||||
Marks the end of a processing step.
|
||||
|
||||
Fields:
|
||||
- `type`: `"step_finish"`
|
||||
- `timestamp`: Unix timestamp in milliseconds
|
||||
- `sessionID`: Session identifier
|
||||
- `part.id`: Part identifier
|
||||
- `part.type`: `"step-finish"`
|
||||
- `part.reason`: Optional. `"stop"` (final) or `"tool-calls"` (continuing) when present.
|
||||
- `part.snapshot`: Git snapshot hash
|
||||
- `part.cost`: Cost in USD
|
||||
- `part.tokens.input`: Input token count
|
||||
- `part.tokens.output`: Output token count
|
||||
- `part.tokens.reasoning`: Reasoning token count
|
||||
- `part.tokens.cache.read`: Cache read tokens
|
||||
- `part.tokens.cache.write`: Cache write tokens
|
||||
|
||||
Example (final step):
|
||||
```json
|
||||
{"type":"step_finish","timestamp":1767036064273,"sessionID":"ses_494719016ffe85dkDMj0FPRbHK","part":{"id":"prt_b6b8e9209001ojZ4ECN1geZISm","sessionID":"ses_494719016ffe85dkDMj0FPRbHK","messageID":"msg_b6b8e8627001yM4qKJCXdC7W1L","type":"step-finish","reason":"stop","snapshot":"09dd05d11a4ac013136c1df10932efc0ad9116e8","cost":0.001,"tokens":{"input":671,"output":8,"reasoning":0,"cache":{"read":21415,"write":0}}}}
|
||||
```
|
||||
|
||||
Example (tool-calls step):
|
||||
```json
|
||||
{"type":"step_finish","timestamp":1767036061205,"sessionID":"ses_494719016ffe85dkDMj0FPRbHK","part":{"id":"prt_b6b8e85fb001L4I3WHMqH6EQNI","sessionID":"ses_494719016ffe85dkDMj0FPRbHK","messageID":"msg_b6b8e702b0012XuEC4bGe0XhKa","type":"step-finish","reason":"tool-calls","snapshot":"ee3406d50c7d9048674bbb1a3e325d82513b74ed","cost":0,"tokens":{"input":21772,"output":110,"reasoning":0,"cache":{"read":0,"write":0}}}}
|
||||
```
|
||||
|
||||
### `error`
|
||||
|
||||
Session error event.
|
||||
|
||||
Fields:
|
||||
- `type`: `"error"`
|
||||
- `timestamp`: Unix timestamp in milliseconds
|
||||
- `sessionID`: Session identifier
|
||||
- `error.name`: Error type
|
||||
- `error.data.message`: Human-readable error (when available)
|
||||
|
||||
Example:
|
||||
```json
|
||||
{"type":"error","timestamp":1767036065000,"sessionID":"ses_494719016ffe85dkDMj0FPRbHK","error":{"name":"APIError","data":{"message":"Rate limit exceeded","statusCode":429,"isRetryable":true}}}
|
||||
```
|
||||
|
||||
## Mapping to Takopi Events
|
||||
|
||||
| OpenCode Event | Takopi Event | Condition |
|
||||
|----------------|--------------|-----------|
|
||||
| `step_start` | `StartedEvent` | First occurrence |
|
||||
| `tool_use` | `ActionEvent(phase="completed")` | `status == "completed"` |
|
||||
| `text` | (accumulate text) | - |
|
||||
| `step_finish` | `CompletedEvent` | `reason == "stop"` |
|
||||
| `step_finish` | (ignored) | `reason == "tool-calls"` |
|
||||
| `error` | `CompletedEvent(ok=False)` | - |
|
||||
|
||||
If `step_finish` omits `reason`, Takopi treats a clean process exit as successful completion and emits `CompletedEvent(ok=True)` with accumulated usage.
|
||||
|
||||
## Session ID Format
|
||||
|
||||
OpenCode uses session IDs in the format: `ses_XXXXXXXXXXXXXXXXXXXX`
|
||||
|
||||
Example: `ses_494719016ffe85dkDMj0FPRbHK`
|
||||
|
||||
## Tool Types
|
||||
|
||||
Common tool names in OpenCode:
|
||||
- `bash`: Shell command execution
|
||||
- `read`: Read file contents
|
||||
- `write`: Write file contents
|
||||
- `edit`: Edit file contents
|
||||
- `glob`: File pattern matching
|
||||
- `grep`: Content search
|
||||
- `webfetch`: Fetch web content
|
||||
- `websearch`: Web search
|
||||
- `task`: Spawn sub-agent tasks
|
||||
@@ -0,0 +1,80 @@
|
||||
# OpenCode to Takopi Event Mapping
|
||||
|
||||
This document describes how OpenCode JSON events are translated to Takopi's normalized event model.
|
||||
|
||||
## Event Translation
|
||||
|
||||
### StartedEvent
|
||||
|
||||
Emitted on the first `step_start` event that contains a `sessionID`.
|
||||
|
||||
```
|
||||
OpenCode: {"type":"step_start","sessionID":"ses_XXX",...}
|
||||
Takopi: StartedEvent(engine="opencode", resume=ResumeToken(engine="opencode", value="ses_XXX"))
|
||||
```
|
||||
|
||||
### ActionEvent
|
||||
|
||||
Tool usage is translated to action events. Note: `opencode run --format json` currently only emits `tool_use` events when the tool finishes (`status == "completed"`). Pending/running tool states exist in the schema but are not emitted by the CLI JSON stream.
|
||||
|
||||
**Started phase** (when tool is pending/running, if emitted by the JSON stream):
|
||||
```
|
||||
OpenCode: {"type":"tool_use","part":{"tool":"bash","state":{"status":"pending",...}}}
|
||||
Takopi: ActionEvent(engine="opencode", action=Action(kind="command"), phase="started")
|
||||
```
|
||||
|
||||
**Completed phase** (when tool finishes):
|
||||
```
|
||||
OpenCode: {"type":"tool_use","part":{"tool":"bash","state":{"status":"completed","metadata":{"exit":0}}}}
|
||||
Takopi: ActionEvent(engine="opencode", action=Action(kind="command"), phase="completed", ok=True)
|
||||
```
|
||||
|
||||
### CompletedEvent
|
||||
|
||||
Emitted on `step_finish` with `reason="stop"` or on `error` events.
|
||||
|
||||
**Success**:
|
||||
```
|
||||
OpenCode: {"type":"step_finish","part":{"reason":"stop","tokens":{...},"cost":0.001}}
|
||||
Takopi: CompletedEvent(engine="opencode", ok=True, answer="<accumulated text>", usage={...})
|
||||
```
|
||||
|
||||
If `step_finish` omits `reason`, Takopi treats a clean process exit as successful completion and emits `CompletedEvent(ok=True)` with the accumulated usage.
|
||||
|
||||
**Error**:
|
||||
```
|
||||
OpenCode: {"type":"error","error":{"name":"APIError","data":{"message":"API rate limit exceeded"}}}
|
||||
Takopi: CompletedEvent(engine="opencode", ok=False, error="API rate limit exceeded")
|
||||
```
|
||||
|
||||
## Tool Kind Mapping
|
||||
|
||||
| OpenCode Tool | Takopi ActionKind |
|
||||
|---------------|-------------------|
|
||||
| `bash`, `shell` | `command` |
|
||||
| `edit`, `write`, `multiedit` | `file_change` |
|
||||
| `read` | `tool` |
|
||||
| `glob` | `tool` |
|
||||
| `grep` | `tool` |
|
||||
| `websearch`, `web_search` | `web_search` |
|
||||
| `webfetch`, `web_fetch` | `web_search` |
|
||||
| `todowrite`, `todoread` | `note` |
|
||||
| `task` | `tool` |
|
||||
| (other) | `tool` |
|
||||
|
||||
## Usage Accumulation
|
||||
|
||||
Token usage is accumulated across all `step_finish` events and reported in the final `CompletedEvent.usage`:
|
||||
|
||||
```json
|
||||
{
|
||||
"total_cost_usd": 0.001,
|
||||
"tokens": {
|
||||
"input": 22443,
|
||||
"output": 118,
|
||||
"reasoning": 0,
|
||||
"cache_read": 21415,
|
||||
"cache_write": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -43,6 +43,8 @@ The canonical ResumeLine embedded in chat MUST be the engine’s CLI resume comm
|
||||
- `claude --resume <id>`
|
||||
- `pi --session <path>`
|
||||
|
||||
ResumeLine MUST resume the interactive session when the engine offers both interactive and headless modes. It MUST NOT point to a headless/batch command that requires a new prompt (e.g., a `run` subcommand that errors without a message).
|
||||
|
||||
Takopi MUST treat the runner as authoritative for:
|
||||
|
||||
- formatting a ResumeToken into a ResumeLine
|
||||
|
||||
@@ -0,0 +1,564 @@
|
||||
"""OpenCode CLI runner.
|
||||
|
||||
This runner integrates with the OpenCode CLI (https://github.com/sst/opencode).
|
||||
|
||||
OpenCode outputs JSON events in a streaming format with types:
|
||||
- step_start: Marks the beginning of a processing step
|
||||
- tool_use: Tool invocation with input/output
|
||||
- text: Text output from the model
|
||||
- step_finish: Marks the end of a step (with reason: "stop" or "tool-calls")
|
||||
|
||||
Session IDs use the format: ses_XXXX (e.g., ses_494719016ffe85dkDMj0FPRbHK)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
from ..backends import EngineBackend, EngineConfig
|
||||
from ..config import ConfigError
|
||||
from ..model import (
|
||||
Action,
|
||||
ActionEvent,
|
||||
ActionKind,
|
||||
CompletedEvent,
|
||||
EngineId,
|
||||
ResumeToken,
|
||||
StartedEvent,
|
||||
TakopiEvent,
|
||||
)
|
||||
from ..runner import JsonlSubprocessRunner, ResumeTokenMixin, Runner
|
||||
from ..utils.paths import relativize_command, relativize_path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ENGINE: EngineId = EngineId("opencode")
|
||||
STDERR_TAIL_LINES = 200
|
||||
|
||||
_RESUME_RE = re.compile(
|
||||
r"(?im)^\s*`?opencode(?:\s+run)?\s+(?:--session|-s)\s+(?P<token>ses_[A-Za-z0-9]+)`?\s*$"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class OpenCodeStreamState:
|
||||
"""State tracked during OpenCode JSONL streaming."""
|
||||
|
||||
pending_actions: dict[str, Action] = field(default_factory=dict)
|
||||
last_text: str | None = None
|
||||
note_seq: int = 0
|
||||
session_id: str | None = None
|
||||
emitted_started: bool = False
|
||||
saw_step_finish: bool = False
|
||||
total_cost: float = 0.0
|
||||
total_tokens: dict[str, int] = field(default_factory=dict)
|
||||
|
||||
|
||||
def _action_event(
|
||||
*,
|
||||
phase: Literal["started", "updated", "completed"],
|
||||
action: Action,
|
||||
ok: bool | None = None,
|
||||
message: str | None = None,
|
||||
level: Literal["debug", "info", "warning", "error"] | None = None,
|
||||
) -> ActionEvent:
|
||||
return ActionEvent(
|
||||
engine=ENGINE,
|
||||
action=action,
|
||||
phase=phase,
|
||||
ok=ok,
|
||||
message=message,
|
||||
level=level,
|
||||
)
|
||||
|
||||
|
||||
def _tool_kind_and_title(
|
||||
tool_name: str, tool_input: dict[str, Any]
|
||||
) -> tuple[ActionKind, str]:
|
||||
"""Map OpenCode tool names to Takopi action kinds and titles."""
|
||||
name_lower = tool_name.lower()
|
||||
|
||||
if name_lower in {"bash", "shell"}:
|
||||
command = tool_input.get("command")
|
||||
display = relativize_command(str(command or tool_name))
|
||||
return "command", display
|
||||
|
||||
if name_lower in {"edit", "write", "multiedit"}:
|
||||
path = tool_input.get("file_path") or tool_input.get("filePath")
|
||||
if path:
|
||||
return "file_change", relativize_path(str(path))
|
||||
return "file_change", str(tool_name)
|
||||
|
||||
if name_lower == "read":
|
||||
path = tool_input.get("file_path") or tool_input.get("filePath")
|
||||
if path:
|
||||
return "tool", f"read: `{relativize_path(str(path))}`"
|
||||
return "tool", "read"
|
||||
|
||||
if name_lower == "glob":
|
||||
pattern = tool_input.get("pattern")
|
||||
if pattern:
|
||||
return "tool", f"glob: `{pattern}`"
|
||||
return "tool", "glob"
|
||||
|
||||
if name_lower == "grep":
|
||||
pattern = tool_input.get("pattern")
|
||||
if pattern:
|
||||
return "tool", f"grep: {pattern}"
|
||||
return "tool", "grep"
|
||||
|
||||
if name_lower in {"websearch", "web_search"}:
|
||||
query = tool_input.get("query")
|
||||
return "web_search", str(query or "search")
|
||||
|
||||
if name_lower in {"webfetch", "web_fetch"}:
|
||||
url = tool_input.get("url")
|
||||
return "web_search", str(url or "fetch")
|
||||
|
||||
if name_lower in {"todowrite", "todoread"}:
|
||||
return "note", "update todos" if "write" in name_lower else "read todos"
|
||||
|
||||
if name_lower == "task":
|
||||
desc = tool_input.get("description") or tool_input.get("prompt")
|
||||
return "tool", str(desc or tool_name)
|
||||
|
||||
return "tool", tool_name
|
||||
|
||||
|
||||
def _normalize_tool_title(
|
||||
title: str,
|
||||
*,
|
||||
tool_input: dict[str, Any],
|
||||
) -> str:
|
||||
if "`" in title:
|
||||
return title
|
||||
|
||||
path = tool_input.get("file_path") or tool_input.get("filePath")
|
||||
if isinstance(path, str) and path:
|
||||
rel_path = relativize_path(path)
|
||||
if title == path or title == rel_path:
|
||||
return f"`{rel_path}`"
|
||||
|
||||
return title
|
||||
|
||||
|
||||
def _extract_tool_action(event: dict[str, Any]) -> Action | None:
|
||||
"""Extract an Action from an OpenCode tool_use event."""
|
||||
part = event.get("part") or {}
|
||||
state = part.get("state") or {}
|
||||
|
||||
call_id = part.get("callID")
|
||||
if not isinstance(call_id, str) or not call_id:
|
||||
call_id = part.get("id")
|
||||
if not isinstance(call_id, str) or not call_id:
|
||||
return None
|
||||
|
||||
tool_name = part.get("tool") or "tool"
|
||||
tool_input = state.get("input") or {}
|
||||
if not isinstance(tool_input, dict):
|
||||
tool_input = {}
|
||||
|
||||
kind, title = _tool_kind_and_title(tool_name, tool_input)
|
||||
|
||||
state_title = state.get("title")
|
||||
if isinstance(state_title, str) and state_title:
|
||||
title = _normalize_tool_title(state_title, tool_input=tool_input)
|
||||
|
||||
detail: dict[str, Any] = {
|
||||
"name": tool_name,
|
||||
"input": tool_input,
|
||||
"callID": call_id,
|
||||
}
|
||||
|
||||
if kind == "file_change":
|
||||
path = tool_input.get("file_path") or tool_input.get("filePath")
|
||||
if path:
|
||||
detail["changes"] = [{"path": path, "kind": "update"}]
|
||||
|
||||
return Action(id=call_id, kind=kind, title=title, detail=detail)
|
||||
|
||||
|
||||
def _usage_from_tokens(tokens: dict[str, int], cost: float) -> dict[str, Any]:
|
||||
"""Build usage payload from accumulated token counts."""
|
||||
usage: dict[str, Any] = {}
|
||||
if cost > 0:
|
||||
usage["total_cost_usd"] = cost
|
||||
if tokens:
|
||||
usage["tokens"] = tokens
|
||||
return usage
|
||||
|
||||
|
||||
def translate_opencode_event(
|
||||
event: dict[str, Any],
|
||||
*,
|
||||
title: str,
|
||||
state: OpenCodeStreamState,
|
||||
) -> list[TakopiEvent]:
|
||||
"""Translate an OpenCode JSON event into Takopi events."""
|
||||
etype = event.get("type")
|
||||
session_id = event.get("sessionID")
|
||||
|
||||
if isinstance(session_id, str) and session_id:
|
||||
if state.session_id is None:
|
||||
state.session_id = session_id
|
||||
|
||||
if etype == "step_start":
|
||||
if not state.emitted_started and state.session_id:
|
||||
state.emitted_started = True
|
||||
return [
|
||||
StartedEvent(
|
||||
engine=ENGINE,
|
||||
resume=ResumeToken(engine=ENGINE, value=state.session_id),
|
||||
title=title,
|
||||
)
|
||||
]
|
||||
return []
|
||||
|
||||
if etype == "tool_use":
|
||||
part = event.get("part") or {}
|
||||
tool_state = part.get("state") or {}
|
||||
status = tool_state.get("status")
|
||||
|
||||
action = _extract_tool_action(event)
|
||||
if action is None:
|
||||
return []
|
||||
|
||||
if status == "completed":
|
||||
output = tool_state.get("output")
|
||||
metadata = tool_state.get("metadata") or {}
|
||||
exit_code = metadata.get("exit")
|
||||
|
||||
is_error = False
|
||||
if isinstance(exit_code, int) and exit_code != 0:
|
||||
is_error = True
|
||||
|
||||
detail = dict(action.detail)
|
||||
if output is not None:
|
||||
detail["output_preview"] = (
|
||||
str(output)[:500] if len(str(output)) > 500 else str(output)
|
||||
)
|
||||
detail["exit_code"] = exit_code
|
||||
|
||||
state.pending_actions.pop(action.id, None)
|
||||
|
||||
return [
|
||||
_action_event(
|
||||
phase="completed",
|
||||
action=Action(
|
||||
id=action.id,
|
||||
kind=action.kind,
|
||||
title=action.title,
|
||||
detail=detail,
|
||||
),
|
||||
ok=not is_error,
|
||||
)
|
||||
]
|
||||
if status == "error":
|
||||
error = tool_state.get("error")
|
||||
metadata = tool_state.get("metadata") or {}
|
||||
exit_code = metadata.get("exit")
|
||||
|
||||
detail = dict(action.detail)
|
||||
if error is not None:
|
||||
detail["error"] = error
|
||||
detail["exit_code"] = exit_code
|
||||
|
||||
state.pending_actions.pop(action.id, None)
|
||||
|
||||
return [
|
||||
_action_event(
|
||||
phase="completed",
|
||||
action=Action(
|
||||
id=action.id,
|
||||
kind=action.kind,
|
||||
title=action.title,
|
||||
detail=detail,
|
||||
),
|
||||
ok=False,
|
||||
message=str(error) if error is not None else None,
|
||||
)
|
||||
]
|
||||
else:
|
||||
state.pending_actions[action.id] = action
|
||||
return [_action_event(phase="started", action=action)]
|
||||
|
||||
if etype == "text":
|
||||
part = event.get("part") or {}
|
||||
text = part.get("text")
|
||||
if isinstance(text, str) and text:
|
||||
if state.last_text is None:
|
||||
state.last_text = text
|
||||
else:
|
||||
state.last_text += text
|
||||
return []
|
||||
|
||||
if etype == "step_finish":
|
||||
part = event.get("part") or {}
|
||||
reason = part.get("reason")
|
||||
state.saw_step_finish = True
|
||||
|
||||
tokens = part.get("tokens") or {}
|
||||
if isinstance(tokens, dict):
|
||||
for key in ("input", "output", "reasoning"):
|
||||
value = tokens.get(key)
|
||||
if isinstance(value, int):
|
||||
state.total_tokens[key] = state.total_tokens.get(key, 0) + value
|
||||
cache = tokens.get("cache") or {}
|
||||
if isinstance(cache, dict):
|
||||
for key in ("read", "write"):
|
||||
value = cache.get(key)
|
||||
if not isinstance(value, int):
|
||||
continue
|
||||
cache_key = f"cache_{key}"
|
||||
state.total_tokens[cache_key] = (
|
||||
state.total_tokens.get(cache_key, 0) + value
|
||||
)
|
||||
|
||||
cost = part.get("cost")
|
||||
if isinstance(cost, (int, float)):
|
||||
state.total_cost += cost
|
||||
|
||||
if reason == "stop":
|
||||
resume = None
|
||||
if state.session_id:
|
||||
resume = ResumeToken(engine=ENGINE, value=state.session_id)
|
||||
|
||||
usage = _usage_from_tokens(state.total_tokens, state.total_cost)
|
||||
|
||||
return [
|
||||
CompletedEvent(
|
||||
engine=ENGINE,
|
||||
ok=True,
|
||||
answer=state.last_text or "",
|
||||
resume=resume,
|
||||
usage=usage or None,
|
||||
)
|
||||
]
|
||||
return []
|
||||
|
||||
if etype == "error":
|
||||
raw_message = event.get("message")
|
||||
if raw_message is None:
|
||||
raw_message = event.get("error")
|
||||
|
||||
message = raw_message
|
||||
if isinstance(message, dict):
|
||||
data = message.get("data")
|
||||
if isinstance(data, dict) and data.get("message"):
|
||||
message = data.get("message")
|
||||
else:
|
||||
message = (
|
||||
message.get("message") or message.get("name") or "opencode error"
|
||||
)
|
||||
elif message is None:
|
||||
message = "opencode error"
|
||||
|
||||
resume = None
|
||||
if state.session_id:
|
||||
resume = ResumeToken(engine=ENGINE, value=state.session_id)
|
||||
|
||||
return [
|
||||
CompletedEvent(
|
||||
engine=ENGINE,
|
||||
ok=False,
|
||||
answer=state.last_text or "",
|
||||
resume=resume,
|
||||
error=str(message),
|
||||
)
|
||||
]
|
||||
|
||||
return []
|
||||
|
||||
|
||||
@dataclass
|
||||
class OpenCodeRunner(ResumeTokenMixin, JsonlSubprocessRunner):
|
||||
"""Runner for OpenCode CLI."""
|
||||
|
||||
engine: EngineId = ENGINE
|
||||
resume_re: re.Pattern[str] = _RESUME_RE
|
||||
|
||||
opencode_cmd: str = "opencode"
|
||||
model: str | None = None
|
||||
session_title: str = "opencode"
|
||||
stderr_tail_lines: int = STDERR_TAIL_LINES
|
||||
logger: logging.Logger = logger
|
||||
|
||||
def format_resume(self, token: ResumeToken) -> str:
|
||||
if token.engine != ENGINE:
|
||||
raise RuntimeError(f"resume token is for engine {token.engine!r}")
|
||||
return f"`opencode --session {token.value}`"
|
||||
|
||||
def command(self) -> str:
|
||||
return self.opencode_cmd
|
||||
|
||||
def build_args(
|
||||
self,
|
||||
prompt: str,
|
||||
resume: ResumeToken | None,
|
||||
*,
|
||||
state: Any,
|
||||
) -> list[str]:
|
||||
_ = state
|
||||
args = ["run", "--format", "json"]
|
||||
if resume is not None:
|
||||
args.extend(["--session", resume.value])
|
||||
if self.model is not None:
|
||||
args.extend(["--model", str(self.model)])
|
||||
args.extend(["--", prompt])
|
||||
return args
|
||||
|
||||
def stdin_payload(
|
||||
self,
|
||||
prompt: str,
|
||||
resume: ResumeToken | None,
|
||||
*,
|
||||
state: Any,
|
||||
) -> bytes | None:
|
||||
_ = prompt, resume, state
|
||||
return None
|
||||
|
||||
def new_state(self, prompt: str, resume: ResumeToken | None) -> OpenCodeStreamState:
|
||||
_ = prompt, resume
|
||||
return OpenCodeStreamState()
|
||||
|
||||
def start_run(
|
||||
self,
|
||||
prompt: str,
|
||||
resume: ResumeToken | None,
|
||||
*,
|
||||
state: OpenCodeStreamState,
|
||||
) -> None:
|
||||
_ = state
|
||||
logger.info(
|
||||
"[opencode] start run resume=%r",
|
||||
resume.value if resume else None,
|
||||
)
|
||||
logger.debug("[opencode] prompt: %s", prompt)
|
||||
|
||||
def invalid_json_events(
|
||||
self,
|
||||
*,
|
||||
raw: str,
|
||||
line: str,
|
||||
state: OpenCodeStreamState,
|
||||
) -> list[TakopiEvent]:
|
||||
_ = line
|
||||
message = "invalid JSON from opencode; ignoring line"
|
||||
return [self.note_event(message, state=state, detail={"line": raw})]
|
||||
|
||||
def translate(
|
||||
self,
|
||||
data: dict[str, Any],
|
||||
*,
|
||||
state: OpenCodeStreamState,
|
||||
resume: ResumeToken | None,
|
||||
found_session: ResumeToken | None,
|
||||
) -> list[TakopiEvent]:
|
||||
_ = resume, found_session
|
||||
return translate_opencode_event(
|
||||
data,
|
||||
title=self.session_title,
|
||||
state=state,
|
||||
)
|
||||
|
||||
def process_error_events(
|
||||
self,
|
||||
rc: int,
|
||||
*,
|
||||
resume: ResumeToken | None,
|
||||
found_session: ResumeToken | None,
|
||||
stderr_tail: str,
|
||||
state: OpenCodeStreamState,
|
||||
) -> list[TakopiEvent]:
|
||||
message = f"opencode failed (rc={rc})."
|
||||
resume_for_completed = found_session or resume
|
||||
return [
|
||||
self.note_event(
|
||||
message,
|
||||
state=state,
|
||||
ok=False,
|
||||
detail={"stderr_tail": stderr_tail},
|
||||
),
|
||||
CompletedEvent(
|
||||
engine=ENGINE,
|
||||
ok=False,
|
||||
answer=state.last_text or "",
|
||||
resume=resume_for_completed,
|
||||
error=message,
|
||||
),
|
||||
]
|
||||
|
||||
def stream_end_events(
|
||||
self,
|
||||
*,
|
||||
resume: ResumeToken | None,
|
||||
found_session: ResumeToken | None,
|
||||
stderr_tail: str,
|
||||
state: OpenCodeStreamState,
|
||||
) -> list[TakopiEvent]:
|
||||
_ = stderr_tail
|
||||
if not found_session:
|
||||
message = "opencode finished but no session_id was captured"
|
||||
resume_for_completed = resume
|
||||
return [
|
||||
CompletedEvent(
|
||||
engine=ENGINE,
|
||||
ok=False,
|
||||
answer=state.last_text or "",
|
||||
resume=resume_for_completed,
|
||||
error=message,
|
||||
)
|
||||
]
|
||||
|
||||
if state.saw_step_finish:
|
||||
usage = _usage_from_tokens(state.total_tokens, state.total_cost)
|
||||
return [
|
||||
CompletedEvent(
|
||||
engine=ENGINE,
|
||||
ok=True,
|
||||
answer=state.last_text or "",
|
||||
resume=found_session,
|
||||
usage=usage or None,
|
||||
)
|
||||
]
|
||||
|
||||
message = "opencode finished without a result event"
|
||||
return [
|
||||
CompletedEvent(
|
||||
engine=ENGINE,
|
||||
ok=False,
|
||||
answer=state.last_text or "",
|
||||
resume=found_session,
|
||||
error=message,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def build_runner(config: EngineConfig, config_path: Path) -> Runner:
|
||||
"""Build an OpenCodeRunner from configuration."""
|
||||
opencode_cmd = "opencode"
|
||||
|
||||
model = config.get("model")
|
||||
if model is not None and not isinstance(model, str):
|
||||
raise ConfigError(
|
||||
f"Invalid `opencode.model` in {config_path}; expected a string."
|
||||
)
|
||||
|
||||
title = str(model) if model is not None else "opencode"
|
||||
|
||||
return OpenCodeRunner(
|
||||
opencode_cmd=opencode_cmd,
|
||||
model=model,
|
||||
session_title=title,
|
||||
)
|
||||
|
||||
|
||||
BACKEND = EngineBackend(
|
||||
id="opencode",
|
||||
build_runner=build_runner,
|
||||
install_cmd="npm i -g opencode-ai@latest",
|
||||
)
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
{"type":"step_start","timestamp":1767037000000,"sessionID":"ses_error123","part":{"id":"prt_error1","sessionID":"ses_error123","messageID":"msg_error1","type":"step-start"}}
|
||||
{"type":"error","timestamp":1767037001000,"sessionID":"ses_error123","error":{"name":"APIError","data":{"message":"Rate limit exceeded","statusCode":429,"isRetryable":true}}}
|
||||
@@ -0,0 +1,6 @@
|
||||
{"type":"step_start","timestamp":1767036059338,"sessionID":"ses_494719016ffe85dkDMj0FPRbHK","part":{"id":"prt_b6b8e7ec7001qAZUB7eTENxPpI","sessionID":"ses_494719016ffe85dkDMj0FPRbHK","messageID":"msg_b6b8e702b0012XuEC4bGe0XhKa","type":"step-start","snapshot":"71db24a798b347669c0ebadb2dfad238f991753d"}}
|
||||
{"type":"tool_use","timestamp":1767036061199,"sessionID":"ses_494719016ffe85dkDMj0FPRbHK","part":{"id":"prt_b6b8e85bb001CzBoN2dDlEZJnP","sessionID":"ses_494719016ffe85dkDMj0FPRbHK","messageID":"msg_b6b8e702b0012XuEC4bGe0XhKa","type":"tool","callID":"r9bQWsNLvOrJGIOz","tool":"bash","state":{"status":"completed","input":{"command":"echo hello","description":"Print hello to stdout"},"output":"hello\n","title":"Print hello to stdout","metadata":{"output":"hello\n","exit":0,"description":"Print hello to stdout"},"time":{"start":1767036061123,"end":1767036061173}}}}
|
||||
{"type":"step_finish","timestamp":1767036061205,"sessionID":"ses_494719016ffe85dkDMj0FPRbHK","part":{"id":"prt_b6b8e85fb001L4I3WHMqH6EQNI","sessionID":"ses_494719016ffe85dkDMj0FPRbHK","messageID":"msg_b6b8e702b0012XuEC4bGe0XhKa","type":"step-finish","reason":"tool-calls","snapshot":"ee3406d50c7d9048674bbb1a3e325d82513b74ed","cost":0,"tokens":{"input":21772,"output":110,"reasoning":0,"cache":{"read":0,"write":0}}}}
|
||||
{"type":"step_start","timestamp":1767036063732,"sessionID":"ses_494719016ffe85dkDMj0FPRbHK","part":{"id":"prt_b6b8e8ff2001hIElz1HRSMdHJY","sessionID":"ses_494719016ffe85dkDMj0FPRbHK","messageID":"msg_b6b8e8627001yM4qKJCXdC7W1L","type":"step-start","snapshot":"9017313c64af88e12921b4c81d57fd4806192416"}}
|
||||
{"type":"text","timestamp":1767036064268,"sessionID":"ses_494719016ffe85dkDMj0FPRbHK","part":{"id":"prt_b6b8e8ff2002mxSx9LtvAlf8Ng","sessionID":"ses_494719016ffe85dkDMj0FPRbHK","messageID":"msg_b6b8e8627001yM4qKJCXdC7W1L","type":"text","text":"```\nhello\n```","time":{"start":1767036064265,"end":1767036064265}}}
|
||||
{"type":"step_finish","timestamp":1767036064273,"sessionID":"ses_494719016ffe85dkDMj0FPRbHK","part":{"id":"prt_b6b8e9209001ojZ4ECN1geZISm","sessionID":"ses_494719016ffe85dkDMj0FPRbHK","messageID":"msg_b6b8e8627001yM4qKJCXdC7W1L","type":"step-finish","reason":"stop","snapshot":"09dd05d11a4ac013136c1df10932efc0ad9116e8","cost":0.001,"tokens":{"input":671,"output":8,"reasoning":0,"cache":{"read":21415,"write":0}}}}
|
||||
@@ -0,0 +1,3 @@
|
||||
{"type":"step_start","timestamp":1767038000000,"sessionID":"ses_no_reason","part":{"id":"prt_nr_start","sessionID":"ses_no_reason","messageID":"msg_nr_1","type":"step-start"}}
|
||||
{"type":"text","timestamp":1767038000500,"sessionID":"ses_no_reason","part":{"id":"prt_nr_text","sessionID":"ses_no_reason","messageID":"msg_nr_1","type":"text","text":"All done.","time":{"start":1767038000500,"end":1767038000500}}}
|
||||
{"type":"step_finish","timestamp":1767038001000,"sessionID":"ses_no_reason","part":{"id":"prt_nr_finish","sessionID":"ses_no_reason","messageID":"msg_nr_1","type":"step-finish","cost":0.002,"tokens":{"input":12,"output":3,"reasoning":0,"cache":{"read":0,"write":0}}}}
|
||||
@@ -0,0 +1,341 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
|
||||
from takopi.model import ActionEvent, CompletedEvent, ResumeToken, StartedEvent
|
||||
from takopi.runners.opencode import (
|
||||
OpenCodeRunner,
|
||||
OpenCodeStreamState,
|
||||
ENGINE,
|
||||
translate_opencode_event,
|
||||
)
|
||||
|
||||
|
||||
def _load_fixture(name: str) -> list[dict]:
|
||||
path = Path(__file__).parent / "fixtures" / name
|
||||
return [json.loads(line) for line in path.read_text().splitlines() if line.strip()]
|
||||
|
||||
|
||||
def test_opencode_resume_format_and_extract() -> None:
|
||||
runner = OpenCodeRunner(opencode_cmd="opencode")
|
||||
token = ResumeToken(engine=ENGINE, value="ses_abc123")
|
||||
|
||||
assert runner.format_resume(token) == "`opencode --session ses_abc123`"
|
||||
assert runner.extract_resume("`opencode --session ses_abc123`") == token
|
||||
assert runner.extract_resume("opencode run -s ses_other") == ResumeToken(
|
||||
engine=ENGINE, value="ses_other"
|
||||
)
|
||||
assert runner.extract_resume("opencode -s ses_other") == ResumeToken(
|
||||
engine=ENGINE, value="ses_other"
|
||||
)
|
||||
assert runner.extract_resume("`claude --resume sid`") is None
|
||||
assert runner.extract_resume("`codex resume sid`") is None
|
||||
|
||||
|
||||
def test_translate_success_fixture() -> None:
|
||||
state = OpenCodeStreamState()
|
||||
events: list = []
|
||||
for event in _load_fixture("opencode_stream_success.jsonl"):
|
||||
events.extend(translate_opencode_event(event, title="opencode", state=state))
|
||||
|
||||
assert isinstance(events[0], StartedEvent)
|
||||
started = next(evt for evt in events if isinstance(evt, StartedEvent))
|
||||
assert started.resume.value == "ses_494719016ffe85dkDMj0FPRbHK"
|
||||
assert started.resume.engine == ENGINE
|
||||
|
||||
action_events = [evt for evt in events if isinstance(evt, ActionEvent)]
|
||||
assert len(action_events) == 1
|
||||
|
||||
completed_actions = [evt for evt in action_events if evt.phase == "completed"]
|
||||
assert len(completed_actions) == 1
|
||||
assert completed_actions[0].action.kind == "command"
|
||||
assert completed_actions[0].ok is True
|
||||
|
||||
completed = next(evt for evt in events if isinstance(evt, CompletedEvent))
|
||||
assert events[-1] == completed
|
||||
assert completed.ok is True
|
||||
assert completed.resume == started.resume
|
||||
assert completed.answer == "```\nhello\n```"
|
||||
|
||||
assert completed.usage is not None
|
||||
assert "tokens" in completed.usage
|
||||
|
||||
|
||||
def test_translate_missing_reason_success() -> None:
|
||||
state = OpenCodeStreamState()
|
||||
events: list = []
|
||||
for event in _load_fixture("opencode_stream_success_no_reason.jsonl"):
|
||||
events.extend(translate_opencode_event(event, title="opencode", state=state))
|
||||
|
||||
started = next(evt for evt in events if isinstance(evt, StartedEvent))
|
||||
runner = OpenCodeRunner(opencode_cmd="opencode")
|
||||
fallback = runner.stream_end_events(
|
||||
resume=None,
|
||||
found_session=started.resume,
|
||||
stderr_tail="",
|
||||
state=state,
|
||||
)
|
||||
|
||||
completed = next(evt for evt in fallback if isinstance(evt, CompletedEvent))
|
||||
assert completed.ok is True
|
||||
assert completed.resume == started.resume
|
||||
assert completed.answer == "All done."
|
||||
assert completed.usage is not None
|
||||
|
||||
|
||||
def test_translate_accumulates_text() -> None:
|
||||
state = OpenCodeStreamState()
|
||||
|
||||
events = translate_opencode_event(
|
||||
{"type": "step_start", "sessionID": "ses_test123", "part": {}},
|
||||
title="opencode",
|
||||
state=state,
|
||||
)
|
||||
assert len(events) == 1
|
||||
assert isinstance(events[0], StartedEvent)
|
||||
|
||||
translate_opencode_event(
|
||||
{
|
||||
"type": "text",
|
||||
"sessionID": "ses_test123",
|
||||
"part": {"type": "text", "text": "Hello "},
|
||||
},
|
||||
title="opencode",
|
||||
state=state,
|
||||
)
|
||||
translate_opencode_event(
|
||||
{
|
||||
"type": "text",
|
||||
"sessionID": "ses_test123",
|
||||
"part": {"type": "text", "text": "World"},
|
||||
},
|
||||
title="opencode",
|
||||
state=state,
|
||||
)
|
||||
|
||||
assert state.last_text == "Hello World"
|
||||
|
||||
events = translate_opencode_event(
|
||||
{
|
||||
"type": "step_finish",
|
||||
"sessionID": "ses_test123",
|
||||
"part": {"reason": "stop", "tokens": {"input": 100, "output": 10}},
|
||||
},
|
||||
title="opencode",
|
||||
state=state,
|
||||
)
|
||||
|
||||
assert len(events) == 1
|
||||
completed = events[0]
|
||||
assert isinstance(completed, CompletedEvent)
|
||||
assert completed.answer == "Hello World"
|
||||
assert completed.ok is True
|
||||
|
||||
|
||||
def test_translate_tool_use_completed() -> None:
|
||||
state = OpenCodeStreamState()
|
||||
state.session_id = "ses_test123"
|
||||
state.emitted_started = True
|
||||
|
||||
events = translate_opencode_event(
|
||||
{
|
||||
"type": "tool_use",
|
||||
"sessionID": "ses_test123",
|
||||
"part": {
|
||||
"id": "prt_123",
|
||||
"callID": "call_abc",
|
||||
"tool": "bash",
|
||||
"state": {
|
||||
"status": "completed",
|
||||
"input": {"command": "ls -la"},
|
||||
"output": "file1.txt\nfile2.txt",
|
||||
"title": "List files",
|
||||
"metadata": {"exit": 0},
|
||||
},
|
||||
},
|
||||
},
|
||||
title="opencode",
|
||||
state=state,
|
||||
)
|
||||
|
||||
assert len(events) == 1
|
||||
action_event = events[0]
|
||||
assert isinstance(action_event, ActionEvent)
|
||||
assert action_event.phase == "completed"
|
||||
assert action_event.action.kind == "command"
|
||||
assert action_event.action.title == "List files"
|
||||
assert action_event.ok is True
|
||||
|
||||
|
||||
def test_translate_tool_use_with_error() -> None:
|
||||
state = OpenCodeStreamState()
|
||||
state.session_id = "ses_test123"
|
||||
state.emitted_started = True
|
||||
|
||||
events = translate_opencode_event(
|
||||
{
|
||||
"type": "tool_use",
|
||||
"sessionID": "ses_test123",
|
||||
"part": {
|
||||
"id": "prt_123",
|
||||
"callID": "call_abc",
|
||||
"tool": "bash",
|
||||
"state": {
|
||||
"status": "completed",
|
||||
"input": {"command": "exit 1"},
|
||||
"output": "error",
|
||||
"title": "Run failing command",
|
||||
"metadata": {"exit": 1},
|
||||
},
|
||||
},
|
||||
},
|
||||
title="opencode",
|
||||
state=state,
|
||||
)
|
||||
|
||||
assert len(events) == 1
|
||||
action_event = events[0]
|
||||
assert isinstance(action_event, ActionEvent)
|
||||
assert action_event.phase == "completed"
|
||||
assert action_event.ok is False
|
||||
|
||||
|
||||
def test_translate_tool_use_read_title_wraps_path() -> None:
|
||||
state = OpenCodeStreamState()
|
||||
state.session_id = "ses_test123"
|
||||
state.emitted_started = True
|
||||
path = Path.cwd() / "src" / "takopi" / "runners" / "opencode.py"
|
||||
|
||||
events = translate_opencode_event(
|
||||
{
|
||||
"type": "tool_use",
|
||||
"sessionID": "ses_test123",
|
||||
"part": {
|
||||
"id": "prt_123",
|
||||
"callID": "call_abc",
|
||||
"tool": "read",
|
||||
"state": {
|
||||
"status": "completed",
|
||||
"input": {"filePath": str(path)},
|
||||
"output": "file contents",
|
||||
"title": "src/takopi/runners/opencode.py",
|
||||
},
|
||||
},
|
||||
},
|
||||
title="opencode",
|
||||
state=state,
|
||||
)
|
||||
|
||||
assert len(events) == 1
|
||||
action_event = events[0]
|
||||
assert isinstance(action_event, ActionEvent)
|
||||
assert action_event.action.kind == "tool"
|
||||
assert action_event.action.title == "`src/takopi/runners/opencode.py`"
|
||||
|
||||
|
||||
def test_translate_error_fixture() -> None:
|
||||
state = OpenCodeStreamState()
|
||||
events: list = []
|
||||
for event in _load_fixture("opencode_stream_error.jsonl"):
|
||||
events.extend(translate_opencode_event(event, title="opencode", state=state))
|
||||
|
||||
started = next(evt for evt in events if isinstance(evt, StartedEvent))
|
||||
completed = next(evt for evt in events if isinstance(evt, CompletedEvent))
|
||||
|
||||
assert completed.ok is False
|
||||
assert completed.error == "Rate limit exceeded"
|
||||
assert completed.resume == started.resume
|
||||
|
||||
|
||||
def test_step_finish_tool_calls_does_not_complete() -> None:
|
||||
state = OpenCodeStreamState()
|
||||
state.session_id = "ses_test123"
|
||||
state.emitted_started = True
|
||||
|
||||
events = translate_opencode_event(
|
||||
{
|
||||
"type": "step_finish",
|
||||
"sessionID": "ses_test123",
|
||||
"part": {"reason": "tool-calls", "tokens": {"input": 100, "output": 10}},
|
||||
},
|
||||
title="opencode",
|
||||
state=state,
|
||||
)
|
||||
|
||||
assert len(events) == 0
|
||||
|
||||
|
||||
def test_build_args_new_session() -> None:
|
||||
runner = OpenCodeRunner(opencode_cmd="opencode", model="claude-sonnet")
|
||||
args = runner.build_args("hello world", None, state=OpenCodeStreamState())
|
||||
|
||||
assert args == [
|
||||
"run",
|
||||
"--format",
|
||||
"json",
|
||||
"--model",
|
||||
"claude-sonnet",
|
||||
"--",
|
||||
"hello world",
|
||||
]
|
||||
|
||||
|
||||
def test_build_args_with_resume() -> None:
|
||||
runner = OpenCodeRunner(opencode_cmd="opencode")
|
||||
token = ResumeToken(engine=ENGINE, value="ses_abc123")
|
||||
args = runner.build_args("continue", token, state=OpenCodeStreamState())
|
||||
|
||||
assert args == [
|
||||
"run",
|
||||
"--format",
|
||||
"json",
|
||||
"--session",
|
||||
"ses_abc123",
|
||||
"--",
|
||||
"continue",
|
||||
]
|
||||
|
||||
|
||||
def test_stdin_payload_returns_none() -> None:
|
||||
runner = OpenCodeRunner(opencode_cmd="opencode")
|
||||
payload = runner.stdin_payload("prompt", None, state=OpenCodeStreamState())
|
||||
assert payload is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_run_serializes_same_session() -> None:
|
||||
runner = OpenCodeRunner(opencode_cmd="opencode")
|
||||
gate = anyio.Event()
|
||||
in_flight = 0
|
||||
max_in_flight = 0
|
||||
|
||||
async def run_stub(*_args, **_kwargs):
|
||||
nonlocal in_flight, max_in_flight
|
||||
in_flight += 1
|
||||
max_in_flight = max(max_in_flight, in_flight)
|
||||
try:
|
||||
await gate.wait()
|
||||
yield CompletedEvent(
|
||||
engine=ENGINE,
|
||||
resume=ResumeToken(engine=ENGINE, value="ses_test"),
|
||||
ok=True,
|
||||
answer="ok",
|
||||
)
|
||||
finally:
|
||||
in_flight -= 1
|
||||
|
||||
runner.run_impl = run_stub # type: ignore[assignment]
|
||||
|
||||
async def drain(prompt: str, resume: ResumeToken | None) -> None:
|
||||
async for _event in runner.run(prompt, resume):
|
||||
pass
|
||||
|
||||
token = ResumeToken(engine=ENGINE, value="ses_test")
|
||||
async with anyio.create_task_group() as tg:
|
||||
tg.start_soon(drain, "a", token)
|
||||
tg.start_soon(drain, "b", token)
|
||||
await anyio.sleep(0)
|
||||
gate.set()
|
||||
assert max_in_flight == 1
|
||||
Reference in New Issue
Block a user