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
+81 -12
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`). **normalized event model** (`StartedEvent`, `ActionEvent`, `CompletedEvent`).
Takopi is designed so that adding a runner usually means **adding one new module** under 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 walkthrough below uses an **imaginary engine** named **Pi** (`pi`) and intentionally mirrors
the patterns used in `runners/claude.py`. the patterns used in `runners/claude.py`.
@@ -97,11 +98,15 @@ 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/ src/takopi/runners/
codex.py codex.py
claude.py claude.py
@@ -121,9 +126,9 @@ Most CLIs we integrate are JSONL-streaming processes.
Takopi provides `JsonlSubprocessRunner`, which: Takopi provides `JsonlSubprocessRunner`, which:
- spawns the CLI - spawns the CLI
- drains stderr into a bounded tail - drains stderr and logs it
- reads stdout line-by-line as JSONL - reads stdout line-by-line as JSONL bytes
- calls your `translate(...)` method to convert each JSON object into Takopi events - calls your `decode_jsonl(...)` and then `translate(...)` to convert each event into Takopi events
- guarantees “exactly one CompletedEvent” behavior - guarantees “exactly one CompletedEvent” behavior
- provides safe fallbacks for rc != 0 or stream ending without a completion event - provides safe fallbacks for rc != 0 or stream ending without a completion event
@@ -147,6 +152,55 @@ class PiStreamState:
note_seq: int = 0 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 #### Decide what Pi emits
For this guide, assume Pi outputs events like: For this guide, assume Pi outputs events like:
@@ -323,7 +377,7 @@ import logging
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, cast
from ..backends import EngineBackend, EngineConfig from ..backends import EngineBackend, EngineConfig
from ..model import ( from ..model import (
@@ -333,13 +387,14 @@ from ..model import (
StartedEvent, StartedEvent,
TakopiEvent, TakopiEvent,
) )
import msgspec
from ..runner import JsonlSubprocessRunner, ResumeTokenMixin, Runner from ..runner import JsonlSubprocessRunner, ResumeTokenMixin, Runner
from ..schemas import pi as pi_schema
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
ENGINE: EngineId = EngineId("pi") ENGINE: EngineId = EngineId("pi")
STDERR_TAIL_LINES = 200
_RESUME_RE = re.compile( _RESUME_RE = re.compile(
r"(?im)^\s*`?pi\s+--resume\s+(?P<token>[^`\s]+)`?\s*$" r"(?im)^\s*`?pi\s+--resume\s+(?P<token>[^`\s]+)`?\s*$"
) )
@@ -354,7 +409,6 @@ class PiRunner(ResumeTokenMixin, JsonlSubprocessRunner):
model: str | None = None model: str | None = None
allowed_tools: list[str] | None = None allowed_tools: list[str] | None = None
session_title: str = "pi" session_title: str = "pi"
stderr_tail_lines = STDERR_TAIL_LINES
logger = logger logger = logger
def format_resume(self, token: ResumeToken) -> str: def format_resume(self, token: ResumeToken) -> str:
@@ -398,6 +452,17 @@ class PiRunner(ResumeTokenMixin, JsonlSubprocessRunner):
_ = prompt, resume _ = prompt, resume
return PiStreamState() 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( def translate(
self, self,
data: dict[str, Any], 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` - `env(...)`: to strip or inject environment variables (Claude strips `ANTHROPIC_API_KEY`
unless configured to use API billing). unless configured to use API billing).
- `invalid_json_events(...)`: emit a helpful warning `ActionEvent` on malformed JSONL. - `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”. - `stream_end_events(...)`: handle “process exited cleanly but never emitted a final event”.
Claude uses these to produce better failures instead of silent hangs. Claude uses these to produce better failures instead of silent hangs.
@@ -503,6 +569,10 @@ Then assert:
- the last event is a `CompletedEvent` - the last event is a `CompletedEvent`
- completed.resume matches started.resume - 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) #### 3) Lock/serialization tests (optional, but great)
Claude has async tests proving that: 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`). - [ ] rc != 0 produces a failure `CompletedEvent` (via `process_error_events`).
- [ ] “no final event” produces a failure `CompletedEvent` (via `stream_end_events`). - [ ] “no final event” produces a failure `CompletedEvent` (via `stream_end_events`).
- [ ] Tests cover resume parsing + at least one translation fixture. - [ ] Tests cover resume parsing + at least one translation fixture.
+1
View File
@@ -10,6 +10,7 @@ dependencies = [
"anyio>=4.12.0", "anyio>=4.12.0",
"httpx>=0.28.1", "httpx>=0.28.1",
"markdown-it-py", "markdown-it-py",
"msgspec>=0.20.0",
"questionary>=2.1.1", "questionary>=2.1.1",
"rich>=14.2.0", "rich>=14.2.0",
"sulguk>=0.11.1", "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 from __future__ import annotations
import errno
import logging import logging
import re import re
import sys import sys
@@ -23,6 +24,24 @@ class RedactTokenFilter(logging.Filter):
return True 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: def setup_logging(*, debug: bool = False) -> None:
root_logger = logging.getLogger() root_logger = logging.getLogger()
root_logger.setLevel(logging.DEBUG if debug else logging.INFO) 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") fmt = logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")
redactor = RedactTokenFilter() redactor = RedactTokenFilter()
console = logging.StreamHandler(sys.stdout) console = SafeStreamHandler(sys.stdout)
console.setLevel(logging.DEBUG if debug else logging.INFO) console.setLevel(logging.DEBUG if debug else logging.INFO)
console.setFormatter(fmt) console.setFormatter(fmt)
console.addFilter(redactor) console.addFilter(redactor)
+1
View File
@@ -12,6 +12,7 @@ ActionKind: TypeAlias = Literal[
"tool", "tool",
"file_change", "file_change",
"web_search", "web_search",
"subagent",
"note", "note",
"turn", "turn",
"warning", "warning",
+3
View File
@@ -162,6 +162,9 @@ def format_action_title(action: Action, *, command_width: int | None) -> str:
if kind == "web_search": if kind == "web_search":
title = shorten(title, command_width) title = shorten(title, command_width)
return f"searched: {title}" return f"searched: {title}"
if kind == "subagent":
title = shorten(title, command_width)
return f"subagent: {title}"
if kind == "file_change": if kind == "file_change":
return format_file_change_title(action, command_width=command_width) return format_file_change_title(action, command_width=command_width)
if kind in {"note", "warning"}: if kind in {"note", "warning"}:
+83 -20
View File
@@ -2,13 +2,13 @@
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
import re import re
import subprocess import subprocess
from collections import deque
from collections.abc import AsyncIterator, Callable from collections.abc import AsyncIterator, Callable
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Protocol from typing import Any, Protocol, cast
from weakref import WeakValueDictionary from weakref import WeakValueDictionary
import anyio import anyio
@@ -22,7 +22,7 @@ from .model import (
StartedEvent, StartedEvent,
TakopiEvent, TakopiEvent,
) )
from .utils.streams import drain_stderr, iter_jsonl from .utils.streams import drain_stderr, iter_bytes_lines
from .utils.subprocess import manage_subprocess from .utils.subprocess import manage_subprocess
@@ -131,8 +131,6 @@ class JsonlRunState:
class JsonlSubprocessRunner(BaseRunner): class JsonlSubprocessRunner(BaseRunner):
stderr_tail_lines: int = 200
def get_logger(self) -> logging.Logger: def get_logger(self) -> logging.Logger:
return getattr(self, "logger", logging.getLogger(__name__)) return getattr(self, "logger", logging.getLogger(__name__))
@@ -222,19 +220,66 @@ class JsonlSubprocessRunner(BaseRunner):
message = f"invalid JSON from {self.tag()}; ignoring line" message = f"invalid JSON from {self.tag()}; ignoring line"
return [self.note_event(message, state=state, detail={"line": 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( def process_error_events(
self, self,
rc: int, rc: int,
*, *,
resume: ResumeToken | None, resume: ResumeToken | None,
found_session: ResumeToken | None, found_session: ResumeToken | None,
stderr_tail: str,
state: Any, state: Any,
) -> list[TakopiEvent]: ) -> list[TakopiEvent]:
message = f"{self.tag()} failed (rc={rc})." message = f"{self.tag()} failed (rc={rc})."
resume_for_completed = found_session or resume resume_for_completed = found_session or resume
return [ return [
self.note_event(message, state=state, detail={"stderr_tail": stderr_tail}), self.note_event(message, state=state),
CompletedEvent( CompletedEvent(
engine=self.engine, engine=self.engine,
ok=False, ok=False,
@@ -249,7 +294,6 @@ class JsonlSubprocessRunner(BaseRunner):
*, *,
resume: ResumeToken | None, resume: ResumeToken | None,
found_session: ResumeToken | None, found_session: ResumeToken | None,
stderr_tail: str,
state: Any, state: Any,
) -> list[TakopiEvent]: ) -> list[TakopiEvent]:
message = f"{self.tag()} finished without a result event" message = f"{self.tag()} finished without a result event"
@@ -266,7 +310,7 @@ class JsonlSubprocessRunner(BaseRunner):
def translate( def translate(
self, self,
data: dict[str, Any], data: Any,
*, *,
state: Any, state: Any,
resume: ResumeToken | None, resume: ResumeToken | None,
@@ -334,7 +378,6 @@ class JsonlSubprocessRunner(BaseRunner):
elif proc.stdin is not None: elif proc.stdin is not None:
await proc.stdin.aclose() await proc.stdin.aclose()
stderr_chunks: deque[str] = deque(maxlen=self.stderr_tail_lines)
rc: int | None = None rc: int | None = None
expected_session: ResumeToken | None = resume expected_session: ResumeToken | None = resume
found_session: ResumeToken | None = None found_session: ResumeToken | None = None
@@ -344,26 +387,49 @@ class JsonlSubprocessRunner(BaseRunner):
tg.start_soon( tg.start_soon(
drain_stderr, drain_stderr,
proc.stderr, proc.stderr,
stderr_chunks,
logger, logger,
tag, 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: if did_emit_completed:
continue continue
if json_line.data is None: line = raw_line.strip()
events = self.invalid_json_events( if not line:
raw=json_line.raw, continue
line=json_line.line, 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, state=state,
) )
else: else:
if decoded is None:
events = self.invalid_json_events(
raw=raw_text,
line=line_text,
state=state,
)
else:
try:
events = self.translate( events = self.translate(
json_line.data, decoded,
state=state, state=state,
resume=resume, resume=resume,
found_session=found_session, found_session=found_session,
) )
except Exception as exc:
events = self.translate_error_events(
data=decoded,
error=exc,
state=state,
)
for evt in events: for evt in events:
if isinstance(evt, StartedEvent): if isinstance(evt, StartedEvent):
@@ -385,13 +451,11 @@ class JsonlSubprocessRunner(BaseRunner):
logger.debug("[%s] process exit pid=%s rc=%s", tag, proc.pid, rc) logger.debug("[%s] process exit pid=%s rc=%s", tag, proc.pid, rc)
if did_emit_completed: if did_emit_completed:
return return
stderr_tail = "".join(stderr_chunks)
if rc is not None and rc != 0: if rc is not None and rc != 0:
events = self.process_error_events( events = self.process_error_events(
rc, rc,
resume=resume, resume=resume,
found_session=found_session, found_session=found_session,
stderr_tail=stderr_tail,
state=state, state=state,
) )
for evt in events: for evt in events:
@@ -401,7 +465,6 @@ class JsonlSubprocessRunner(BaseRunner):
events = self.stream_end_events( events = self.stream_end_events(
resume=resume, resume=resume,
found_session=found_session, found_session=found_session,
stderr_tail=stderr_tail,
state=state, state=state,
) )
for evt in events: for evt in events:
+156 -187
View File
@@ -5,26 +5,20 @@ import os
import re import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any, Literal from typing import Any
import msgspec
from ..backends import EngineBackend, EngineConfig from ..backends import EngineBackend, EngineConfig
from ..model import ( from ..events import EventFactory
Action, from ..model import Action, ActionKind, EngineId, ResumeToken, TakopiEvent
ActionEvent,
ActionKind,
CompletedEvent,
EngineId,
ResumeToken,
StartedEvent,
TakopiEvent,
)
from ..runner import JsonlSubprocessRunner, ResumeTokenMixin, Runner from ..runner import JsonlSubprocessRunner, ResumeTokenMixin, Runner
from ..schemas import claude as claude_schema
from ..utils.paths import relativize_command, relativize_path from ..utils.paths import relativize_command, relativize_path
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
ENGINE: EngineId = EngineId("claude") ENGINE: EngineId = EngineId("claude")
STDERR_TAIL_LINES = 200
DEFAULT_ALLOWED_TOOLS = ["Bash", "Read", "Edit", "Write"] DEFAULT_ALLOWED_TOOLS = ["Bash", "Read", "Edit", "Write"]
_RESUME_RE = re.compile( _RESUME_RE = re.compile(
@@ -34,45 +28,31 @@ _RESUME_RE = re.compile(
@dataclass(slots=True) @dataclass(slots=True)
class ClaudeStreamState: class ClaudeStreamState:
factory: EventFactory = field(default_factory=lambda: EventFactory(ENGINE))
pending_actions: dict[str, Action] = field(default_factory=dict) pending_actions: dict[str, Action] = field(default_factory=dict)
last_assistant_text: str | None = None last_assistant_text: str | None = None
note_seq: int = 0 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: 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: if content is None:
return "" return ""
if isinstance(content, str): if isinstance(content, str):
return content 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) return str(content)
@@ -134,19 +114,18 @@ def _tool_kind_and_title(
return "note", "ask user" return "note", "ask user"
if name in {"Task", "Agent"}: if name in {"Task", "Agent"}:
desc = tool_input.get("description") or tool_input.get("prompt") 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 return "tool", name
def _tool_action( def _tool_action(
content: dict[str, Any], content: claude_schema.StreamToolUseBlock,
*, *,
message_id: str | None,
parent_tool_use_id: str | None, parent_tool_use_id: str | None,
) -> Action: ) -> Action:
tool_id = content["id"] tool_id = content.id
tool_name = str(content.get("name") or "tool") tool_name = str(content.name or "tool")
tool_input = content["input"] tool_input = content.input
kind, title = _tool_kind_and_title(tool_name, tool_input) kind, title = _tool_kind_and_title(tool_name, tool_input)
@@ -154,8 +133,6 @@ def _tool_action(
"name": tool_name, "name": tool_name,
"input": tool_input, "input": tool_input,
} }
if message_id:
detail["message_id"] = message_id
if parent_tool_use_id: if parent_tool_use_id:
detail["parent_tool_use_id"] = parent_tool_use_id detail["parent_tool_use_id"] = parent_tool_use_id
@@ -168,59 +145,46 @@ def _tool_action(
def _tool_result_event( def _tool_result_event(
content: dict[str, Any], content: claude_schema.StreamToolResultBlock,
*, *,
action: Action, action: Action,
message_id: str | None, factory: EventFactory,
) -> ActionEvent: ) -> TakopiEvent:
is_error = content.get("is_error") is True is_error = content.is_error is True
raw_result = content.get("content") raw_result = content.content
normalized = _normalize_tool_result(raw_result) normalized = _normalize_tool_result(raw_result)
preview = normalized preview = normalized
detail = dict(action.detail) detail = dict(action.detail)
detail.update( detail.update(
{ {
"tool_use_id": content.get("tool_use_id"), "tool_use_id": content.tool_use_id,
"result_preview": preview, "result_preview": preview,
"result_len": len(normalized), "result_len": len(normalized),
"is_error": is_error, "is_error": is_error,
} }
) )
if message_id: return factory.action_completed(
detail["message_id"] = message_id action_id=action.id,
return _action_event(
phase="completed",
action=Action(
id=action.id,
kind=action.kind, kind=action.kind,
title=action.title, title=action.title,
detail=detail,
),
ok=not is_error, ok=not is_error,
detail=detail,
) )
def _extract_error(event: dict[str, Any]) -> str | None: def _extract_error(event: claude_schema.StreamResultMessage) -> str | None:
error = event.get("error") if event.is_error:
if isinstance(error, str) and error: if isinstance(event.result, str) and event.result:
return error return event.result
errors = event.get("errors") subtype = event.subtype
if isinstance(errors, list): if subtype:
for item in errors: return f"claude run failed ({subtype})"
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"):
return "claude run failed" return "claude run failed"
return None 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] = {} usage: dict[str, Any] = {}
for key in ( for key in (
"total_cost_usd", "total_cost_usd",
@@ -228,28 +192,28 @@ def _usage_payload(event: dict[str, Any]) -> dict[str, Any]:
"duration_api_ms", "duration_api_ms",
"num_turns", "num_turns",
): ):
value = event.get(key) value = getattr(event, key, None)
if value is not None:
usage[key] = value
for key in ("usage", "modelUsage"):
value = event.get(key)
if value is not None: if value is not None:
usage[key] = value usage[key] = value
if event.usage is not None:
usage["usage"] = event.usage
return usage return usage
def translate_claude_event( def translate_claude_event(
event: dict[str, Any], event: claude_schema.StreamJsonMessage,
*, *,
title: str, title: str,
state: ClaudeStreamState, state: ClaudeStreamState,
factory: EventFactory,
) -> list[TakopiEvent]: ) -> list[TakopiEvent]:
etype = event["type"] match event:
match etype: case claude_schema.StreamSystemMessage(subtype=subtype):
case "system" if event.get("subtype") == "init": if subtype != "init":
session_id = event["session_id"] return []
model = event.get("model") session_id = event.session_id
event_title = str(model) if model else title if not session_id:
return []
meta: dict[str, Any] = {} meta: dict[str, Any] = {}
for key in ( for key in (
"cwd", "cwd",
@@ -257,52 +221,70 @@ def translate_claude_event(
"permissionMode", "permissionMode",
"output_style", "output_style",
"apiKeySource", "apiKeySource",
"mcp_servers",
):
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
): ):
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"]
out: list[TakopiEvent] = [] out: list[TakopiEvent] = []
for content in content_blocks: for content in message.content:
match content["type"]: match content:
case "tool_use": case claude_schema.StreamToolUseBlock():
action = _tool_action( action = _tool_action(
content, content,
message_id=message_id,
parent_tool_use_id=parent_tool_use_id, parent_tool_use_id=parent_tool_use_id,
) )
state.pending_actions[action.id] = action state.pending_actions[action.id] = action
out.append(_action_event(phase="started", action=action)) out.append(
case "text": factory.action_started(
text = content["text"] 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: if text:
state.last_assistant_text = text state.last_assistant_text = text
case _: case _:
continue continue
return out return out
case "user": case claude_schema.StreamUserMessage(message=message):
message = event["message"] if not isinstance(message.content, list):
message_id = message.get("id") return []
content_blocks = message["content"]
out: list[TakopiEvent] = [] out: list[TakopiEvent] = []
for content in content_blocks: for content in message.content:
if content["type"] != "tool_result": if not isinstance(content, claude_schema.StreamToolResultBlock):
continue continue
tool_use_id = content["tool_use_id"] tool_use_id = content.tool_use_id
action = state.pending_actions.pop(tool_use_id, None) action = state.pending_actions.pop(tool_use_id, None)
if action is None: if action is None:
action = Action( action = Action(
@@ -312,56 +294,32 @@ def translate_claude_event(
detail={}, detail={},
) )
out.append( out.append(
_tool_result_event(content, action=action, message_id=message_id) _tool_result_event(
content,
action=action,
factory=factory,
)
) )
return out return out
case "result": case claude_schema.StreamResultMessage():
out: list[TakopiEvent] = [] ok = not event.is_error
for idx, denial in enumerate(event.get("permission_denials", [])): result_text = event.result or ""
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",
)
)
ok = not event.get("is_error", False)
result_text = event["result"]
if ok and not result_text and state.last_assistant_text: if ok and not result_text and state.last_assistant_text:
result_text = 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) error = None if ok else _extract_error(event)
usage = _usage_payload(event) usage = _usage_payload(event)
out.append( return [
CompletedEvent( factory.completed(
engine=ENGINE,
ok=ok, ok=ok,
answer=result_text, answer=result_text,
resume=resume, resume=resume,
error=error, error=error,
usage=usage or None, usage=usage or None,
) )
) ]
return out
case _: case _:
return [] return []
@@ -377,7 +335,6 @@ class ClaudeRunner(ResumeTokenMixin, JsonlSubprocessRunner):
dangerously_skip_permissions: bool = False dangerously_skip_permissions: bool = False
use_api_billing: bool = False use_api_billing: bool = False
session_title: str = "claude" session_title: str = "claude"
stderr_tail_lines = STDERR_TAIL_LINES
logger = logger logger = logger
def format_resume(self, token: ResumeToken) -> str: def format_resume(self, token: ResumeToken) -> str:
@@ -449,6 +406,34 @@ class ClaudeRunner(ResumeTokenMixin, JsonlSubprocessRunner):
) )
logger.debug("[claude] prompt: %s", prompt) 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( def invalid_json_events(
self, self,
*, *,
@@ -456,13 +441,12 @@ class ClaudeRunner(ResumeTokenMixin, JsonlSubprocessRunner):
line: str, line: str,
state: ClaudeStreamState, state: ClaudeStreamState,
) -> list[TakopiEvent]: ) -> list[TakopiEvent]:
_ = line _ = raw, line, state
message = "invalid JSON from claude; ignoring line" return []
return [self.note_event(message, state=state, detail={"line": raw})]
def translate( def translate(
self, self,
data: dict[str, Any], data: claude_schema.StreamJsonMessage,
*, *,
state: ClaudeStreamState, state: ClaudeStreamState,
resume: ResumeToken | None, resume: ResumeToken | None,
@@ -473,6 +457,7 @@ class ClaudeRunner(ResumeTokenMixin, JsonlSubprocessRunner):
data, data,
title=self.session_title, title=self.session_title,
state=state, state=state,
factory=state.factory,
) )
def process_error_events( def process_error_events(
@@ -481,24 +466,15 @@ class ClaudeRunner(ResumeTokenMixin, JsonlSubprocessRunner):
*, *,
resume: ResumeToken | None, resume: ResumeToken | None,
found_session: ResumeToken | None, found_session: ResumeToken | None,
stderr_tail: str,
state: ClaudeStreamState, state: ClaudeStreamState,
) -> list[TakopiEvent]: ) -> list[TakopiEvent]:
message = f"claude failed (rc={rc})." message = f"claude failed (rc={rc})."
resume_for_completed = found_session or resume resume_for_completed = found_session or resume
return [ return [
self.note_event( self.note_event(message, state=state, ok=False),
message, state.factory.completed_error(
state=state,
ok=False,
detail={"stderr_tail": stderr_tail},
),
CompletedEvent(
engine=ENGINE,
ok=False,
answer="",
resume=resume_for_completed,
error=message, error=message,
resume=resume_for_completed,
), ),
] ]
@@ -507,31 +483,24 @@ class ClaudeRunner(ResumeTokenMixin, JsonlSubprocessRunner):
*, *,
resume: ResumeToken | None, resume: ResumeToken | None,
found_session: ResumeToken | None, found_session: ResumeToken | None,
stderr_tail: str,
state: ClaudeStreamState, state: ClaudeStreamState,
) -> list[TakopiEvent]: ) -> list[TakopiEvent]:
_ = stderr_tail
if not found_session: if not found_session:
message = "claude finished but no session_id was captured" message = "claude finished but no session_id was captured"
resume_for_completed = resume resume_for_completed = resume
return [ return [
CompletedEvent( state.factory.completed_error(
engine=ENGINE,
ok=False,
answer="",
resume=resume_for_completed,
error=message, error=message,
resume=resume_for_completed,
) )
] ]
message = "claude finished without a result event" message = "claude finished without a result event"
return [ return [
CompletedEvent( state.factory.completed_error(
engine=ENGINE, error=message,
ok=False,
answer=state.last_assistant_text or "", answer=state.last_assistant_text or "",
resume=found_session, resume=found_session,
error=message,
) )
] ]
+206 -292
View File
@@ -4,66 +4,29 @@ import logging
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, cast from typing import Any
import msgspec
from ..backends import EngineBackend, EngineConfig from ..backends import EngineBackend, EngineConfig
from ..config import ConfigError from ..config import ConfigError
from ..model import ( from ..events import EventFactory
Action, from ..model import ActionPhase, EngineId, ResumeToken, TakopiEvent
ActionEvent,
ActionKind,
ActionLevel,
ActionPhase,
CompletedEvent,
EngineId,
ResumeToken,
StartedEvent,
TakopiEvent,
)
from ..runner import JsonlSubprocessRunner, ResumeTokenMixin, Runner from ..runner import JsonlSubprocessRunner, ResumeTokenMixin, Runner
from ..schemas import codex as codex_schema
from ..utils.paths import relativize_command from ..utils.paths import relativize_command
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
ENGINE: EngineId = EngineId("codex") 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*$") _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( _RECONNECTING_RE = re.compile(
r"^Reconnecting\.{3}\s*(?P<attempt>\d+)/(?P<max>\d+)\s*$", r"^Reconnecting\.{3}\s*(?P<attempt>\d+)/(?P<max>\d+)\s*$",
re.IGNORECASE, 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: def _parse_reconnect_message(message: str) -> tuple[int, int] | None:
match = _RECONNECTING_RE.match(message) match = _RECONNECTING_RE.match(message)
if not match: if not match:
@@ -76,64 +39,24 @@ def _parse_reconnect_message(message: str) -> tuple[int, int] | None:
return (attempt, max_attempts) return (attempt, max_attempts)
def _started_event(token: ResumeToken, *, title: str) -> StartedEvent: def _short_tool_name(server: str | None, tool: str | None) -> str:
return StartedEvent(engine=token.engine, resume=token, title=title) name = ".".join(part for part in (server, tool) if part)
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)
return name or "tool" return name or "tool"
def _summarize_tool_result(result: Any) -> dict[str, Any] | None: def _summarize_tool_result(result: Any) -> dict[str, Any] | None:
if not isinstance(result, dict): if isinstance(result, codex_schema.McpToolCallItemResult):
return None
summary: dict[str, Any] = {} 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
if isinstance(result, dict):
summary = {}
content = result.get("content") content = result.get("content")
if isinstance(content, list): if isinstance(content, list):
summary["content_blocks"] = len(content) summary["content_blocks"] = len(content)
@@ -150,10 +73,20 @@ def _summarize_tool_result(result: Any) -> dict[str, Any] | None:
summary["has_structured"] = result.get(structured_key) is not None summary["has_structured"] = result.get(structured_key) is not None
return summary or None return summary or None
return None
def _format_change_summary(item: dict[str, Any]) -> str:
changes = item.get("changes") or [] def _format_change_summary(changes: list[Any]) -> str:
paths = [c.get("path") for c in changes if c.get("path")] 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: if not paths:
total = len(changes) total = len(changes)
if total <= 0: if total <= 0:
@@ -178,6 +111,14 @@ def _summarize_todo_list(items: Any) -> _TodoSummary:
next_text: str | None = None next_text: str | None = None
for raw_item in items: 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): if not isinstance(raw_item, dict):
continue continue
total += 1 total += 1
@@ -200,25 +141,17 @@ def _todo_title(summary: _TodoSummary) -> str:
return f"todo {summary.done}/{summary.total}: done" return f"todo {summary.done}/{summary.total}: done"
def _translate_item_event(etype: str, item: dict[str, Any]) -> list[TakopiEvent]: def _translate_item_event(
item_type = cast(str, item.get("type") or item.get("item_type")) phase: ActionPhase, item: codex_schema.ThreadItem, *, factory: EventFactory
if item_type == "assistant_message": ) -> list[TakopiEvent]:
item_type = "agent_message" match item:
case codex_schema.AgentMessageItem():
if item_type == "agent_message":
return [] return []
case codex_schema.ErrorItem(id=action_id, message=message):
action_id = str(item["id"])
phase = cast(ActionPhase, etype.split(".")[-1])
if item_type == "error":
if phase != "completed": if phase != "completed":
return [] return []
message = str(item["message"])
return [ return [
_action_event( factory.action_completed(
phase="completed",
action_id=action_id, action_id=action_id,
kind="warning", kind="warning",
title=message, title=message,
@@ -226,191 +159,191 @@ def _translate_item_event(etype: str, item: dict[str, Any]) -> list[TakopiEvent]
ok=False, ok=False,
message=message, message=message,
level="warning", level="warning",
) ),
] ]
case codex_schema.CommandExecutionItem(
kind = _ACTION_KIND_MAP.get(item_type) id=action_id,
if kind is None: command=command,
return [] exit_code=exit_code,
status=status,
if kind == "command": ):
title = relativize_command(str(item["command"])) title = relativize_command(command)
if phase in {"started", "updated"}: if phase in {"started", "updated"}:
return [ return [
_action_event( factory.action(
phase=phase, phase=phase,
action_id=action_id, action_id=action_id,
kind=kind, kind="command",
title=title, title=title,
) )
] ]
if phase == "completed": if phase == "completed":
status = item["status"]
exit_code = item.get("exit_code")
ok = status == "completed" ok = status == "completed"
if isinstance(exit_code, int): if isinstance(exit_code, int):
ok = ok and exit_code == 0 ok = ok and exit_code == 0
detail = { detail = {"exit_code": exit_code, "status": status}
"exit_code": exit_code,
"status": status,
}
return [ return [
_action_event( factory.action_completed(
phase="completed",
action_id=action_id, action_id=action_id,
kind=kind, kind="command",
title=title, title=title,
detail=detail, detail=detail,
ok=ok, ok=ok,
) ),
] ]
case codex_schema.McpToolCallItem(
if kind == "tool": id=action_id,
if item_type == "tool_call": server=server,
name = item["name"] tool=tool,
title = str(name) if name else "tool" arguments=arguments,
detail = { status=status,
"name": name, result=result,
"status": item.get("status"), error=error,
"arguments": item.get("arguments"), ):
} title = _short_tool_name(server, tool)
else: detail: dict[str, Any] = {
tool_name = _short_tool_name(item) "server": server,
title = tool_name "tool": tool,
detail = { "status": status,
"server": item["server"], "arguments": arguments,
"tool": item["tool"],
"status": item.get("status"),
"arguments": item.get("arguments"),
} }
if phase in {"started", "updated"}: if phase in {"started", "updated"}:
return [ return [
_action_event( factory.action(
phase=phase, phase=phase,
action_id=action_id, action_id=action_id,
kind=kind, kind="tool",
title=title, title=title,
detail=detail, detail=detail,
) )
] ]
if phase == "completed": if phase == "completed":
status = item.get("status") ok = status == "completed" and error is None
error = item.get("error") if error is not None:
ok = status == "completed" and not error detail["error_message"] = str(error.message)
if error: result_summary = _summarize_tool_result(result)
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: if result_summary is not None:
detail["result_summary"] = result_summary detail["result_summary"] = result_summary
return [ return [
_action_event( factory.action_completed(
phase="completed",
action_id=action_id, action_id=action_id,
kind=kind, kind="tool",
title=title, title=title,
detail=detail, detail=detail,
ok=ok, ok=ok,
) ),
] ]
case codex_schema.WebSearchItem(id=action_id, query=query):
if kind == "web_search": detail = {"query": query}
title = str(item["query"])
detail = {"query": item["query"]}
if phase in {"started", "updated"}: if phase in {"started", "updated"}:
return [ return [
_action_event( factory.action(
phase=phase, phase=phase,
action_id=action_id, action_id=action_id,
kind=kind, kind="web_search",
title=title, title=query,
detail=detail, detail=detail,
) )
] ]
if phase == "completed": if phase == "completed":
return [ return [
_action_event( factory.action_completed(
phase="completed",
action_id=action_id, action_id=action_id,
kind=kind, kind="web_search",
title=title, title=query,
detail=detail, detail=detail,
ok=True, ok=True,
) )
] ]
case codex_schema.FileChangeItem(id=action_id, changes=changes, status=status):
if kind == "file_change":
if phase != "completed": if phase != "completed":
return [] return []
title = _format_change_summary(item) title = _format_change_summary(changes)
detail = { detail = {
"changes": item.get("changes", []), "changes": changes,
"status": item.get("status"), "status": status,
"error": item.get("error"), "error": None,
} }
ok = item.get("status") == "completed" ok = status == "completed"
return [ return [
_action_event( factory.action_completed(
phase="completed",
action_id=action_id, action_id=action_id,
kind=kind, kind="file_change",
title=title, title=title,
detail=detail, detail=detail,
ok=ok, ok=ok,
) )
] ]
case codex_schema.TodoListItem(id=action_id, items=items):
if kind == "note": summary = _summarize_todo_list(items)
if item_type == "todo_list":
summary = _summarize_todo_list(item["items"])
title = _todo_title(summary) title = _todo_title(summary)
detail = {"done": summary.done, "total": summary.total} detail = {"done": summary.done, "total": summary.total}
else:
title = str(item["text"])
detail = None
if phase in {"started", "updated"}: if phase in {"started", "updated"}:
return [ return [
_action_event( factory.action(
phase=phase, phase=phase,
action_id=action_id, action_id=action_id,
kind=kind, kind="note",
title=title, title=title,
detail=detail, detail=detail,
) )
] ]
if phase == "completed": if phase == "completed":
return [ return [
_action_event( factory.action_completed(
phase="completed",
action_id=action_id, action_id=action_id,
kind=kind, kind="note",
title=title, title=title,
detail=detail, detail=detail,
ok=True, 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 [] return []
def translate_codex_event(event: dict[str, Any], *, title: str) -> list[TakopiEvent]: def translate_codex_event(
etype = event["type"] event: codex_schema.ThreadEvent,
match etype: *,
case "thread.started": title: str,
token = ResumeToken(engine=ENGINE, value=str(event["thread_id"])) factory: EventFactory,
return [_started_event(token, title=title)] ) -> list[TakopiEvent]:
case "item.started" | "item.updated" | "item.completed": match event:
return _translate_item_event(etype, event["item"]) 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 _: case _:
return [] return []
@dataclass(slots=True) @dataclass(slots=True)
class CodexRunState: class CodexRunState:
factory: EventFactory
note_seq: int = 0 note_seq: int = 0
final_answer: str | None = None final_answer: str | None = None
turn_index: int = 0 turn_index: int = 0
@@ -419,7 +352,6 @@ class CodexRunState:
class CodexRunner(ResumeTokenMixin, JsonlSubprocessRunner): class CodexRunner(ResumeTokenMixin, JsonlSubprocessRunner):
engine: EngineId = ENGINE engine: EngineId = ENGINE
resume_re = _RESUME_RE resume_re = _RESUME_RE
stderr_tail_lines = STDERR_TAIL_LINES
logger = logger logger = logger
def __init__( def __init__(
@@ -453,7 +385,7 @@ class CodexRunner(ResumeTokenMixin, JsonlSubprocessRunner):
def new_state(self, prompt: str, resume: ResumeToken | None) -> CodexRunState: def new_state(self, prompt: str, resume: ResumeToken | None) -> CodexRunState:
_ = prompt, resume _ = prompt, resume
return CodexRunState() return CodexRunState(factory=EventFactory(ENGINE))
def start_run( def start_run(
self, self,
@@ -466,27 +398,50 @@ class CodexRunner(ResumeTokenMixin, JsonlSubprocessRunner):
logger.info("[codex] start run resume=%r", resume.value if resume else None) logger.info("[codex] start run resume=%r", resume.value if resume else None)
logger.debug("[codex] prompt: %s", prompt) 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: def pipes_error_message(self) -> str:
return "codex exec failed to open subprocess pipes" return "codex exec failed to open subprocess pipes"
def translate( def translate(
self, self,
data: dict[str, Any], data: codex_schema.ThreadEvent,
*, *,
state: CodexRunState, state: CodexRunState,
resume: ResumeToken | None, resume: ResumeToken | None,
found_session: ResumeToken | None, found_session: ResumeToken | None,
) -> list[TakopiEvent]: ) -> list[TakopiEvent]:
etype = data["type"] factory = state.factory
match etype: match data:
case "error": case codex_schema.StreamError(message=message):
message = str(data.get("message") or "")
reconnect = _parse_reconnect_message(message) reconnect = _parse_reconnect_message(message)
if reconnect is not None: if reconnect is not None:
attempt, max_attempts = reconnect attempt, max_attempts = reconnect
phase: ActionPhase = "started" if attempt <= 1 else "updated" phase: ActionPhase = "started" if attempt <= 1 else "updated"
return [ return [
_action_event( factory.action(
phase=phase, phase=phase,
action_id="codex.reconnect", action_id="codex.reconnect",
kind="note", kind="note",
@@ -495,83 +450,53 @@ class CodexRunner(ResumeTokenMixin, JsonlSubprocessRunner):
level="info", 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"])
resume_for_completed = found_session or resume
return [
_completed_event(
resume=resume_for_completed,
ok=False,
answer=state.final_answer or "",
error=message,
)
]
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)] return [self.note_event(message, state=state, ok=False)]
case "turn.started": case codex_schema.TurnFailed(error=error):
resume_for_completed = found_session or resume
return [
factory.completed_error(
error=error.message,
answer=state.final_answer or "",
resume=resume_for_completed,
)
]
case codex_schema.TurnStarted():
action_id = f"turn_{state.turn_index}" action_id = f"turn_{state.turn_index}"
state.turn_index += 1 state.turn_index += 1
return [ return [
_action_event( factory.action_started(
phase="started",
action_id=action_id, action_id=action_id,
kind="turn", kind="turn",
title="turn started", title="turn started",
) )
] ]
case "turn.completed": case codex_schema.TurnCompleted(usage=usage):
resume_for_completed = found_session or resume resume_for_completed = found_session or resume
return [ return [
_completed_event( factory.completed_ok(
resume=resume_for_completed,
ok=True,
answer=state.final_answer or "", answer=state.final_answer or "",
usage=data.get("usage"), resume=resume_for_completed,
usage=msgspec.to_builtins(usage),
) )
] ]
case "item.completed": case codex_schema.ItemCompleted(
item = data["item"] item=codex_schema.AgentMessageItem(text=text)
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: if state.final_answer is None:
state.final_answer = item["text"] state.final_answer = text
else: else:
logger.debug( logger.debug(
"[codex] emitted multiple agent messages; using the last one" "[codex] emitted multiple agent messages; using the last one"
) )
state.final_answer = item["text"] state.final_answer = text
case _: case _:
pass 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( def process_error_events(
self, self,
@@ -579,13 +504,8 @@ class CodexRunner(ResumeTokenMixin, JsonlSubprocessRunner):
*, *,
resume: ResumeToken | None, resume: ResumeToken | None,
found_session: ResumeToken | None, found_session: ResumeToken | None,
stderr_tail: str,
state: CodexRunState, state: CodexRunState,
) -> list[TakopiEvent]: ) -> 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 resume_for_completed = found_session or resume
return [ return [
@@ -593,13 +513,11 @@ class CodexRunner(ResumeTokenMixin, JsonlSubprocessRunner):
message, message,
state=state, state=state,
ok=False, ok=False,
detail={"stderr_tail": stderr_tail},
), ),
_completed_event( state.factory.completed_error(
resume=resume_for_completed,
ok=False,
answer=state.final_answer or "",
error=message, error=message,
answer=state.final_answer or "",
resume=resume_for_completed,
), ),
] ]
@@ -608,27 +526,23 @@ class CodexRunner(ResumeTokenMixin, JsonlSubprocessRunner):
*, *,
resume: ResumeToken | None, resume: ResumeToken | None,
found_session: ResumeToken | None, found_session: ResumeToken | None,
stderr_tail: str,
state: CodexRunState, state: CodexRunState,
) -> list[TakopiEvent]: ) -> list[TakopiEvent]:
_ = stderr_tail
if not found_session: if not found_session:
message = "codex exec finished but no session_id/thread_id was captured" message = "codex exec finished but no session_id/thread_id was captured"
resume_for_completed = resume resume_for_completed = resume
return [ return [
_completed_event( state.factory.completed_error(
resume=resume_for_completed,
ok=False,
answer=state.final_answer or "",
error=message, error=message,
answer=state.final_answer or "",
resume=resume_for_completed,
) )
] ]
logger.info("[codex] done run session=%s", found_session.value) logger.info("[codex] done run session=%s", found_session.value)
return [ return [
_completed_event( state.factory.completed_ok(
resume=found_session,
ok=True,
answer=state.final_answer or "", answer=state.final_answer or "",
resume=found_session,
) )
] ]
+47 -64
View File
@@ -19,6 +19,8 @@ from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any, Literal from typing import Any, Literal
import msgspec
from ..backends import EngineBackend, EngineConfig from ..backends import EngineBackend, EngineConfig
from ..config import ConfigError from ..config import ConfigError
from ..model import ( from ..model import (
@@ -32,12 +34,12 @@ from ..model import (
TakopiEvent, TakopiEvent,
) )
from ..runner import JsonlSubprocessRunner, ResumeTokenMixin, Runner from ..runner import JsonlSubprocessRunner, ResumeTokenMixin, Runner
from ..schemas import opencode as opencode_schema
from ..utils.paths import relativize_command, relativize_path from ..utils.paths import relativize_command, relativize_path
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
ENGINE: EngineId = EngineId("opencode") ENGINE: EngineId = EngineId("opencode")
STDERR_TAIL_LINES = 200
_RESUME_RE = re.compile( _RESUME_RE = re.compile(
r"(?im)^\s*`?opencode(?:\s+run)?\s+(?:--session|-s)\s+(?P<token>ses_[A-Za-z0-9]+)`?\s*$" 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 session_id: str | None = None
emitted_started: bool = False emitted_started: bool = False
saw_step_finish: bool = False saw_step_finish: bool = False
total_cost: float = 0.0
total_tokens: dict[str, int] = field(default_factory=dict)
def _action_event( def _action_event(
@@ -146,9 +146,8 @@ def _normalize_tool_title(
return title return title
def _extract_tool_action(event: dict[str, Any]) -> Action | None: def _extract_tool_action(part: dict[str, Any]) -> Action | None:
"""Extract an Action from an OpenCode tool_use event.""" """Extract an Action from an OpenCode tool_use part."""
part = event.get("part") or {}
state = part.get("state") or {} state = part.get("state") or {}
call_id = part.get("callID") call_id = part.get("callID")
@@ -182,31 +181,21 @@ def _extract_tool_action(event: dict[str, Any]) -> Action | None:
return Action(id=call_id, kind=kind, title=title, detail=detail) 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( def translate_opencode_event(
event: dict[str, Any], event: opencode_schema.OpenCodeEvent,
*, *,
title: str, title: str,
state: OpenCodeStreamState, state: OpenCodeStreamState,
) -> list[TakopiEvent]: ) -> list[TakopiEvent]:
"""Translate an OpenCode JSON event into Takopi events.""" """Translate an OpenCode JSON event into Takopi events."""
etype = event.get("type") session_id = event.sessionID
session_id = event.get("sessionID")
if isinstance(session_id, str) and session_id: if isinstance(session_id, str) and session_id:
if state.session_id is None: if state.session_id is None:
state.session_id = session_id state.session_id = session_id
if etype == "step_start": match event:
case opencode_schema.StepStart():
if not state.emitted_started and state.session_id: if not state.emitted_started and state.session_id:
state.emitted_started = True state.emitted_started = True
return [ return [
@@ -218,12 +207,12 @@ def translate_opencode_event(
] ]
return [] return []
if etype == "tool_use": case opencode_schema.ToolUse(part=part):
part = event.get("part") or {} part = part or {}
tool_state = part.get("state") or {} tool_state = part.get("state") or {}
status = tool_state.get("status") status = tool_state.get("status")
action = _extract_tool_action(event) action = _extract_tool_action(part)
if action is None: if action is None:
return [] return []
@@ -286,8 +275,8 @@ def translate_opencode_event(
state.pending_actions[action.id] = action state.pending_actions[action.id] = action
return [_action_event(phase="started", action=action)] return [_action_event(phase="started", action=action)]
if etype == "text": case opencode_schema.Text(part=part):
part = event.get("part") or {} part = part or {}
text = part.get("text") text = part.get("text")
if isinstance(text, str) and text: if isinstance(text, str) and text:
if state.last_text is None: if state.last_text is None:
@@ -296,54 +285,28 @@ def translate_opencode_event(
state.last_text += text state.last_text += text
return [] return []
if etype == "step_finish": case opencode_schema.StepFinish(part=part):
part = event.get("part") or {} part = part or {}
reason = part.get("reason") reason = part.get("reason")
state.saw_step_finish = True state.saw_step_finish = True
tokens = part.get("tokens") or {}
if isinstance(tokens, dict):
for key in ("input", "output", "reasoning"):
value = tokens.get(key)
if isinstance(value, int):
state.total_tokens[key] = state.total_tokens.get(key, 0) + value
cache = tokens.get("cache") or {}
if isinstance(cache, dict):
for key in ("read", "write"):
value = cache.get(key)
if not isinstance(value, int):
continue
cache_key = f"cache_{key}"
state.total_tokens[cache_key] = (
state.total_tokens.get(cache_key, 0) + value
)
cost = part.get("cost")
if isinstance(cost, (int, float)):
state.total_cost += cost
if reason == "stop": if reason == "stop":
resume = None resume = None
if state.session_id: if state.session_id:
resume = ResumeToken(engine=ENGINE, value=state.session_id) resume = ResumeToken(engine=ENGINE, value=state.session_id)
usage = _usage_from_tokens(state.total_tokens, state.total_cost)
return [ return [
CompletedEvent( CompletedEvent(
engine=ENGINE, engine=ENGINE,
ok=True, ok=True,
answer=state.last_text or "", answer=state.last_text or "",
resume=resume, resume=resume,
usage=usage or None,
) )
] ]
return [] return []
if etype == "error": case opencode_schema.Error(error=error_value, message=message_value):
raw_message = event.get("message") raw_message = message_value if message_value is not None else error_value
if raw_message is None:
raw_message = event.get("error")
message = raw_message message = raw_message
if isinstance(message, dict): if isinstance(message, dict):
@@ -352,7 +315,9 @@ def translate_opencode_event(
message = data.get("message") message = data.get("message")
else: else:
message = ( message = (
message.get("message") or message.get("name") or "opencode error" message.get("message")
or message.get("name")
or "opencode error"
) )
elif message is None: elif message is None:
message = "opencode error" message = "opencode error"
@@ -371,6 +336,7 @@ def translate_opencode_event(
) )
] ]
case _:
return [] return []
@@ -384,7 +350,6 @@ class OpenCodeRunner(ResumeTokenMixin, JsonlSubprocessRunner):
opencode_cmd: str = "opencode" opencode_cmd: str = "opencode"
model: str | None = None model: str | None = None
session_title: str = "opencode" session_title: str = "opencode"
stderr_tail_lines: int = STDERR_TAIL_LINES
logger: logging.Logger = logger logger: logging.Logger = logger
def format_resume(self, token: ResumeToken) -> str: def format_resume(self, token: ResumeToken) -> str:
@@ -452,7 +417,7 @@ class OpenCodeRunner(ResumeTokenMixin, JsonlSubprocessRunner):
def translate( def translate(
self, self,
data: dict[str, Any], data: opencode_schema.OpenCodeEvent,
*, *,
state: OpenCodeStreamState, state: OpenCodeStreamState,
resume: ResumeToken | None, resume: ResumeToken | None,
@@ -465,13 +430,36 @@ class OpenCodeRunner(ResumeTokenMixin, JsonlSubprocessRunner):
state=state, 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( def process_error_events(
self, self,
rc: int, rc: int,
*, *,
resume: ResumeToken | None, resume: ResumeToken | None,
found_session: ResumeToken | None, found_session: ResumeToken | None,
stderr_tail: str,
state: OpenCodeStreamState, state: OpenCodeStreamState,
) -> list[TakopiEvent]: ) -> list[TakopiEvent]:
message = f"opencode failed (rc={rc})." message = f"opencode failed (rc={rc})."
@@ -481,7 +469,6 @@ class OpenCodeRunner(ResumeTokenMixin, JsonlSubprocessRunner):
message, message,
state=state, state=state,
ok=False, ok=False,
detail={"stderr_tail": stderr_tail},
), ),
CompletedEvent( CompletedEvent(
engine=ENGINE, engine=ENGINE,
@@ -497,10 +484,8 @@ class OpenCodeRunner(ResumeTokenMixin, JsonlSubprocessRunner):
*, *,
resume: ResumeToken | None, resume: ResumeToken | None,
found_session: ResumeToken | None, found_session: ResumeToken | None,
stderr_tail: str,
state: OpenCodeStreamState, state: OpenCodeStreamState,
) -> list[TakopiEvent]: ) -> list[TakopiEvent]:
_ = stderr_tail
if not found_session: if not found_session:
message = "opencode finished but no session_id was captured" message = "opencode finished but no session_id was captured"
resume_for_completed = resume resume_for_completed = resume
@@ -515,14 +500,12 @@ class OpenCodeRunner(ResumeTokenMixin, JsonlSubprocessRunner):
] ]
if state.saw_step_finish: if state.saw_step_finish:
usage = _usage_from_tokens(state.total_tokens, state.total_cost)
return [ return [
CompletedEvent( CompletedEvent(
engine=ENGINE, engine=ENGINE,
ok=True, ok=True,
answer=state.last_text or "", answer=state.last_text or "",
resume=found_session, resume=found_session,
usage=usage or None,
) )
] ]
+47 -24
View File
@@ -9,6 +9,8 @@ from pathlib import Path
from typing import Any from typing import Any
from uuid import uuid4 from uuid import uuid4
import msgspec
from ..backends import EngineBackend, EngineConfig from ..backends import EngineBackend, EngineConfig
from ..config import ConfigError from ..config import ConfigError
from ..model import ( from ..model import (
@@ -24,12 +26,12 @@ from ..model import (
TakopiEvent, TakopiEvent,
) )
from ..runner import JsonlSubprocessRunner, ResumeTokenMixin, Runner from ..runner import JsonlSubprocessRunner, ResumeTokenMixin, Runner
from ..schemas import pi as pi_schema
from ..utils.paths import relativize_command, relativize_path from ..utils.paths import relativize_command, relativize_path
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
ENGINE: EngineId = EngineId("pi") ENGINE: EngineId = EngineId("pi")
STDERR_TAIL_LINES = 200
_RESUME_RE = re.compile(r"(?im)^\s*`?pi\s+--session\s+(?P<token>.+?)`?\s*$") _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( def translate_pi_event(
event: dict[str, Any], event: pi_schema.PiEvent,
*, *,
title: str, title: str,
meta: dict[str, Any] | None, meta: dict[str, Any] | None,
@@ -150,12 +152,10 @@ def translate_pi_event(
) )
state.started = True state.started = True
etype = event.get("type") match event:
case pi_schema.ToolExecutionStart(
if etype == "tool_execution_start": toolCallId=tool_id, toolName=tool_name, args=args
tool_id = event.get("toolCallId") ):
tool_name = event.get("toolName")
args = event.get("args") or {}
if not isinstance(args, dict): if not isinstance(args, dict):
args = {} args = {}
if isinstance(tool_id, str) and tool_id: if isinstance(tool_id, str) and tool_id:
@@ -171,18 +171,17 @@ def translate_pi_event(
out.append(_action_event(phase="started", action=action)) out.append(_action_event(phase="started", action=action))
return out return out
if etype == "tool_execution_end": case pi_schema.ToolExecutionEnd(
tool_id = event.get("toolCallId") toolCallId=tool_id, toolName=tool_name, result=result, isError=is_error
tool_name = event.get("toolName") ):
if isinstance(tool_id, str) and tool_id: if isinstance(tool_id, str) and tool_id:
action = state.pending_actions.pop(tool_id, None) action = state.pending_actions.pop(tool_id, None)
name = str(tool_name or "tool") name = str(tool_name or "tool")
if action is None: if action is None:
action = Action(id=tool_id, kind="tool", title=name, detail={}) action = Action(id=tool_id, kind="tool", title=name, detail={})
detail = dict(action.detail) detail = dict(action.detail)
detail["result"] = event.get("result") detail["result"] = result
detail["is_error"] = event.get("isError") detail["is_error"] = is_error
is_error = event.get("isError") is True
out.append( out.append(
_action_event( _action_event(
phase="completed", phase="completed",
@@ -197,8 +196,7 @@ def translate_pi_event(
) )
return out return out
if etype == "message_end": case pi_schema.MessageEnd(message=message):
message = event.get("message")
if isinstance(message, dict) and message.get("role") == "assistant": if isinstance(message, dict) and message.get("role") == "assistant":
text = _extract_text_blocks(message.get("content")) text = _extract_text_blocks(message.get("content"))
if text: if text:
@@ -211,8 +209,8 @@ def translate_pi_event(
state.last_assistant_error = error state.last_assistant_error = error
return out return out
if etype == "agent_end": case pi_schema.AgentEnd(messages=messages):
assistant = _last_assistant_message(event.get("messages")) assistant = _last_assistant_message(messages)
if assistant: if assistant:
text = _extract_text_blocks(assistant.get("content")) text = _extract_text_blocks(assistant.get("content"))
if text: if text:
@@ -240,13 +238,13 @@ def translate_pi_event(
) )
return out return out
case _:
return out return out
class PiRunner(ResumeTokenMixin, JsonlSubprocessRunner): class PiRunner(ResumeTokenMixin, JsonlSubprocessRunner):
engine: EngineId = ENGINE engine: EngineId = ENGINE
resume_re: re.Pattern[str] = _RESUME_RE resume_re: re.Pattern[str] = _RESUME_RE
stderr_tail_lines = STDERR_TAIL_LINES
logger = logger logger = logger
def __init__( def __init__(
@@ -335,7 +333,7 @@ class PiRunner(ResumeTokenMixin, JsonlSubprocessRunner):
def translate( def translate(
self, self,
data: dict[str, Any], data: pi_schema.PiEvent,
*, *,
state: PiStreamState, state: PiStreamState,
resume: ResumeToken | None, resume: ResumeToken | None,
@@ -354,19 +352,46 @@ class PiRunner(ResumeTokenMixin, JsonlSubprocessRunner):
state=state, 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( def process_error_events(
self, self,
rc: int, rc: int,
*, *,
resume: ResumeToken | None, resume: ResumeToken | None,
found_session: ResumeToken | None, found_session: ResumeToken | None,
stderr_tail: str,
state: PiStreamState, state: PiStreamState,
) -> list[TakopiEvent]: ) -> list[TakopiEvent]:
message = f"pi failed (rc={rc})." message = f"pi failed (rc={rc})."
resume_for_completed = found_session or resume or state.resume resume_for_completed = found_session or resume or state.resume
return [ return [
self.note_event(message, state=state, detail={"stderr_tail": stderr_tail}), self.note_event(message, state=state),
CompletedEvent( CompletedEvent(
engine=ENGINE, engine=ENGINE,
ok=False, ok=False,
@@ -382,10 +407,8 @@ class PiRunner(ResumeTokenMixin, JsonlSubprocessRunner):
*, *,
resume: ResumeToken | None, resume: ResumeToken | None,
found_session: ResumeToken | None, found_session: ResumeToken | None,
stderr_tail: str,
state: PiStreamState, state: PiStreamState,
) -> list[TakopiEvent]: ) -> list[TakopiEvent]:
_ = stderr_tail
resume_for_completed = found_session or resume or state.resume resume_for_completed = found_session or resume or state.resume
message = "pi finished without an agent_end event" message = "pi finished without an agent_end event"
return [ 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)
+9 -50
View File
@@ -1,73 +1,32 @@
from __future__ import annotations from __future__ import annotations
from collections import deque
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from dataclasses import dataclass
import json
import logging import logging
from typing import Any import sys
import anyio import anyio
from anyio.abc import ByteReceiveStream 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]: async def iter_bytes_lines(stream: ByteReceiveStream) -> AsyncIterator[bytes]:
text_stream = TextReceiveStream(stream, errors="replace") buffered = BufferedByteReceiveStream(stream)
buffer = ""
while True: while True:
try: try:
chunk = await text_stream.receive() line = await buffered.receive_until(b"\n", sys.maxsize)
except anyio.EndOfStream: except anyio.IncompleteRead:
if buffer:
yield buffer
return 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 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)
async def drain_stderr( async def drain_stderr(
stream: ByteReceiveStream, stream: ByteReceiveStream,
chunks: deque[str],
logger: logging.Logger, logger: logging.Logger,
tag: str, tag: str,
) -> None: ) -> None:
try: try:
async for line in iter_text_lines(stream): async for line in iter_bytes_lines(stream):
logger.debug("[%s][stderr] %s", tag, line.rstrip()) text = line.decode("utf-8", errors="replace")
chunks.append(line) logger.debug("[%s][stderr] %s", tag, text)
except Exception as e: except Exception as e:
logger.debug("[%s][stderr] drain error: %s", tag, 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":"0199a213-81c0-7800-8aa1-bbab2a035a53"}
{"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":"turn.started"} {"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.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.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.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_0010","type":"error","message":"Command `npm` not found in PATH (exit 127)."}} {"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":"turn.failed","error":{"message":"Aborted: required dependency `npm` is missing; cannot continue."},"exit_code":1} {"type":"item.started","item":{"id":"item_1","type":"command_execution","command":"pytest -q","aggregated_output":"","exit_code":null,"status":"in_progress"}}
{"type":"error","message":"codex exec exited non-zero (1) after turn.failed"} {"type":"item.completed","item":{"id":"item_1","type":"command_execution","command":"pytest -q","aggregated_output":"....\n","exit_code":0,"status":"completed"}}
{"type":"thread.started","thread_id":"thread_legacy_7f9c2d3e"} {"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":"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":"turn.failed","error":{"message":"Aborted: required dependency `npm` is missing; cannot continue."}}
{"type":"item.completed","item":{"id":"item_l_0002","item_type":"command_execution","command":"echo legacy","output":"legacy\n","exit_code":0,"status":"completed"}} {"type":"error","message":"codex exec exited non-zero after turn.failed"}
{"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}
+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, ENGINE,
translate_claude_event, 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 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: 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: def test_translate_success_fixture() -> None:
state = ClaudeStreamState() state = ClaudeStreamState()
events: list = [] events: list = []
for event in _load_fixture("claude_stream_success.jsonl"): for event in _load_fixture(
events.extend(translate_claude_event(event, title="claude", state=state)) "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) assert isinstance(events[0], StartedEvent)
started = next(evt for evt in events if isinstance(evt, 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 for evt in action_events
if evt.phase == "started" if evt.phase == "started"
} }
assert started_actions[("toolu_1", "started")].action.kind == "command" assert (
write_action = started_actions[("toolu_2", "started")].action 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.kind == "file_change"
assert write_action.detail["changes"][0]["path"] == "notes.md" assert write_action.detail["changes"][0]["path"] == "notes.md"
@@ -57,34 +101,37 @@ def test_translate_success_fixture() -> None:
for evt in action_events for evt in action_events
if evt.phase == "completed" if evt.phase == "completed"
} }
assert completed_actions[("toolu_1", "completed")].ok is True assert completed_actions[("toolu_01BASH_LS_EXAMPLE", "completed")].ok is True
assert completed_actions[("toolu_2", "completed")].ok is True assert completed_actions[("toolu_02", "completed")].ok is True
completed = next(evt for evt in events if isinstance(evt, CompletedEvent)) completed = next(evt for evt in events if isinstance(evt, CompletedEvent))
assert events[-1] == completed assert events[-1] == completed
assert completed.ok is True assert completed.ok is True
assert completed.resume == started.resume 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: def test_translate_error_fixture_permission_denials() -> None:
state = ClaudeStreamState() state = ClaudeStreamState()
events: list = [] events: list = []
for event in _load_fixture("claude_stream_error.jsonl"): for event in _load_fixture(
events.extend(translate_claude_event(event, title="claude", state=state)) "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)) started = next(evt for evt in events if isinstance(evt, StartedEvent))
completed = next(evt for evt in events if isinstance(evt, CompletedEvent)) 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.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 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 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 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 @pytest.mark.anyio
async def test_run_serializes_same_session() -> None: async def test_run_serializes_same_session() -> None:
runner = ClaudeRunner(claude_cmd="claude") 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" "resume_marker = os.environ['CLAUDE_TEST_RESUME_MARKER']\n"
"session_id = os.environ['CLAUDE_TEST_SESSION_ID']\n" "session_id = os.environ['CLAUDE_TEST_SESSION_ID']\n"
"\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" "args = sys.argv[1:]\n"
"if '--resume' in args or '-r' in args:\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" " with open(resume_marker, 'w', encoding='utf-8') as f:\n"
" f.write('started')\n" " f.write('started')\n"
" f.flush()\n" " f.flush()\n"
" sys.exit(0)\n" " sys.exit(0)\n"
"\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" "while not os.path.exists(gate):\n"
" time.sleep(0.001)\n" " time.sleep(0.001)\n"
"sys.exit(0)\n", "sys.exit(0)\n",
@@ -252,8 +355,37 @@ async def test_run_strips_anthropic_api_key_by_default(tmp_path, monkeypatch) ->
"\n" "\n"
"session_id = 'session_01'\n" "session_id = 'session_01'\n"
"status = 'set' if os.environ.get('ANTHROPIC_API_KEY') else 'unset'\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" "init = {\n"
"print(json.dumps({'type': 'result', 'subtype': 'success', 'is_error': False, 'result': f'api={status}', 'session_id': session_id}), flush=True)\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", "raise SystemExit(0)\n",
encoding="utf-8", 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.model import ActionEvent
from takopi.runners.codex import translate_codex_event 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: 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 len(out) == 1
assert isinstance(out[0], ActionEvent) assert isinstance(out[0], ActionEvent)
summary = out[0].action.detail["result_summary"] 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", "type": "mcp_tool_call",
"server": "docs", "server": "docs",
"tool": "search", "tool": "search",
"arguments": None,
"result": {"content": [], "structured_content": None}, "result": {"content": [], "structured_content": None},
"error": None, "error": None,
"status": "completed", "status": "completed",
}, },
} }
out = translate_codex_event(evt, title="Codex") out = _translate_event(evt)
assert len(out) == 1 assert len(out) == 1
assert isinstance(out[0], ActionEvent) assert isinstance(out[0], ActionEvent)
assert out[0].action.detail["result_summary"]["has_structured"] is False 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: def test_translate_mcp_tool_call_missing_error_is_ok() -> None:
evt = { evt = {
"type": "item.completed", "type": "item.completed",
@@ -76,18 +73,20 @@ def test_translate_mcp_tool_call_missing_error_is_ok() -> None:
"type": "mcp_tool_call", "type": "mcp_tool_call",
"server": "docs", "server": "docs",
"tool": "search", "tool": "search",
"arguments": None,
"status": "completed", "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 len(out) == 1
assert isinstance(out[0], ActionEvent) assert isinstance(out[0], ActionEvent)
assert out[0].ok is True 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 = { evt = {
"type": "item.completed", "type": "item.completed",
"item": { "item": {
@@ -95,11 +94,12 @@ def test_translate_command_execution_allows_missing_exit_code() -> None:
"type": "command_execution", "type": "command_execution",
"command": "ls -la", "command": "ls -la",
"aggregated_output": "", "aggregated_output": "",
"exit_code": None,
"status": "completed", "status": "completed",
}, },
} }
out = translate_codex_event(evt, title="Codex") out = _translate_event(evt)
assert len(out) == 1 assert len(out) == 1
assert isinstance(out[0], ActionEvent) assert isinstance(out[0], ActionEvent)
assert out[0].ok is True 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" "import sys\n"
"\n" "\n"
"sys.stdin.read()\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" 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", "print(json.dumps({'type': 'item.completed', 'item': {'id': 'item_0', 'type': 'agent_message', 'text': 'ok'}}), flush=True)\n",
encoding="utf-8", 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.ok is False
assert completed.error is not None assert completed.error is not None
assert "codex exec failed (rc=1)." in completed.error 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 @pytest.mark.anyio
+42 -15
View File
@@ -11,11 +11,26 @@ from takopi.runners.opencode import (
ENGINE, ENGINE,
translate_opencode_event, 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 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: 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.resume == started.resume
assert completed.answer == "```\nhello\n```" assert completed.answer == "```\nhello\n```"
assert completed.usage is not None
assert "tokens" in completed.usage
def test_translate_missing_reason_success() -> None: def test_translate_missing_reason_success() -> None:
state = OpenCodeStreamState() state = OpenCodeStreamState()
@@ -74,7 +86,6 @@ def test_translate_missing_reason_success() -> None:
fallback = runner.stream_end_events( fallback = runner.stream_end_events(
resume=None, resume=None,
found_session=started.resume, found_session=started.resume,
stderr_tail="",
state=state, state=state,
) )
@@ -82,14 +93,13 @@ def test_translate_missing_reason_success() -> None:
assert completed.ok is True assert completed.ok is True
assert completed.resume == started.resume assert completed.resume == started.resume
assert completed.answer == "All done." assert completed.answer == "All done."
assert completed.usage is not None
def test_translate_accumulates_text() -> None: def test_translate_accumulates_text() -> None:
state = OpenCodeStreamState() state = OpenCodeStreamState()
events = translate_opencode_event( events = translate_opencode_event(
{"type": "step_start", "sessionID": "ses_test123", "part": {}}, _decode_event({"type": "step_start", "sessionID": "ses_test123", "part": {}}),
title="opencode", title="opencode",
state=state, state=state,
) )
@@ -97,20 +107,24 @@ def test_translate_accumulates_text() -> None:
assert isinstance(events[0], StartedEvent) assert isinstance(events[0], StartedEvent)
translate_opencode_event( translate_opencode_event(
_decode_event(
{ {
"type": "text", "type": "text",
"sessionID": "ses_test123", "sessionID": "ses_test123",
"part": {"type": "text", "text": "Hello "}, "part": {"type": "text", "text": "Hello "},
}, }
),
title="opencode", title="opencode",
state=state, state=state,
) )
translate_opencode_event( translate_opencode_event(
_decode_event(
{ {
"type": "text", "type": "text",
"sessionID": "ses_test123", "sessionID": "ses_test123",
"part": {"type": "text", "text": "World"}, "part": {"type": "text", "text": "World"},
}, }
),
title="opencode", title="opencode",
state=state, state=state,
) )
@@ -118,11 +132,13 @@ def test_translate_accumulates_text() -> None:
assert state.last_text == "Hello World" assert state.last_text == "Hello World"
events = translate_opencode_event( events = translate_opencode_event(
_decode_event(
{ {
"type": "step_finish", "type": "step_finish",
"sessionID": "ses_test123", "sessionID": "ses_test123",
"part": {"reason": "stop", "tokens": {"input": 100, "output": 10}}, "part": {"reason": "stop", "tokens": {"input": 100, "output": 10}},
}, }
),
title="opencode", title="opencode",
state=state, state=state,
) )
@@ -140,6 +156,7 @@ def test_translate_tool_use_completed() -> None:
state.emitted_started = True state.emitted_started = True
events = translate_opencode_event( events = translate_opencode_event(
_decode_event(
{ {
"type": "tool_use", "type": "tool_use",
"sessionID": "ses_test123", "sessionID": "ses_test123",
@@ -155,7 +172,8 @@ def test_translate_tool_use_completed() -> None:
"metadata": {"exit": 0}, "metadata": {"exit": 0},
}, },
}, },
}, }
),
title="opencode", title="opencode",
state=state, state=state,
) )
@@ -175,6 +193,7 @@ def test_translate_tool_use_with_error() -> None:
state.emitted_started = True state.emitted_started = True
events = translate_opencode_event( events = translate_opencode_event(
_decode_event(
{ {
"type": "tool_use", "type": "tool_use",
"sessionID": "ses_test123", "sessionID": "ses_test123",
@@ -190,7 +209,8 @@ def test_translate_tool_use_with_error() -> None:
"metadata": {"exit": 1}, "metadata": {"exit": 1},
}, },
}, },
}, }
),
title="opencode", title="opencode",
state=state, state=state,
) )
@@ -209,6 +229,7 @@ def test_translate_tool_use_read_title_wraps_path() -> None:
path = Path.cwd() / "src" / "takopi" / "runners" / "opencode.py" path = Path.cwd() / "src" / "takopi" / "runners" / "opencode.py"
events = translate_opencode_event( events = translate_opencode_event(
_decode_event(
{ {
"type": "tool_use", "type": "tool_use",
"sessionID": "ses_test123", "sessionID": "ses_test123",
@@ -223,7 +244,8 @@ def test_translate_tool_use_read_title_wraps_path() -> None:
"title": "src/takopi/runners/opencode.py", "title": "src/takopi/runners/opencode.py",
}, },
}, },
}, }
),
title="opencode", title="opencode",
state=state, state=state,
) )
@@ -255,11 +277,16 @@ def test_step_finish_tool_calls_does_not_complete() -> None:
state.emitted_started = True state.emitted_started = True
events = translate_opencode_event( events = translate_opencode_event(
_decode_event(
{ {
"type": "step_finish", "type": "step_finish",
"sessionID": "ses_test123", "sessionID": "ses_test123",
"part": {"reason": "tool-calls", "tokens": {"input": 100, "output": 10}}, "part": {
"reason": "tool-calls",
"tokens": {"input": 100, "output": 10},
}, },
}
),
title="opencode", title="opencode",
state=state, 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 from pathlib import Path
import anyio import anyio
@@ -6,11 +5,21 @@ import pytest
from takopi.model import ActionEvent, CompletedEvent, ResumeToken, StartedEvent from takopi.model import ActionEvent, CompletedEvent, ResumeToken, StartedEvent
from takopi.runners.pi import ENGINE, PiRunner, PiStreamState, translate_pi_event 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 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: 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" }, { 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]] [[package]]
name = "packaging" name = "packaging"
version = "25.0" version = "25.0"
@@ -384,6 +408,7 @@ dependencies = [
{ name = "anyio" }, { name = "anyio" },
{ name = "httpx" }, { name = "httpx" },
{ name = "markdown-it-py" }, { name = "markdown-it-py" },
{ name = "msgspec" },
{ name = "questionary" }, { name = "questionary" },
{ name = "rich" }, { name = "rich" },
{ name = "sulguk" }, { name = "sulguk" },
@@ -404,6 +429,7 @@ requires-dist = [
{ name = "anyio", specifier = ">=4.12.0" }, { name = "anyio", specifier = ">=4.12.0" },
{ name = "httpx", specifier = ">=0.28.1" }, { name = "httpx", specifier = ">=0.28.1" },
{ name = "markdown-it-py" }, { name = "markdown-it-py" },
{ name = "msgspec", specifier = ">=0.20.0" },
{ name = "questionary", specifier = ">=2.1.1" }, { name = "questionary", specifier = ">=2.1.1" },
{ name = "rich", specifier = ">=14.2.0" }, { name = "rich", specifier = ">=14.2.0" },
{ name = "sulguk", specifier = ">=0.11.1" }, { name = "sulguk", specifier = ">=0.11.1" },