feat: msgspec schemas for jsonl decoding (#37)

This commit is contained in:
banteg
2026-01-04 03:41:07 +04:00
committed by GitHub
parent 30fe5cef43
commit a0c16c325e
33 changed files with 2235 additions and 1148 deletions
+82 -13
View File
@@ -6,7 +6,8 @@ A *runner* is the adapter between an engine-specific CLI (Codex, Claude Code,
**normalized event model** (`StartedEvent`, `ActionEvent`, `CompletedEvent`).
Takopi is designed so that adding a runner usually means **adding one new module** under
`src/takopi/runners/`—no changes to the bridge, renderer, or CLI.
`src/takopi/runners/` plus a small **msgspec schema** module under `src/takopi/schemas/`
no changes to the bridge, renderer, or CLI.
The walkthrough below uses an **imaginary engine** named **Pi** (`pi`) and intentionally mirrors
the patterns used in `runners/claude.py`.
@@ -97,16 +98,20 @@ Why this shape?
---
### Step 2 — Create `src/takopi/runners/pi.py`
### Step 2 — Create `src/takopi/schemas/pi.py` + `src/takopi/runners/pi.py`
Create a new module next to the existing runners:
Create a new schema module and a runner module:
```
src/takopi/schemas/
codex.py
pi.py # ← new
src/takopi/runners/
codex.py
claude.py
mock.py
pi.py # ← new
pi.py # ← new
```
Takopi discovers engines by importing modules in `takopi.runners` and looking for a
@@ -121,9 +126,9 @@ Most CLIs we integrate are JSONL-streaming processes.
Takopi provides `JsonlSubprocessRunner`, which:
- spawns the CLI
- drains stderr into a bounded tail
- reads stdout line-by-line as JSONL
- calls your `translate(...)` method to convert each JSON object into Takopi events
- drains stderr and logs it
- reads stdout line-by-line as JSONL bytes
- calls your `decode_jsonl(...)` and then `translate(...)` to convert each event into Takopi events
- guarantees “exactly one CompletedEvent” behavior
- provides safe fallbacks for rc != 0 or stream ending without a completion event
@@ -147,6 +152,55 @@ class PiStreamState:
note_seq: int = 0
```
#### Define a msgspec schema (recommended path)
Codex now decodes JSONL with **msgspec**, and new runners should follow that pattern.
Create a small schema module under `src/takopi/schemas/` and expose a `decode_event(...)`
function. Only include the event shapes your CLI actually emits.
Minimal example:
```py
from __future__ import annotations
from typing import Any, Literal, TypeAlias
import msgspec
class SessionStart(msgspec.Struct, tag="session.start", kw_only=True):
session_id: str
model: str | None = None
class ToolUse(msgspec.Struct, tag="tool.use", kw_only=True):
id: str
name: str
input: dict[str, Any] | None = None
class ToolResult(msgspec.Struct, tag="tool.result", kw_only=True):
tool_use_id: str
content: Any
is_error: bool | None = None
class Final(msgspec.Struct, tag="final", kw_only=True):
session_id: str
ok: bool
answer: str | None = None
error: str | None = None
PiEvent: TypeAlias = SessionStart | ToolUse | ToolResult | Final
_DECODER = msgspec.json.Decoder(PiEvent)
def decode_event(data: bytes | str) -> PiEvent:
return _DECODER.decode(data)
```
#### Decide what Pi emits
For this guide, assume Pi outputs events like:
@@ -323,7 +377,7 @@ import logging
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from typing import Any, cast
from ..backends import EngineBackend, EngineConfig
from ..model import (
@@ -333,13 +387,14 @@ from ..model import (
StartedEvent,
TakopiEvent,
)
import msgspec
from ..runner import JsonlSubprocessRunner, ResumeTokenMixin, Runner
from ..schemas import pi as pi_schema
logger = logging.getLogger(__name__)
ENGINE: EngineId = EngineId("pi")
STDERR_TAIL_LINES = 200
_RESUME_RE = re.compile(
r"(?im)^\s*`?pi\s+--resume\s+(?P<token>[^`\s]+)`?\s*$"
)
@@ -354,7 +409,6 @@ class PiRunner(ResumeTokenMixin, JsonlSubprocessRunner):
model: str | None = None
allowed_tools: list[str] | None = None
session_title: str = "pi"
stderr_tail_lines = STDERR_TAIL_LINES
logger = logger
def format_resume(self, token: ResumeToken) -> str:
@@ -398,6 +452,17 @@ class PiRunner(ResumeTokenMixin, JsonlSubprocessRunner):
_ = prompt, resume
return PiStreamState()
def decode_jsonl(
self,
*,
raw: bytes,
line: bytes,
state: PiStreamState,
) -> dict[str, Any] | None:
_ = raw, state
event = pi_schema.decode_event(line)
return cast(dict[str, Any], msgspec.to_builtins(event))
def translate(
self,
data: dict[str, Any],
@@ -423,7 +488,8 @@ Depending on how robust you want the integration, consider adding:
- `env(...)`: to strip or inject environment variables (Claude strips `ANTHROPIC_API_KEY`
unless configured to use API billing).
- `invalid_json_events(...)`: emit a helpful warning `ActionEvent` on malformed JSONL.
- `process_error_events(...)`: customize rc != 0 behavior (include stderr tail in detail).
- `decode_error_events(...)`: log + drop `msgspec.DecodeError` if the engine emits garbage.
- `process_error_events(...)`: customize rc != 0 behavior.
- `stream_end_events(...)`: handle “process exited cleanly but never emitted a final event”.
Claude uses these to produce better failures instead of silent hangs.
@@ -503,6 +569,10 @@ Then assert:
- the last event is a `CompletedEvent`
- completed.resume matches started.resume
If you use msgspec, also add a tiny schema sanity test (pattern from
`tests/test_codex_schema.py`) that decodes your fixture with
`takopi.schemas.<engine>.decode_event`.
#### 3) Lock/serialization tests (optional, but great)
Claude has async tests proving that:
@@ -554,4 +624,3 @@ Before you call the runner “done”:
- [ ] rc != 0 produces a failure `CompletedEvent` (via `process_error_events`).
- [ ] “no final event” produces a failure `CompletedEvent` (via `stream_end_events`).
- [ ] Tests cover resume parsing + at least one translation fixture.
+1
View File
@@ -10,6 +10,7 @@ dependencies = [
"anyio>=4.12.0",
"httpx>=0.28.1",
"markdown-it-py",
"msgspec>=0.20.0",
"questionary>=2.1.1",
"rich>=14.2.0",
"sulguk>=0.11.1",
+170
View File
@@ -0,0 +1,170 @@
"""Event factory helpers for runner implementations."""
from __future__ import annotations
from typing import Any
from .model import (
Action,
ActionEvent,
ActionKind,
ActionLevel,
ActionPhase,
CompletedEvent,
EngineId,
ResumeToken,
StartedEvent,
)
class EventFactory:
__slots__ = ("engine", "_resume")
def __init__(self, engine: EngineId) -> None:
self.engine = engine
self._resume: ResumeToken | None = None
@property
def resume(self) -> ResumeToken | None:
return self._resume
def started(
self,
token: ResumeToken,
*,
title: str | None = None,
meta: dict[str, Any] | None = None,
) -> StartedEvent:
if token.engine != self.engine:
raise RuntimeError(f"resume token is for engine {token.engine!r}")
if self._resume is not None and self._resume != token:
raise RuntimeError(
f"resume token mismatch: {self._resume.value} vs {token.value}"
)
self._resume = token
return StartedEvent(engine=self.engine, resume=token, title=title, meta=meta)
def action(
self,
*,
phase: ActionPhase,
action_id: str,
kind: ActionKind,
title: str,
detail: dict[str, Any] | None = None,
ok: bool | None = None,
message: str | None = None,
level: ActionLevel | None = None,
) -> ActionEvent:
action = Action(
id=action_id,
kind=kind,
title=title,
detail=detail or {},
)
return ActionEvent(
engine=self.engine,
action=action,
phase=phase,
ok=ok,
message=message,
level=level,
)
def action_started(
self,
*,
action_id: str,
kind: ActionKind,
title: str,
detail: dict[str, Any] | None = None,
) -> ActionEvent:
return self.action(
phase="started",
action_id=action_id,
kind=kind,
title=title,
detail=detail,
)
def action_updated(
self,
*,
action_id: str,
kind: ActionKind,
title: str,
detail: dict[str, Any] | None = None,
) -> ActionEvent:
return self.action(
phase="updated",
action_id=action_id,
kind=kind,
title=title,
detail=detail,
)
def action_completed(
self,
*,
action_id: str,
kind: ActionKind,
title: str,
ok: bool,
detail: dict[str, Any] | None = None,
message: str | None = None,
level: ActionLevel | None = None,
) -> ActionEvent:
return self.action(
phase="completed",
action_id=action_id,
kind=kind,
title=title,
detail=detail,
ok=ok,
message=message,
level=level,
)
def completed(
self,
*,
ok: bool,
answer: str,
resume: ResumeToken | None = None,
error: str | None = None,
usage: dict[str, Any] | None = None,
) -> CompletedEvent:
resolved_resume = resume if resume is not None else self._resume
return CompletedEvent(
engine=self.engine,
ok=ok,
answer=answer,
resume=resolved_resume,
error=error,
usage=usage,
)
def completed_ok(
self,
*,
answer: str,
resume: ResumeToken | None = None,
usage: dict[str, Any] | None = None,
) -> CompletedEvent:
return self.completed(ok=True, answer=answer, resume=resume, usage=usage)
def completed_error(
self,
*,
error: str,
answer: str = "",
resume: ResumeToken | None = None,
usage: dict[str, Any] | None = None,
) -> CompletedEvent:
return self.completed(
ok=False,
answer=answer,
resume=resume,
error=error,
usage=usage,
)
+20 -1
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import errno
import logging
import re
import sys
@@ -23,6 +24,24 @@ class RedactTokenFilter(logging.Filter):
return True
class SafeStreamHandler(logging.StreamHandler):
def handleError(self, record: logging.LogRecord) -> None:
exc = sys.exc_info()[1]
if isinstance(exc, BrokenPipeError):
try:
self.stream.close()
except Exception:
pass
return
if isinstance(exc, OSError) and exc.errno == errno.EPIPE:
try:
self.stream.close()
except Exception:
pass
return
super().handleError(record)
def setup_logging(*, debug: bool = False) -> None:
root_logger = logging.getLogger()
root_logger.setLevel(logging.DEBUG if debug else logging.INFO)
@@ -35,7 +54,7 @@ def setup_logging(*, debug: bool = False) -> None:
fmt = logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")
redactor = RedactTokenFilter()
console = logging.StreamHandler(sys.stdout)
console = SafeStreamHandler(sys.stdout)
console.setLevel(logging.DEBUG if debug else logging.INFO)
console.setFormatter(fmt)
console.addFilter(redactor)
+1
View File
@@ -12,6 +12,7 @@ ActionKind: TypeAlias = Literal[
"tool",
"file_change",
"web_search",
"subagent",
"note",
"turn",
"warning",
+3
View File
@@ -162,6 +162,9 @@ def format_action_title(action: Action, *, command_width: int | None) -> str:
if kind == "web_search":
title = shorten(title, command_width)
return f"searched: {title}"
if kind == "subagent":
title = shorten(title, command_width)
return f"subagent: {title}"
if kind == "file_change":
return format_file_change_title(action, command_width=command_width)
if kind in {"note", "warning"}:
+88 -25
View File
@@ -2,13 +2,13 @@
from __future__ import annotations
import json
import logging
import re
import subprocess
from collections import deque
from collections.abc import AsyncIterator, Callable
from dataclasses import dataclass
from typing import Any, Protocol
from typing import Any, Protocol, cast
from weakref import WeakValueDictionary
import anyio
@@ -22,7 +22,7 @@ from .model import (
StartedEvent,
TakopiEvent,
)
from .utils.streams import drain_stderr, iter_jsonl
from .utils.streams import drain_stderr, iter_bytes_lines
from .utils.subprocess import manage_subprocess
@@ -131,8 +131,6 @@ class JsonlRunState:
class JsonlSubprocessRunner(BaseRunner):
stderr_tail_lines: int = 200
def get_logger(self) -> logging.Logger:
return getattr(self, "logger", logging.getLogger(__name__))
@@ -222,19 +220,66 @@ class JsonlSubprocessRunner(BaseRunner):
message = f"invalid JSON from {self.tag()}; ignoring line"
return [self.note_event(message, state=state, detail={"line": line})]
def decode_jsonl(self, *, line: bytes) -> Any | None:
text = line.decode("utf-8", errors="replace")
try:
return cast(dict[str, Any], json.loads(text))
except json.JSONDecodeError:
return None
async def iter_json_lines(
self,
stream: Any,
*,
logger: logging.Logger,
tag: str,
) -> AsyncIterator[bytes]:
async for raw_line in iter_bytes_lines(stream):
raw = raw_line.rstrip(b"\n")
text = raw.decode("utf-8", errors="replace")
logger.debug("[%s][jsonl] %s", tag, text)
yield raw
def decode_error_events(
self,
*,
raw: str,
line: str,
error: Exception,
state: Any,
) -> list[TakopiEvent]:
message = f"invalid event from {self.tag()}; ignoring line"
detail = {"line": line, "error": str(error)}
return [self.note_event(message, state=state, detail=detail)]
def translate_error_events(
self,
*,
data: Any,
error: Exception,
state: Any,
) -> list[TakopiEvent]:
message = f"{self.tag()} translation error; ignoring event"
detail: dict[str, Any] = {"error": str(error)}
if isinstance(data, dict):
detail["type"] = data.get("type")
item = data.get("item")
if isinstance(item, dict):
detail["item_type"] = item.get("type") or item.get("item_type")
return [self.note_event(message, state=state, detail=detail)]
def process_error_events(
self,
rc: int,
*,
resume: ResumeToken | None,
found_session: ResumeToken | None,
stderr_tail: str,
state: Any,
) -> list[TakopiEvent]:
message = f"{self.tag()} failed (rc={rc})."
resume_for_completed = found_session or resume
return [
self.note_event(message, state=state, detail={"stderr_tail": stderr_tail}),
self.note_event(message, state=state),
CompletedEvent(
engine=self.engine,
ok=False,
@@ -249,7 +294,6 @@ class JsonlSubprocessRunner(BaseRunner):
*,
resume: ResumeToken | None,
found_session: ResumeToken | None,
stderr_tail: str,
state: Any,
) -> list[TakopiEvent]:
message = f"{self.tag()} finished without a result event"
@@ -266,7 +310,7 @@ class JsonlSubprocessRunner(BaseRunner):
def translate(
self,
data: dict[str, Any],
data: Any,
*,
state: Any,
resume: ResumeToken | None,
@@ -334,7 +378,6 @@ class JsonlSubprocessRunner(BaseRunner):
elif proc.stdin is not None:
await proc.stdin.aclose()
stderr_chunks: deque[str] = deque(maxlen=self.stderr_tail_lines)
rc: int | None = None
expected_session: ResumeToken | None = resume
found_session: ResumeToken | None = None
@@ -344,26 +387,49 @@ class JsonlSubprocessRunner(BaseRunner):
tg.start_soon(
drain_stderr,
proc.stderr,
stderr_chunks,
logger,
tag,
)
async for json_line in iter_jsonl(proc.stdout, logger=logger, tag=tag):
async for raw_line in self.iter_json_lines(
proc.stdout, logger=logger, tag=tag
):
if did_emit_completed:
continue
if json_line.data is None:
events = self.invalid_json_events(
raw=json_line.raw,
line=json_line.line,
line = raw_line.strip()
if not line:
continue
raw_text = raw_line.decode("utf-8", errors="replace")
line_text = line.decode("utf-8", errors="replace")
try:
decoded = self.decode_jsonl(line=line)
except Exception as exc:
events = self.decode_error_events(
raw=raw_text,
line=line_text,
error=exc,
state=state,
)
else:
events = self.translate(
json_line.data,
state=state,
resume=resume,
found_session=found_session,
)
if decoded is None:
events = self.invalid_json_events(
raw=raw_text,
line=line_text,
state=state,
)
else:
try:
events = self.translate(
decoded,
state=state,
resume=resume,
found_session=found_session,
)
except Exception as exc:
events = self.translate_error_events(
data=decoded,
error=exc,
state=state,
)
for evt in events:
if isinstance(evt, StartedEvent):
@@ -385,13 +451,11 @@ class JsonlSubprocessRunner(BaseRunner):
logger.debug("[%s] process exit pid=%s rc=%s", tag, proc.pid, rc)
if did_emit_completed:
return
stderr_tail = "".join(stderr_chunks)
if rc is not None and rc != 0:
events = self.process_error_events(
rc,
resume=resume,
found_session=found_session,
stderr_tail=stderr_tail,
state=state,
)
for evt in events:
@@ -401,7 +465,6 @@ class JsonlSubprocessRunner(BaseRunner):
events = self.stream_end_events(
resume=resume,
found_session=found_session,
stderr_tail=stderr_tail,
state=state,
)
for evt in events:
+158 -189
View File
@@ -5,26 +5,20 @@ import os
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Literal
from typing import Any
import msgspec
from ..backends import EngineBackend, EngineConfig
from ..model import (
Action,
ActionEvent,
ActionKind,
CompletedEvent,
EngineId,
ResumeToken,
StartedEvent,
TakopiEvent,
)
from ..events import EventFactory
from ..model import Action, ActionKind, EngineId, ResumeToken, TakopiEvent
from ..runner import JsonlSubprocessRunner, ResumeTokenMixin, Runner
from ..schemas import claude as claude_schema
from ..utils.paths import relativize_command, relativize_path
logger = logging.getLogger(__name__)
ENGINE: EngineId = EngineId("claude")
STDERR_TAIL_LINES = 200
DEFAULT_ALLOWED_TOOLS = ["Bash", "Read", "Edit", "Write"]
_RESUME_RE = re.compile(
@@ -34,45 +28,31 @@ _RESUME_RE = re.compile(
@dataclass(slots=True)
class ClaudeStreamState:
factory: EventFactory = field(default_factory=lambda: EventFactory(ENGINE))
pending_actions: dict[str, Action] = field(default_factory=dict)
last_assistant_text: str | None = None
note_seq: int = 0
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 _normalize_tool_result(content: Any) -> str:
if isinstance(content, list):
parts: list[str] = []
for item in content:
if isinstance(item, dict):
if item.get("type") == "text" and isinstance(item.get("text"), str):
parts.append(item["text"])
elif isinstance(item.get("text"), str):
parts.append(item["text"])
elif isinstance(item, str):
parts.append(item)
return "\n".join(part for part in parts if part)
if content is None:
return ""
if isinstance(content, str):
return content
if isinstance(content, list):
parts: list[str] = []
for item in content:
if isinstance(item, dict):
text = item.get("text")
if isinstance(text, str) and text:
parts.append(text)
elif isinstance(item, str):
parts.append(item)
return "\n".join(part for part in parts if part)
if isinstance(content, dict):
text = content.get("text")
if isinstance(text, str):
return text
return str(content)
@@ -134,19 +114,18 @@ def _tool_kind_and_title(
return "note", "ask user"
if name in {"Task", "Agent"}:
desc = tool_input.get("description") or tool_input.get("prompt")
return "tool", str(desc or name)
return "subagent", str(desc or name)
return "tool", name
def _tool_action(
content: dict[str, Any],
content: claude_schema.StreamToolUseBlock,
*,
message_id: str | None,
parent_tool_use_id: str | None,
) -> Action:
tool_id = content["id"]
tool_name = str(content.get("name") or "tool")
tool_input = content["input"]
tool_id = content.id
tool_name = str(content.name or "tool")
tool_input = content.input
kind, title = _tool_kind_and_title(tool_name, tool_input)
@@ -154,8 +133,6 @@ def _tool_action(
"name": tool_name,
"input": tool_input,
}
if message_id:
detail["message_id"] = message_id
if parent_tool_use_id:
detail["parent_tool_use_id"] = parent_tool_use_id
@@ -168,59 +145,46 @@ def _tool_action(
def _tool_result_event(
content: dict[str, Any],
content: claude_schema.StreamToolResultBlock,
*,
action: Action,
message_id: str | None,
) -> ActionEvent:
is_error = content.get("is_error") is True
raw_result = content.get("content")
factory: EventFactory,
) -> TakopiEvent:
is_error = content.is_error is True
raw_result = content.content
normalized = _normalize_tool_result(raw_result)
preview = normalized
detail = dict(action.detail)
detail.update(
{
"tool_use_id": content.get("tool_use_id"),
"tool_use_id": content.tool_use_id,
"result_preview": preview,
"result_len": len(normalized),
"is_error": is_error,
}
)
if message_id:
detail["message_id"] = message_id
return _action_event(
phase="completed",
action=Action(
id=action.id,
kind=action.kind,
title=action.title,
detail=detail,
),
return factory.action_completed(
action_id=action.id,
kind=action.kind,
title=action.title,
ok=not is_error,
detail=detail,
)
def _extract_error(event: dict[str, Any]) -> str | None:
error = event.get("error")
if isinstance(error, str) and error:
return error
errors = event.get("errors")
if isinstance(errors, list):
for item in errors:
if isinstance(item, dict):
message = item.get("message") or item.get("error")
if isinstance(message, str) and message:
return message
elif isinstance(item, str) and item:
return item
if event.get("is_error"):
def _extract_error(event: claude_schema.StreamResultMessage) -> str | None:
if event.is_error:
if isinstance(event.result, str) and event.result:
return event.result
subtype = event.subtype
if subtype:
return f"claude run failed ({subtype})"
return "claude run failed"
return None
def _usage_payload(event: dict[str, Any]) -> dict[str, Any]:
def _usage_payload(event: claude_schema.StreamResultMessage) -> dict[str, Any]:
usage: dict[str, Any] = {}
for key in (
"total_cost_usd",
@@ -228,28 +192,28 @@ def _usage_payload(event: dict[str, Any]) -> dict[str, Any]:
"duration_api_ms",
"num_turns",
):
value = event.get(key)
if value is not None:
usage[key] = value
for key in ("usage", "modelUsage"):
value = event.get(key)
value = getattr(event, key, None)
if value is not None:
usage[key] = value
if event.usage is not None:
usage["usage"] = event.usage
return usage
def translate_claude_event(
event: dict[str, Any],
event: claude_schema.StreamJsonMessage,
*,
title: str,
state: ClaudeStreamState,
factory: EventFactory,
) -> list[TakopiEvent]:
etype = event["type"]
match etype:
case "system" if event.get("subtype") == "init":
session_id = event["session_id"]
model = event.get("model")
event_title = str(model) if model else title
match event:
case claude_schema.StreamSystemMessage(subtype=subtype):
if subtype != "init":
return []
session_id = event.session_id
if not session_id:
return []
meta: dict[str, Any] = {}
for key in (
"cwd",
@@ -257,52 +221,70 @@ def translate_claude_event(
"permissionMode",
"output_style",
"apiKeySource",
"mcp_servers",
):
if key in event:
meta[key] = event.get(key)
if "mcp_servers" in event:
meta["mcp_servers"] = event.get("mcp_servers")
return [
StartedEvent(
engine=ENGINE,
resume=ResumeToken(engine=ENGINE, value=str(session_id)),
title=event_title,
meta=meta or None,
)
]
case "assistant":
message = event["message"]
message_id = message.get("id")
parent_tool_use_id = event.get("parent_tool_use_id")
content_blocks = message["content"]
value = getattr(event, key, None)
if value is not None:
meta[key] = value
model = event.model
token = ResumeToken(engine=ENGINE, value=session_id)
event_title = str(model) if isinstance(model, str) and model else title
return [factory.started(token, title=event_title, meta=meta or None)]
case claude_schema.StreamAssistantMessage(
message=message, parent_tool_use_id=parent_tool_use_id
):
out: list[TakopiEvent] = []
for content in content_blocks:
match content["type"]:
case "tool_use":
for content in message.content:
match content:
case claude_schema.StreamToolUseBlock():
action = _tool_action(
content,
message_id=message_id,
parent_tool_use_id=parent_tool_use_id,
)
state.pending_actions[action.id] = action
out.append(_action_event(phase="started", action=action))
case "text":
text = content["text"]
out.append(
factory.action_started(
action_id=action.id,
kind=action.kind,
title=action.title,
detail=action.detail,
)
)
case claude_schema.StreamThinkingBlock(
thinking=thinking, signature=signature
):
if not thinking:
continue
state.note_seq += 1
action_id = f"claude.thinking.{state.note_seq}"
detail: dict[str, Any] = {}
if parent_tool_use_id:
detail["parent_tool_use_id"] = parent_tool_use_id
if signature:
detail["signature"] = signature
out.append(
factory.action_completed(
action_id=action_id,
kind="note",
title=thinking,
ok=True,
detail=detail,
)
)
case claude_schema.StreamTextBlock(text=text):
if text:
state.last_assistant_text = text
case _:
continue
return out
case "user":
message = event["message"]
message_id = message.get("id")
content_blocks = message["content"]
case claude_schema.StreamUserMessage(message=message):
if not isinstance(message.content, list):
return []
out: list[TakopiEvent] = []
for content in content_blocks:
if content["type"] != "tool_result":
for content in message.content:
if not isinstance(content, claude_schema.StreamToolResultBlock):
continue
tool_use_id = content["tool_use_id"]
tool_use_id = content.tool_use_id
action = state.pending_actions.pop(tool_use_id, None)
if action is None:
action = Action(
@@ -312,56 +294,32 @@ def translate_claude_event(
detail={},
)
out.append(
_tool_result_event(content, action=action, message_id=message_id)
)
return out
case "result":
out: list[TakopiEvent] = []
for idx, denial in enumerate(event.get("permission_denials", [])):
tool_name = denial.get("tool_name")
denial_title = "permission denied"
if tool_name:
denial_title = f"permission denied: {tool_name}"
tool_use_id = denial.get("tool_use_id")
action_id = (
f"claude.permission.{tool_use_id}"
if tool_use_id
else f"claude.permission.{idx}"
)
out.append(
_action_event(
phase="completed",
action=Action(
id=action_id,
kind="warning",
title=denial_title,
detail=denial,
),
ok=False,
level="warning",
_tool_result_event(
content,
action=action,
factory=factory,
)
)
ok = not event.get("is_error", False)
result_text = event["result"]
return out
case claude_schema.StreamResultMessage():
ok = not event.is_error
result_text = event.result or ""
if ok and not result_text and state.last_assistant_text:
result_text = state.last_assistant_text
resume = ResumeToken(engine=ENGINE, value=str(event["session_id"]))
resume = ResumeToken(engine=ENGINE, value=event.session_id)
error = None if ok else _extract_error(event)
usage = _usage_payload(event)
out.append(
CompletedEvent(
engine=ENGINE,
return [
factory.completed(
ok=ok,
answer=result_text,
resume=resume,
error=error,
usage=usage or None,
)
)
return out
]
case _:
return []
@@ -377,7 +335,6 @@ class ClaudeRunner(ResumeTokenMixin, JsonlSubprocessRunner):
dangerously_skip_permissions: bool = False
use_api_billing: bool = False
session_title: str = "claude"
stderr_tail_lines = STDERR_TAIL_LINES
logger = logger
def format_resume(self, token: ResumeToken) -> str:
@@ -449,6 +406,34 @@ class ClaudeRunner(ResumeTokenMixin, JsonlSubprocessRunner):
)
logger.debug("[claude] prompt: %s", prompt)
def decode_jsonl(
self,
*,
line: bytes,
) -> claude_schema.StreamJsonMessage:
return claude_schema.decode_stream_json_line(line)
def decode_error_events(
self,
*,
raw: str,
line: str,
error: Exception,
state: ClaudeStreamState,
) -> list[TakopiEvent]:
_ = raw, line, state
if isinstance(error, msgspec.DecodeError):
self.get_logger().warning(
"[%s] invalid msgspec event: %s", self.tag(), error
)
return []
return super().decode_error_events(
raw=raw,
line=line,
error=error,
state=state,
)
def invalid_json_events(
self,
*,
@@ -456,13 +441,12 @@ class ClaudeRunner(ResumeTokenMixin, JsonlSubprocessRunner):
line: str,
state: ClaudeStreamState,
) -> list[TakopiEvent]:
_ = line
message = "invalid JSON from claude; ignoring line"
return [self.note_event(message, state=state, detail={"line": raw})]
_ = raw, line, state
return []
def translate(
self,
data: dict[str, Any],
data: claude_schema.StreamJsonMessage,
*,
state: ClaudeStreamState,
resume: ResumeToken | None,
@@ -473,6 +457,7 @@ class ClaudeRunner(ResumeTokenMixin, JsonlSubprocessRunner):
data,
title=self.session_title,
state=state,
factory=state.factory,
)
def process_error_events(
@@ -481,24 +466,15 @@ class ClaudeRunner(ResumeTokenMixin, JsonlSubprocessRunner):
*,
resume: ResumeToken | None,
found_session: ResumeToken | None,
stderr_tail: str,
state: ClaudeStreamState,
) -> list[TakopiEvent]:
message = f"claude 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="",
resume=resume_for_completed,
self.note_event(message, state=state, ok=False),
state.factory.completed_error(
error=message,
resume=resume_for_completed,
),
]
@@ -507,31 +483,24 @@ class ClaudeRunner(ResumeTokenMixin, JsonlSubprocessRunner):
*,
resume: ResumeToken | None,
found_session: ResumeToken | None,
stderr_tail: str,
state: ClaudeStreamState,
) -> list[TakopiEvent]:
_ = stderr_tail
if not found_session:
message = "claude finished but no session_id was captured"
resume_for_completed = resume
return [
CompletedEvent(
engine=ENGINE,
ok=False,
answer="",
resume=resume_for_completed,
state.factory.completed_error(
error=message,
resume=resume_for_completed,
)
]
message = "claude finished without a result event"
return [
CompletedEvent(
engine=ENGINE,
ok=False,
state.factory.completed_error(
error=message,
answer=state.last_assistant_text or "",
resume=found_session,
error=message,
)
]
+298 -384
View File
@@ -4,66 +4,29 @@ import logging
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any, cast
from typing import Any
import msgspec
from ..backends import EngineBackend, EngineConfig
from ..config import ConfigError
from ..model import (
Action,
ActionEvent,
ActionKind,
ActionLevel,
ActionPhase,
CompletedEvent,
EngineId,
ResumeToken,
StartedEvent,
TakopiEvent,
)
from ..events import EventFactory
from ..model import ActionPhase, EngineId, ResumeToken, TakopiEvent
from ..runner import JsonlSubprocessRunner, ResumeTokenMixin, Runner
from ..schemas import codex as codex_schema
from ..utils.paths import relativize_command
logger = logging.getLogger(__name__)
ENGINE: EngineId = EngineId("codex")
STDERR_TAIL_LINES = 200
_ACTION_KIND_MAP: dict[str, ActionKind] = {
"command_execution": "command",
"mcp_tool_call": "tool",
"tool_call": "tool",
"web_search": "web_search",
"file_change": "file_change",
"reasoning": "note",
"todo_list": "note",
}
_RESUME_RE = re.compile(r"(?im)^\s*`?codex\s+resume\s+(?P<token>[^`\s]+)`?\s*$")
_ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
_TRUSTED_DIR_RE = re.compile(r"not inside a trusted directory", re.IGNORECASE)
_RECONNECTING_RE = re.compile(
r"^Reconnecting\.{3}\s*(?P<attempt>\d+)/(?P<max>\d+)\s*$",
re.IGNORECASE,
)
def _strip_ansi(text: str) -> str:
return _ANSI_ESCAPE_RE.sub("", text)
def _extract_stderr_reason(stderr_tail: str) -> str | None:
if not stderr_tail:
return None
cleaned = _strip_ansi(stderr_tail)
lines = [line.strip() for line in cleaned.splitlines() if line.strip()]
if not lines:
return None
for line in lines:
if _TRUSTED_DIR_RE.search(line):
return line
return lines[-1]
def _parse_reconnect_message(message: str) -> tuple[int, int] | None:
match = _RECONNECTING_RE.match(message)
if not match:
@@ -76,84 +39,54 @@ def _parse_reconnect_message(message: str) -> tuple[int, int] | None:
return (attempt, max_attempts)
def _started_event(token: ResumeToken, *, title: str) -> StartedEvent:
return StartedEvent(engine=token.engine, resume=token, title=title)
def _completed_event(
*,
resume: ResumeToken | None,
ok: bool,
answer: str,
error: str | None = None,
usage: dict[str, Any] | None = None,
) -> TakopiEvent:
return CompletedEvent(
engine=ENGINE,
ok=ok,
answer=answer,
resume=resume,
error=error,
usage=usage,
)
def _action_event(
*,
phase: ActionPhase,
action_id: str,
kind: ActionKind,
title: str,
detail: dict[str, Any] | None = None,
ok: bool | None = None,
message: str | None = None,
level: ActionLevel | None = None,
) -> TakopiEvent:
action = Action(
id=action_id,
kind=kind,
title=title,
detail=detail or {},
)
return ActionEvent(
engine=ENGINE,
action=action,
phase=phase,
ok=ok,
message=message,
level=level,
)
def _short_tool_name(item: dict[str, Any]) -> str:
name = ".".join(part for part in (item.get("server"), item.get("tool")) if part)
def _short_tool_name(server: str | None, tool: str | None) -> str:
name = ".".join(part for part in (server, tool) if part)
return name or "tool"
def _summarize_tool_result(result: Any) -> dict[str, Any] | None:
if not isinstance(result, dict):
return None
summary: dict[str, Any] = {}
content = result.get("content")
if isinstance(content, list):
summary["content_blocks"] = len(content)
elif content is not None:
summary["content_blocks"] = 1
if isinstance(result, codex_schema.McpToolCallItemResult):
summary: dict[str, Any] = {}
content = result.content
if isinstance(content, list):
summary["content_blocks"] = len(content)
elif content is not None:
summary["content_blocks"] = 1
summary["has_structured"] = result.structured_content is not None
return summary or None
structured_key: str | None = None
if "structured_content" in result:
structured_key = "structured_content"
elif "structured" in result:
structured_key = "structured"
if isinstance(result, dict):
summary = {}
content = result.get("content")
if isinstance(content, list):
summary["content_blocks"] = len(content)
elif content is not None:
summary["content_blocks"] = 1
if structured_key is not None:
summary["has_structured"] = result.get(structured_key) is not None
return summary or None
structured_key: str | None = None
if "structured_content" in result:
structured_key = "structured_content"
elif "structured" in result:
structured_key = "structured"
if structured_key is not None:
summary["has_structured"] = result.get(structured_key) is not None
return summary or None
return None
def _format_change_summary(item: dict[str, Any]) -> str:
changes = item.get("changes") or []
paths = [c.get("path") for c in changes if c.get("path")]
def _format_change_summary(changes: list[Any]) -> str:
paths: list[str] = []
for change in changes:
if isinstance(change, codex_schema.FileUpdateChange):
if change.path:
paths.append(change.path)
continue
if isinstance(change, dict):
path = change.get("path")
if isinstance(path, str) and path:
paths.append(path)
if not paths:
total = len(changes)
if total <= 0:
@@ -178,6 +111,14 @@ def _summarize_todo_list(items: Any) -> _TodoSummary:
next_text: str | None = None
for raw_item in items:
if isinstance(raw_item, codex_schema.TodoItem):
total += 1
if raw_item.completed:
done += 1
continue
if next_text is None:
next_text = raw_item.text
continue
if not isinstance(raw_item, dict):
continue
total += 1
@@ -200,217 +141,209 @@ def _todo_title(summary: _TodoSummary) -> str:
return f"todo {summary.done}/{summary.total}: done"
def _translate_item_event(etype: str, item: dict[str, Any]) -> list[TakopiEvent]:
item_type = cast(str, item.get("type") or item.get("item_type"))
if item_type == "assistant_message":
item_type = "agent_message"
if item_type == "agent_message":
return []
action_id = str(item["id"])
phase = cast(ActionPhase, etype.split(".")[-1])
if item_type == "error":
if phase != "completed":
def _translate_item_event(
phase: ActionPhase, item: codex_schema.ThreadItem, *, factory: EventFactory
) -> list[TakopiEvent]:
match item:
case codex_schema.AgentMessageItem():
return []
message = str(item["message"])
return [
_action_event(
phase="completed",
action_id=action_id,
kind="warning",
title=message,
detail={"message": message},
ok=False,
message=message,
level="warning",
)
]
kind = _ACTION_KIND_MAP.get(item_type)
if kind is None:
return []
if kind == "command":
title = relativize_command(str(item["command"]))
if phase in {"started", "updated"}:
case codex_schema.ErrorItem(id=action_id, message=message):
if phase != "completed":
return []
return [
_action_event(
phase=phase,
factory.action_completed(
action_id=action_id,
kind=kind,
title=title,
)
kind="warning",
title=message,
detail={"message": message},
ok=False,
message=message,
level="warning",
),
]
if phase == "completed":
status = item["status"]
exit_code = item.get("exit_code")
ok = status == "completed"
if isinstance(exit_code, int):
ok = ok and exit_code == 0
detail = {
"exit_code": exit_code,
case codex_schema.CommandExecutionItem(
id=action_id,
command=command,
exit_code=exit_code,
status=status,
):
title = relativize_command(command)
if phase in {"started", "updated"}:
return [
factory.action(
phase=phase,
action_id=action_id,
kind="command",
title=title,
)
]
if phase == "completed":
ok = status == "completed"
if isinstance(exit_code, int):
ok = ok and exit_code == 0
detail = {"exit_code": exit_code, "status": status}
return [
factory.action_completed(
action_id=action_id,
kind="command",
title=title,
detail=detail,
ok=ok,
),
]
case codex_schema.McpToolCallItem(
id=action_id,
server=server,
tool=tool,
arguments=arguments,
status=status,
result=result,
error=error,
):
title = _short_tool_name(server, tool)
detail: dict[str, Any] = {
"server": server,
"tool": tool,
"status": status,
"arguments": arguments,
}
if phase in {"started", "updated"}:
return [
factory.action(
phase=phase,
action_id=action_id,
kind="tool",
title=title,
detail=detail,
)
]
if phase == "completed":
ok = status == "completed" and error is None
if error is not None:
detail["error_message"] = str(error.message)
result_summary = _summarize_tool_result(result)
if result_summary is not None:
detail["result_summary"] = result_summary
return [
factory.action_completed(
action_id=action_id,
kind="tool",
title=title,
detail=detail,
ok=ok,
),
]
case codex_schema.WebSearchItem(id=action_id, query=query):
detail = {"query": query}
if phase in {"started", "updated"}:
return [
factory.action(
phase=phase,
action_id=action_id,
kind="web_search",
title=query,
detail=detail,
)
]
if phase == "completed":
return [
factory.action_completed(
action_id=action_id,
kind="web_search",
title=query,
detail=detail,
ok=True,
)
]
case codex_schema.FileChangeItem(id=action_id, changes=changes, status=status):
if phase != "completed":
return []
title = _format_change_summary(changes)
detail = {
"changes": changes,
"status": status,
"error": None,
}
ok = status == "completed"
return [
_action_event(
phase="completed",
factory.action_completed(
action_id=action_id,
kind=kind,
kind="file_change",
title=title,
detail=detail,
ok=ok,
)
]
if kind == "tool":
if item_type == "tool_call":
name = item["name"]
title = str(name) if name else "tool"
detail = {
"name": name,
"status": item.get("status"),
"arguments": item.get("arguments"),
}
else:
tool_name = _short_tool_name(item)
title = tool_name
detail = {
"server": item["server"],
"tool": item["tool"],
"status": item.get("status"),
"arguments": item.get("arguments"),
}
if phase in {"started", "updated"}:
return [
_action_event(
phase=phase,
action_id=action_id,
kind=kind,
title=title,
detail=detail,
)
]
if phase == "completed":
status = item.get("status")
error = item.get("error")
ok = status == "completed" and not error
if error:
if isinstance(error, dict):
detail["error_message"] = str(error.get("message") or error)
else:
detail["error_message"] = str(error)
result_summary = _summarize_tool_result(item.get("result"))
if result_summary is not None:
detail["result_summary"] = result_summary
return [
_action_event(
phase="completed",
action_id=action_id,
kind=kind,
title=title,
detail=detail,
ok=ok,
)
]
if kind == "web_search":
title = str(item["query"])
detail = {"query": item["query"]}
if phase in {"started", "updated"}:
return [
_action_event(
phase=phase,
action_id=action_id,
kind=kind,
title=title,
detail=detail,
)
]
if phase == "completed":
return [
_action_event(
phase="completed",
action_id=action_id,
kind=kind,
title=title,
detail=detail,
ok=True,
)
]
if kind == "file_change":
if phase != "completed":
return []
title = _format_change_summary(item)
detail = {
"changes": item.get("changes", []),
"status": item.get("status"),
"error": item.get("error"),
}
ok = item.get("status") == "completed"
return [
_action_event(
phase="completed",
action_id=action_id,
kind=kind,
title=title,
detail=detail,
ok=ok,
)
]
if kind == "note":
if item_type == "todo_list":
summary = _summarize_todo_list(item["items"])
case codex_schema.TodoListItem(id=action_id, items=items):
summary = _summarize_todo_list(items)
title = _todo_title(summary)
detail = {"done": summary.done, "total": summary.total}
else:
title = str(item["text"])
detail = None
if phase in {"started", "updated"}:
return [
_action_event(
phase=phase,
action_id=action_id,
kind=kind,
title=title,
detail=detail,
)
]
if phase == "completed":
return [
_action_event(
phase="completed",
action_id=action_id,
kind=kind,
title=title,
detail=detail,
ok=True,
)
]
if phase in {"started", "updated"}:
return [
factory.action(
phase=phase,
action_id=action_id,
kind="note",
title=title,
detail=detail,
)
]
if phase == "completed":
return [
factory.action_completed(
action_id=action_id,
kind="note",
title=title,
detail=detail,
ok=True,
)
]
case codex_schema.ReasoningItem(id=action_id, text=text):
if phase in {"started", "updated"}:
return [
factory.action(
phase=phase,
action_id=action_id,
kind="note",
title=text,
)
]
if phase == "completed":
return [
factory.action_completed(
action_id=action_id,
kind="note",
title=text,
ok=True,
)
]
return []
def translate_codex_event(event: dict[str, Any], *, title: str) -> list[TakopiEvent]:
etype = event["type"]
match etype:
case "thread.started":
token = ResumeToken(engine=ENGINE, value=str(event["thread_id"]))
return [_started_event(token, title=title)]
case "item.started" | "item.updated" | "item.completed":
return _translate_item_event(etype, event["item"])
def translate_codex_event(
event: codex_schema.ThreadEvent,
*,
title: str,
factory: EventFactory,
) -> list[TakopiEvent]:
match event:
case codex_schema.ThreadStarted(thread_id=thread_id):
token = ResumeToken(engine=ENGINE, value=thread_id)
return [factory.started(token, title=title)]
case codex_schema.ItemStarted(item=item):
return _translate_item_event("started", item, factory=factory)
case codex_schema.ItemUpdated(item=item):
return _translate_item_event("updated", item, factory=factory)
case codex_schema.ItemCompleted(item=item):
return _translate_item_event("completed", item, factory=factory)
case _:
return []
@dataclass(slots=True)
class CodexRunState:
factory: EventFactory
note_seq: int = 0
final_answer: str | None = None
turn_index: int = 0
@@ -419,7 +352,6 @@ class CodexRunState:
class CodexRunner(ResumeTokenMixin, JsonlSubprocessRunner):
engine: EngineId = ENGINE
resume_re = _RESUME_RE
stderr_tail_lines = STDERR_TAIL_LINES
logger = logger
def __init__(
@@ -453,7 +385,7 @@ class CodexRunner(ResumeTokenMixin, JsonlSubprocessRunner):
def new_state(self, prompt: str, resume: ResumeToken | None) -> CodexRunState:
_ = prompt, resume
return CodexRunState()
return CodexRunState(factory=EventFactory(ENGINE))
def start_run(
self,
@@ -466,27 +398,50 @@ class CodexRunner(ResumeTokenMixin, JsonlSubprocessRunner):
logger.info("[codex] start run resume=%r", resume.value if resume else None)
logger.debug("[codex] prompt: %s", prompt)
def decode_jsonl(self, *, line: bytes) -> codex_schema.ThreadEvent:
return codex_schema.decode_event(line)
def decode_error_events(
self,
*,
raw: str,
line: str,
error: Exception,
state: CodexRunState,
) -> list[TakopiEvent]:
_ = raw, line
if isinstance(error, msgspec.DecodeError):
self.get_logger().warning(
"[%s] invalid msgspec event: %s", self.tag(), error
)
return []
return super().decode_error_events(
raw=raw,
line=line,
error=error,
state=state,
)
def pipes_error_message(self) -> str:
return "codex exec failed to open subprocess pipes"
def translate(
self,
data: dict[str, Any],
data: codex_schema.ThreadEvent,
*,
state: CodexRunState,
resume: ResumeToken | None,
found_session: ResumeToken | None,
) -> list[TakopiEvent]:
etype = data["type"]
match etype:
case "error":
message = str(data.get("message") or "")
factory = state.factory
match data:
case codex_schema.StreamError(message=message):
reconnect = _parse_reconnect_message(message)
if reconnect is not None:
attempt, max_attempts = reconnect
phase: ActionPhase = "started" if attempt <= 1 else "updated"
return [
_action_event(
factory.action(
phase=phase,
action_id="codex.reconnect",
kind="note",
@@ -495,83 +450,53 @@ class CodexRunner(ResumeTokenMixin, JsonlSubprocessRunner):
level="info",
)
]
fatal_flag = data.get("fatal")
fatal = fatal_flag is True or fatal_flag is None
if fatal:
resume_for_completed = found_session or resume
return [
_completed_event(
resume=resume_for_completed,
ok=False,
answer=state.final_answer or "",
error=message,
)
]
return [
self.note_event(
message,
state=state,
ok=False,
detail={"code": data.get("code"), "fatal": data.get("fatal")},
)
]
case "turn.failed":
error = data["error"]
message = str(error["message"])
return [self.note_event(message, state=state, ok=False)]
case codex_schema.TurnFailed(error=error):
resume_for_completed = found_session or resume
return [
_completed_event(
resume=resume_for_completed,
ok=False,
factory.completed_error(
error=error.message,
answer=state.final_answer or "",
error=message,
resume=resume_for_completed,
)
]
case "turn.rate_limited":
retry_ms = data.get("retry_after_ms")
message = "rate limited"
if isinstance(retry_ms, int):
message = f"rate limited (retry after {retry_ms}ms)"
return [self.note_event(message, state=state, ok=False)]
case "turn.started":
case codex_schema.TurnStarted():
action_id = f"turn_{state.turn_index}"
state.turn_index += 1
return [
_action_event(
phase="started",
factory.action_started(
action_id=action_id,
kind="turn",
title="turn started",
)
]
case "turn.completed":
case codex_schema.TurnCompleted(usage=usage):
resume_for_completed = found_session or resume
return [
_completed_event(
resume=resume_for_completed,
ok=True,
factory.completed_ok(
answer=state.final_answer or "",
usage=data.get("usage"),
resume=resume_for_completed,
usage=msgspec.to_builtins(usage),
)
]
case "item.completed":
item = data["item"]
item_type = cast(str, item.get("type") or item.get("item_type"))
if item_type == "assistant_message":
item_type = "agent_message"
if item_type == "agent_message":
if state.final_answer is None:
state.final_answer = item["text"]
else:
logger.debug(
"[codex] emitted multiple agent messages; using the last one"
)
state.final_answer = item["text"]
case codex_schema.ItemCompleted(
item=codex_schema.AgentMessageItem(text=text)
):
if state.final_answer is None:
state.final_answer = text
else:
logger.debug(
"[codex] emitted multiple agent messages; using the last one"
)
state.final_answer = text
case _:
pass
return translate_codex_event(data, title=self.session_title)
return translate_codex_event(
data,
title=self.session_title,
factory=factory,
)
def process_error_events(
self,
@@ -579,27 +504,20 @@ class CodexRunner(ResumeTokenMixin, JsonlSubprocessRunner):
*,
resume: ResumeToken | None,
found_session: ResumeToken | None,
stderr_tail: str,
state: CodexRunState,
) -> list[TakopiEvent]:
reason = _extract_stderr_reason(stderr_tail)
if reason:
message = f"codex exec failed (rc={rc}).\n\n{reason}"
else:
message = f"codex exec failed (rc={rc})."
message = f"codex exec failed (rc={rc})."
resume_for_completed = found_session or resume
return [
self.note_event(
message,
state=state,
ok=False,
detail={"stderr_tail": stderr_tail},
),
_completed_event(
resume=resume_for_completed,
ok=False,
answer=state.final_answer or "",
state.factory.completed_error(
error=message,
answer=state.final_answer or "",
resume=resume_for_completed,
),
]
@@ -608,27 +526,23 @@ class CodexRunner(ResumeTokenMixin, JsonlSubprocessRunner):
*,
resume: ResumeToken | None,
found_session: ResumeToken | None,
stderr_tail: str,
state: CodexRunState,
) -> list[TakopiEvent]:
_ = stderr_tail
if not found_session:
message = "codex exec finished but no session_id/thread_id was captured"
resume_for_completed = resume
return [
_completed_event(
resume=resume_for_completed,
ok=False,
answer=state.final_answer or "",
state.factory.completed_error(
error=message,
answer=state.final_answer or "",
resume=resume_for_completed,
)
]
logger.info("[codex] done run session=%s", found_session.value)
return [
_completed_event(
resume=found_session,
ok=True,
state.factory.completed_ok(
answer=state.final_answer or "",
resume=found_session,
)
]
+155 -172
View File
@@ -19,6 +19,8 @@ from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Literal
import msgspec
from ..backends import EngineBackend, EngineConfig
from ..config import ConfigError
from ..model import (
@@ -32,12 +34,12 @@ from ..model import (
TakopiEvent,
)
from ..runner import JsonlSubprocessRunner, ResumeTokenMixin, Runner
from ..schemas import opencode as opencode_schema
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*$"
@@ -54,8 +56,6 @@ class OpenCodeStreamState:
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(
@@ -146,9 +146,8 @@ def _normalize_tool_title(
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 {}
def _extract_tool_action(part: dict[str, Any]) -> Action | None:
"""Extract an Action from an OpenCode tool_use part."""
state = part.get("state") or {}
call_id = part.get("callID")
@@ -182,196 +181,163 @@ def _extract_tool_action(event: dict[str, Any]) -> Action | None:
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],
event: opencode_schema.OpenCodeEvent,
*,
title: str,
state: OpenCodeStreamState,
) -> list[TakopiEvent]:
"""Translate an OpenCode JSON event into Takopi events."""
etype = event.get("type")
session_id = event.get("sessionID")
session_id = event.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:
match event:
case opencode_schema.StepStart():
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 status == "completed":
output = tool_state.get("output")
metadata = tool_state.get("metadata") or {}
exit_code = metadata.get("exit")
case opencode_schema.ToolUse(part=part):
part = part or {}
tool_state = part.get("state") or {}
status = tool_state.get("status")
is_error = False
if isinstance(exit_code, int) and exit_code != 0:
is_error = True
action = _extract_tool_action(part)
if action is None:
return []
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
if status == "completed":
output = tool_state.get("output")
metadata = tool_state.get("metadata") or {}
exit_code = metadata.get("exit")
state.pending_actions.pop(action.id, None)
is_error = False
if isinstance(exit_code, int) and exit_code != 0:
is_error = True
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
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
cost = part.get("cost")
if isinstance(cost, (int, float)):
state.total_cost += cost
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)]
case opencode_schema.Text(part=part):
part = 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 []
case opencode_schema.StepFinish(part=part):
part = part or {}
reason = part.get("reason")
state.saw_step_finish = True
if reason == "stop":
resume = None
if state.session_id:
resume = ResumeToken(engine=ENGINE, value=state.session_id)
return [
CompletedEvent(
engine=ENGINE,
ok=True,
answer=state.last_text or "",
resume=resume,
)
]
return []
case opencode_schema.Error(error=error_value, message=message_value):
raw_message = message_value if message_value is not None else error_value
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"
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,
ok=False,
answer=state.last_text or "",
resume=resume,
usage=usage or None,
error=str(message),
)
]
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 []
case _:
return []
@dataclass
@@ -384,7 +350,6 @@ class OpenCodeRunner(ResumeTokenMixin, JsonlSubprocessRunner):
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:
@@ -452,7 +417,7 @@ class OpenCodeRunner(ResumeTokenMixin, JsonlSubprocessRunner):
def translate(
self,
data: dict[str, Any],
data: opencode_schema.OpenCodeEvent,
*,
state: OpenCodeStreamState,
resume: ResumeToken | None,
@@ -465,13 +430,36 @@ class OpenCodeRunner(ResumeTokenMixin, JsonlSubprocessRunner):
state=state,
)
def decode_jsonl(self, *, line: bytes) -> opencode_schema.OpenCodeEvent:
return opencode_schema.decode_event(line)
def decode_error_events(
self,
*,
raw: str,
line: str,
error: Exception,
state: OpenCodeStreamState,
) -> list[TakopiEvent]:
_ = raw, line, state
if isinstance(error, msgspec.DecodeError):
self.get_logger().warning(
"[%s] invalid msgspec event: %s", self.tag(), error
)
return []
return super().decode_error_events(
raw=raw,
line=line,
error=error,
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})."
@@ -481,7 +469,6 @@ class OpenCodeRunner(ResumeTokenMixin, JsonlSubprocessRunner):
message,
state=state,
ok=False,
detail={"stderr_tail": stderr_tail},
),
CompletedEvent(
engine=ENGINE,
@@ -497,10 +484,8 @@ class OpenCodeRunner(ResumeTokenMixin, JsonlSubprocessRunner):
*,
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
@@ -515,14 +500,12 @@ class OpenCodeRunner(ResumeTokenMixin, JsonlSubprocessRunner):
]
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,
)
]
+116 -93
View File
@@ -9,6 +9,8 @@ from pathlib import Path
from typing import Any
from uuid import uuid4
import msgspec
from ..backends import EngineBackend, EngineConfig
from ..config import ConfigError
from ..model import (
@@ -24,12 +26,12 @@ from ..model import (
TakopiEvent,
)
from ..runner import JsonlSubprocessRunner, ResumeTokenMixin, Runner
from ..schemas import pi as pi_schema
from ..utils.paths import relativize_command, relativize_path
logger = logging.getLogger(__name__)
ENGINE: EngineId = EngineId("pi")
STDERR_TAIL_LINES = 200
_RESUME_RE = re.compile(r"(?im)^\s*`?pi\s+--session\s+(?P<token>.+?)`?\s*$")
@@ -132,7 +134,7 @@ def _last_assistant_message(messages: Any) -> dict[str, Any] | None:
def translate_pi_event(
event: dict[str, Any],
event: pi_schema.PiEvent,
*,
title: str,
meta: dict[str, Any] | None,
@@ -150,103 +152,99 @@ def translate_pi_event(
)
state.started = True
etype = event.get("type")
match event:
case pi_schema.ToolExecutionStart(
toolCallId=tool_id, toolName=tool_name, args=args
):
if not isinstance(args, dict):
args = {}
if isinstance(tool_id, str) and tool_id:
name = str(tool_name or "tool")
kind, title_str = _tool_kind_and_title(name, args)
detail: dict[str, Any] = {"tool_name": name, "args": args}
if kind == "file_change":
path = args.get("path")
if path:
detail["changes"] = [{"path": str(path), "kind": "update"}]
action = Action(id=tool_id, kind=kind, title=title_str, detail=detail)
state.pending_actions[action.id] = action
out.append(_action_event(phase="started", action=action))
return out
if etype == "tool_execution_start":
tool_id = event.get("toolCallId")
tool_name = event.get("toolName")
args = event.get("args") or {}
if not isinstance(args, dict):
args = {}
if isinstance(tool_id, str) and tool_id:
name = str(tool_name or "tool")
kind, title_str = _tool_kind_and_title(name, args)
detail: dict[str, Any] = {"tool_name": name, "args": args}
if kind == "file_change":
path = args.get("path")
if path:
detail["changes"] = [{"path": str(path), "kind": "update"}]
action = Action(id=tool_id, kind=kind, title=title_str, detail=detail)
state.pending_actions[action.id] = action
out.append(_action_event(phase="started", action=action))
return out
case pi_schema.ToolExecutionEnd(
toolCallId=tool_id, toolName=tool_name, result=result, isError=is_error
):
if isinstance(tool_id, str) and tool_id:
action = state.pending_actions.pop(tool_id, None)
name = str(tool_name or "tool")
if action is None:
action = Action(id=tool_id, kind="tool", title=name, detail={})
detail = dict(action.detail)
detail["result"] = result
detail["is_error"] = is_error
out.append(
_action_event(
phase="completed",
action=Action(
id=action.id,
kind=action.kind,
title=action.title,
detail=detail,
),
ok=not is_error,
)
)
return out
case pi_schema.MessageEnd(message=message):
if isinstance(message, dict) and message.get("role") == "assistant":
text = _extract_text_blocks(message.get("content"))
if text:
state.last_assistant_text = text
usage = message.get("usage")
if isinstance(usage, dict):
state.last_usage = usage
error = _assistant_error(message)
if error:
state.last_assistant_error = error
return out
case pi_schema.AgentEnd(messages=messages):
assistant = _last_assistant_message(messages)
if assistant:
text = _extract_text_blocks(assistant.get("content"))
if text:
state.last_assistant_text = text
usage = assistant.get("usage")
if isinstance(usage, dict):
state.last_usage = usage
error = _assistant_error(assistant)
if error:
state.last_assistant_error = error
ok = state.last_assistant_error is None
error = state.last_assistant_error
answer = state.last_assistant_text or ""
if etype == "tool_execution_end":
tool_id = event.get("toolCallId")
tool_name = event.get("toolName")
if isinstance(tool_id, str) and tool_id:
action = state.pending_actions.pop(tool_id, None)
name = str(tool_name or "tool")
if action is None:
action = Action(id=tool_id, kind="tool", title=name, detail={})
detail = dict(action.detail)
detail["result"] = event.get("result")
detail["is_error"] = event.get("isError")
is_error = event.get("isError") is True
out.append(
_action_event(
phase="completed",
action=Action(
id=action.id,
kind=action.kind,
title=action.title,
detail=detail,
),
ok=not is_error,
CompletedEvent(
engine=ENGINE,
ok=ok,
answer=answer,
resume=state.resume,
error=error,
usage=state.last_usage,
)
)
return out
return out
if etype == "message_end":
message = event.get("message")
if isinstance(message, dict) and message.get("role") == "assistant":
text = _extract_text_blocks(message.get("content"))
if text:
state.last_assistant_text = text
usage = message.get("usage")
if isinstance(usage, dict):
state.last_usage = usage
error = _assistant_error(message)
if error:
state.last_assistant_error = error
return out
if etype == "agent_end":
assistant = _last_assistant_message(event.get("messages"))
if assistant:
text = _extract_text_blocks(assistant.get("content"))
if text:
state.last_assistant_text = text
usage = assistant.get("usage")
if isinstance(usage, dict):
state.last_usage = usage
error = _assistant_error(assistant)
if error:
state.last_assistant_error = error
ok = state.last_assistant_error is None
error = state.last_assistant_error
answer = state.last_assistant_text or ""
out.append(
CompletedEvent(
engine=ENGINE,
ok=ok,
answer=answer,
resume=state.resume,
error=error,
usage=state.last_usage,
)
)
return out
return out
case _:
return out
class PiRunner(ResumeTokenMixin, JsonlSubprocessRunner):
engine: EngineId = ENGINE
resume_re: re.Pattern[str] = _RESUME_RE
stderr_tail_lines = STDERR_TAIL_LINES
logger = logger
def __init__(
@@ -335,7 +333,7 @@ class PiRunner(ResumeTokenMixin, JsonlSubprocessRunner):
def translate(
self,
data: dict[str, Any],
data: pi_schema.PiEvent,
*,
state: PiStreamState,
resume: ResumeToken | None,
@@ -354,19 +352,46 @@ class PiRunner(ResumeTokenMixin, JsonlSubprocessRunner):
state=state,
)
def decode_jsonl(
self,
*,
line: bytes,
) -> pi_schema.PiEvent:
return pi_schema.decode_event(line)
def decode_error_events(
self,
*,
raw: str,
line: str,
error: Exception,
state: PiStreamState,
) -> list[TakopiEvent]:
_ = raw, line, state
if isinstance(error, msgspec.DecodeError):
self.get_logger().warning(
"[%s] invalid msgspec event: %s", self.tag(), error
)
return []
return super().decode_error_events(
raw=raw,
line=line,
error=error,
state=state,
)
def process_error_events(
self,
rc: int,
*,
resume: ResumeToken | None,
found_session: ResumeToken | None,
stderr_tail: str,
state: PiStreamState,
) -> list[TakopiEvent]:
message = f"pi failed (rc={rc})."
resume_for_completed = found_session or resume or state.resume
return [
self.note_event(message, state=state, detail={"stderr_tail": stderr_tail}),
self.note_event(message, state=state),
CompletedEvent(
engine=ENGINE,
ok=False,
@@ -382,10 +407,8 @@ class PiRunner(ResumeTokenMixin, JsonlSubprocessRunner):
*,
resume: ResumeToken | None,
found_session: ResumeToken | None,
stderr_tail: str,
state: PiStreamState,
) -> list[TakopiEvent]:
_ = stderr_tail
resume_for_completed = found_session or resume or state.resume
message = "pi finished without an agent_end event"
return [
+1
View File
@@ -0,0 +1 @@
"""Event schemas for runner JSONL streams."""
+238
View File
@@ -0,0 +1,238 @@
"""Msgspec models and decoder for Claude Code stream-json output."""
from __future__ import annotations
from typing import Any, Literal, TypeAlias
import msgspec
class StreamTextBlock(
msgspec.Struct, tag="text", tag_field="type", forbid_unknown_fields=False
):
text: str
class StreamThinkingBlock(
msgspec.Struct, tag="thinking", tag_field="type", forbid_unknown_fields=False
):
thinking: str
signature: str
class StreamToolUseBlock(
msgspec.Struct, tag="tool_use", tag_field="type", forbid_unknown_fields=False
):
id: str
name: str
input: dict[str, Any]
class StreamToolResultBlock(
msgspec.Struct, tag="tool_result", tag_field="type", forbid_unknown_fields=False
):
tool_use_id: str
content: str | list[dict[str, Any]] | None = None
is_error: bool | None = None
StreamContentBlock: TypeAlias = (
StreamTextBlock | StreamThinkingBlock | StreamToolUseBlock | StreamToolResultBlock
)
class StreamUserMessageBody(msgspec.Struct, forbid_unknown_fields=False):
role: Literal["user"]
content: str | list[StreamContentBlock]
class StreamAssistantMessageBody(msgspec.Struct, forbid_unknown_fields=False):
role: Literal["assistant"]
content: list[StreamContentBlock]
model: str
error: str | None = None
class StreamUserMessage(
msgspec.Struct, tag="user", tag_field="type", forbid_unknown_fields=False
):
message: StreamUserMessageBody
uuid: str | None = None
parent_tool_use_id: str | None = None
session_id: str | None = None
class StreamAssistantMessage(
msgspec.Struct, tag="assistant", tag_field="type", forbid_unknown_fields=False
):
message: StreamAssistantMessageBody
parent_tool_use_id: str | None = None
uuid: str | None = None
session_id: str | None = None
class StreamSystemMessage(
msgspec.Struct, tag="system", tag_field="type", forbid_unknown_fields=False
):
subtype: str
session_id: str | None = None
uuid: str | None = None
cwd: str | None = None
tools: list[str] | None = None
mcp_servers: list[Any] | None = None
model: str | None = None
permissionMode: str | None = None
output_style: str | None = None
apiKeySource: str | None = None
class StreamResultMessage(
msgspec.Struct, tag="result", tag_field="type", forbid_unknown_fields=False
):
subtype: str
duration_ms: int
duration_api_ms: int
is_error: bool
num_turns: int
session_id: str
total_cost_usd: float | None = None
usage: dict[str, Any] | None = None
result: str | None = None
structured_output: Any = None
class StreamEventMessage(
msgspec.Struct, tag="stream_event", tag_field="type", forbid_unknown_fields=False
):
uuid: str
session_id: str
event: dict[str, Any]
parent_tool_use_id: str | None = None
class ControlInterruptRequest(
msgspec.Struct, tag="interrupt", tag_field="subtype", forbid_unknown_fields=False
):
pass
class ControlCanUseToolRequest(
msgspec.Struct, tag="can_use_tool", tag_field="subtype", forbid_unknown_fields=False
):
tool_name: str
input: dict[str, Any]
permission_suggestions: list[Any] | None = None
blocked_path: str | None = None
class ControlInitializeRequest(
msgspec.Struct, tag="initialize", tag_field="subtype", forbid_unknown_fields=False
):
hooks: dict[str, Any] | None = None
class ControlSetPermissionModeRequest(
msgspec.Struct,
tag="set_permission_mode",
tag_field="subtype",
forbid_unknown_fields=False,
):
mode: str
class ControlHookCallbackRequest(
msgspec.Struct,
tag="hook_callback",
tag_field="subtype",
forbid_unknown_fields=False,
):
callback_id: str
input: Any
tool_use_id: str | None = None
class ControlMcpMessageRequest(
msgspec.Struct, tag="mcp_message", tag_field="subtype", forbid_unknown_fields=False
):
server_name: str
message: Any
class ControlRewindFilesRequest(
msgspec.Struct, tag="rewind_files", tag_field="subtype", forbid_unknown_fields=False
):
user_message_id: str
ControlRequest: TypeAlias = (
ControlInterruptRequest
| ControlCanUseToolRequest
| ControlInitializeRequest
| ControlSetPermissionModeRequest
| ControlHookCallbackRequest
| ControlMcpMessageRequest
| ControlRewindFilesRequest
)
class StreamControlRequest(
msgspec.Struct, tag="control_request", tag_field="type", forbid_unknown_fields=False
):
request_id: str
request: ControlRequest
class ControlSuccessResponse(
msgspec.Struct, tag="success", tag_field="subtype", forbid_unknown_fields=False
):
request_id: str
response: dict[str, Any] | None = None
class ControlErrorResponse(
msgspec.Struct, tag="error", tag_field="subtype", forbid_unknown_fields=False
):
request_id: str
error: str
ControlResponse: TypeAlias = ControlSuccessResponse | ControlErrorResponse
class StreamControlResponse(
msgspec.Struct,
tag="control_response",
tag_field="type",
forbid_unknown_fields=False,
):
response: ControlResponse
class StreamControlCancelRequest(
msgspec.Struct,
tag="control_cancel_request",
tag_field="type",
forbid_unknown_fields=False,
):
request_id: str | None = None
StreamJsonMessage: TypeAlias = (
StreamUserMessage
| StreamAssistantMessage
| StreamSystemMessage
| StreamResultMessage
| StreamEventMessage
| StreamControlRequest
| StreamControlResponse
| StreamControlCancelRequest
)
STREAM_JSON_SCHEMA = msgspec.json.schema(StreamJsonMessage)
_DECODER = msgspec.json.Decoder(StreamJsonMessage)
def decode_stream_json_line(line: str | bytes) -> StreamJsonMessage:
return _DECODER.decode(line)
+169
View File
@@ -0,0 +1,169 @@
from __future__ import annotations
# Headless JSONL schema derived from tag rust-v0.77.0 (git 112f40e91c12af0f7146d7e03f20283516a8af0b).
from typing import Any, Literal, TypeAlias
import msgspec
CommandExecutionStatus: TypeAlias = Literal[
"in_progress",
"completed",
"failed",
"declined",
]
PatchApplyStatus: TypeAlias = Literal[
"in_progress",
"completed",
"failed",
]
PatchChangeKind: TypeAlias = Literal[
"add",
"delete",
"update",
]
McpToolCallStatus: TypeAlias = Literal[
"in_progress",
"completed",
"failed",
]
class Usage(msgspec.Struct, kw_only=True):
input_tokens: int
cached_input_tokens: int
output_tokens: int
class ThreadError(msgspec.Struct, kw_only=True):
message: str
class ThreadStarted(msgspec.Struct, tag="thread.started", kw_only=True):
thread_id: str
class TurnStarted(msgspec.Struct, tag="turn.started", kw_only=True):
pass
class TurnCompleted(msgspec.Struct, tag="turn.completed", kw_only=True):
usage: Usage
class TurnFailed(msgspec.Struct, tag="turn.failed", kw_only=True):
error: ThreadError
class StreamError(msgspec.Struct, tag="error", kw_only=True):
message: str
class AgentMessageItem(msgspec.Struct, tag="agent_message", kw_only=True):
id: str
text: str
class ReasoningItem(msgspec.Struct, tag="reasoning", kw_only=True):
id: str
text: str
class CommandExecutionItem(msgspec.Struct, tag="command_execution", kw_only=True):
id: str
command: str
aggregated_output: str
exit_code: int | None
status: CommandExecutionStatus
class FileUpdateChange(msgspec.Struct, kw_only=True):
path: str
kind: PatchChangeKind
class FileChangeItem(msgspec.Struct, tag="file_change", kw_only=True):
id: str
changes: list[FileUpdateChange]
status: PatchApplyStatus
class McpToolCallItemResult(msgspec.Struct, kw_only=True):
content: list[dict[str, Any]]
structured_content: Any
class McpToolCallItemError(msgspec.Struct, kw_only=True):
message: str
class McpToolCallItem(msgspec.Struct, tag="mcp_tool_call", kw_only=True):
id: str
server: str
tool: str
arguments: Any
result: McpToolCallItemResult | None
error: McpToolCallItemError | None
status: McpToolCallStatus
class WebSearchItem(msgspec.Struct, tag="web_search", kw_only=True):
id: str
query: str
class ErrorItem(msgspec.Struct, tag="error", kw_only=True):
id: str
message: str
class TodoItem(msgspec.Struct, kw_only=True):
text: str
completed: bool
class TodoListItem(msgspec.Struct, tag="todo_list", kw_only=True):
id: str
items: list[TodoItem]
ThreadItem: TypeAlias = (
AgentMessageItem
| ReasoningItem
| CommandExecutionItem
| FileChangeItem
| McpToolCallItem
| WebSearchItem
| TodoListItem
| ErrorItem
)
class ItemStarted(msgspec.Struct, tag="item.started", kw_only=True):
item: ThreadItem
class ItemUpdated(msgspec.Struct, tag="item.updated", kw_only=True):
item: ThreadItem
class ItemCompleted(msgspec.Struct, tag="item.completed", kw_only=True):
item: ThreadItem
ThreadEvent: TypeAlias = (
ThreadStarted
| TurnStarted
| TurnCompleted
| TurnFailed
| ItemStarted
| ItemUpdated
| ItemCompleted
| StreamError
)
_DECODER = msgspec.json.Decoder(ThreadEvent)
def decode_event(data: bytes | str) -> ThreadEvent:
return _DECODER.decode(data)
+51
View File
@@ -0,0 +1,51 @@
"""Msgspec models and decoder for opencode --format json output."""
from __future__ import annotations
from typing import Any, TypeAlias
import msgspec
class _Event(msgspec.Struct, tag_field="type", forbid_unknown_fields=False):
pass
class StepStart(_Event, tag="step_start"):
timestamp: int | None = None
sessionID: str | None = None
part: dict[str, Any] | None = None
class StepFinish(_Event, tag="step_finish"):
timestamp: int | None = None
sessionID: str | None = None
part: dict[str, Any] | None = None
class ToolUse(_Event, tag="tool_use"):
timestamp: int | None = None
sessionID: str | None = None
part: dict[str, Any] | None = None
class Text(_Event, tag="text"):
timestamp: int | None = None
sessionID: str | None = None
part: dict[str, Any] | None = None
class Error(_Event, tag="error"):
timestamp: int | None = None
sessionID: str | None = None
error: Any = None
message: Any = None
OpenCodeEvent: TypeAlias = StepStart | StepFinish | ToolUse | Text | Error
_DECODER = msgspec.json.Decoder(OpenCodeEvent)
def decode_event(line: str | bytes) -> OpenCodeEvent:
return _DECODER.decode(line)
+108
View File
@@ -0,0 +1,108 @@
"""Msgspec models and decoder for pi --mode json output."""
from __future__ import annotations
from typing import Any, TypeAlias
import msgspec
class _Event(msgspec.Struct, tag_field="type", forbid_unknown_fields=False):
pass
class AgentStart(_Event, tag="agent_start"):
pass
class AgentEnd(_Event, tag="agent_end"):
messages: list[dict[str, Any]]
class MessageEnd(_Event, tag="message_end"):
message: dict[str, Any]
class MessageStart(_Event, tag="message_start"):
message: dict[str, Any] | None = None
class MessageUpdate(_Event, tag="message_update"):
message: dict[str, Any] | None = None
assistantMessageEvent: dict[str, Any] | None = None
class TurnStart(_Event, tag="turn_start"):
pass
class TurnEnd(_Event, tag="turn_end"):
message: dict[str, Any] | None = None
toolResults: list[dict[str, Any]] | None = None
class ToolExecutionStart(_Event, tag="tool_execution_start"):
toolCallId: str
toolName: str | None = None
args: dict[str, Any] = msgspec.field(default_factory=dict)
class ToolExecutionUpdate(_Event, tag="tool_execution_update"):
toolCallId: str | None = None
toolName: str | None = None
args: dict[str, Any] = msgspec.field(default_factory=dict)
partialResult: Any = None
class ToolExecutionEnd(_Event, tag="tool_execution_end"):
toolCallId: str
toolName: str | None = None
result: Any = None
isError: bool = False
class AutoCompactionStart(_Event, tag="auto_compaction_start"):
reason: str | None = None
class AutoCompactionEnd(_Event, tag="auto_compaction_end"):
result: dict[str, Any] | None = None
aborted: bool | None = None
willRetry: bool | None = None
class AutoRetryStart(_Event, tag="auto_retry_start"):
attempt: int | None = None
maxAttempts: int | None = None
delayMs: int | None = None
errorMessage: str | None = None
class AutoRetryEnd(_Event, tag="auto_retry_end"):
success: bool | None = None
attempt: int | None = None
finalError: str | None = None
PiEvent: TypeAlias = (
AgentStart
| AgentEnd
| MessageStart
| MessageUpdate
| MessageEnd
| TurnStart
| TurnEnd
| ToolExecutionStart
| ToolExecutionUpdate
| ToolExecutionEnd
| AutoCompactionStart
| AutoCompactionEnd
| AutoRetryStart
| AutoRetryEnd
)
_DECODER = msgspec.json.Decoder(PiEvent)
def decode_event(line: str | bytes) -> PiEvent:
return _DECODER.decode(line)
+10 -51
View File
@@ -1,73 +1,32 @@
from __future__ import annotations
from collections import deque
from collections.abc import AsyncIterator
from dataclasses import dataclass
import json
import logging
from typing import Any
import sys
import anyio
from anyio.abc import ByteReceiveStream
from anyio.streams.text import TextReceiveStream
from anyio.streams.buffered import BufferedByteReceiveStream
async def iter_text_lines(stream: ByteReceiveStream) -> AsyncIterator[str]:
text_stream = TextReceiveStream(stream, errors="replace")
buffer = ""
async def iter_bytes_lines(stream: ByteReceiveStream) -> AsyncIterator[bytes]:
buffered = BufferedByteReceiveStream(stream)
while True:
try:
chunk = await text_stream.receive()
except anyio.EndOfStream:
if buffer:
yield buffer
line = await buffered.receive_until(b"\n", sys.maxsize)
except anyio.IncompleteRead:
return
buffer += chunk
while True:
split_at = buffer.find("\n")
if split_at < 0:
break
line = buffer[: split_at + 1]
buffer = buffer[split_at + 1 :]
yield line
@dataclass(frozen=True, slots=True)
class JsonLine:
raw: str
line: str
data: dict[str, Any] | None
async def iter_jsonl(
stream: ByteReceiveStream,
*,
logger: logging.Logger,
tag: str,
) -> AsyncIterator[JsonLine]:
async for raw_line in iter_text_lines(stream):
raw = raw_line.rstrip("\n")
logger.debug("[%s][jsonl] %s", tag, raw)
line = raw.strip()
if not line:
continue
try:
data = json.loads(line)
except json.JSONDecodeError:
logger.debug("[%s] invalid json line: %s", tag, line)
data = None
yield JsonLine(raw=raw, line=line, data=data)
yield line
async def drain_stderr(
stream: ByteReceiveStream,
chunks: deque[str],
logger: logging.Logger,
tag: str,
) -> None:
try:
async for line in iter_text_lines(stream):
logger.debug("[%s][stderr] %s", tag, line.rstrip())
chunks.append(line)
async for line in iter_bytes_lines(stream):
text = line.decode("utf-8", errors="replace")
logger.debug("[%s][stderr] %s", tag, text)
except Exception as e:
logger.debug("[%s][stderr] drain error: %s", tag, e)
-5
View File
@@ -1,5 +0,0 @@
{"type":"system","subtype":"init","session_id":"session_02","cwd":"/Users/banteg/dev/project","model":"sonnet","permissionMode":"manual","apiKeySource":"env","tools":["Bash","Read","Write"],"mcp_servers":[{"name":"approvals","status":"connected"}]}
{"type":"assistant","session_id":"session_02","message":{"id":"msg_10","type":"message","role":"assistant","content":[{"type":"text","text":"I need permission to run this command."}],"usage":{"input_tokens":80,"output_tokens":20}}}
{"type":"assistant","session_id":"session_02","parent_tool_use_id":"toolu_parent","message":{"id":"msg_11","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_9","name":"Bash","input":{"command":"git fetch origin main"}}]}}
{"type":"user","session_id":"session_02","message":{"id":"msg_12","type":"message","role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_9","content":"permission denied"}]}}
{"type":"result","subtype":"error","session_id":"session_02","total_cost_usd":0.001,"is_error":true,"duration_ms":2000,"duration_api_ms":1800,"num_turns":1,"result":"","error":"Permission denied","permission_denials":[{"tool_name":"Bash","tool_use_id":"toolu_9","tool_input":{"command":"git fetch origin main"}}]}
-8
View File
@@ -1,8 +0,0 @@
{"type":"system","subtype":"init","session_id":"session_01","cwd":"/Users/banteg/dev/project","model":"sonnet","permissionMode":"auto","apiKeySource":"env","tools":["Bash","Read","Write","WebSearch","Task"],"mcp_servers":[{"name":"approvals","status":"connected"}]}
{"type":"assistant","session_id":"session_01","message":{"id":"msg_1","type":"message","role":"assistant","model":"claude-3-5-sonnet","content":[{"type":"text","text":"I'll inspect the repo, then add notes."}],"usage":{"input_tokens":120,"output_tokens":45}}}
{"type":"assistant","session_id":"session_01","message":{"id":"msg_2","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"ls -la"}}],"usage":{"input_tokens":10,"output_tokens":5}}}
{"type":"user","session_id":"session_01","message":{"id":"msg_3","type":"message","role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":[{"type":"text","text":"total 2\nREADME.md\nsrc\n"}]}]}}
{"type":"assistant","session_id":"session_01","message":{"id":"msg_4","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_2","name":"Write","input":{"path":"notes.md","content":"hello"}}]}}
{"type":"user","session_id":"session_01","message":{"id":"msg_5","type":"message","role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_2","content":"ok"}]}}
{"type":"assistant","session_id":"session_01","message":{"id":"msg_6","type":"message","role":"assistant","content":[{"type":"text","text":"Done. Added notes.md."}],"usage":{"input_tokens":20,"output_tokens":12}}}
{"type":"result","subtype":"success","session_id":"session_01","total_cost_usd":0.0123,"is_error":false,"duration_ms":12345,"duration_api_ms":12000,"num_turns":2,"result":"Done. Added notes.md.","usage":{"input_tokens":150,"output_tokens":70,"service_tier":"standard","server_tool_use":{"web_search_requests":0}},"modelUsage":{"sonnet":{"inputTokens":150,"outputTokens":70,"cacheReadInputTokens":0,"cacheCreationInputTokens":0,"webSearchRequests":0,"costUSD":0.0123,"contextWindow":200000}}}
+9
View File
@@ -0,0 +1,9 @@
{"type":"system","subtype":"init","uuid":"11111111-1111-1111-1111-111111111111","session_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","apiKeySource":"ANTHROPIC_API_KEY","cwd":"/home/alex/demo-project","tools":["Task","Bash","Read","Edit","Write","WebFetch","WebSearch"],"mcp_servers":[{"name":"github","status":"connected"},{"name":"sentry","status":"error"}],"model":"claude-sonnet-4-5-20250929","permissionMode":"default","slash_commands":["help","status","clear","compact","resume"],"output_style":"default","claude_code_version":"2.0.75","agents":["general-purpose","Plan","Explore"],"skills":["python","git"],"plugins":[{"name":"local-plugin-example","path":"/home/alex/.claude/plugins/cache/local-plugin-example"}]}
{"type":"user","session_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","parent_tool_use_id":null,"message":{"role":"user","content":[{"type":"text","text":"List the files in the current directory, then summarize what you see."}]}}
{"type":"assistant","uuid":"33333333-3333-3333-3333-333333333333","session_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","parent_tool_use_id":null,"message":{"id":"msg_02EXAMPLEASSIST","type":"message","role":"assistant","model":"claude-sonnet-4-5-20250929","content":[{"type":"text","text":"Sure - I-ll list the directory contents."},{"type":"tool_use","id":"toolu_01BASH_LS_EXAMPLE","name":"Bash","input":{"command":"ls","timeout":600000}},{"type":"tool_use","id":"toolu_02","name":"Write","input":{"file_path":"notes.md","content":"hello"}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":123,"output_tokens":42}}}
{"type":"user","session_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","parent_tool_use_id":null,"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01BASH_LS_EXAMPLE","content":[{"type":"text","text":"README.md\npyproject.toml\nsrc/\n"}],"is_error":false}]},"tool_use_result":{"stdout":"README.md\npyproject.toml\nsrc/\n","stderr":"","interrupted":false,"exit_code":0}}
{"type":"user","session_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","parent_tool_use_id":null,"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_02","content":"ok","is_error":false}]}}
{"type":"assistant","uuid":"44444444-4444-4444-4444-444444444444","session_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","parent_tool_use_id":null,"message":{"id":"msg_03EXAMPLEASSIST","type":"message","role":"assistant","model":"claude-sonnet-4-5-20250929","content":[{"type":"text","text":"I see README.md, pyproject.toml, and src/."}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":130,"output_tokens":76}}}
{"type":"result","subtype":"success","uuid":"77777777-7777-7777-7777-777777777777","session_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","duration_ms":2450,"duration_api_ms":2100,"is_error":false,"num_turns":2,"result":"I see README.md, pyproject.toml, and src/.","total_cost_usd":0.012345,"usage":{"input_tokens":130,"output_tokens":76,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"service_tier":"standard"},"modelUsage":{"claude-sonnet-4-5-20250929":{"input_tokens":130,"output_tokens":76,"service_tier":"standard"}},"permission_denials":[],"structured_output":null}
{"type":"system","subtype":"init","uuid":"aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb","session_id":"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb","apiKeySource":"none","cwd":"/home/alex/demo-project","tools":["Bash","Read","Write"],"mcp_servers":[],"model":"claude-sonnet-4-5-20250929","permissionMode":"default","slash_commands":["help","status"],"output_style":"default"}
{"type":"result","subtype":"error_during_execution","uuid":"99999999-9999-9999-9999-999999999999","session_id":"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb","duration_ms":1200,"duration_api_ms":800,"is_error":true,"num_turns":1,"total_cost_usd":0.001,"usage":{"input_tokens":40,"output_tokens":12,"service_tier":"standard"},"modelUsage":{"claude-sonnet-4-5-20250929":{"input_tokens":40,"output_tokens":12,"service_tier":"standard"}},"permission_denials":[{"tool_name":"Write","tool_use_id":"toolu_01WRITE_SECRET_EXAMPLE","tool_input":{"file_path":"/root/secret.txt","content":"hello\n"}}],"errors":["Permission denied: cannot write to /root/secret.txt"],"result":""}
-37
View File
File diff suppressed because one or more lines are too long
+21 -41
View File
@@ -1,43 +1,23 @@
{"type":"error","message":"Failed to load optional config file ~/.codex/local.toml (ENOENT); continuing with defaults","code":"CONFIG_NOT_FOUND","fatal":false}
{"type":"thread.started","thread_id":"thread_01JHEM1P9M8Z7Y2YQJ4G6N2C3D","cli_version":"0.56.0","model":"gpt-5-codex","sandbox_mode":"workspace-write","cwd":"/home/user/project"}
{"type":"turn.started","turn_id":"turn_01JHEM1P9M8Z7Y2YQJ4G6N2C3E"}
{"type":"item.started","item":{"id":"item_0001","type":"todo_list","items":[{"text":"Inspect repo structure","completed":false},{"text":"Run tests","completed":false},{"text":"Fix failing tests","completed":false},{"text":"Summarize changes","completed":false}]}}
{"type":"item.updated","item":{"id":"item_0001","type":"todo_list","items":[{"text":"Inspect repo structure","completed":true},{"text":"Run tests","completed":false},{"text":"Fix failing tests","completed":false},{"text":"Summarize changes","completed":false}]}}
{"type":"item.completed","item":{"id":"item_0001","type":"todo_list","items":[{"text":"Inspect repo structure","completed":true},{"text":"Run tests","completed":true},{"text":"Fix failing tests","completed":true},{"text":"Summarize changes","completed":false}]}}
{"type":"item.started","item":{"id":"item_0002","type":"web_search","query":"python jsonlines parser handle unknown fields"}}
{"type":"item.completed","item":{"id":"item_0002","type":"web_search","query":"python jsonlines parser handle unknown fields"}}
{"type":"error","message":"Web search disabled by policy; returned cached results only","code":"WEB_SEARCH_POLICY","fatal":false}
{"type":"item.started","item":{"id":"item_0003","type":"mcp_tool_call","server":"github","tool":"search_issues","status":"in_progress"}}
{"type":"item.updated","item":{"id":"item_0003","type":"mcp_tool_call","server":"github","tool":"search_issues","status":"completed"}}
{"type":"item.completed","item":{"id":"item_0003","type":"mcp_tool_call","server":"github","tool":"search_issues","status":"completed"}}
{"type":"item.started","item":{"id":"item_0004","type":"command_execution","command":"pytest -q","aggregated_output":"","exit_code":null,"status":"in_progress"}}
{"type":"item.updated","item":{"id":"item_0004","type":"command_execution","command":"pytest -q","aggregated_output":"....F\n","exit_code":null,"status":"in_progress"}}
{"type":"item.updated","item":{"id":"item_0004","type":"command_execution","command":"pytest -q","aggregated_output":"....F....\nFAILURES\n_________________________________ test_beta __________________________________\nE AssertionError: expected 42, got 0\n","exit_code":null,"status":"in_progress"}}
{"type":"item.completed","item":{"id":"item_0004","type":"command_execution","command":"pytest -q","aggregated_output":"....F....\n\nFAILURES\n_________________________________ test_beta __________________________________\nE AssertionError: expected 42, got 0\n\n=========================== short test summary info ===========================\nFAILED tests/test_beta.py::test_beta - AssertionError: expected 42, got 0\n1 failed, 11 passed in 0.98s\n","exit_code":1,"status":"failed"}}
{"type":"item.completed","item":{"id":"item_0005","type":"file_change","changes":[{"path":"src/compute_answer.py","kind":"update"},{"path":"tests/test_beta.py","kind":"update"}],"status":"completed"}}
{"type":"item.started","item":{"id":"item_0006","type":"command_execution","command":"pytest -q","aggregated_output":"","status":"in_progress","exit_code":null}}
{"type":"item.updated","item":{"id":"item_0006","type":"command_execution","command":"pytest -q","aggregated_output":"............\n","status":"in_progress"}}
{"type":"item.completed","item":{"id":"item_0006","type":"command_execution","command":"pytest -q","aggregated_output":"............\n12 passed in 1.23s\n","status":"completed","exit_code":0}}
{"type":"item.started","item":{"id":"item_0007","type":"reasoning","text":"Root cause: compute_answer() returned 0. Updated logic to return 42 for the valid input path. Re-ran pytest to confirm all tests pass."}}
{"type":"item.completed","item":{"id":"item_0007","type":"reasoning","text":"Root cause: compute_answer() returned 0. Updated logic to return 42 for the valid input path. Re-ran pytest to confirm all tests pass."}}
{"type":"item.started","item":{"id":"item_0008","type":"agent_message","text":"I found the failing assertion in "}}
{"type":"item.updated","item":{"id":"item_0008","type":"agent_message","text":"I found the failing assertion in tests/test_beta.py and updated src/compute_answer.py to return the expected value."}}
{"type":"item.completed","item":{"id":"item_0008","type":"agent_message","text":"I found the failing assertion in tests/test_beta.py and updated src/compute_answer.py to return the expected value (42). After the change, `pytest -q` reports 12 passed."}}
{"type":"turn.completed","usage":{"input_tokens":1840,"cached_input_tokens":256,"output_tokens":732},"latency_ms":8421}
{"type":"thread.started","thread_id":"0199a213-81c0-7800-8aa1-bbab2a035a53"}
{"type":"turn.started"}
{"type":"item.started","item":{"id":"item_0009","type":"command_execution","command":"npm test","aggregated_output":"","exit_code":null,"status":"in_progress"}}
{"type":"item.completed","item":{"id":"item_0009","type":"command_execution","command":"npm test","aggregated_output":"sh: npm: command not found\n","exit_code":127,"status":"failed"}}
{"type":"item.completed","item":{"id":"item_0010","type":"error","message":"Command `npm` not found in PATH (exit 127)."}}
{"type":"turn.failed","error":{"message":"Aborted: required dependency `npm` is missing; cannot continue."},"exit_code":1}
{"type":"error","message":"codex exec exited non-zero (1) after turn.failed"}
{"type":"thread.started","thread_id":"thread_legacy_7f9c2d3e"}
{"type":"item.started","item":{"id":"item_0","type":"todo_list","items":[{"text":"Inspect repo structure","completed":false},{"text":"Run tests","completed":false},{"text":"Fix failing tests","completed":false}]}}
{"type":"item.updated","item":{"id":"item_0","type":"todo_list","items":[{"text":"Inspect repo structure","completed":true},{"text":"Run tests","completed":false},{"text":"Fix failing tests","completed":false}]}}
{"type":"item.completed","item":{"id":"item_0","type":"todo_list","items":[{"text":"Inspect repo structure","completed":true},{"text":"Run tests","completed":true},{"text":"Fix failing tests","completed":true}]}}
{"type":"item.started","item":{"id":"item_1","type":"command_execution","command":"pytest -q","aggregated_output":"","exit_code":null,"status":"in_progress"}}
{"type":"item.completed","item":{"id":"item_1","type":"command_execution","command":"pytest -q","aggregated_output":"....\n","exit_code":0,"status":"completed"}}
{"type":"item.started","item":{"id":"item_2","type":"command_execution","command":"pytest -q","aggregated_output":"","exit_code":null,"status":"in_progress"}}
{"type":"item.completed","item":{"id":"item_2","type":"command_execution","command":"pytest -q","aggregated_output":"....F\n","exit_code":1,"status":"failed"}}
{"type":"item.completed","item":{"id":"item_3","type":"file_change","changes":[{"path":"src/compute_answer.py","kind":"update"},{"path":"README.md","kind":"add"}],"status":"completed"}}
{"type":"item.completed","item":{"id":"item_4","type":"file_change","changes":[{"path":"src/compute_answer.py","kind":"update"}],"status":"failed"}}
{"type":"item.started","item":{"id":"item_5","type":"mcp_tool_call","server":"github","tool":"search_issues","arguments":{"q":"exec --json"},"result":null,"error":null,"status":"in_progress"}}
{"type":"item.completed","item":{"id":"item_5","type":"mcp_tool_call","server":"github","tool":"search_issues","arguments":{"q":"exec --json"},"result":{"content":[{"type":"text","text":"Found 3 matches."}],"structured_content":{"matches":3}},"error":null,"status":"completed"}}
{"type":"item.started","item":{"id":"item_6","type":"mcp_tool_call","server":"github","tool":"search_issues","arguments":null,"result":null,"error":null,"status":"in_progress"}}
{"type":"item.completed","item":{"id":"item_6","type":"mcp_tool_call","server":"github","tool":"search_issues","arguments":null,"result":null,"error":{"message":"tool timeout"},"status":"failed"}}
{"type":"item.completed","item":{"id":"item_7","type":"web_search","query":"codex exec --json schema"}}
{"type":"item.completed","item":{"id":"item_8","type":"reasoning","text":"Root cause: compute_answer() returned 0."}}
{"type":"item.completed","item":{"id":"item_9","type":"agent_message","text":"Updated src/compute_answer.py and tests pass."}}
{"type":"item.completed","item":{"id":"item_10","type":"error","message":"command output truncated"}}
{"type":"turn.completed","usage":{"input_tokens":1840,"cached_input_tokens":256,"output_tokens":732}}
{"type":"turn.started"}
{"type":"item.completed","item":{"id":"item_l_0001","type":"agent_message","item_type":"assistant_message","text":"Legacy schema example: hello (item_type=assistant_message)."}}
{"type":"item.completed","item":{"id":"item_l_0002","item_type":"command_execution","command":"echo legacy","output":"legacy\n","exit_code":0,"status":"completed"}}
{"type":"turn.completed","usage":{"input_tokens":12,"output_tokens":9}}
{"type":"thread.started","thread_id":"thread_future_01JK0Y6F8K6C7R3N1MGZ9G9A2B"}
{"type":"turn.started"}
{"type":"item.completed","item":{"id":"item_f_0001","type":"tool_call","name":"my_custom_tool","arguments":{"foo":"bar","n":3},"status":"completed","result":{"ok":true}}}
{"type":"item.completed","item":{"id":"item_f_0002","type":"file_change","changes":[{"path":"README.md","kind":"add"}],"status":"failed","error":"permission denied"}}
{"type":"turn.rate_limited","retry_after_ms":1200}
{"type":"turn.completed","usage":null}
{"type":"turn.failed","error":{"message":"Aborted: required dependency `npm` is missing; cannot continue."}}
{"type":"error","message":"codex exec exited non-zero after turn.failed"}
+50
View File
@@ -0,0 +1,50 @@
{"type":"agent_start"}
{"type":"turn_start"}
{"type":"message_start","message":{"role":"user","content":[{"type":"text","text":"Summarize README and list the main CLI commands."},{"type":"image","data":"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAASsJTYQAAAAASUVORK5CYII=","mimeType":"image/png"}],"timestamp":1767401000000}}
{"type":"message_end","message":{"role":"user","content":[{"type":"text","text":"Summarize README and list the main CLI commands."},{"type":"image","data":"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAASsJTYQAAAAASUVORK5CYII=","mimeType":"image/png"}],"timestamp":1767401000000}}
{"type":"message_start","message":{"role":"assistant","content":[{"type":"thinking","thinking":""},{"type":"text","text":""},{"type":"toolCall","id":"toolu_01HXYZREAD","name":"read","arguments":{}}],"api":"openai-responses","provider":"openai","model":"gpt-5.1-codex","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0,"total":0.0}},"stopReason":"toolUse","timestamp":1767401001200}}
{"type":"message_update","message":{"role":"assistant","content":[{"type":"thinking","thinking":""},{"type":"text","text":""},{"type":"toolCall","id":"toolu_01HXYZREAD","name":"read","arguments":{}}],"api":"openai-responses","provider":"openai","model":"gpt-5.1-codex","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0,"total":0.0}},"stopReason":"toolUse","timestamp":1767401001200},"assistantMessageEvent":{"type":"thinking_start","contentIndex":0,"partial":{"role":"assistant","content":[{"type":"thinking","thinking":""},{"type":"text","text":""},{"type":"toolCall","id":"toolu_01HXYZREAD","name":"read","arguments":{}}],"api":"openai-responses","provider":"openai","model":"gpt-5.1-codex","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0,"total":0.0}},"stopReason":"toolUse","timestamp":1767401001200}}}
{"type":"message_update","message":{"role":"assistant","content":[{"type":"thinking","thinking":"Need to read README"},{"type":"text","text":""},{"type":"toolCall","id":"toolu_01HXYZREAD","name":"read","arguments":{}}],"api":"openai-responses","provider":"openai","model":"gpt-5.1-codex","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0,"total":0.0}},"stopReason":"toolUse","timestamp":1767401001200},"assistantMessageEvent":{"type":"thinking_delta","contentIndex":0,"delta":"Need to read README","partial":{"role":"assistant","content":[{"type":"thinking","thinking":"Need to read README"},{"type":"text","text":""},{"type":"toolCall","id":"toolu_01HXYZREAD","name":"read","arguments":{}}],"api":"openai-responses","provider":"openai","model":"gpt-5.1-codex","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0,"total":0.0}},"stopReason":"toolUse","timestamp":1767401001200}}}
{"type":"message_update","message":{"role":"assistant","content":[{"type":"thinking","thinking":"Need to read README for the CLI commands."},{"type":"text","text":""},{"type":"toolCall","id":"toolu_01HXYZREAD","name":"read","arguments":{}}],"api":"openai-responses","provider":"openai","model":"gpt-5.1-codex","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0,"total":0.0}},"stopReason":"toolUse","timestamp":1767401001200},"assistantMessageEvent":{"type":"thinking_end","contentIndex":0,"content":"Need to read README for the CLI commands.","partial":{"role":"assistant","content":[{"type":"thinking","thinking":"Need to read README for the CLI commands."},{"type":"text","text":""},{"type":"toolCall","id":"toolu_01HXYZREAD","name":"read","arguments":{}}],"api":"openai-responses","provider":"openai","model":"gpt-5.1-codex","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0,"total":0.0}},"stopReason":"toolUse","timestamp":1767401001200}}}
{"type":"message_update","message":{"role":"assistant","content":[{"type":"thinking","thinking":"Need to read README for the CLI commands."},{"type":"text","text":""},{"type":"toolCall","id":"toolu_01HXYZREAD","name":"read","arguments":{}}],"api":"openai-responses","provider":"openai","model":"gpt-5.1-codex","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0,"total":0.0}},"stopReason":"toolUse","timestamp":1767401001200},"assistantMessageEvent":{"type":"text_start","contentIndex":1,"partial":{"role":"assistant","content":[{"type":"thinking","thinking":"Need to read README for the CLI commands."},{"type":"text","text":""},{"type":"toolCall","id":"toolu_01HXYZREAD","name":"read","arguments":{}}],"api":"openai-responses","provider":"openai","model":"gpt-5.1-codex","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0,"total":0.0}},"stopReason":"toolUse","timestamp":1767401001200}}}
{"type":"message_update","message":{"role":"assistant","content":[{"type":"thinking","thinking":"Need to read README for the CLI commands."},{"type":"text","text":"I'll check the README"},{"type":"toolCall","id":"toolu_01HXYZREAD","name":"read","arguments":{}}],"api":"openai-responses","provider":"openai","model":"gpt-5.1-codex","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0,"total":0.0}},"stopReason":"toolUse","timestamp":1767401001200},"assistantMessageEvent":{"type":"text_delta","contentIndex":1,"delta":"I'll check the README","partial":{"role":"assistant","content":[{"type":"thinking","thinking":"Need to read README for the CLI commands."},{"type":"text","text":"I'll check the README"},{"type":"toolCall","id":"toolu_01HXYZREAD","name":"read","arguments":{}}],"api":"openai-responses","provider":"openai","model":"gpt-5.1-codex","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0,"total":0.0}},"stopReason":"toolUse","timestamp":1767401001200}}}
{"type":"message_update","message":{"role":"assistant","content":[{"type":"thinking","thinking":"Need to read README for the CLI commands."},{"type":"text","text":"I'll check the README for the CLI commands."},{"type":"toolCall","id":"toolu_01HXYZREAD","name":"read","arguments":{}}],"api":"openai-responses","provider":"openai","model":"gpt-5.1-codex","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0,"total":0.0}},"stopReason":"toolUse","timestamp":1767401001200},"assistantMessageEvent":{"type":"text_end","contentIndex":1,"content":"I'll check the README for the CLI commands.","partial":{"role":"assistant","content":[{"type":"thinking","thinking":"Need to read README for the CLI commands."},{"type":"text","text":"I'll check the README for the CLI commands."},{"type":"toolCall","id":"toolu_01HXYZREAD","name":"read","arguments":{}}],"api":"openai-responses","provider":"openai","model":"gpt-5.1-codex","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0,"total":0.0}},"stopReason":"toolUse","timestamp":1767401001200}}}
{"type":"message_update","message":{"role":"assistant","content":[{"type":"thinking","thinking":"Need to read README for the CLI commands."},{"type":"text","text":"I'll check the README for the CLI commands."},{"type":"toolCall","id":"toolu_01HXYZREAD","name":"read","arguments":{}}],"api":"openai-responses","provider":"openai","model":"gpt-5.1-codex","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0,"total":0.0}},"stopReason":"toolUse","timestamp":1767401001200},"assistantMessageEvent":{"type":"toolcall_start","contentIndex":2,"partial":{"role":"assistant","content":[{"type":"thinking","thinking":"Need to read README for the CLI commands."},{"type":"text","text":"I'll check the README for the CLI commands."},{"type":"toolCall","id":"toolu_01HXYZREAD","name":"read","arguments":{}}],"api":"openai-responses","provider":"openai","model":"gpt-5.1-codex","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0,"total":0.0}},"stopReason":"toolUse","timestamp":1767401001200}}}
{"type":"message_update","message":{"role":"assistant","content":[{"type":"thinking","thinking":"Need to read README for the CLI commands."},{"type":"text","text":"I'll check the README for the CLI commands."},{"type":"toolCall","id":"toolu_01HXYZREAD","name":"read","arguments":{"path":"README.md"}}],"api":"openai-responses","provider":"openai","model":"gpt-5.1-codex","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0,"total":0.0}},"stopReason":"toolUse","timestamp":1767401001200},"assistantMessageEvent":{"type":"toolcall_delta","contentIndex":2,"delta":"{\"path\":\"README.md\"}","partial":{"role":"assistant","content":[{"type":"thinking","thinking":"Need to read README for the CLI commands."},{"type":"text","text":"I'll check the README for the CLI commands."},{"type":"toolCall","id":"toolu_01HXYZREAD","name":"read","arguments":{"path":"README.md"}}],"api":"openai-responses","provider":"openai","model":"gpt-5.1-codex","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0,"total":0.0}},"stopReason":"toolUse","timestamp":1767401001200}}}
{"type":"message_update","message":{"role":"assistant","content":[{"type":"thinking","thinking":"Need to read README for the CLI commands."},{"type":"text","text":"I'll check the README for the CLI commands."},{"type":"toolCall","id":"toolu_01HXYZREAD","name":"read","arguments":{"path":"README.md"}}],"api":"openai-responses","provider":"openai","model":"gpt-5.1-codex","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0,"total":0.0}},"stopReason":"toolUse","timestamp":1767401001200},"assistantMessageEvent":{"type":"toolcall_end","contentIndex":2,"toolCall":{"type":"toolCall","id":"toolu_01HXYZREAD","name":"read","arguments":{"path":"README.md"}},"partial":{"role":"assistant","content":[{"type":"thinking","thinking":"Need to read README for the CLI commands."},{"type":"text","text":"I'll check the README for the CLI commands."},{"type":"toolCall","id":"toolu_01HXYZREAD","name":"read","arguments":{"path":"README.md"}}],"api":"openai-responses","provider":"openai","model":"gpt-5.1-codex","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0,"total":0.0}},"stopReason":"toolUse","timestamp":1767401001200}}}
{"type":"message_end","message":{"role":"assistant","content":[{"type":"thinking","thinking":"Need to read README for the CLI commands."},{"type":"text","text":"I'll check the README for the CLI commands."},{"type":"toolCall","id":"toolu_01HXYZREAD","name":"read","arguments":{"path":"README.md"}}],"api":"openai-responses","provider":"openai","model":"gpt-5.1-codex","usage":{"input":1240,"output":182,"cacheRead":0,"cacheWrite":0,"totalTokens":1422,"cost":{"input":0.00124,"output":0.000364,"cacheRead":0.0,"cacheWrite":0.0,"total":0.001604}},"stopReason":"toolUse","timestamp":1767401001500}}
{"type":"tool_execution_start","toolCallId":"toolu_01HXYZREAD","toolName":"read","args":{"path":"README.md"}}
{"type":"tool_execution_update","toolCallId":"toolu_01HXYZREAD","toolName":"read","args":{"path":"README.md"},"partialResult":{"content":[{"type":"text","text":"Reading README.md..."}],"details":{"bytesRead":512}}}
{"type":"tool_execution_end","toolCallId":"toolu_01HXYZREAD","toolName":"read","result":{"content":[{"type":"text","text":"# Pi Coding Agent\n\nUsage:\n pi [options] [@files...] [messages...]\n\nOptions:\n --mode <mode>\n --print, -p\n"}],"details":{"path":"README.md","bytesRead":2048,"truncated":false}},"isError":false}
{"type":"message_start","message":{"role":"toolResult","toolCallId":"toolu_01HXYZREAD","toolName":"read","content":[{"type":"text","text":"# Pi Coding Agent\n\nUsage:\n pi [options] [@files...] [messages...]\n\nOptions:\n --mode <mode>\n --print, -p\n"}],"details":{"path":"README.md","bytesRead":2048,"truncated":false},"isError":false,"timestamp":1767401002500}}
{"type":"message_end","message":{"role":"toolResult","toolCallId":"toolu_01HXYZREAD","toolName":"read","content":[{"type":"text","text":"# Pi Coding Agent\n\nUsage:\n pi [options] [@files...] [messages...]\n\nOptions:\n --mode <mode>\n --print, -p\n"}],"details":{"path":"README.md","bytesRead":2048,"truncated":false},"isError":false,"timestamp":1767401002500}}
{"type":"turn_end","message":{"role":"assistant","content":[{"type":"thinking","thinking":"Need to read README for the CLI commands."},{"type":"text","text":"I'll check the README for the CLI commands."},{"type":"toolCall","id":"toolu_01HXYZREAD","name":"read","arguments":{"path":"README.md"}}],"api":"openai-responses","provider":"openai","model":"gpt-5.1-codex","usage":{"input":1240,"output":182,"cacheRead":0,"cacheWrite":0,"totalTokens":1422,"cost":{"input":0.00124,"output":0.000364,"cacheRead":0.0,"cacheWrite":0.0,"total":0.001604}},"stopReason":"toolUse","timestamp":1767401001500},"toolResults":[{"role":"toolResult","toolCallId":"toolu_01HXYZREAD","toolName":"read","content":[{"type":"text","text":"# Pi Coding Agent\n\nUsage:\n pi [options] [@files...] [messages...]\n\nOptions:\n --mode <mode>\n --print, -p\n"}],"details":{"path":"README.md","bytesRead":2048,"truncated":false},"isError":false,"timestamp":1767401002500}]}
{"type":"turn_start"}
{"type":"message_start","message":{"role":"assistant","content":[{"type":"text","text":""}],"api":"openai-responses","provider":"openai","model":"gpt-5.1-codex","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0,"total":0.0}},"stopReason":"stop","timestamp":1767401004500}}
{"type":"message_update","message":{"role":"assistant","content":[{"type":"text","text":""}],"api":"openai-responses","provider":"openai","model":"gpt-5.1-codex","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0,"total":0.0}},"stopReason":"stop","timestamp":1767401004500},"assistantMessageEvent":{"type":"text_start","contentIndex":0,"partial":{"role":"assistant","content":[{"type":"text","text":""}],"api":"openai-responses","provider":"openai","model":"gpt-5.1-codex","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0,"total":0.0}},"stopReason":"stop","timestamp":1767401004500}}}
{"type":"message_update","message":{"role":"assistant","content":[{"type":"text","text":"Main CLI commands"}],"api":"openai-responses","provider":"openai","model":"gpt-5.1-codex","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0,"total":0.0}},"stopReason":"stop","timestamp":1767401004500},"assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":"Main CLI commands","partial":{"role":"assistant","content":[{"type":"text","text":"Main CLI commands"}],"api":"openai-responses","provider":"openai","model":"gpt-5.1-codex","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0,"total":0.0}},"stopReason":"stop","timestamp":1767401004500}}}
{"type":"message_update","message":{"role":"assistant","content":[{"type":"text","text":"Main CLI commands include `pi --mode <mode>` for output format and `pi --print` for non-interactive runs."}],"api":"openai-responses","provider":"openai","model":"gpt-5.1-codex","usage":{"input":680,"output":128,"cacheRead":0,"cacheWrite":0,"totalTokens":808,"cost":{"input":0.00068,"output":0.000256,"cacheRead":0.0,"cacheWrite":0.0,"total":0.000936}},"stopReason":"stop","timestamp":1767401004500},"assistantMessageEvent":{"type":"text_end","contentIndex":0,"content":"Main CLI commands include `pi --mode <mode>` for output format and `pi --print` for non-interactive runs.","partial":{"role":"assistant","content":[{"type":"text","text":"Main CLI commands include `pi --mode <mode>` for output format and `pi --print` for non-interactive runs."}],"api":"openai-responses","provider":"openai","model":"gpt-5.1-codex","usage":{"input":680,"output":128,"cacheRead":0,"cacheWrite":0,"totalTokens":808,"cost":{"input":0.00068,"output":0.000256,"cacheRead":0.0,"cacheWrite":0.0,"total":0.000936}},"stopReason":"stop","timestamp":1767401004500}}}
{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"Main CLI commands include `pi --mode <mode>` for output format and `pi --print` for non-interactive runs."}],"api":"openai-responses","provider":"openai","model":"gpt-5.1-codex","usage":{"input":680,"output":128,"cacheRead":0,"cacheWrite":0,"totalTokens":808,"cost":{"input":0.00068,"output":0.000256,"cacheRead":0.0,"cacheWrite":0.0,"total":0.000936}},"stopReason":"stop","timestamp":1767401004500}}
{"type":"turn_end","message":{"role":"assistant","content":[{"type":"text","text":"Main CLI commands include `pi --mode <mode>` for output format and `pi --print` for non-interactive runs."}],"api":"openai-responses","provider":"openai","model":"gpt-5.1-codex","usage":{"input":680,"output":128,"cacheRead":0,"cacheWrite":0,"totalTokens":808,"cost":{"input":0.00068,"output":0.000256,"cacheRead":0.0,"cacheWrite":0.0,"total":0.000936}},"stopReason":"stop","timestamp":1767401004500},"toolResults":[]}
{"type":"agent_end","messages":[{"role":"user","content":[{"type":"text","text":"Summarize README and list the main CLI commands."},{"type":"image","data":"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAASsJTYQAAAAASUVORK5CYII=","mimeType":"image/png"}],"timestamp":1767401000000},{"role":"assistant","content":[{"type":"thinking","thinking":"Need to read README for the CLI commands."},{"type":"text","text":"I'll check the README for the CLI commands."},{"type":"toolCall","id":"toolu_01HXYZREAD","name":"read","arguments":{"path":"README.md"}}],"api":"openai-responses","provider":"openai","model":"gpt-5.1-codex","usage":{"input":1240,"output":182,"cacheRead":0,"cacheWrite":0,"totalTokens":1422,"cost":{"input":0.00124,"output":0.000364,"cacheRead":0.0,"cacheWrite":0.0,"total":0.001604}},"stopReason":"toolUse","timestamp":1767401001500},{"role":"toolResult","toolCallId":"toolu_01HXYZREAD","toolName":"read","content":[{"type":"text","text":"# Pi Coding Agent\n\nUsage:\n pi [options] [@files...] [messages...]\n\nOptions:\n --mode <mode>\n --print, -p\n"}],"details":{"path":"README.md","bytesRead":2048,"truncated":false},"isError":false,"timestamp":1767401002500},{"role":"assistant","content":[{"type":"text","text":"Main CLI commands include `pi --mode <mode>` for output format and `pi --print` for non-interactive runs."}],"api":"openai-responses","provider":"openai","model":"gpt-5.1-codex","usage":{"input":680,"output":128,"cacheRead":0,"cacheWrite":0,"totalTokens":808,"cost":{"input":0.00068,"output":0.000256,"cacheRead":0.0,"cacheWrite":0.0,"total":0.000936}},"stopReason":"stop","timestamp":1767401004500}]}
{"type":"auto_compaction_start","reason":"threshold"}
{"type":"auto_compaction_end","result":{"summary":"## Goal\nSummarize CLI usage.\n\n## Progress\n- Read README for CLI commands.","firstKeptEntryId":"b1b2c3d4-e5f6-7890-abcd-ef0123456789","tokensBefore":118432,"details":{"readFiles":["README.md"],"modifiedFiles":[]}},"aborted":false,"willRetry":false}
{"type":"agent_start"}
{"type":"turn_start"}
{"type":"message_start","message":{"role":"user","content":[{"type":"text","text":"List the latest release notes for the CLI."}],"timestamp":1767402000000}}
{"type":"message_end","message":{"role":"user","content":[{"type":"text","text":"List the latest release notes for the CLI."}],"timestamp":1767402000000}}
{"type":"message_start","message":{"role":"assistant","content":[],"api":"openai-responses","provider":"openai","model":"gpt-5.1-codex","usage":{"input":320,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":320,"cost":{"input":0.00032,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0,"total":0.00032}},"stopReason":"error","errorMessage":"Rate limit exceeded","timestamp":1767402001200}}
{"type":"message_end","message":{"role":"assistant","content":[],"api":"openai-responses","provider":"openai","model":"gpt-5.1-codex","usage":{"input":320,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":320,"cost":{"input":0.00032,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0,"total":0.00032}},"stopReason":"error","errorMessage":"Rate limit exceeded","timestamp":1767402001200}}
{"type":"turn_end","message":{"role":"assistant","content":[],"api":"openai-responses","provider":"openai","model":"gpt-5.1-codex","usage":{"input":320,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":320,"cost":{"input":0.00032,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0,"total":0.00032}},"stopReason":"error","errorMessage":"Rate limit exceeded","timestamp":1767402001200},"toolResults":[]}
{"type":"agent_end","messages":[{"role":"user","content":[{"type":"text","text":"List the latest release notes for the CLI."}],"timestamp":1767402000000},{"role":"assistant","content":[],"api":"openai-responses","provider":"openai","model":"gpt-5.1-codex","usage":{"input":320,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":320,"cost":{"input":0.00032,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0,"total":0.00032}},"stopReason":"error","errorMessage":"Rate limit exceeded","timestamp":1767402001200}]}
{"type":"auto_retry_start","attempt":1,"maxAttempts":3,"delayMs":2000,"errorMessage":"Rate limit exceeded"}
{"type":"agent_start"}
{"type":"turn_start"}
{"type":"message_start","message":{"role":"assistant","content":[{"type":"text","text":""}],"api":"openai-responses","provider":"openai","model":"gpt-5.1-codex","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0,"total":0.0}},"stopReason":"stop","timestamp":1767402004200}}
{"type":"message_update","message":{"role":"assistant","content":[{"type":"text","text":""}],"api":"openai-responses","provider":"openai","model":"gpt-5.1-codex","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0,"total":0.0}},"stopReason":"stop","timestamp":1767402004200},"assistantMessageEvent":{"type":"text_start","contentIndex":0,"partial":{"role":"assistant","content":[{"type":"text","text":""}],"api":"openai-responses","provider":"openai","model":"gpt-5.1-codex","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0,"total":0.0}},"stopReason":"stop","timestamp":1767402004200}}}
{"type":"message_update","message":{"role":"assistant","content":[{"type":"text","text":"Retry succeeded"}],"api":"openai-responses","provider":"openai","model":"gpt-5.1-codex","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0,"total":0.0}},"stopReason":"stop","timestamp":1767402004200},"assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":"Retry succeeded","partial":{"role":"assistant","content":[{"type":"text","text":"Retry succeeded"}],"api":"openai-responses","provider":"openai","model":"gpt-5.1-codex","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0,"total":0.0}},"stopReason":"stop","timestamp":1767402004200}}}
{"type":"message_update","message":{"role":"assistant","content":[{"type":"text","text":"Retry succeeded. Recent notes: 1) Added --mode json output. 2) Improved session export."}],"api":"openai-responses","provider":"openai","model":"gpt-5.1-codex","usage":{"input":410,"output":96,"cacheRead":0,"cacheWrite":0,"totalTokens":506,"cost":{"input":0.00041,"output":0.000192,"cacheRead":0.0,"cacheWrite":0.0,"total":0.000602}},"stopReason":"stop","timestamp":1767402004200},"assistantMessageEvent":{"type":"text_end","contentIndex":0,"content":"Retry succeeded. Recent notes: 1) Added --mode json output. 2) Improved session export.","partial":{"role":"assistant","content":[{"type":"text","text":"Retry succeeded. Recent notes: 1) Added --mode json output. 2) Improved session export."}],"api":"openai-responses","provider":"openai","model":"gpt-5.1-codex","usage":{"input":410,"output":96,"cacheRead":0,"cacheWrite":0,"totalTokens":506,"cost":{"input":0.00041,"output":0.000192,"cacheRead":0.0,"cacheWrite":0.0,"total":0.000602}},"stopReason":"stop","timestamp":1767402004200}}}
{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"Retry succeeded. Recent notes: 1) Added --mode json output. 2) Improved session export."}],"api":"openai-responses","provider":"openai","model":"gpt-5.1-codex","usage":{"input":410,"output":96,"cacheRead":0,"cacheWrite":0,"totalTokens":506,"cost":{"input":0.00041,"output":0.000192,"cacheRead":0.0,"cacheWrite":0.0,"total":0.000602}},"stopReason":"stop","timestamp":1767402004200}}
{"type":"turn_end","message":{"role":"assistant","content":[{"type":"text","text":"Retry succeeded. Recent notes: 1) Added --mode json output. 2) Improved session export."}],"api":"openai-responses","provider":"openai","model":"gpt-5.1-codex","usage":{"input":410,"output":96,"cacheRead":0,"cacheWrite":0,"totalTokens":506,"cost":{"input":0.00041,"output":0.000192,"cacheRead":0.0,"cacheWrite":0.0,"total":0.000602}},"stopReason":"stop","timestamp":1767402004200},"toolResults":[]}
{"type":"agent_end","messages":[{"role":"assistant","content":[{"type":"text","text":"Retry succeeded. Recent notes: 1) Added --mode json output. 2) Improved session export."}],"api":"openai-responses","provider":"openai","model":"gpt-5.1-codex","usage":{"input":410,"output":96,"cacheRead":0,"cacheWrite":0,"totalTokens":506,"cost":{"input":0.00041,"output":0.000192,"cacheRead":0.0,"cacheWrite":0.0,"total":0.000602}},"stopReason":"stop","timestamp":1767402004200}]}
{"type":"auto_retry_end","success":true,"attempt":1}
+158 -26
View File
@@ -11,11 +11,43 @@ from takopi.runners.claude import (
ENGINE,
translate_claude_event,
)
from takopi.schemas import claude as claude_schema
def _load_fixture(name: str) -> list[dict]:
def _load_fixture(
name: str, *, session_id: str | None = None
) -> list[claude_schema.StreamJsonMessage]:
path = Path(__file__).parent / "fixtures" / name
return [json.loads(line) for line in path.read_text().splitlines() if line.strip()]
events = [
claude_schema.decode_stream_json_line(line)
for line in path.read_bytes().splitlines()
if line.strip()
]
if session_id is None:
return events
return [
event for event in events if getattr(event, "session_id", None) == session_id
]
def _decode_event(payload: dict) -> claude_schema.StreamJsonMessage:
data_payload = dict(payload)
data_payload.setdefault("uuid", "uuid")
data_payload.setdefault("session_id", "session")
match data_payload.get("type"):
case "assistant":
message = dict(data_payload.get("message", {}))
message.setdefault("role", "assistant")
message.setdefault("content", [])
message.setdefault("model", "claude")
data_payload["message"] = message
case "user":
message = dict(data_payload.get("message", {}))
message.setdefault("role", "user")
message.setdefault("content", [])
data_payload["message"] = message
data = json.dumps(data_payload).encode("utf-8")
return claude_schema.decode_stream_json_line(data)
def test_claude_resume_format_and_extract() -> None:
@@ -33,8 +65,18 @@ def test_claude_resume_format_and_extract() -> None:
def test_translate_success_fixture() -> None:
state = ClaudeStreamState()
events: list = []
for event in _load_fixture("claude_stream_success.jsonl"):
events.extend(translate_claude_event(event, title="claude", state=state))
for event in _load_fixture(
"claude_streamjson_session.jsonl",
session_id="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
):
events.extend(
translate_claude_event(
event,
title="claude",
state=state,
factory=state.factory,
)
)
assert isinstance(events[0], StartedEvent)
started = next(evt for evt in events if isinstance(evt, StartedEvent))
@@ -47,8 +89,10 @@ def test_translate_success_fixture() -> None:
for evt in action_events
if evt.phase == "started"
}
assert started_actions[("toolu_1", "started")].action.kind == "command"
write_action = started_actions[("toolu_2", "started")].action
assert (
started_actions[("toolu_01BASH_LS_EXAMPLE", "started")].action.kind == "command"
)
write_action = started_actions[("toolu_02", "started")].action
assert write_action.kind == "file_change"
assert write_action.detail["changes"][0]["path"] == "notes.md"
@@ -57,34 +101,37 @@ def test_translate_success_fixture() -> None:
for evt in action_events
if evt.phase == "completed"
}
assert completed_actions[("toolu_1", "completed")].ok is True
assert completed_actions[("toolu_2", "completed")].ok is True
assert completed_actions[("toolu_01BASH_LS_EXAMPLE", "completed")].ok is True
assert completed_actions[("toolu_02", "completed")].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 == "Done. Added notes.md."
assert completed.answer == "I see README.md, pyproject.toml, and src/."
def test_translate_error_fixture_permission_denials() -> None:
state = ClaudeStreamState()
events: list = []
for event in _load_fixture("claude_stream_error.jsonl"):
events.extend(translate_claude_event(event, title="claude", state=state))
for event in _load_fixture(
"claude_streamjson_session.jsonl",
session_id="bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
):
events.extend(
translate_claude_event(
event,
title="claude",
state=state,
factory=state.factory,
)
)
started = next(evt for evt in events if isinstance(evt, StartedEvent))
completed = next(evt for evt in events if isinstance(evt, CompletedEvent))
warnings = [
evt
for evt in events
if isinstance(evt, ActionEvent) and evt.action.kind == "warning"
]
assert warnings
assert events.index(warnings[0]) < events.index(completed)
assert completed.ok is False
assert completed.error == "Permission denied"
assert completed.error is not None
assert "claude run failed" in completed.error
assert completed.resume == started.resume
@@ -120,13 +167,54 @@ def test_tool_results_pop_pending_actions() -> None:
},
}
translate_claude_event(tool_use_event, title="claude", state=state)
translate_claude_event(
_decode_event(tool_use_event),
title="claude",
state=state,
factory=state.factory,
)
assert "toolu_1" in state.pending_actions
translate_claude_event(tool_result_event, title="claude", state=state)
translate_claude_event(
_decode_event(tool_result_event),
title="claude",
state=state,
factory=state.factory,
)
assert not state.pending_actions
def test_translate_thinking_block() -> None:
state = ClaudeStreamState()
event = {
"type": "assistant",
"message": {
"id": "msg_1",
"content": [
{
"type": "thinking",
"thinking": "Consider the options.",
"signature": "sig",
}
],
},
}
events = translate_claude_event(
_decode_event(event),
title="claude",
state=state,
factory=state.factory,
)
assert len(events) == 1
assert isinstance(events[0], ActionEvent)
assert events[0].phase == "completed"
assert events[0].action.kind == "note"
assert events[0].action.title == "Consider the options."
assert events[0].ok is True
@pytest.mark.anyio
async def test_run_serializes_same_session() -> None:
runner = ClaudeRunner(claude_cmd="claude")
@@ -184,15 +272,30 @@ async def test_run_serializes_new_session_after_session_is_known(
"resume_marker = os.environ['CLAUDE_TEST_RESUME_MARKER']\n"
"session_id = os.environ['CLAUDE_TEST_SESSION_ID']\n"
"\n"
"init = {\n"
" 'type': 'system',\n"
" 'subtype': 'init',\n"
" 'uuid': 'uuid',\n"
" 'session_id': session_id,\n"
" 'apiKeySource': 'env',\n"
" 'cwd': '.',\n"
" 'tools': [],\n"
" 'mcp_servers': [],\n"
" 'model': 'claude',\n"
" 'permissionMode': 'default',\n"
" 'slash_commands': [],\n"
" 'output_style': 'default',\n"
"}\n"
"\n"
"args = sys.argv[1:]\n"
"if '--resume' in args or '-r' in args:\n"
" print(json.dumps({'type': 'system', 'subtype': 'init', 'session_id': session_id}), flush=True)\n"
" print(json.dumps(init), flush=True)\n"
" with open(resume_marker, 'w', encoding='utf-8') as f:\n"
" f.write('started')\n"
" f.flush()\n"
" sys.exit(0)\n"
"\n"
"print(json.dumps({'type': 'system', 'subtype': 'init', 'session_id': session_id}), flush=True)\n"
"print(json.dumps(init), flush=True)\n"
"while not os.path.exists(gate):\n"
" time.sleep(0.001)\n"
"sys.exit(0)\n",
@@ -252,8 +355,37 @@ async def test_run_strips_anthropic_api_key_by_default(tmp_path, monkeypatch) ->
"\n"
"session_id = 'session_01'\n"
"status = 'set' if os.environ.get('ANTHROPIC_API_KEY') else 'unset'\n"
"print(json.dumps({'type': 'system', 'subtype': 'init', 'session_id': session_id}), flush=True)\n"
"print(json.dumps({'type': 'result', 'subtype': 'success', 'is_error': False, 'result': f'api={status}', 'session_id': session_id}), flush=True)\n"
"init = {\n"
" 'type': 'system',\n"
" 'subtype': 'init',\n"
" 'uuid': 'uuid',\n"
" 'session_id': session_id,\n"
" 'apiKeySource': 'env',\n"
" 'cwd': '.',\n"
" 'tools': [],\n"
" 'mcp_servers': [],\n"
" 'model': 'claude',\n"
" 'permissionMode': 'default',\n"
" 'slash_commands': [],\n"
" 'output_style': 'default',\n"
"}\n"
"print(json.dumps(init), flush=True)\n"
"result = {\n"
" 'type': 'result',\n"
" 'subtype': 'success',\n"
" 'uuid': 'uuid',\n"
" 'session_id': session_id,\n"
" 'duration_ms': 0,\n"
" 'duration_api_ms': 0,\n"
" 'is_error': False,\n"
" 'num_turns': 1,\n"
" 'result': f'api={status}',\n"
" 'total_cost_usd': 0.0,\n"
" 'usage': {'input_tokens': 0, 'output_tokens': 0},\n"
" 'modelUsage': {},\n"
" 'permission_denials': [],\n"
"}\n"
"print(json.dumps(result), flush=True)\n"
"raise SystemExit(0)\n",
encoding="utf-8",
)
+41
View File
@@ -0,0 +1,41 @@
from __future__ import annotations
from pathlib import Path
import pytest
from takopi.schemas import claude as claude_schema
def _fixture_path(name: str) -> Path:
return Path(__file__).parent / "fixtures" / name
def _decode_fixture(name: str) -> list[str]:
path = _fixture_path(name)
errors: list[str] = []
for lineno, line in enumerate(path.read_bytes().splitlines(), 1):
if not line.strip():
continue
try:
decoded = claude_schema.decode_stream_json_line(line)
except Exception as exc:
errors.append(f"line {lineno}: {exc.__class__.__name__}: {exc}")
continue
_ = decoded
return errors
@pytest.mark.parametrize(
"fixture",
[
"claude_streamjson_session.jsonl",
],
)
def test_claude_schema_parses_fixture(fixture: str) -> None:
errors = _decode_fixture(fixture)
assert not errors, f"{fixture} had {len(errors)} errors: " + "; ".join(errors[:5])
+44
View File
@@ -0,0 +1,44 @@
from __future__ import annotations
import json
from pathlib import Path
import pytest
from takopi.schemas import codex as codex_schema
def _fixture_path(name: str) -> Path:
return Path(__file__).parent / "fixtures" / name
def _decode_fixture(name: str) -> list[str]:
path = _fixture_path(name)
errors: list[str] = []
for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
if not line.strip():
continue
try:
json.loads(line)
except Exception as exc:
errors.append(f"line {lineno}: invalid JSON ({exc})")
continue
try:
codex_schema.decode_event(line)
except Exception as exc:
errors.append(f"line {lineno}: {exc.__class__.__name__}: {exc}")
return errors
@pytest.mark.parametrize(
"fixture",
[
"codex_exec_json_all_formats.jsonl",
],
)
def test_codex_schema_parses_fixture(fixture: str) -> None:
errors = _decode_fixture(fixture)
assert not errors, f"{fixture} had {len(errors)} errors: " + "; ".join(errors[:5])
+26 -26
View File
@@ -1,5 +1,21 @@
import json
from takopi.events import EventFactory
from takopi.model import ActionEvent
from takopi.runners.codex import translate_codex_event
from takopi.schemas import codex as codex_schema
def _decode_event(payload: dict) -> codex_schema.ThreadEvent:
return codex_schema.decode_event(json.dumps(payload))
def _translate_event(payload: dict) -> list:
return translate_codex_event(
_decode_event(payload),
title="Codex",
factory=EventFactory("codex"),
)
def test_translate_mcp_tool_call_summarizes_structured_content() -> None:
@@ -20,7 +36,7 @@ def test_translate_mcp_tool_call_summarizes_structured_content() -> None:
},
}
out = translate_codex_event(evt, title="Codex")
out = _translate_event(evt)
assert len(out) == 1
assert isinstance(out[0], ActionEvent)
summary = out[0].action.detail["result_summary"]
@@ -36,38 +52,19 @@ def test_translate_mcp_tool_call_summarizes_null_structured_content() -> None:
"type": "mcp_tool_call",
"server": "docs",
"tool": "search",
"arguments": None,
"result": {"content": [], "structured_content": None},
"error": None,
"status": "completed",
},
}
out = translate_codex_event(evt, title="Codex")
out = _translate_event(evt)
assert len(out) == 1
assert isinstance(out[0], ActionEvent)
assert out[0].action.detail["result_summary"]["has_structured"] is False
def test_translate_mcp_tool_call_summarizes_legacy_structured_key() -> None:
evt = {
"type": "item.completed",
"item": {
"id": "item_3",
"type": "mcp_tool_call",
"server": "docs",
"tool": "search",
"result": {"structured": {"matches": 3}},
"error": None,
"status": "completed",
},
}
out = translate_codex_event(evt, title="Codex")
assert len(out) == 1
assert isinstance(out[0], ActionEvent)
assert out[0].action.detail["result_summary"]["has_structured"] is True
def test_translate_mcp_tool_call_missing_error_is_ok() -> None:
evt = {
"type": "item.completed",
@@ -76,18 +73,20 @@ def test_translate_mcp_tool_call_missing_error_is_ok() -> None:
"type": "mcp_tool_call",
"server": "docs",
"tool": "search",
"arguments": None,
"status": "completed",
"result": {"content": []},
"result": {"content": [], "structured_content": None},
"error": None,
},
}
out = translate_codex_event(evt, title="Codex")
out = _translate_event(evt)
assert len(out) == 1
assert isinstance(out[0], ActionEvent)
assert out[0].ok is True
def test_translate_command_execution_allows_missing_exit_code() -> None:
def test_translate_command_execution_allows_null_exit_code() -> None:
evt = {
"type": "item.completed",
"item": {
@@ -95,11 +94,12 @@ def test_translate_command_execution_allows_missing_exit_code() -> None:
"type": "command_execution",
"command": "ls -la",
"aggregated_output": "",
"exit_code": None,
"status": "completed",
},
}
out = translate_codex_event(evt, title="Codex")
out = _translate_event(evt)
assert len(out) == 1
assert isinstance(out[0], ActionEvent)
assert out[0].ok is True
+2 -2
View File
@@ -218,7 +218,7 @@ async def test_codex_runner_preserves_warning_order(tmp_path) -> None:
"import sys\n"
"\n"
"sys.stdin.read()\n"
"print(json.dumps({'type': 'error', 'message': 'warning one', 'fatal': False}), flush=True)\n"
"print(json.dumps({'type': 'error', 'message': 'warning one'}), flush=True)\n"
f"print(json.dumps({{'type': 'thread.started', 'thread_id': '{thread_id}'}}), flush=True)\n"
"print(json.dumps({'type': 'item.completed', 'item': {'id': 'item_0', 'type': 'agent_message', 'text': 'ok'}}), flush=True)\n",
encoding="utf-8",
@@ -335,7 +335,7 @@ async def test_codex_runner_includes_stderr_reason(tmp_path) -> None:
assert completed.ok is False
assert completed.error is not None
assert "codex exec failed (rc=1)." in completed.error
assert "\n\nNot inside a trusted directory" in completed.error
assert "Not inside a trusted directory" not in completed.error
@pytest.mark.anyio
+99 -72
View File
@@ -11,11 +11,26 @@ from takopi.runners.opencode import (
ENGINE,
translate_opencode_event,
)
from takopi.schemas import opencode as opencode_schema
def _load_fixture(name: str) -> list[dict]:
def _load_fixture(name: str) -> list[opencode_schema.OpenCodeEvent]:
path = Path(__file__).parent / "fixtures" / name
return [json.loads(line) for line in path.read_text().splitlines() if line.strip()]
events: list[opencode_schema.OpenCodeEvent] = []
for line in path.read_bytes().splitlines():
if not line.strip():
continue
try:
events.append(opencode_schema.decode_event(line))
except Exception as exc:
raise AssertionError(
f"{name} contained unparseable line: {line!r}"
) from exc
return events
def _decode_event(payload: dict) -> opencode_schema.OpenCodeEvent:
return opencode_schema.decode_event(json.dumps(payload).encode("utf-8"))
def test_opencode_resume_format_and_extract() -> None:
@@ -59,9 +74,6 @@ def test_translate_success_fixture() -> None:
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()
@@ -74,7 +86,6 @@ def test_translate_missing_reason_success() -> None:
fallback = runner.stream_end_events(
resume=None,
found_session=started.resume,
stderr_tail="",
state=state,
)
@@ -82,14 +93,13 @@ def test_translate_missing_reason_success() -> None:
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": {}},
_decode_event({"type": "step_start", "sessionID": "ses_test123", "part": {}}),
title="opencode",
state=state,
)
@@ -97,20 +107,24 @@ def test_translate_accumulates_text() -> None:
assert isinstance(events[0], StartedEvent)
translate_opencode_event(
{
"type": "text",
"sessionID": "ses_test123",
"part": {"type": "text", "text": "Hello "},
},
_decode_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"},
},
_decode_event(
{
"type": "text",
"sessionID": "ses_test123",
"part": {"type": "text", "text": "World"},
}
),
title="opencode",
state=state,
)
@@ -118,11 +132,13 @@ def test_translate_accumulates_text() -> None:
assert state.last_text == "Hello World"
events = translate_opencode_event(
{
"type": "step_finish",
"sessionID": "ses_test123",
"part": {"reason": "stop", "tokens": {"input": 100, "output": 10}},
},
_decode_event(
{
"type": "step_finish",
"sessionID": "ses_test123",
"part": {"reason": "stop", "tokens": {"input": 100, "output": 10}},
}
),
title="opencode",
state=state,
)
@@ -140,22 +156,24 @@ def test_translate_tool_use_completed() -> None:
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},
_decode_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,
)
@@ -175,22 +193,24 @@ def test_translate_tool_use_with_error() -> None:
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},
_decode_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,
)
@@ -209,21 +229,23 @@ def test_translate_tool_use_read_title_wraps_path() -> None:
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",
_decode_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,
)
@@ -255,11 +277,16 @@ def test_step_finish_tool_calls_does_not_complete() -> None:
state.emitted_started = True
events = translate_opencode_event(
{
"type": "step_finish",
"sessionID": "ses_test123",
"part": {"reason": "tool-calls", "tokens": {"input": 100, "output": 10}},
},
_decode_event(
{
"type": "step_finish",
"sessionID": "ses_test123",
"part": {
"reason": "tool-calls",
"tokens": {"input": 100, "output": 10},
},
}
),
title="opencode",
state=state,
)
+39
View File
@@ -0,0 +1,39 @@
from __future__ import annotations
from pathlib import Path
import pytest
from takopi.schemas import opencode as opencode_schema
def _fixture_path(name: str) -> Path:
return Path(__file__).parent / "fixtures" / name
def _decode_fixture(name: str) -> list[str]:
path = _fixture_path(name)
errors: list[str] = []
for lineno, line in enumerate(path.read_bytes().splitlines(), 1):
if not line.strip():
continue
try:
opencode_schema.decode_event(line)
except Exception as exc:
errors.append(f"line {lineno}: {exc.__class__.__name__}: {exc}")
return errors
@pytest.mark.parametrize(
"fixture",
[
"opencode_stream_success.jsonl",
"opencode_stream_success_no_reason.jsonl",
"opencode_stream_error.jsonl",
],
)
def test_opencode_schema_parses_fixture(fixture: str) -> None:
errors = _decode_fixture(fixture)
assert not errors, f"{fixture} had {len(errors)} errors: " + "; ".join(errors[:5])
+12 -3
View File
@@ -1,4 +1,3 @@
import json
from pathlib import Path
import anyio
@@ -6,11 +5,21 @@ import pytest
from takopi.model import ActionEvent, CompletedEvent, ResumeToken, StartedEvent
from takopi.runners.pi import ENGINE, PiRunner, PiStreamState, translate_pi_event
from takopi.schemas import pi as pi_schema
def _load_fixture(name: str) -> list[dict]:
def _load_fixture(name: str) -> list[pi_schema.PiEvent]:
path = Path(__file__).parent / "fixtures" / name
return [json.loads(line) for line in path.read_text().splitlines() if line.strip()]
events: list[pi_schema.PiEvent] = []
for line in path.read_text().splitlines():
if not line.strip():
continue
try:
decoded = pi_schema.decode_event(line)
except Exception as exc:
raise AssertionError(f"{name} contained unparseable line: {line}") from exc
events.append(decoded)
return events
def test_pi_resume_format_and_extract() -> None:
+39
View File
@@ -0,0 +1,39 @@
from __future__ import annotations
from pathlib import Path
import pytest
from takopi.schemas import pi as pi_schema
def _fixture_path(name: str) -> Path:
return Path(__file__).parent / "fixtures" / name
def _decode_fixture(name: str) -> list[str]:
path = _fixture_path(name)
errors: list[str] = []
for lineno, line in enumerate(path.read_text().splitlines(), 1):
if not line.strip():
continue
try:
pi_schema.decode_event(line)
except Exception as exc:
errors.append(f"line {lineno}: {exc.__class__.__name__}: {exc}")
return errors
@pytest.mark.parametrize(
"fixture",
[
"pi_stream_success.jsonl",
"pi_stream_error.jsonl",
"pi_print_mode_events.jsonl",
],
)
def test_pi_schema_parses_fixture(fixture: str) -> None:
errors = _decode_fixture(fixture)
assert not errors, f"{fixture} had {len(errors)} errors: " + "; ".join(errors[:5])
Generated
+26
View File
@@ -212,6 +212,30 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "msgspec"
version = "0.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ea/9c/bfbd12955a49180cbd234c5d29ec6f74fe641698f0cd9df154a854fc8a15/msgspec-0.20.0.tar.gz", hash = "sha256:692349e588fde322875f8d3025ac01689fead5901e7fb18d6870a44519d62a29", size = 317862, upload-time = "2025-11-24T03:56:28.934Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bb/18/62dc13ab0260c7d741dda8dc7f481495b93ac9168cd887dda5929880eef8/msgspec-0.20.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:eead16538db1b3f7ec6e3ed1f6f7c5dec67e90f76e76b610e1ffb5671815633a", size = 196407, upload-time = "2025-11-24T03:55:55.001Z" },
{ url = "https://files.pythonhosted.org/packages/dd/1d/b9949e4ad6953e9f9a142c7997b2f7390c81e03e93570c7c33caf65d27e1/msgspec-0.20.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:703c3bb47bf47801627fb1438f106adbfa2998fe586696d1324586a375fca238", size = 188889, upload-time = "2025-11-24T03:55:56.311Z" },
{ url = "https://files.pythonhosted.org/packages/1e/19/f8bb2dc0f1bfe46cc7d2b6b61c5e9b5a46c62298e8f4d03bbe499c926180/msgspec-0.20.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6cdb227dc585fb109305cee0fd304c2896f02af93ecf50a9c84ee54ee67dbb42", size = 219691, upload-time = "2025-11-24T03:55:57.908Z" },
{ url = "https://files.pythonhosted.org/packages/b8/8e/6b17e43f6eb9369d9858ee32c97959fcd515628a1df376af96c11606cf70/msgspec-0.20.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27d35044dd8818ac1bd0fedb2feb4fbdff4e3508dd7c5d14316a12a2d96a0de0", size = 224918, upload-time = "2025-11-24T03:55:59.322Z" },
{ url = "https://files.pythonhosted.org/packages/1c/db/0e833a177db1a4484797adba7f429d4242585980b90882cc38709e1b62df/msgspec-0.20.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b4296393a29ee42dd25947981c65506fd4ad39beaf816f614146fa0c5a6c91ae", size = 223436, upload-time = "2025-11-24T03:56:00.716Z" },
{ url = "https://files.pythonhosted.org/packages/c3/30/d2ee787f4c918fd2b123441d49a7707ae9015e0e8e1ab51aa7967a97b90e/msgspec-0.20.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:205fbdadd0d8d861d71c8f3399fe1a82a2caf4467bc8ff9a626df34c12176980", size = 227190, upload-time = "2025-11-24T03:56:02.371Z" },
{ url = "https://files.pythonhosted.org/packages/ff/37/9c4b58ff11d890d788e700b827db2366f4d11b3313bf136780da7017278b/msgspec-0.20.0-cp314-cp314-win_amd64.whl", hash = "sha256:7dfebc94fe7d3feec6bc6c9df4f7e9eccc1160bb5b811fbf3e3a56899e398a6b", size = 193950, upload-time = "2025-11-24T03:56:03.668Z" },
{ url = "https://files.pythonhosted.org/packages/e9/4e/cab707bf2fa57408e2934e5197fc3560079db34a1e3cd2675ff2e47e07de/msgspec-0.20.0-cp314-cp314-win_arm64.whl", hash = "sha256:2ad6ae36e4a602b24b4bf4eaf8ab5a441fec03e1f1b5931beca8ebda68f53fc0", size = 179018, upload-time = "2025-11-24T03:56:05.038Z" },
{ url = "https://files.pythonhosted.org/packages/4c/06/3da3fc9aaa55618a8f43eb9052453cfe01f82930bca3af8cea63a89f3a11/msgspec-0.20.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f84703e0e6ef025663dd1de828ca028774797b8155e070e795c548f76dde65d5", size = 200389, upload-time = "2025-11-24T03:56:06.375Z" },
{ url = "https://files.pythonhosted.org/packages/83/3b/cc4270a5ceab40dfe1d1745856951b0a24fd16ac8539a66ed3004a60c91e/msgspec-0.20.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7c83fc24dd09cf1275934ff300e3951b3adc5573f0657a643515cc16c7dee131", size = 193198, upload-time = "2025-11-24T03:56:07.742Z" },
{ url = "https://files.pythonhosted.org/packages/cd/ae/4c7905ac53830c8e3c06fdd60e3cdcfedc0bbc993872d1549b84ea21a1bd/msgspec-0.20.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f13ccb1c335a124e80c4562573b9b90f01ea9521a1a87f7576c2e281d547f56", size = 225973, upload-time = "2025-11-24T03:56:09.18Z" },
{ url = "https://files.pythonhosted.org/packages/d9/da/032abac1de4d0678d99eaeadb1323bd9d247f4711c012404ba77ed6f15ca/msgspec-0.20.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17c2b5ca19f19306fc83c96d85e606d2cc107e0caeea85066b5389f664e04846", size = 229509, upload-time = "2025-11-24T03:56:10.898Z" },
{ url = "https://files.pythonhosted.org/packages/69/52/fdc7bdb7057a166f309e0b44929e584319e625aaba4771b60912a9321ccd/msgspec-0.20.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d931709355edabf66c2dd1a756b2d658593e79882bc81aae5964969d5a291b63", size = 230434, upload-time = "2025-11-24T03:56:12.48Z" },
{ url = "https://files.pythonhosted.org/packages/cb/fe/1dfd5f512b26b53043884e4f34710c73e294e7cc54278c3fe28380e42c37/msgspec-0.20.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:565f915d2e540e8a0c93a01ff67f50aebe1f7e22798c6a25873f9fda8d1325f8", size = 231758, upload-time = "2025-11-24T03:56:13.765Z" },
{ url = "https://files.pythonhosted.org/packages/97/f6/9ba7121b8e0c4e0beee49575d1dbc804e2e72467692f0428cf39ceba1ea5/msgspec-0.20.0-cp314-cp314t-win_amd64.whl", hash = "sha256:726f3e6c3c323f283f6021ebb6c8ccf58d7cd7baa67b93d73bfbe9a15c34ab8d", size = 206540, upload-time = "2025-11-24T03:56:15.029Z" },
{ url = "https://files.pythonhosted.org/packages/c8/3e/c5187de84bb2c2ca334ab163fcacf19a23ebb1d876c837f81a1b324a15bf/msgspec-0.20.0-cp314-cp314t-win_arm64.whl", hash = "sha256:93f23528edc51d9f686808a361728e903d6f2be55c901d6f5c92e44c6d546bfc", size = 183011, upload-time = "2025-11-24T03:56:16.442Z" },
]
[[package]]
name = "packaging"
version = "25.0"
@@ -384,6 +408,7 @@ dependencies = [
{ name = "anyio" },
{ name = "httpx" },
{ name = "markdown-it-py" },
{ name = "msgspec" },
{ name = "questionary" },
{ name = "rich" },
{ name = "sulguk" },
@@ -404,6 +429,7 @@ requires-dist = [
{ name = "anyio", specifier = ">=4.12.0" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "markdown-it-py" },
{ name = "msgspec", specifier = ">=0.20.0" },
{ name = "questionary", specifier = ">=2.1.1" },
{ name = "rich", specifier = ">=14.2.0" },
{ name = "sulguk", specifier = ">=0.11.1" },