feat: msgspec schemas for jsonl decoding (#37)
This commit is contained in:
+81
-12
@@ -6,7 +6,8 @@ A *runner* is the adapter between an engine-specific CLI (Codex, Claude Code,
|
||||
**normalized event model** (`StartedEvent`, `ActionEvent`, `CompletedEvent`).
|
||||
|
||||
Takopi is designed so that adding a runner usually means **adding one new module** under
|
||||
`src/takopi/runners/`—no changes to the bridge, renderer, or CLI.
|
||||
`src/takopi/runners/` plus a small **msgspec schema** module under `src/takopi/schemas/`—
|
||||
no changes to the bridge, renderer, or CLI.
|
||||
|
||||
The walkthrough below uses an **imaginary engine** named **Pi** (`pi`) and intentionally mirrors
|
||||
the patterns used in `runners/claude.py`.
|
||||
@@ -97,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/
|
||||
codex.py
|
||||
claude.py
|
||||
@@ -121,9 +126,9 @@ Most CLIs we integrate are JSONL-streaming processes.
|
||||
Takopi provides `JsonlSubprocessRunner`, which:
|
||||
|
||||
- spawns the CLI
|
||||
- drains stderr into a bounded tail
|
||||
- reads stdout line-by-line as JSONL
|
||||
- calls your `translate(...)` method to convert each JSON object into Takopi events
|
||||
- drains stderr and logs it
|
||||
- reads stdout line-by-line as JSONL bytes
|
||||
- calls your `decode_jsonl(...)` and then `translate(...)` to convert each event into Takopi events
|
||||
- guarantees “exactly one CompletedEvent” behavior
|
||||
- provides safe fallbacks for rc != 0 or stream ending without a completion event
|
||||
|
||||
@@ -147,6 +152,55 @@ class PiStreamState:
|
||||
note_seq: int = 0
|
||||
```
|
||||
|
||||
#### Define a msgspec schema (recommended path)
|
||||
|
||||
Codex now decodes JSONL with **msgspec**, and new runners should follow that pattern.
|
||||
Create a small schema module under `src/takopi/schemas/` and expose a `decode_event(...)`
|
||||
function. Only include the event shapes your CLI actually emits.
|
||||
|
||||
Minimal example:
|
||||
|
||||
```py
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal, TypeAlias
|
||||
|
||||
import msgspec
|
||||
|
||||
|
||||
class SessionStart(msgspec.Struct, tag="session.start", kw_only=True):
|
||||
session_id: str
|
||||
model: str | None = None
|
||||
|
||||
|
||||
class ToolUse(msgspec.Struct, tag="tool.use", kw_only=True):
|
||||
id: str
|
||||
name: str
|
||||
input: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class ToolResult(msgspec.Struct, tag="tool.result", kw_only=True):
|
||||
tool_use_id: str
|
||||
content: Any
|
||||
is_error: bool | None = None
|
||||
|
||||
|
||||
class Final(msgspec.Struct, tag="final", kw_only=True):
|
||||
session_id: str
|
||||
ok: bool
|
||||
answer: str | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
PiEvent: TypeAlias = SessionStart | ToolUse | ToolResult | Final
|
||||
|
||||
_DECODER = msgspec.json.Decoder(PiEvent)
|
||||
|
||||
|
||||
def decode_event(data: bytes | str) -> PiEvent:
|
||||
return _DECODER.decode(data)
|
||||
```
|
||||
|
||||
#### Decide what Pi emits
|
||||
|
||||
For this guide, assume Pi outputs events like:
|
||||
@@ -323,7 +377,7 @@ import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
from ..backends import EngineBackend, EngineConfig
|
||||
from ..model import (
|
||||
@@ -333,13 +387,14 @@ from ..model import (
|
||||
StartedEvent,
|
||||
TakopiEvent,
|
||||
)
|
||||
import msgspec
|
||||
|
||||
from ..runner import JsonlSubprocessRunner, ResumeTokenMixin, Runner
|
||||
from ..schemas import pi as pi_schema
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ENGINE: EngineId = EngineId("pi")
|
||||
STDERR_TAIL_LINES = 200
|
||||
|
||||
_RESUME_RE = re.compile(
|
||||
r"(?im)^\s*`?pi\s+--resume\s+(?P<token>[^`\s]+)`?\s*$"
|
||||
)
|
||||
@@ -354,7 +409,6 @@ class PiRunner(ResumeTokenMixin, JsonlSubprocessRunner):
|
||||
model: str | None = None
|
||||
allowed_tools: list[str] | None = None
|
||||
session_title: str = "pi"
|
||||
stderr_tail_lines = STDERR_TAIL_LINES
|
||||
logger = logger
|
||||
|
||||
def format_resume(self, token: ResumeToken) -> str:
|
||||
@@ -398,6 +452,17 @@ class PiRunner(ResumeTokenMixin, JsonlSubprocessRunner):
|
||||
_ = prompt, resume
|
||||
return PiStreamState()
|
||||
|
||||
def decode_jsonl(
|
||||
self,
|
||||
*,
|
||||
raw: bytes,
|
||||
line: bytes,
|
||||
state: PiStreamState,
|
||||
) -> dict[str, Any] | None:
|
||||
_ = raw, state
|
||||
event = pi_schema.decode_event(line)
|
||||
return cast(dict[str, Any], msgspec.to_builtins(event))
|
||||
|
||||
def translate(
|
||||
self,
|
||||
data: dict[str, Any],
|
||||
@@ -423,7 +488,8 @@ Depending on how robust you want the integration, consider adding:
|
||||
- `env(...)`: to strip or inject environment variables (Claude strips `ANTHROPIC_API_KEY`
|
||||
unless configured to use API billing).
|
||||
- `invalid_json_events(...)`: emit a helpful warning `ActionEvent` on malformed JSONL.
|
||||
- `process_error_events(...)`: customize rc != 0 behavior (include stderr tail in detail).
|
||||
- `decode_error_events(...)`: log + drop `msgspec.DecodeError` if the engine emits garbage.
|
||||
- `process_error_events(...)`: customize rc != 0 behavior.
|
||||
- `stream_end_events(...)`: handle “process exited cleanly but never emitted a final event”.
|
||||
|
||||
Claude uses these to produce better failures instead of silent hangs.
|
||||
@@ -503,6 +569,10 @@ Then assert:
|
||||
- the last event is a `CompletedEvent`
|
||||
- completed.resume matches started.resume
|
||||
|
||||
If you use msgspec, also add a tiny schema sanity test (pattern from
|
||||
`tests/test_codex_schema.py`) that decodes your fixture with
|
||||
`takopi.schemas.<engine>.decode_event`.
|
||||
|
||||
#### 3) Lock/serialization tests (optional, but great)
|
||||
|
||||
Claude has async tests proving that:
|
||||
@@ -554,4 +624,3 @@ Before you call the runner “done”:
|
||||
- [ ] rc != 0 produces a failure `CompletedEvent` (via `process_error_events`).
|
||||
- [ ] “no final event” produces a failure `CompletedEvent` (via `stream_end_events`).
|
||||
- [ ] Tests cover resume parsing + at least one translation fixture.
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ dependencies = [
|
||||
"anyio>=4.12.0",
|
||||
"httpx>=0.28.1",
|
||||
"markdown-it-py",
|
||||
"msgspec>=0.20.0",
|
||||
"questionary>=2.1.1",
|
||||
"rich>=14.2.0",
|
||||
"sulguk>=0.11.1",
|
||||
|
||||
@@ -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
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
@@ -23,6 +24,24 @@ class RedactTokenFilter(logging.Filter):
|
||||
return True
|
||||
|
||||
|
||||
class SafeStreamHandler(logging.StreamHandler):
|
||||
def handleError(self, record: logging.LogRecord) -> None:
|
||||
exc = sys.exc_info()[1]
|
||||
if isinstance(exc, BrokenPipeError):
|
||||
try:
|
||||
self.stream.close()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
if isinstance(exc, OSError) and exc.errno == errno.EPIPE:
|
||||
try:
|
||||
self.stream.close()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
super().handleError(record)
|
||||
|
||||
|
||||
def setup_logging(*, debug: bool = False) -> None:
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(logging.DEBUG if debug else logging.INFO)
|
||||
@@ -35,7 +54,7 @@ def setup_logging(*, debug: bool = False) -> None:
|
||||
fmt = logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||
redactor = RedactTokenFilter()
|
||||
|
||||
console = logging.StreamHandler(sys.stdout)
|
||||
console = SafeStreamHandler(sys.stdout)
|
||||
console.setLevel(logging.DEBUG if debug else logging.INFO)
|
||||
console.setFormatter(fmt)
|
||||
console.addFilter(redactor)
|
||||
|
||||
@@ -12,6 +12,7 @@ ActionKind: TypeAlias = Literal[
|
||||
"tool",
|
||||
"file_change",
|
||||
"web_search",
|
||||
"subagent",
|
||||
"note",
|
||||
"turn",
|
||||
"warning",
|
||||
|
||||
@@ -162,6 +162,9 @@ def format_action_title(action: Action, *, command_width: int | None) -> str:
|
||||
if kind == "web_search":
|
||||
title = shorten(title, command_width)
|
||||
return f"searched: {title}"
|
||||
if kind == "subagent":
|
||||
title = shorten(title, command_width)
|
||||
return f"subagent: {title}"
|
||||
if kind == "file_change":
|
||||
return format_file_change_title(action, command_width=command_width)
|
||||
if kind in {"note", "warning"}:
|
||||
|
||||
+83
-20
@@ -2,13 +2,13 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
from collections import deque
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Protocol
|
||||
from typing import Any, Protocol, cast
|
||||
from weakref import WeakValueDictionary
|
||||
|
||||
import anyio
|
||||
@@ -22,7 +22,7 @@ from .model import (
|
||||
StartedEvent,
|
||||
TakopiEvent,
|
||||
)
|
||||
from .utils.streams import drain_stderr, iter_jsonl
|
||||
from .utils.streams import drain_stderr, iter_bytes_lines
|
||||
from .utils.subprocess import manage_subprocess
|
||||
|
||||
|
||||
@@ -131,8 +131,6 @@ class JsonlRunState:
|
||||
|
||||
|
||||
class JsonlSubprocessRunner(BaseRunner):
|
||||
stderr_tail_lines: int = 200
|
||||
|
||||
def get_logger(self) -> logging.Logger:
|
||||
return getattr(self, "logger", logging.getLogger(__name__))
|
||||
|
||||
@@ -222,19 +220,66 @@ class JsonlSubprocessRunner(BaseRunner):
|
||||
message = f"invalid JSON from {self.tag()}; ignoring line"
|
||||
return [self.note_event(message, state=state, detail={"line": line})]
|
||||
|
||||
def decode_jsonl(self, *, line: bytes) -> Any | None:
|
||||
text = line.decode("utf-8", errors="replace")
|
||||
try:
|
||||
return cast(dict[str, Any], json.loads(text))
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
async def iter_json_lines(
|
||||
self,
|
||||
stream: Any,
|
||||
*,
|
||||
logger: logging.Logger,
|
||||
tag: str,
|
||||
) -> AsyncIterator[bytes]:
|
||||
async for raw_line in iter_bytes_lines(stream):
|
||||
raw = raw_line.rstrip(b"\n")
|
||||
text = raw.decode("utf-8", errors="replace")
|
||||
logger.debug("[%s][jsonl] %s", tag, text)
|
||||
yield raw
|
||||
|
||||
def decode_error_events(
|
||||
self,
|
||||
*,
|
||||
raw: str,
|
||||
line: str,
|
||||
error: Exception,
|
||||
state: Any,
|
||||
) -> list[TakopiEvent]:
|
||||
message = f"invalid event from {self.tag()}; ignoring line"
|
||||
detail = {"line": line, "error": str(error)}
|
||||
return [self.note_event(message, state=state, detail=detail)]
|
||||
|
||||
def translate_error_events(
|
||||
self,
|
||||
*,
|
||||
data: Any,
|
||||
error: Exception,
|
||||
state: Any,
|
||||
) -> list[TakopiEvent]:
|
||||
message = f"{self.tag()} translation error; ignoring event"
|
||||
detail: dict[str, Any] = {"error": str(error)}
|
||||
if isinstance(data, dict):
|
||||
detail["type"] = data.get("type")
|
||||
item = data.get("item")
|
||||
if isinstance(item, dict):
|
||||
detail["item_type"] = item.get("type") or item.get("item_type")
|
||||
return [self.note_event(message, state=state, detail=detail)]
|
||||
|
||||
def process_error_events(
|
||||
self,
|
||||
rc: int,
|
||||
*,
|
||||
resume: ResumeToken | None,
|
||||
found_session: ResumeToken | None,
|
||||
stderr_tail: str,
|
||||
state: Any,
|
||||
) -> list[TakopiEvent]:
|
||||
message = f"{self.tag()} failed (rc={rc})."
|
||||
resume_for_completed = found_session or resume
|
||||
return [
|
||||
self.note_event(message, state=state, detail={"stderr_tail": stderr_tail}),
|
||||
self.note_event(message, state=state),
|
||||
CompletedEvent(
|
||||
engine=self.engine,
|
||||
ok=False,
|
||||
@@ -249,7 +294,6 @@ class JsonlSubprocessRunner(BaseRunner):
|
||||
*,
|
||||
resume: ResumeToken | None,
|
||||
found_session: ResumeToken | None,
|
||||
stderr_tail: str,
|
||||
state: Any,
|
||||
) -> list[TakopiEvent]:
|
||||
message = f"{self.tag()} finished without a result event"
|
||||
@@ -266,7 +310,7 @@ class JsonlSubprocessRunner(BaseRunner):
|
||||
|
||||
def translate(
|
||||
self,
|
||||
data: dict[str, Any],
|
||||
data: Any,
|
||||
*,
|
||||
state: Any,
|
||||
resume: ResumeToken | None,
|
||||
@@ -334,7 +378,6 @@ class JsonlSubprocessRunner(BaseRunner):
|
||||
elif proc.stdin is not None:
|
||||
await proc.stdin.aclose()
|
||||
|
||||
stderr_chunks: deque[str] = deque(maxlen=self.stderr_tail_lines)
|
||||
rc: int | None = None
|
||||
expected_session: ResumeToken | None = resume
|
||||
found_session: ResumeToken | None = None
|
||||
@@ -344,26 +387,49 @@ class JsonlSubprocessRunner(BaseRunner):
|
||||
tg.start_soon(
|
||||
drain_stderr,
|
||||
proc.stderr,
|
||||
stderr_chunks,
|
||||
logger,
|
||||
tag,
|
||||
)
|
||||
async for json_line in iter_jsonl(proc.stdout, logger=logger, tag=tag):
|
||||
async for raw_line in self.iter_json_lines(
|
||||
proc.stdout, logger=logger, tag=tag
|
||||
):
|
||||
if did_emit_completed:
|
||||
continue
|
||||
if json_line.data is None:
|
||||
events = self.invalid_json_events(
|
||||
raw=json_line.raw,
|
||||
line=json_line.line,
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
raw_text = raw_line.decode("utf-8", errors="replace")
|
||||
line_text = line.decode("utf-8", errors="replace")
|
||||
try:
|
||||
decoded = self.decode_jsonl(line=line)
|
||||
except Exception as exc:
|
||||
events = self.decode_error_events(
|
||||
raw=raw_text,
|
||||
line=line_text,
|
||||
error=exc,
|
||||
state=state,
|
||||
)
|
||||
else:
|
||||
if decoded is None:
|
||||
events = self.invalid_json_events(
|
||||
raw=raw_text,
|
||||
line=line_text,
|
||||
state=state,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
events = self.translate(
|
||||
json_line.data,
|
||||
decoded,
|
||||
state=state,
|
||||
resume=resume,
|
||||
found_session=found_session,
|
||||
)
|
||||
except Exception as exc:
|
||||
events = self.translate_error_events(
|
||||
data=decoded,
|
||||
error=exc,
|
||||
state=state,
|
||||
)
|
||||
|
||||
for evt in events:
|
||||
if isinstance(evt, StartedEvent):
|
||||
@@ -385,13 +451,11 @@ class JsonlSubprocessRunner(BaseRunner):
|
||||
logger.debug("[%s] process exit pid=%s rc=%s", tag, proc.pid, rc)
|
||||
if did_emit_completed:
|
||||
return
|
||||
stderr_tail = "".join(stderr_chunks)
|
||||
if rc is not None and rc != 0:
|
||||
events = self.process_error_events(
|
||||
rc,
|
||||
resume=resume,
|
||||
found_session=found_session,
|
||||
stderr_tail=stderr_tail,
|
||||
state=state,
|
||||
)
|
||||
for evt in events:
|
||||
@@ -401,7 +465,6 @@ class JsonlSubprocessRunner(BaseRunner):
|
||||
events = self.stream_end_events(
|
||||
resume=resume,
|
||||
found_session=found_session,
|
||||
stderr_tail=stderr_tail,
|
||||
state=state,
|
||||
)
|
||||
for evt in events:
|
||||
|
||||
+156
-187
@@ -5,26 +5,20 @@ import os
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
from typing import Any
|
||||
|
||||
import msgspec
|
||||
|
||||
from ..backends import EngineBackend, EngineConfig
|
||||
from ..model import (
|
||||
Action,
|
||||
ActionEvent,
|
||||
ActionKind,
|
||||
CompletedEvent,
|
||||
EngineId,
|
||||
ResumeToken,
|
||||
StartedEvent,
|
||||
TakopiEvent,
|
||||
)
|
||||
from ..events import EventFactory
|
||||
from ..model import Action, ActionKind, EngineId, ResumeToken, TakopiEvent
|
||||
from ..runner import JsonlSubprocessRunner, ResumeTokenMixin, Runner
|
||||
from ..schemas import claude as claude_schema
|
||||
from ..utils.paths import relativize_command, relativize_path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ENGINE: EngineId = EngineId("claude")
|
||||
STDERR_TAIL_LINES = 200
|
||||
DEFAULT_ALLOWED_TOOLS = ["Bash", "Read", "Edit", "Write"]
|
||||
|
||||
_RESUME_RE = re.compile(
|
||||
@@ -34,45 +28,31 @@ _RESUME_RE = re.compile(
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ClaudeStreamState:
|
||||
factory: EventFactory = field(default_factory=lambda: EventFactory(ENGINE))
|
||||
pending_actions: dict[str, Action] = field(default_factory=dict)
|
||||
last_assistant_text: str | None = None
|
||||
note_seq: int = 0
|
||||
|
||||
|
||||
def _action_event(
|
||||
*,
|
||||
phase: Literal["started", "updated", "completed"],
|
||||
action: Action,
|
||||
ok: bool | None = None,
|
||||
message: str | None = None,
|
||||
level: Literal["debug", "info", "warning", "error"] | None = None,
|
||||
) -> ActionEvent:
|
||||
return ActionEvent(
|
||||
engine=ENGINE,
|
||||
action=action,
|
||||
phase=phase,
|
||||
ok=ok,
|
||||
message=message,
|
||||
level=level,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_tool_result(content: Any) -> str:
|
||||
if isinstance(content, list):
|
||||
parts: list[str] = []
|
||||
for item in content:
|
||||
if isinstance(item, dict):
|
||||
if item.get("type") == "text" and isinstance(item.get("text"), str):
|
||||
parts.append(item["text"])
|
||||
elif isinstance(item.get("text"), str):
|
||||
parts.append(item["text"])
|
||||
elif isinstance(item, str):
|
||||
parts.append(item)
|
||||
return "\n".join(part for part in parts if part)
|
||||
if content is None:
|
||||
return ""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
parts: list[str] = []
|
||||
for item in content:
|
||||
if isinstance(item, dict):
|
||||
text = item.get("text")
|
||||
if isinstance(text, str) and text:
|
||||
parts.append(text)
|
||||
elif isinstance(item, str):
|
||||
parts.append(item)
|
||||
return "\n".join(part for part in parts if part)
|
||||
if isinstance(content, dict):
|
||||
text = content.get("text")
|
||||
if isinstance(text, str):
|
||||
return text
|
||||
return str(content)
|
||||
|
||||
|
||||
@@ -134,19 +114,18 @@ def _tool_kind_and_title(
|
||||
return "note", "ask user"
|
||||
if name in {"Task", "Agent"}:
|
||||
desc = tool_input.get("description") or tool_input.get("prompt")
|
||||
return "tool", str(desc or name)
|
||||
return "subagent", str(desc or name)
|
||||
return "tool", name
|
||||
|
||||
|
||||
def _tool_action(
|
||||
content: dict[str, Any],
|
||||
content: claude_schema.StreamToolUseBlock,
|
||||
*,
|
||||
message_id: str | None,
|
||||
parent_tool_use_id: str | None,
|
||||
) -> Action:
|
||||
tool_id = content["id"]
|
||||
tool_name = str(content.get("name") or "tool")
|
||||
tool_input = content["input"]
|
||||
tool_id = content.id
|
||||
tool_name = str(content.name or "tool")
|
||||
tool_input = content.input
|
||||
|
||||
kind, title = _tool_kind_and_title(tool_name, tool_input)
|
||||
|
||||
@@ -154,8 +133,6 @@ def _tool_action(
|
||||
"name": tool_name,
|
||||
"input": tool_input,
|
||||
}
|
||||
if message_id:
|
||||
detail["message_id"] = message_id
|
||||
if parent_tool_use_id:
|
||||
detail["parent_tool_use_id"] = parent_tool_use_id
|
||||
|
||||
@@ -168,59 +145,46 @@ def _tool_action(
|
||||
|
||||
|
||||
def _tool_result_event(
|
||||
content: dict[str, Any],
|
||||
content: claude_schema.StreamToolResultBlock,
|
||||
*,
|
||||
action: Action,
|
||||
message_id: str | None,
|
||||
) -> ActionEvent:
|
||||
is_error = content.get("is_error") is True
|
||||
raw_result = content.get("content")
|
||||
factory: EventFactory,
|
||||
) -> TakopiEvent:
|
||||
is_error = content.is_error is True
|
||||
raw_result = content.content
|
||||
normalized = _normalize_tool_result(raw_result)
|
||||
preview = normalized
|
||||
|
||||
detail = dict(action.detail)
|
||||
detail.update(
|
||||
{
|
||||
"tool_use_id": content.get("tool_use_id"),
|
||||
"tool_use_id": content.tool_use_id,
|
||||
"result_preview": preview,
|
||||
"result_len": len(normalized),
|
||||
"is_error": is_error,
|
||||
}
|
||||
)
|
||||
if message_id:
|
||||
detail["message_id"] = message_id
|
||||
|
||||
return _action_event(
|
||||
phase="completed",
|
||||
action=Action(
|
||||
id=action.id,
|
||||
return factory.action_completed(
|
||||
action_id=action.id,
|
||||
kind=action.kind,
|
||||
title=action.title,
|
||||
detail=detail,
|
||||
),
|
||||
ok=not is_error,
|
||||
detail=detail,
|
||||
)
|
||||
|
||||
|
||||
def _extract_error(event: dict[str, Any]) -> str | None:
|
||||
error = event.get("error")
|
||||
if isinstance(error, str) and error:
|
||||
return error
|
||||
errors = event.get("errors")
|
||||
if isinstance(errors, list):
|
||||
for item in errors:
|
||||
if isinstance(item, dict):
|
||||
message = item.get("message") or item.get("error")
|
||||
if isinstance(message, str) and message:
|
||||
return message
|
||||
elif isinstance(item, str) and item:
|
||||
return item
|
||||
if event.get("is_error"):
|
||||
def _extract_error(event: claude_schema.StreamResultMessage) -> str | None:
|
||||
if event.is_error:
|
||||
if isinstance(event.result, str) and event.result:
|
||||
return event.result
|
||||
subtype = event.subtype
|
||||
if subtype:
|
||||
return f"claude run failed ({subtype})"
|
||||
return "claude run failed"
|
||||
return None
|
||||
|
||||
|
||||
def _usage_payload(event: dict[str, Any]) -> dict[str, Any]:
|
||||
def _usage_payload(event: claude_schema.StreamResultMessage) -> dict[str, Any]:
|
||||
usage: dict[str, Any] = {}
|
||||
for key in (
|
||||
"total_cost_usd",
|
||||
@@ -228,28 +192,28 @@ def _usage_payload(event: dict[str, Any]) -> dict[str, Any]:
|
||||
"duration_api_ms",
|
||||
"num_turns",
|
||||
):
|
||||
value = event.get(key)
|
||||
if value is not None:
|
||||
usage[key] = value
|
||||
for key in ("usage", "modelUsage"):
|
||||
value = event.get(key)
|
||||
value = getattr(event, key, None)
|
||||
if value is not None:
|
||||
usage[key] = value
|
||||
if event.usage is not None:
|
||||
usage["usage"] = event.usage
|
||||
return usage
|
||||
|
||||
|
||||
def translate_claude_event(
|
||||
event: dict[str, Any],
|
||||
event: claude_schema.StreamJsonMessage,
|
||||
*,
|
||||
title: str,
|
||||
state: ClaudeStreamState,
|
||||
factory: EventFactory,
|
||||
) -> list[TakopiEvent]:
|
||||
etype = event["type"]
|
||||
match etype:
|
||||
case "system" if event.get("subtype") == "init":
|
||||
session_id = event["session_id"]
|
||||
model = event.get("model")
|
||||
event_title = str(model) if model else title
|
||||
match event:
|
||||
case claude_schema.StreamSystemMessage(subtype=subtype):
|
||||
if subtype != "init":
|
||||
return []
|
||||
session_id = event.session_id
|
||||
if not session_id:
|
||||
return []
|
||||
meta: dict[str, Any] = {}
|
||||
for key in (
|
||||
"cwd",
|
||||
@@ -257,52 +221,70 @@ def translate_claude_event(
|
||||
"permissionMode",
|
||||
"output_style",
|
||||
"apiKeySource",
|
||||
"mcp_servers",
|
||||
):
|
||||
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] = []
|
||||
for content in content_blocks:
|
||||
match content["type"]:
|
||||
case "tool_use":
|
||||
for content in message.content:
|
||||
match content:
|
||||
case claude_schema.StreamToolUseBlock():
|
||||
action = _tool_action(
|
||||
content,
|
||||
message_id=message_id,
|
||||
parent_tool_use_id=parent_tool_use_id,
|
||||
)
|
||||
state.pending_actions[action.id] = action
|
||||
out.append(_action_event(phase="started", action=action))
|
||||
case "text":
|
||||
text = content["text"]
|
||||
out.append(
|
||||
factory.action_started(
|
||||
action_id=action.id,
|
||||
kind=action.kind,
|
||||
title=action.title,
|
||||
detail=action.detail,
|
||||
)
|
||||
)
|
||||
case claude_schema.StreamThinkingBlock(
|
||||
thinking=thinking, signature=signature
|
||||
):
|
||||
if not thinking:
|
||||
continue
|
||||
state.note_seq += 1
|
||||
action_id = f"claude.thinking.{state.note_seq}"
|
||||
detail: dict[str, Any] = {}
|
||||
if parent_tool_use_id:
|
||||
detail["parent_tool_use_id"] = parent_tool_use_id
|
||||
if signature:
|
||||
detail["signature"] = signature
|
||||
out.append(
|
||||
factory.action_completed(
|
||||
action_id=action_id,
|
||||
kind="note",
|
||||
title=thinking,
|
||||
ok=True,
|
||||
detail=detail,
|
||||
)
|
||||
)
|
||||
case claude_schema.StreamTextBlock(text=text):
|
||||
if text:
|
||||
state.last_assistant_text = text
|
||||
case _:
|
||||
continue
|
||||
return out
|
||||
case "user":
|
||||
message = event["message"]
|
||||
message_id = message.get("id")
|
||||
content_blocks = message["content"]
|
||||
case claude_schema.StreamUserMessage(message=message):
|
||||
if not isinstance(message.content, list):
|
||||
return []
|
||||
out: list[TakopiEvent] = []
|
||||
for content in content_blocks:
|
||||
if content["type"] != "tool_result":
|
||||
for content in message.content:
|
||||
if not isinstance(content, claude_schema.StreamToolResultBlock):
|
||||
continue
|
||||
tool_use_id = content["tool_use_id"]
|
||||
tool_use_id = content.tool_use_id
|
||||
action = state.pending_actions.pop(tool_use_id, None)
|
||||
if action is None:
|
||||
action = Action(
|
||||
@@ -312,56 +294,32 @@ def translate_claude_event(
|
||||
detail={},
|
||||
)
|
||||
out.append(
|
||||
_tool_result_event(content, action=action, message_id=message_id)
|
||||
_tool_result_event(
|
||||
content,
|
||||
action=action,
|
||||
factory=factory,
|
||||
)
|
||||
)
|
||||
return out
|
||||
case "result":
|
||||
out: list[TakopiEvent] = []
|
||||
for idx, denial in enumerate(event.get("permission_denials", [])):
|
||||
tool_name = denial.get("tool_name")
|
||||
denial_title = "permission denied"
|
||||
if tool_name:
|
||||
denial_title = f"permission denied: {tool_name}"
|
||||
tool_use_id = denial.get("tool_use_id")
|
||||
action_id = (
|
||||
f"claude.permission.{tool_use_id}"
|
||||
if tool_use_id
|
||||
else f"claude.permission.{idx}"
|
||||
)
|
||||
out.append(
|
||||
_action_event(
|
||||
phase="completed",
|
||||
action=Action(
|
||||
id=action_id,
|
||||
kind="warning",
|
||||
title=denial_title,
|
||||
detail=denial,
|
||||
),
|
||||
ok=False,
|
||||
level="warning",
|
||||
)
|
||||
)
|
||||
|
||||
ok = not event.get("is_error", False)
|
||||
result_text = event["result"]
|
||||
case claude_schema.StreamResultMessage():
|
||||
ok = not event.is_error
|
||||
result_text = event.result or ""
|
||||
if ok and not result_text and state.last_assistant_text:
|
||||
result_text = state.last_assistant_text
|
||||
|
||||
resume = ResumeToken(engine=ENGINE, value=str(event["session_id"]))
|
||||
resume = ResumeToken(engine=ENGINE, value=event.session_id)
|
||||
error = None if ok else _extract_error(event)
|
||||
usage = _usage_payload(event)
|
||||
|
||||
out.append(
|
||||
CompletedEvent(
|
||||
engine=ENGINE,
|
||||
return [
|
||||
factory.completed(
|
||||
ok=ok,
|
||||
answer=result_text,
|
||||
resume=resume,
|
||||
error=error,
|
||||
usage=usage or None,
|
||||
)
|
||||
)
|
||||
return out
|
||||
]
|
||||
case _:
|
||||
return []
|
||||
|
||||
@@ -377,7 +335,6 @@ class ClaudeRunner(ResumeTokenMixin, JsonlSubprocessRunner):
|
||||
dangerously_skip_permissions: bool = False
|
||||
use_api_billing: bool = False
|
||||
session_title: str = "claude"
|
||||
stderr_tail_lines = STDERR_TAIL_LINES
|
||||
logger = logger
|
||||
|
||||
def format_resume(self, token: ResumeToken) -> str:
|
||||
@@ -449,6 +406,34 @@ class ClaudeRunner(ResumeTokenMixin, JsonlSubprocessRunner):
|
||||
)
|
||||
logger.debug("[claude] prompt: %s", prompt)
|
||||
|
||||
def decode_jsonl(
|
||||
self,
|
||||
*,
|
||||
line: bytes,
|
||||
) -> claude_schema.StreamJsonMessage:
|
||||
return claude_schema.decode_stream_json_line(line)
|
||||
|
||||
def decode_error_events(
|
||||
self,
|
||||
*,
|
||||
raw: str,
|
||||
line: str,
|
||||
error: Exception,
|
||||
state: ClaudeStreamState,
|
||||
) -> list[TakopiEvent]:
|
||||
_ = raw, line, state
|
||||
if isinstance(error, msgspec.DecodeError):
|
||||
self.get_logger().warning(
|
||||
"[%s] invalid msgspec event: %s", self.tag(), error
|
||||
)
|
||||
return []
|
||||
return super().decode_error_events(
|
||||
raw=raw,
|
||||
line=line,
|
||||
error=error,
|
||||
state=state,
|
||||
)
|
||||
|
||||
def invalid_json_events(
|
||||
self,
|
||||
*,
|
||||
@@ -456,13 +441,12 @@ class ClaudeRunner(ResumeTokenMixin, JsonlSubprocessRunner):
|
||||
line: str,
|
||||
state: ClaudeStreamState,
|
||||
) -> list[TakopiEvent]:
|
||||
_ = line
|
||||
message = "invalid JSON from claude; ignoring line"
|
||||
return [self.note_event(message, state=state, detail={"line": raw})]
|
||||
_ = raw, line, state
|
||||
return []
|
||||
|
||||
def translate(
|
||||
self,
|
||||
data: dict[str, Any],
|
||||
data: claude_schema.StreamJsonMessage,
|
||||
*,
|
||||
state: ClaudeStreamState,
|
||||
resume: ResumeToken | None,
|
||||
@@ -473,6 +457,7 @@ class ClaudeRunner(ResumeTokenMixin, JsonlSubprocessRunner):
|
||||
data,
|
||||
title=self.session_title,
|
||||
state=state,
|
||||
factory=state.factory,
|
||||
)
|
||||
|
||||
def process_error_events(
|
||||
@@ -481,24 +466,15 @@ class ClaudeRunner(ResumeTokenMixin, JsonlSubprocessRunner):
|
||||
*,
|
||||
resume: ResumeToken | None,
|
||||
found_session: ResumeToken | None,
|
||||
stderr_tail: str,
|
||||
state: ClaudeStreamState,
|
||||
) -> list[TakopiEvent]:
|
||||
message = f"claude failed (rc={rc})."
|
||||
resume_for_completed = found_session or resume
|
||||
return [
|
||||
self.note_event(
|
||||
message,
|
||||
state=state,
|
||||
ok=False,
|
||||
detail={"stderr_tail": stderr_tail},
|
||||
),
|
||||
CompletedEvent(
|
||||
engine=ENGINE,
|
||||
ok=False,
|
||||
answer="",
|
||||
resume=resume_for_completed,
|
||||
self.note_event(message, state=state, ok=False),
|
||||
state.factory.completed_error(
|
||||
error=message,
|
||||
resume=resume_for_completed,
|
||||
),
|
||||
]
|
||||
|
||||
@@ -507,31 +483,24 @@ class ClaudeRunner(ResumeTokenMixin, JsonlSubprocessRunner):
|
||||
*,
|
||||
resume: ResumeToken | None,
|
||||
found_session: ResumeToken | None,
|
||||
stderr_tail: str,
|
||||
state: ClaudeStreamState,
|
||||
) -> list[TakopiEvent]:
|
||||
_ = stderr_tail
|
||||
if not found_session:
|
||||
message = "claude finished but no session_id was captured"
|
||||
resume_for_completed = resume
|
||||
return [
|
||||
CompletedEvent(
|
||||
engine=ENGINE,
|
||||
ok=False,
|
||||
answer="",
|
||||
resume=resume_for_completed,
|
||||
state.factory.completed_error(
|
||||
error=message,
|
||||
resume=resume_for_completed,
|
||||
)
|
||||
]
|
||||
|
||||
message = "claude finished without a result event"
|
||||
return [
|
||||
CompletedEvent(
|
||||
engine=ENGINE,
|
||||
ok=False,
|
||||
state.factory.completed_error(
|
||||
error=message,
|
||||
answer=state.last_assistant_text or "",
|
||||
resume=found_session,
|
||||
error=message,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
+206
-292
@@ -4,66 +4,29 @@ import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
from typing import Any
|
||||
|
||||
import msgspec
|
||||
|
||||
from ..backends import EngineBackend, EngineConfig
|
||||
from ..config import ConfigError
|
||||
from ..model import (
|
||||
Action,
|
||||
ActionEvent,
|
||||
ActionKind,
|
||||
ActionLevel,
|
||||
ActionPhase,
|
||||
CompletedEvent,
|
||||
EngineId,
|
||||
ResumeToken,
|
||||
StartedEvent,
|
||||
TakopiEvent,
|
||||
)
|
||||
from ..events import EventFactory
|
||||
from ..model import ActionPhase, EngineId, ResumeToken, TakopiEvent
|
||||
from ..runner import JsonlSubprocessRunner, ResumeTokenMixin, Runner
|
||||
from ..schemas import codex as codex_schema
|
||||
from ..utils.paths import relativize_command
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ENGINE: EngineId = EngineId("codex")
|
||||
STDERR_TAIL_LINES = 200
|
||||
|
||||
_ACTION_KIND_MAP: dict[str, ActionKind] = {
|
||||
"command_execution": "command",
|
||||
"mcp_tool_call": "tool",
|
||||
"tool_call": "tool",
|
||||
"web_search": "web_search",
|
||||
"file_change": "file_change",
|
||||
"reasoning": "note",
|
||||
"todo_list": "note",
|
||||
}
|
||||
|
||||
_RESUME_RE = re.compile(r"(?im)^\s*`?codex\s+resume\s+(?P<token>[^`\s]+)`?\s*$")
|
||||
_ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
|
||||
_TRUSTED_DIR_RE = re.compile(r"not inside a trusted directory", re.IGNORECASE)
|
||||
_RECONNECTING_RE = re.compile(
|
||||
r"^Reconnecting\.{3}\s*(?P<attempt>\d+)/(?P<max>\d+)\s*$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _strip_ansi(text: str) -> str:
|
||||
return _ANSI_ESCAPE_RE.sub("", text)
|
||||
|
||||
|
||||
def _extract_stderr_reason(stderr_tail: str) -> str | None:
|
||||
if not stderr_tail:
|
||||
return None
|
||||
cleaned = _strip_ansi(stderr_tail)
|
||||
lines = [line.strip() for line in cleaned.splitlines() if line.strip()]
|
||||
if not lines:
|
||||
return None
|
||||
for line in lines:
|
||||
if _TRUSTED_DIR_RE.search(line):
|
||||
return line
|
||||
return lines[-1]
|
||||
|
||||
|
||||
def _parse_reconnect_message(message: str) -> tuple[int, int] | None:
|
||||
match = _RECONNECTING_RE.match(message)
|
||||
if not match:
|
||||
@@ -76,64 +39,24 @@ def _parse_reconnect_message(message: str) -> tuple[int, int] | None:
|
||||
return (attempt, max_attempts)
|
||||
|
||||
|
||||
def _started_event(token: ResumeToken, *, title: str) -> StartedEvent:
|
||||
return StartedEvent(engine=token.engine, resume=token, title=title)
|
||||
|
||||
|
||||
def _completed_event(
|
||||
*,
|
||||
resume: ResumeToken | None,
|
||||
ok: bool,
|
||||
answer: str,
|
||||
error: str | None = None,
|
||||
usage: dict[str, Any] | None = None,
|
||||
) -> TakopiEvent:
|
||||
return CompletedEvent(
|
||||
engine=ENGINE,
|
||||
ok=ok,
|
||||
answer=answer,
|
||||
resume=resume,
|
||||
error=error,
|
||||
usage=usage,
|
||||
)
|
||||
|
||||
|
||||
def _action_event(
|
||||
*,
|
||||
phase: ActionPhase,
|
||||
action_id: str,
|
||||
kind: ActionKind,
|
||||
title: str,
|
||||
detail: dict[str, Any] | None = None,
|
||||
ok: bool | None = None,
|
||||
message: str | None = None,
|
||||
level: ActionLevel | None = None,
|
||||
) -> TakopiEvent:
|
||||
action = Action(
|
||||
id=action_id,
|
||||
kind=kind,
|
||||
title=title,
|
||||
detail=detail or {},
|
||||
)
|
||||
return ActionEvent(
|
||||
engine=ENGINE,
|
||||
action=action,
|
||||
phase=phase,
|
||||
ok=ok,
|
||||
message=message,
|
||||
level=level,
|
||||
)
|
||||
|
||||
|
||||
def _short_tool_name(item: dict[str, Any]) -> str:
|
||||
name = ".".join(part for part in (item.get("server"), item.get("tool")) if part)
|
||||
def _short_tool_name(server: str | None, tool: str | None) -> str:
|
||||
name = ".".join(part for part in (server, tool) if part)
|
||||
return name or "tool"
|
||||
|
||||
|
||||
def _summarize_tool_result(result: Any) -> dict[str, Any] | None:
|
||||
if not isinstance(result, dict):
|
||||
return None
|
||||
if isinstance(result, codex_schema.McpToolCallItemResult):
|
||||
summary: dict[str, Any] = {}
|
||||
content = result.content
|
||||
if isinstance(content, list):
|
||||
summary["content_blocks"] = len(content)
|
||||
elif content is not None:
|
||||
summary["content_blocks"] = 1
|
||||
summary["has_structured"] = result.structured_content is not None
|
||||
return summary or None
|
||||
|
||||
if isinstance(result, dict):
|
||||
summary = {}
|
||||
content = result.get("content")
|
||||
if isinstance(content, list):
|
||||
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
|
||||
return summary or None
|
||||
|
||||
return None
|
||||
|
||||
def _format_change_summary(item: dict[str, Any]) -> str:
|
||||
changes = item.get("changes") or []
|
||||
paths = [c.get("path") for c in changes if c.get("path")]
|
||||
|
||||
def _format_change_summary(changes: list[Any]) -> str:
|
||||
paths: list[str] = []
|
||||
for change in changes:
|
||||
if isinstance(change, codex_schema.FileUpdateChange):
|
||||
if change.path:
|
||||
paths.append(change.path)
|
||||
continue
|
||||
if isinstance(change, dict):
|
||||
path = change.get("path")
|
||||
if isinstance(path, str) and path:
|
||||
paths.append(path)
|
||||
if not paths:
|
||||
total = len(changes)
|
||||
if total <= 0:
|
||||
@@ -178,6 +111,14 @@ def _summarize_todo_list(items: Any) -> _TodoSummary:
|
||||
next_text: str | None = None
|
||||
|
||||
for raw_item in items:
|
||||
if isinstance(raw_item, codex_schema.TodoItem):
|
||||
total += 1
|
||||
if raw_item.completed:
|
||||
done += 1
|
||||
continue
|
||||
if next_text is None:
|
||||
next_text = raw_item.text
|
||||
continue
|
||||
if not isinstance(raw_item, dict):
|
||||
continue
|
||||
total += 1
|
||||
@@ -200,25 +141,17 @@ def _todo_title(summary: _TodoSummary) -> str:
|
||||
return f"todo {summary.done}/{summary.total}: done"
|
||||
|
||||
|
||||
def _translate_item_event(etype: str, item: dict[str, Any]) -> list[TakopiEvent]:
|
||||
item_type = cast(str, item.get("type") or item.get("item_type"))
|
||||
if item_type == "assistant_message":
|
||||
item_type = "agent_message"
|
||||
|
||||
if item_type == "agent_message":
|
||||
def _translate_item_event(
|
||||
phase: ActionPhase, item: codex_schema.ThreadItem, *, factory: EventFactory
|
||||
) -> list[TakopiEvent]:
|
||||
match item:
|
||||
case codex_schema.AgentMessageItem():
|
||||
return []
|
||||
|
||||
action_id = str(item["id"])
|
||||
|
||||
phase = cast(ActionPhase, etype.split(".")[-1])
|
||||
|
||||
if item_type == "error":
|
||||
case codex_schema.ErrorItem(id=action_id, message=message):
|
||||
if phase != "completed":
|
||||
return []
|
||||
message = str(item["message"])
|
||||
return [
|
||||
_action_event(
|
||||
phase="completed",
|
||||
factory.action_completed(
|
||||
action_id=action_id,
|
||||
kind="warning",
|
||||
title=message,
|
||||
@@ -226,191 +159,191 @@ def _translate_item_event(etype: str, item: dict[str, Any]) -> list[TakopiEvent]
|
||||
ok=False,
|
||||
message=message,
|
||||
level="warning",
|
||||
)
|
||||
),
|
||||
]
|
||||
|
||||
kind = _ACTION_KIND_MAP.get(item_type)
|
||||
if kind is None:
|
||||
return []
|
||||
|
||||
if kind == "command":
|
||||
title = relativize_command(str(item["command"]))
|
||||
case codex_schema.CommandExecutionItem(
|
||||
id=action_id,
|
||||
command=command,
|
||||
exit_code=exit_code,
|
||||
status=status,
|
||||
):
|
||||
title = relativize_command(command)
|
||||
if phase in {"started", "updated"}:
|
||||
return [
|
||||
_action_event(
|
||||
factory.action(
|
||||
phase=phase,
|
||||
action_id=action_id,
|
||||
kind=kind,
|
||||
kind="command",
|
||||
title=title,
|
||||
)
|
||||
]
|
||||
if phase == "completed":
|
||||
status = item["status"]
|
||||
exit_code = item.get("exit_code")
|
||||
ok = status == "completed"
|
||||
if isinstance(exit_code, int):
|
||||
ok = ok and exit_code == 0
|
||||
detail = {
|
||||
"exit_code": exit_code,
|
||||
"status": status,
|
||||
}
|
||||
detail = {"exit_code": exit_code, "status": status}
|
||||
return [
|
||||
_action_event(
|
||||
phase="completed",
|
||||
factory.action_completed(
|
||||
action_id=action_id,
|
||||
kind=kind,
|
||||
kind="command",
|
||||
title=title,
|
||||
detail=detail,
|
||||
ok=ok,
|
||||
)
|
||||
),
|
||||
]
|
||||
|
||||
if kind == "tool":
|
||||
if item_type == "tool_call":
|
||||
name = item["name"]
|
||||
title = str(name) if name else "tool"
|
||||
detail = {
|
||||
"name": name,
|
||||
"status": item.get("status"),
|
||||
"arguments": item.get("arguments"),
|
||||
}
|
||||
else:
|
||||
tool_name = _short_tool_name(item)
|
||||
title = tool_name
|
||||
detail = {
|
||||
"server": item["server"],
|
||||
"tool": item["tool"],
|
||||
"status": item.get("status"),
|
||||
"arguments": item.get("arguments"),
|
||||
case codex_schema.McpToolCallItem(
|
||||
id=action_id,
|
||||
server=server,
|
||||
tool=tool,
|
||||
arguments=arguments,
|
||||
status=status,
|
||||
result=result,
|
||||
error=error,
|
||||
):
|
||||
title = _short_tool_name(server, tool)
|
||||
detail: dict[str, Any] = {
|
||||
"server": server,
|
||||
"tool": tool,
|
||||
"status": status,
|
||||
"arguments": arguments,
|
||||
}
|
||||
|
||||
if phase in {"started", "updated"}:
|
||||
return [
|
||||
_action_event(
|
||||
factory.action(
|
||||
phase=phase,
|
||||
action_id=action_id,
|
||||
kind=kind,
|
||||
kind="tool",
|
||||
title=title,
|
||||
detail=detail,
|
||||
)
|
||||
]
|
||||
if phase == "completed":
|
||||
status = item.get("status")
|
||||
error = item.get("error")
|
||||
ok = status == "completed" and not error
|
||||
if error:
|
||||
if isinstance(error, dict):
|
||||
detail["error_message"] = str(error.get("message") or error)
|
||||
else:
|
||||
detail["error_message"] = str(error)
|
||||
result_summary = _summarize_tool_result(item.get("result"))
|
||||
ok = status == "completed" and error is None
|
||||
if error is not None:
|
||||
detail["error_message"] = str(error.message)
|
||||
result_summary = _summarize_tool_result(result)
|
||||
if result_summary is not None:
|
||||
detail["result_summary"] = result_summary
|
||||
return [
|
||||
_action_event(
|
||||
phase="completed",
|
||||
factory.action_completed(
|
||||
action_id=action_id,
|
||||
kind=kind,
|
||||
kind="tool",
|
||||
title=title,
|
||||
detail=detail,
|
||||
ok=ok,
|
||||
)
|
||||
),
|
||||
]
|
||||
|
||||
if kind == "web_search":
|
||||
title = str(item["query"])
|
||||
detail = {"query": item["query"]}
|
||||
case codex_schema.WebSearchItem(id=action_id, query=query):
|
||||
detail = {"query": query}
|
||||
if phase in {"started", "updated"}:
|
||||
return [
|
||||
_action_event(
|
||||
factory.action(
|
||||
phase=phase,
|
||||
action_id=action_id,
|
||||
kind=kind,
|
||||
title=title,
|
||||
kind="web_search",
|
||||
title=query,
|
||||
detail=detail,
|
||||
)
|
||||
]
|
||||
if phase == "completed":
|
||||
return [
|
||||
_action_event(
|
||||
phase="completed",
|
||||
factory.action_completed(
|
||||
action_id=action_id,
|
||||
kind=kind,
|
||||
title=title,
|
||||
kind="web_search",
|
||||
title=query,
|
||||
detail=detail,
|
||||
ok=True,
|
||||
)
|
||||
]
|
||||
|
||||
if kind == "file_change":
|
||||
case codex_schema.FileChangeItem(id=action_id, changes=changes, status=status):
|
||||
if phase != "completed":
|
||||
return []
|
||||
title = _format_change_summary(item)
|
||||
title = _format_change_summary(changes)
|
||||
detail = {
|
||||
"changes": item.get("changes", []),
|
||||
"status": item.get("status"),
|
||||
"error": item.get("error"),
|
||||
"changes": changes,
|
||||
"status": status,
|
||||
"error": None,
|
||||
}
|
||||
ok = item.get("status") == "completed"
|
||||
ok = status == "completed"
|
||||
return [
|
||||
_action_event(
|
||||
phase="completed",
|
||||
factory.action_completed(
|
||||
action_id=action_id,
|
||||
kind=kind,
|
||||
kind="file_change",
|
||||
title=title,
|
||||
detail=detail,
|
||||
ok=ok,
|
||||
)
|
||||
]
|
||||
|
||||
if kind == "note":
|
||||
if item_type == "todo_list":
|
||||
summary = _summarize_todo_list(item["items"])
|
||||
case codex_schema.TodoListItem(id=action_id, items=items):
|
||||
summary = _summarize_todo_list(items)
|
||||
title = _todo_title(summary)
|
||||
detail = {"done": summary.done, "total": summary.total}
|
||||
else:
|
||||
title = str(item["text"])
|
||||
detail = None
|
||||
|
||||
if phase in {"started", "updated"}:
|
||||
return [
|
||||
_action_event(
|
||||
factory.action(
|
||||
phase=phase,
|
||||
action_id=action_id,
|
||||
kind=kind,
|
||||
kind="note",
|
||||
title=title,
|
||||
detail=detail,
|
||||
)
|
||||
]
|
||||
if phase == "completed":
|
||||
return [
|
||||
_action_event(
|
||||
phase="completed",
|
||||
factory.action_completed(
|
||||
action_id=action_id,
|
||||
kind=kind,
|
||||
kind="note",
|
||||
title=title,
|
||||
detail=detail,
|
||||
ok=True,
|
||||
)
|
||||
]
|
||||
|
||||
case codex_schema.ReasoningItem(id=action_id, text=text):
|
||||
if phase in {"started", "updated"}:
|
||||
return [
|
||||
factory.action(
|
||||
phase=phase,
|
||||
action_id=action_id,
|
||||
kind="note",
|
||||
title=text,
|
||||
)
|
||||
]
|
||||
if phase == "completed":
|
||||
return [
|
||||
factory.action_completed(
|
||||
action_id=action_id,
|
||||
kind="note",
|
||||
title=text,
|
||||
ok=True,
|
||||
)
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
def translate_codex_event(event: dict[str, Any], *, title: str) -> list[TakopiEvent]:
|
||||
etype = event["type"]
|
||||
match etype:
|
||||
case "thread.started":
|
||||
token = ResumeToken(engine=ENGINE, value=str(event["thread_id"]))
|
||||
return [_started_event(token, title=title)]
|
||||
case "item.started" | "item.updated" | "item.completed":
|
||||
return _translate_item_event(etype, event["item"])
|
||||
def translate_codex_event(
|
||||
event: codex_schema.ThreadEvent,
|
||||
*,
|
||||
title: str,
|
||||
factory: EventFactory,
|
||||
) -> list[TakopiEvent]:
|
||||
match event:
|
||||
case codex_schema.ThreadStarted(thread_id=thread_id):
|
||||
token = ResumeToken(engine=ENGINE, value=thread_id)
|
||||
return [factory.started(token, title=title)]
|
||||
case codex_schema.ItemStarted(item=item):
|
||||
return _translate_item_event("started", item, factory=factory)
|
||||
case codex_schema.ItemUpdated(item=item):
|
||||
return _translate_item_event("updated", item, factory=factory)
|
||||
case codex_schema.ItemCompleted(item=item):
|
||||
return _translate_item_event("completed", item, factory=factory)
|
||||
case _:
|
||||
return []
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CodexRunState:
|
||||
factory: EventFactory
|
||||
note_seq: int = 0
|
||||
final_answer: str | None = None
|
||||
turn_index: int = 0
|
||||
@@ -419,7 +352,6 @@ class CodexRunState:
|
||||
class CodexRunner(ResumeTokenMixin, JsonlSubprocessRunner):
|
||||
engine: EngineId = ENGINE
|
||||
resume_re = _RESUME_RE
|
||||
stderr_tail_lines = STDERR_TAIL_LINES
|
||||
logger = logger
|
||||
|
||||
def __init__(
|
||||
@@ -453,7 +385,7 @@ class CodexRunner(ResumeTokenMixin, JsonlSubprocessRunner):
|
||||
|
||||
def new_state(self, prompt: str, resume: ResumeToken | None) -> CodexRunState:
|
||||
_ = prompt, resume
|
||||
return CodexRunState()
|
||||
return CodexRunState(factory=EventFactory(ENGINE))
|
||||
|
||||
def start_run(
|
||||
self,
|
||||
@@ -466,27 +398,50 @@ class CodexRunner(ResumeTokenMixin, JsonlSubprocessRunner):
|
||||
logger.info("[codex] start run resume=%r", resume.value if resume else None)
|
||||
logger.debug("[codex] prompt: %s", prompt)
|
||||
|
||||
def decode_jsonl(self, *, line: bytes) -> codex_schema.ThreadEvent:
|
||||
return codex_schema.decode_event(line)
|
||||
|
||||
def decode_error_events(
|
||||
self,
|
||||
*,
|
||||
raw: str,
|
||||
line: str,
|
||||
error: Exception,
|
||||
state: CodexRunState,
|
||||
) -> list[TakopiEvent]:
|
||||
_ = raw, line
|
||||
if isinstance(error, msgspec.DecodeError):
|
||||
self.get_logger().warning(
|
||||
"[%s] invalid msgspec event: %s", self.tag(), error
|
||||
)
|
||||
return []
|
||||
return super().decode_error_events(
|
||||
raw=raw,
|
||||
line=line,
|
||||
error=error,
|
||||
state=state,
|
||||
)
|
||||
|
||||
def pipes_error_message(self) -> str:
|
||||
return "codex exec failed to open subprocess pipes"
|
||||
|
||||
def translate(
|
||||
self,
|
||||
data: dict[str, Any],
|
||||
data: codex_schema.ThreadEvent,
|
||||
*,
|
||||
state: CodexRunState,
|
||||
resume: ResumeToken | None,
|
||||
found_session: ResumeToken | None,
|
||||
) -> list[TakopiEvent]:
|
||||
etype = data["type"]
|
||||
match etype:
|
||||
case "error":
|
||||
message = str(data.get("message") or "")
|
||||
factory = state.factory
|
||||
match data:
|
||||
case codex_schema.StreamError(message=message):
|
||||
reconnect = _parse_reconnect_message(message)
|
||||
if reconnect is not None:
|
||||
attempt, max_attempts = reconnect
|
||||
phase: ActionPhase = "started" if attempt <= 1 else "updated"
|
||||
return [
|
||||
_action_event(
|
||||
factory.action(
|
||||
phase=phase,
|
||||
action_id="codex.reconnect",
|
||||
kind="note",
|
||||
@@ -495,83 +450,53 @@ class CodexRunner(ResumeTokenMixin, JsonlSubprocessRunner):
|
||||
level="info",
|
||||
)
|
||||
]
|
||||
|
||||
fatal_flag = data.get("fatal")
|
||||
fatal = fatal_flag is True or fatal_flag is None
|
||||
if fatal:
|
||||
resume_for_completed = found_session or resume
|
||||
return [
|
||||
_completed_event(
|
||||
resume=resume_for_completed,
|
||||
ok=False,
|
||||
answer=state.final_answer or "",
|
||||
error=message,
|
||||
)
|
||||
]
|
||||
return [
|
||||
self.note_event(
|
||||
message,
|
||||
state=state,
|
||||
ok=False,
|
||||
detail={"code": data.get("code"), "fatal": data.get("fatal")},
|
||||
)
|
||||
]
|
||||
case "turn.failed":
|
||||
error = data["error"]
|
||||
message = str(error["message"])
|
||||
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)]
|
||||
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}"
|
||||
state.turn_index += 1
|
||||
return [
|
||||
_action_event(
|
||||
phase="started",
|
||||
factory.action_started(
|
||||
action_id=action_id,
|
||||
kind="turn",
|
||||
title="turn started",
|
||||
)
|
||||
]
|
||||
case "turn.completed":
|
||||
case codex_schema.TurnCompleted(usage=usage):
|
||||
resume_for_completed = found_session or resume
|
||||
return [
|
||||
_completed_event(
|
||||
resume=resume_for_completed,
|
||||
ok=True,
|
||||
factory.completed_ok(
|
||||
answer=state.final_answer or "",
|
||||
usage=data.get("usage"),
|
||||
resume=resume_for_completed,
|
||||
usage=msgspec.to_builtins(usage),
|
||||
)
|
||||
]
|
||||
case "item.completed":
|
||||
item = data["item"]
|
||||
item_type = cast(str, item.get("type") or item.get("item_type"))
|
||||
if item_type == "assistant_message":
|
||||
item_type = "agent_message"
|
||||
if item_type == "agent_message":
|
||||
case codex_schema.ItemCompleted(
|
||||
item=codex_schema.AgentMessageItem(text=text)
|
||||
):
|
||||
if state.final_answer is None:
|
||||
state.final_answer = item["text"]
|
||||
state.final_answer = text
|
||||
else:
|
||||
logger.debug(
|
||||
"[codex] emitted multiple agent messages; using the last one"
|
||||
)
|
||||
state.final_answer = item["text"]
|
||||
state.final_answer = text
|
||||
case _:
|
||||
pass
|
||||
|
||||
return translate_codex_event(data, title=self.session_title)
|
||||
return translate_codex_event(
|
||||
data,
|
||||
title=self.session_title,
|
||||
factory=factory,
|
||||
)
|
||||
|
||||
def process_error_events(
|
||||
self,
|
||||
@@ -579,13 +504,8 @@ class CodexRunner(ResumeTokenMixin, JsonlSubprocessRunner):
|
||||
*,
|
||||
resume: ResumeToken | None,
|
||||
found_session: ResumeToken | None,
|
||||
stderr_tail: str,
|
||||
state: CodexRunState,
|
||||
) -> list[TakopiEvent]:
|
||||
reason = _extract_stderr_reason(stderr_tail)
|
||||
if reason:
|
||||
message = f"codex exec failed (rc={rc}).\n\n{reason}"
|
||||
else:
|
||||
message = f"codex exec failed (rc={rc})."
|
||||
resume_for_completed = found_session or resume
|
||||
return [
|
||||
@@ -593,13 +513,11 @@ class CodexRunner(ResumeTokenMixin, JsonlSubprocessRunner):
|
||||
message,
|
||||
state=state,
|
||||
ok=False,
|
||||
detail={"stderr_tail": stderr_tail},
|
||||
),
|
||||
_completed_event(
|
||||
resume=resume_for_completed,
|
||||
ok=False,
|
||||
answer=state.final_answer or "",
|
||||
state.factory.completed_error(
|
||||
error=message,
|
||||
answer=state.final_answer or "",
|
||||
resume=resume_for_completed,
|
||||
),
|
||||
]
|
||||
|
||||
@@ -608,27 +526,23 @@ class CodexRunner(ResumeTokenMixin, JsonlSubprocessRunner):
|
||||
*,
|
||||
resume: ResumeToken | None,
|
||||
found_session: ResumeToken | None,
|
||||
stderr_tail: str,
|
||||
state: CodexRunState,
|
||||
) -> list[TakopiEvent]:
|
||||
_ = stderr_tail
|
||||
if not found_session:
|
||||
message = "codex exec finished but no session_id/thread_id was captured"
|
||||
resume_for_completed = resume
|
||||
return [
|
||||
_completed_event(
|
||||
resume=resume_for_completed,
|
||||
ok=False,
|
||||
answer=state.final_answer or "",
|
||||
state.factory.completed_error(
|
||||
error=message,
|
||||
answer=state.final_answer or "",
|
||||
resume=resume_for_completed,
|
||||
)
|
||||
]
|
||||
logger.info("[codex] done run session=%s", found_session.value)
|
||||
return [
|
||||
_completed_event(
|
||||
resume=found_session,
|
||||
ok=True,
|
||||
state.factory.completed_ok(
|
||||
answer=state.final_answer or "",
|
||||
resume=found_session,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
import msgspec
|
||||
|
||||
from ..backends import EngineBackend, EngineConfig
|
||||
from ..config import ConfigError
|
||||
from ..model import (
|
||||
@@ -32,12 +34,12 @@ from ..model import (
|
||||
TakopiEvent,
|
||||
)
|
||||
from ..runner import JsonlSubprocessRunner, ResumeTokenMixin, Runner
|
||||
from ..schemas import opencode as opencode_schema
|
||||
from ..utils.paths import relativize_command, relativize_path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ENGINE: EngineId = EngineId("opencode")
|
||||
STDERR_TAIL_LINES = 200
|
||||
|
||||
_RESUME_RE = re.compile(
|
||||
r"(?im)^\s*`?opencode(?:\s+run)?\s+(?:--session|-s)\s+(?P<token>ses_[A-Za-z0-9]+)`?\s*$"
|
||||
@@ -54,8 +56,6 @@ class OpenCodeStreamState:
|
||||
session_id: str | None = None
|
||||
emitted_started: bool = False
|
||||
saw_step_finish: bool = False
|
||||
total_cost: float = 0.0
|
||||
total_tokens: dict[str, int] = field(default_factory=dict)
|
||||
|
||||
|
||||
def _action_event(
|
||||
@@ -146,9 +146,8 @@ def _normalize_tool_title(
|
||||
return title
|
||||
|
||||
|
||||
def _extract_tool_action(event: dict[str, Any]) -> Action | None:
|
||||
"""Extract an Action from an OpenCode tool_use event."""
|
||||
part = event.get("part") or {}
|
||||
def _extract_tool_action(part: dict[str, Any]) -> Action | None:
|
||||
"""Extract an Action from an OpenCode tool_use part."""
|
||||
state = part.get("state") or {}
|
||||
|
||||
call_id = part.get("callID")
|
||||
@@ -182,31 +181,21 @@ def _extract_tool_action(event: dict[str, Any]) -> Action | None:
|
||||
return Action(id=call_id, kind=kind, title=title, detail=detail)
|
||||
|
||||
|
||||
def _usage_from_tokens(tokens: dict[str, int], cost: float) -> dict[str, Any]:
|
||||
"""Build usage payload from accumulated token counts."""
|
||||
usage: dict[str, Any] = {}
|
||||
if cost > 0:
|
||||
usage["total_cost_usd"] = cost
|
||||
if tokens:
|
||||
usage["tokens"] = tokens
|
||||
return usage
|
||||
|
||||
|
||||
def translate_opencode_event(
|
||||
event: dict[str, Any],
|
||||
event: opencode_schema.OpenCodeEvent,
|
||||
*,
|
||||
title: str,
|
||||
state: OpenCodeStreamState,
|
||||
) -> list[TakopiEvent]:
|
||||
"""Translate an OpenCode JSON event into Takopi events."""
|
||||
etype = event.get("type")
|
||||
session_id = event.get("sessionID")
|
||||
session_id = event.sessionID
|
||||
|
||||
if isinstance(session_id, str) and session_id:
|
||||
if state.session_id is None:
|
||||
state.session_id = session_id
|
||||
|
||||
if etype == "step_start":
|
||||
match event:
|
||||
case opencode_schema.StepStart():
|
||||
if not state.emitted_started and state.session_id:
|
||||
state.emitted_started = True
|
||||
return [
|
||||
@@ -218,12 +207,12 @@ def translate_opencode_event(
|
||||
]
|
||||
return []
|
||||
|
||||
if etype == "tool_use":
|
||||
part = event.get("part") or {}
|
||||
case opencode_schema.ToolUse(part=part):
|
||||
part = part or {}
|
||||
tool_state = part.get("state") or {}
|
||||
status = tool_state.get("status")
|
||||
|
||||
action = _extract_tool_action(event)
|
||||
action = _extract_tool_action(part)
|
||||
if action is None:
|
||||
return []
|
||||
|
||||
@@ -286,8 +275,8 @@ def translate_opencode_event(
|
||||
state.pending_actions[action.id] = action
|
||||
return [_action_event(phase="started", action=action)]
|
||||
|
||||
if etype == "text":
|
||||
part = event.get("part") or {}
|
||||
case opencode_schema.Text(part=part):
|
||||
part = part or {}
|
||||
text = part.get("text")
|
||||
if isinstance(text, str) and text:
|
||||
if state.last_text is None:
|
||||
@@ -296,54 +285,28 @@ def translate_opencode_event(
|
||||
state.last_text += text
|
||||
return []
|
||||
|
||||
if etype == "step_finish":
|
||||
part = event.get("part") or {}
|
||||
case opencode_schema.StepFinish(part=part):
|
||||
part = part or {}
|
||||
reason = part.get("reason")
|
||||
state.saw_step_finish = True
|
||||
|
||||
tokens = part.get("tokens") or {}
|
||||
if isinstance(tokens, dict):
|
||||
for key in ("input", "output", "reasoning"):
|
||||
value = tokens.get(key)
|
||||
if isinstance(value, int):
|
||||
state.total_tokens[key] = state.total_tokens.get(key, 0) + value
|
||||
cache = tokens.get("cache") or {}
|
||||
if isinstance(cache, dict):
|
||||
for key in ("read", "write"):
|
||||
value = cache.get(key)
|
||||
if not isinstance(value, int):
|
||||
continue
|
||||
cache_key = f"cache_{key}"
|
||||
state.total_tokens[cache_key] = (
|
||||
state.total_tokens.get(cache_key, 0) + value
|
||||
)
|
||||
|
||||
cost = part.get("cost")
|
||||
if isinstance(cost, (int, float)):
|
||||
state.total_cost += cost
|
||||
|
||||
if reason == "stop":
|
||||
resume = None
|
||||
if state.session_id:
|
||||
resume = ResumeToken(engine=ENGINE, value=state.session_id)
|
||||
|
||||
usage = _usage_from_tokens(state.total_tokens, state.total_cost)
|
||||
|
||||
return [
|
||||
CompletedEvent(
|
||||
engine=ENGINE,
|
||||
ok=True,
|
||||
answer=state.last_text or "",
|
||||
resume=resume,
|
||||
usage=usage or None,
|
||||
)
|
||||
]
|
||||
return []
|
||||
|
||||
if etype == "error":
|
||||
raw_message = event.get("message")
|
||||
if raw_message is None:
|
||||
raw_message = event.get("error")
|
||||
case opencode_schema.Error(error=error_value, message=message_value):
|
||||
raw_message = message_value if message_value is not None else error_value
|
||||
|
||||
message = raw_message
|
||||
if isinstance(message, dict):
|
||||
@@ -352,7 +315,9 @@ def translate_opencode_event(
|
||||
message = data.get("message")
|
||||
else:
|
||||
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:
|
||||
message = "opencode error"
|
||||
@@ -371,6 +336,7 @@ def translate_opencode_event(
|
||||
)
|
||||
]
|
||||
|
||||
case _:
|
||||
return []
|
||||
|
||||
|
||||
@@ -384,7 +350,6 @@ class OpenCodeRunner(ResumeTokenMixin, JsonlSubprocessRunner):
|
||||
opencode_cmd: str = "opencode"
|
||||
model: str | None = None
|
||||
session_title: str = "opencode"
|
||||
stderr_tail_lines: int = STDERR_TAIL_LINES
|
||||
logger: logging.Logger = logger
|
||||
|
||||
def format_resume(self, token: ResumeToken) -> str:
|
||||
@@ -452,7 +417,7 @@ class OpenCodeRunner(ResumeTokenMixin, JsonlSubprocessRunner):
|
||||
|
||||
def translate(
|
||||
self,
|
||||
data: dict[str, Any],
|
||||
data: opencode_schema.OpenCodeEvent,
|
||||
*,
|
||||
state: OpenCodeStreamState,
|
||||
resume: ResumeToken | None,
|
||||
@@ -465,13 +430,36 @@ class OpenCodeRunner(ResumeTokenMixin, JsonlSubprocessRunner):
|
||||
state=state,
|
||||
)
|
||||
|
||||
def decode_jsonl(self, *, line: bytes) -> opencode_schema.OpenCodeEvent:
|
||||
return opencode_schema.decode_event(line)
|
||||
|
||||
def decode_error_events(
|
||||
self,
|
||||
*,
|
||||
raw: str,
|
||||
line: str,
|
||||
error: Exception,
|
||||
state: OpenCodeStreamState,
|
||||
) -> list[TakopiEvent]:
|
||||
_ = raw, line, state
|
||||
if isinstance(error, msgspec.DecodeError):
|
||||
self.get_logger().warning(
|
||||
"[%s] invalid msgspec event: %s", self.tag(), error
|
||||
)
|
||||
return []
|
||||
return super().decode_error_events(
|
||||
raw=raw,
|
||||
line=line,
|
||||
error=error,
|
||||
state=state,
|
||||
)
|
||||
|
||||
def process_error_events(
|
||||
self,
|
||||
rc: int,
|
||||
*,
|
||||
resume: ResumeToken | None,
|
||||
found_session: ResumeToken | None,
|
||||
stderr_tail: str,
|
||||
state: OpenCodeStreamState,
|
||||
) -> list[TakopiEvent]:
|
||||
message = f"opencode failed (rc={rc})."
|
||||
@@ -481,7 +469,6 @@ class OpenCodeRunner(ResumeTokenMixin, JsonlSubprocessRunner):
|
||||
message,
|
||||
state=state,
|
||||
ok=False,
|
||||
detail={"stderr_tail": stderr_tail},
|
||||
),
|
||||
CompletedEvent(
|
||||
engine=ENGINE,
|
||||
@@ -497,10 +484,8 @@ class OpenCodeRunner(ResumeTokenMixin, JsonlSubprocessRunner):
|
||||
*,
|
||||
resume: ResumeToken | None,
|
||||
found_session: ResumeToken | None,
|
||||
stderr_tail: str,
|
||||
state: OpenCodeStreamState,
|
||||
) -> list[TakopiEvent]:
|
||||
_ = stderr_tail
|
||||
if not found_session:
|
||||
message = "opencode finished but no session_id was captured"
|
||||
resume_for_completed = resume
|
||||
@@ -515,14 +500,12 @@ class OpenCodeRunner(ResumeTokenMixin, JsonlSubprocessRunner):
|
||||
]
|
||||
|
||||
if state.saw_step_finish:
|
||||
usage = _usage_from_tokens(state.total_tokens, state.total_cost)
|
||||
return [
|
||||
CompletedEvent(
|
||||
engine=ENGINE,
|
||||
ok=True,
|
||||
answer=state.last_text or "",
|
||||
resume=found_session,
|
||||
usage=usage or None,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
+47
-24
@@ -9,6 +9,8 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import msgspec
|
||||
|
||||
from ..backends import EngineBackend, EngineConfig
|
||||
from ..config import ConfigError
|
||||
from ..model import (
|
||||
@@ -24,12 +26,12 @@ from ..model import (
|
||||
TakopiEvent,
|
||||
)
|
||||
from ..runner import JsonlSubprocessRunner, ResumeTokenMixin, Runner
|
||||
from ..schemas import pi as pi_schema
|
||||
from ..utils.paths import relativize_command, relativize_path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ENGINE: EngineId = EngineId("pi")
|
||||
STDERR_TAIL_LINES = 200
|
||||
|
||||
_RESUME_RE = re.compile(r"(?im)^\s*`?pi\s+--session\s+(?P<token>.+?)`?\s*$")
|
||||
|
||||
@@ -132,7 +134,7 @@ def _last_assistant_message(messages: Any) -> dict[str, Any] | None:
|
||||
|
||||
|
||||
def translate_pi_event(
|
||||
event: dict[str, Any],
|
||||
event: pi_schema.PiEvent,
|
||||
*,
|
||||
title: str,
|
||||
meta: dict[str, Any] | None,
|
||||
@@ -150,12 +152,10 @@ def translate_pi_event(
|
||||
)
|
||||
state.started = True
|
||||
|
||||
etype = event.get("type")
|
||||
|
||||
if etype == "tool_execution_start":
|
||||
tool_id = event.get("toolCallId")
|
||||
tool_name = event.get("toolName")
|
||||
args = event.get("args") or {}
|
||||
match event:
|
||||
case pi_schema.ToolExecutionStart(
|
||||
toolCallId=tool_id, toolName=tool_name, args=args
|
||||
):
|
||||
if not isinstance(args, dict):
|
||||
args = {}
|
||||
if isinstance(tool_id, str) and tool_id:
|
||||
@@ -171,18 +171,17 @@ def translate_pi_event(
|
||||
out.append(_action_event(phase="started", action=action))
|
||||
return out
|
||||
|
||||
if etype == "tool_execution_end":
|
||||
tool_id = event.get("toolCallId")
|
||||
tool_name = event.get("toolName")
|
||||
case pi_schema.ToolExecutionEnd(
|
||||
toolCallId=tool_id, toolName=tool_name, result=result, isError=is_error
|
||||
):
|
||||
if isinstance(tool_id, str) and tool_id:
|
||||
action = state.pending_actions.pop(tool_id, None)
|
||||
name = str(tool_name or "tool")
|
||||
if action is None:
|
||||
action = Action(id=tool_id, kind="tool", title=name, detail={})
|
||||
detail = dict(action.detail)
|
||||
detail["result"] = event.get("result")
|
||||
detail["is_error"] = event.get("isError")
|
||||
is_error = event.get("isError") is True
|
||||
detail["result"] = result
|
||||
detail["is_error"] = is_error
|
||||
out.append(
|
||||
_action_event(
|
||||
phase="completed",
|
||||
@@ -197,8 +196,7 @@ def translate_pi_event(
|
||||
)
|
||||
return out
|
||||
|
||||
if etype == "message_end":
|
||||
message = event.get("message")
|
||||
case pi_schema.MessageEnd(message=message):
|
||||
if isinstance(message, dict) and message.get("role") == "assistant":
|
||||
text = _extract_text_blocks(message.get("content"))
|
||||
if text:
|
||||
@@ -211,8 +209,8 @@ def translate_pi_event(
|
||||
state.last_assistant_error = error
|
||||
return out
|
||||
|
||||
if etype == "agent_end":
|
||||
assistant = _last_assistant_message(event.get("messages"))
|
||||
case pi_schema.AgentEnd(messages=messages):
|
||||
assistant = _last_assistant_message(messages)
|
||||
if assistant:
|
||||
text = _extract_text_blocks(assistant.get("content"))
|
||||
if text:
|
||||
@@ -240,13 +238,13 @@ def translate_pi_event(
|
||||
)
|
||||
return out
|
||||
|
||||
case _:
|
||||
return out
|
||||
|
||||
|
||||
class PiRunner(ResumeTokenMixin, JsonlSubprocessRunner):
|
||||
engine: EngineId = ENGINE
|
||||
resume_re: re.Pattern[str] = _RESUME_RE
|
||||
stderr_tail_lines = STDERR_TAIL_LINES
|
||||
logger = logger
|
||||
|
||||
def __init__(
|
||||
@@ -335,7 +333,7 @@ class PiRunner(ResumeTokenMixin, JsonlSubprocessRunner):
|
||||
|
||||
def translate(
|
||||
self,
|
||||
data: dict[str, Any],
|
||||
data: pi_schema.PiEvent,
|
||||
*,
|
||||
state: PiStreamState,
|
||||
resume: ResumeToken | None,
|
||||
@@ -354,19 +352,46 @@ class PiRunner(ResumeTokenMixin, JsonlSubprocessRunner):
|
||||
state=state,
|
||||
)
|
||||
|
||||
def decode_jsonl(
|
||||
self,
|
||||
*,
|
||||
line: bytes,
|
||||
) -> pi_schema.PiEvent:
|
||||
return pi_schema.decode_event(line)
|
||||
|
||||
def decode_error_events(
|
||||
self,
|
||||
*,
|
||||
raw: str,
|
||||
line: str,
|
||||
error: Exception,
|
||||
state: PiStreamState,
|
||||
) -> list[TakopiEvent]:
|
||||
_ = raw, line, state
|
||||
if isinstance(error, msgspec.DecodeError):
|
||||
self.get_logger().warning(
|
||||
"[%s] invalid msgspec event: %s", self.tag(), error
|
||||
)
|
||||
return []
|
||||
return super().decode_error_events(
|
||||
raw=raw,
|
||||
line=line,
|
||||
error=error,
|
||||
state=state,
|
||||
)
|
||||
|
||||
def process_error_events(
|
||||
self,
|
||||
rc: int,
|
||||
*,
|
||||
resume: ResumeToken | None,
|
||||
found_session: ResumeToken | None,
|
||||
stderr_tail: str,
|
||||
state: PiStreamState,
|
||||
) -> list[TakopiEvent]:
|
||||
message = f"pi failed (rc={rc})."
|
||||
resume_for_completed = found_session or resume or state.resume
|
||||
return [
|
||||
self.note_event(message, state=state, detail={"stderr_tail": stderr_tail}),
|
||||
self.note_event(message, state=state),
|
||||
CompletedEvent(
|
||||
engine=ENGINE,
|
||||
ok=False,
|
||||
@@ -382,10 +407,8 @@ class PiRunner(ResumeTokenMixin, JsonlSubprocessRunner):
|
||||
*,
|
||||
resume: ResumeToken | None,
|
||||
found_session: ResumeToken | None,
|
||||
stderr_tail: str,
|
||||
state: PiStreamState,
|
||||
) -> list[TakopiEvent]:
|
||||
_ = stderr_tail
|
||||
resume_for_completed = found_session or resume or state.resume
|
||||
message = "pi finished without an agent_end event"
|
||||
return [
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Event schemas for runner JSONL streams."""
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -1,73 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
import sys
|
||||
|
||||
import anyio
|
||||
from anyio.abc import ByteReceiveStream
|
||||
from anyio.streams.text import TextReceiveStream
|
||||
from anyio.streams.buffered import BufferedByteReceiveStream
|
||||
|
||||
|
||||
async def iter_text_lines(stream: ByteReceiveStream) -> AsyncIterator[str]:
|
||||
text_stream = TextReceiveStream(stream, errors="replace")
|
||||
buffer = ""
|
||||
async def iter_bytes_lines(stream: ByteReceiveStream) -> AsyncIterator[bytes]:
|
||||
buffered = BufferedByteReceiveStream(stream)
|
||||
while True:
|
||||
try:
|
||||
chunk = await text_stream.receive()
|
||||
except anyio.EndOfStream:
|
||||
if buffer:
|
||||
yield buffer
|
||||
line = await buffered.receive_until(b"\n", sys.maxsize)
|
||||
except anyio.IncompleteRead:
|
||||
return
|
||||
buffer += chunk
|
||||
while True:
|
||||
split_at = buffer.find("\n")
|
||||
if split_at < 0:
|
||||
break
|
||||
line = buffer[: split_at + 1]
|
||||
buffer = buffer[split_at + 1 :]
|
||||
yield line
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class JsonLine:
|
||||
raw: str
|
||||
line: str
|
||||
data: dict[str, Any] | None
|
||||
|
||||
|
||||
async def iter_jsonl(
|
||||
stream: ByteReceiveStream,
|
||||
*,
|
||||
logger: logging.Logger,
|
||||
tag: str,
|
||||
) -> AsyncIterator[JsonLine]:
|
||||
async for raw_line in iter_text_lines(stream):
|
||||
raw = raw_line.rstrip("\n")
|
||||
logger.debug("[%s][jsonl] %s", tag, raw)
|
||||
line = raw.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
data = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
logger.debug("[%s] invalid json line: %s", tag, line)
|
||||
data = None
|
||||
yield JsonLine(raw=raw, line=line, data=data)
|
||||
|
||||
|
||||
async def drain_stderr(
|
||||
stream: ByteReceiveStream,
|
||||
chunks: deque[str],
|
||||
logger: logging.Logger,
|
||||
tag: str,
|
||||
) -> None:
|
||||
try:
|
||||
async for line in iter_text_lines(stream):
|
||||
logger.debug("[%s][stderr] %s", tag, line.rstrip())
|
||||
chunks.append(line)
|
||||
async for line in iter_bytes_lines(stream):
|
||||
text = line.decode("utf-8", errors="replace")
|
||||
logger.debug("[%s][stderr] %s", tag, text)
|
||||
except Exception as e:
|
||||
logger.debug("[%s][stderr] drain error: %s", tag, e)
|
||||
|
||||
-5
@@ -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
@@ -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}}}
|
||||
@@ -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":""}
|
||||
Vendored
-37
File diff suppressed because one or more lines are too long
+21
-41
@@ -1,43 +1,23 @@
|
||||
{"type":"error","message":"Failed to load optional config file ~/.codex/local.toml (ENOENT); continuing with defaults","code":"CONFIG_NOT_FOUND","fatal":false}
|
||||
{"type":"thread.started","thread_id":"thread_01JHEM1P9M8Z7Y2YQJ4G6N2C3D","cli_version":"0.56.0","model":"gpt-5-codex","sandbox_mode":"workspace-write","cwd":"/home/user/project"}
|
||||
{"type":"turn.started","turn_id":"turn_01JHEM1P9M8Z7Y2YQJ4G6N2C3E"}
|
||||
{"type":"item.started","item":{"id":"item_0001","type":"todo_list","items":[{"text":"Inspect repo structure","completed":false},{"text":"Run tests","completed":false},{"text":"Fix failing tests","completed":false},{"text":"Summarize changes","completed":false}]}}
|
||||
{"type":"item.updated","item":{"id":"item_0001","type":"todo_list","items":[{"text":"Inspect repo structure","completed":true},{"text":"Run tests","completed":false},{"text":"Fix failing tests","completed":false},{"text":"Summarize changes","completed":false}]}}
|
||||
{"type":"item.completed","item":{"id":"item_0001","type":"todo_list","items":[{"text":"Inspect repo structure","completed":true},{"text":"Run tests","completed":true},{"text":"Fix failing tests","completed":true},{"text":"Summarize changes","completed":false}]}}
|
||||
{"type":"item.started","item":{"id":"item_0002","type":"web_search","query":"python jsonlines parser handle unknown fields"}}
|
||||
{"type":"item.completed","item":{"id":"item_0002","type":"web_search","query":"python jsonlines parser handle unknown fields"}}
|
||||
{"type":"error","message":"Web search disabled by policy; returned cached results only","code":"WEB_SEARCH_POLICY","fatal":false}
|
||||
{"type":"item.started","item":{"id":"item_0003","type":"mcp_tool_call","server":"github","tool":"search_issues","status":"in_progress"}}
|
||||
{"type":"item.updated","item":{"id":"item_0003","type":"mcp_tool_call","server":"github","tool":"search_issues","status":"completed"}}
|
||||
{"type":"item.completed","item":{"id":"item_0003","type":"mcp_tool_call","server":"github","tool":"search_issues","status":"completed"}}
|
||||
{"type":"item.started","item":{"id":"item_0004","type":"command_execution","command":"pytest -q","aggregated_output":"","exit_code":null,"status":"in_progress"}}
|
||||
{"type":"item.updated","item":{"id":"item_0004","type":"command_execution","command":"pytest -q","aggregated_output":"....F\n","exit_code":null,"status":"in_progress"}}
|
||||
{"type":"item.updated","item":{"id":"item_0004","type":"command_execution","command":"pytest -q","aggregated_output":"....F....\nFAILURES\n_________________________________ test_beta __________________________________\nE AssertionError: expected 42, got 0\n","exit_code":null,"status":"in_progress"}}
|
||||
{"type":"item.completed","item":{"id":"item_0004","type":"command_execution","command":"pytest -q","aggregated_output":"....F....\n\nFAILURES\n_________________________________ test_beta __________________________________\nE AssertionError: expected 42, got 0\n\n=========================== short test summary info ===========================\nFAILED tests/test_beta.py::test_beta - AssertionError: expected 42, got 0\n1 failed, 11 passed in 0.98s\n","exit_code":1,"status":"failed"}}
|
||||
{"type":"item.completed","item":{"id":"item_0005","type":"file_change","changes":[{"path":"src/compute_answer.py","kind":"update"},{"path":"tests/test_beta.py","kind":"update"}],"status":"completed"}}
|
||||
{"type":"item.started","item":{"id":"item_0006","type":"command_execution","command":"pytest -q","aggregated_output":"","status":"in_progress","exit_code":null}}
|
||||
{"type":"item.updated","item":{"id":"item_0006","type":"command_execution","command":"pytest -q","aggregated_output":"............\n","status":"in_progress"}}
|
||||
{"type":"item.completed","item":{"id":"item_0006","type":"command_execution","command":"pytest -q","aggregated_output":"............\n12 passed in 1.23s\n","status":"completed","exit_code":0}}
|
||||
{"type":"item.started","item":{"id":"item_0007","type":"reasoning","text":"Root cause: compute_answer() returned 0. Updated logic to return 42 for the valid input path. Re-ran pytest to confirm all tests pass."}}
|
||||
{"type":"item.completed","item":{"id":"item_0007","type":"reasoning","text":"Root cause: compute_answer() returned 0. Updated logic to return 42 for the valid input path. Re-ran pytest to confirm all tests pass."}}
|
||||
{"type":"item.started","item":{"id":"item_0008","type":"agent_message","text":"I found the failing assertion in "}}
|
||||
{"type":"item.updated","item":{"id":"item_0008","type":"agent_message","text":"I found the failing assertion in tests/test_beta.py and updated src/compute_answer.py to return the expected value."}}
|
||||
{"type":"item.completed","item":{"id":"item_0008","type":"agent_message","text":"I found the failing assertion in tests/test_beta.py and updated src/compute_answer.py to return the expected value (42). After the change, `pytest -q` reports 12 passed."}}
|
||||
{"type":"turn.completed","usage":{"input_tokens":1840,"cached_input_tokens":256,"output_tokens":732},"latency_ms":8421}
|
||||
{"type":"thread.started","thread_id":"0199a213-81c0-7800-8aa1-bbab2a035a53"}
|
||||
{"type":"turn.started"}
|
||||
{"type":"item.started","item":{"id":"item_0009","type":"command_execution","command":"npm test","aggregated_output":"","exit_code":null,"status":"in_progress"}}
|
||||
{"type":"item.completed","item":{"id":"item_0009","type":"command_execution","command":"npm test","aggregated_output":"sh: npm: command not found\n","exit_code":127,"status":"failed"}}
|
||||
{"type":"item.completed","item":{"id":"item_0010","type":"error","message":"Command `npm` not found in PATH (exit 127)."}}
|
||||
{"type":"turn.failed","error":{"message":"Aborted: required dependency `npm` is missing; cannot continue."},"exit_code":1}
|
||||
{"type":"error","message":"codex exec exited non-zero (1) after turn.failed"}
|
||||
{"type":"thread.started","thread_id":"thread_legacy_7f9c2d3e"}
|
||||
{"type":"item.started","item":{"id":"item_0","type":"todo_list","items":[{"text":"Inspect repo structure","completed":false},{"text":"Run tests","completed":false},{"text":"Fix failing tests","completed":false}]}}
|
||||
{"type":"item.updated","item":{"id":"item_0","type":"todo_list","items":[{"text":"Inspect repo structure","completed":true},{"text":"Run tests","completed":false},{"text":"Fix failing tests","completed":false}]}}
|
||||
{"type":"item.completed","item":{"id":"item_0","type":"todo_list","items":[{"text":"Inspect repo structure","completed":true},{"text":"Run tests","completed":true},{"text":"Fix failing tests","completed":true}]}}
|
||||
{"type":"item.started","item":{"id":"item_1","type":"command_execution","command":"pytest -q","aggregated_output":"","exit_code":null,"status":"in_progress"}}
|
||||
{"type":"item.completed","item":{"id":"item_1","type":"command_execution","command":"pytest -q","aggregated_output":"....\n","exit_code":0,"status":"completed"}}
|
||||
{"type":"item.started","item":{"id":"item_2","type":"command_execution","command":"pytest -q","aggregated_output":"","exit_code":null,"status":"in_progress"}}
|
||||
{"type":"item.completed","item":{"id":"item_2","type":"command_execution","command":"pytest -q","aggregated_output":"....F\n","exit_code":1,"status":"failed"}}
|
||||
{"type":"item.completed","item":{"id":"item_3","type":"file_change","changes":[{"path":"src/compute_answer.py","kind":"update"},{"path":"README.md","kind":"add"}],"status":"completed"}}
|
||||
{"type":"item.completed","item":{"id":"item_4","type":"file_change","changes":[{"path":"src/compute_answer.py","kind":"update"}],"status":"failed"}}
|
||||
{"type":"item.started","item":{"id":"item_5","type":"mcp_tool_call","server":"github","tool":"search_issues","arguments":{"q":"exec --json"},"result":null,"error":null,"status":"in_progress"}}
|
||||
{"type":"item.completed","item":{"id":"item_5","type":"mcp_tool_call","server":"github","tool":"search_issues","arguments":{"q":"exec --json"},"result":{"content":[{"type":"text","text":"Found 3 matches."}],"structured_content":{"matches":3}},"error":null,"status":"completed"}}
|
||||
{"type":"item.started","item":{"id":"item_6","type":"mcp_tool_call","server":"github","tool":"search_issues","arguments":null,"result":null,"error":null,"status":"in_progress"}}
|
||||
{"type":"item.completed","item":{"id":"item_6","type":"mcp_tool_call","server":"github","tool":"search_issues","arguments":null,"result":null,"error":{"message":"tool timeout"},"status":"failed"}}
|
||||
{"type":"item.completed","item":{"id":"item_7","type":"web_search","query":"codex exec --json schema"}}
|
||||
{"type":"item.completed","item":{"id":"item_8","type":"reasoning","text":"Root cause: compute_answer() returned 0."}}
|
||||
{"type":"item.completed","item":{"id":"item_9","type":"agent_message","text":"Updated src/compute_answer.py and tests pass."}}
|
||||
{"type":"item.completed","item":{"id":"item_10","type":"error","message":"command output truncated"}}
|
||||
{"type":"turn.completed","usage":{"input_tokens":1840,"cached_input_tokens":256,"output_tokens":732}}
|
||||
{"type":"turn.started"}
|
||||
{"type":"item.completed","item":{"id":"item_l_0001","type":"agent_message","item_type":"assistant_message","text":"Legacy schema example: hello (item_type=assistant_message)."}}
|
||||
{"type":"item.completed","item":{"id":"item_l_0002","item_type":"command_execution","command":"echo legacy","output":"legacy\n","exit_code":0,"status":"completed"}}
|
||||
{"type":"turn.completed","usage":{"input_tokens":12,"output_tokens":9}}
|
||||
{"type":"thread.started","thread_id":"thread_future_01JK0Y6F8K6C7R3N1MGZ9G9A2B"}
|
||||
{"type":"turn.started"}
|
||||
{"type":"item.completed","item":{"id":"item_f_0001","type":"tool_call","name":"my_custom_tool","arguments":{"foo":"bar","n":3},"status":"completed","result":{"ok":true}}}
|
||||
{"type":"item.completed","item":{"id":"item_f_0002","type":"file_change","changes":[{"path":"README.md","kind":"add"}],"status":"failed","error":"permission denied"}}
|
||||
{"type":"turn.rate_limited","retry_after_ms":1200}
|
||||
{"type":"turn.completed","usage":null}
|
||||
{"type":"turn.failed","error":{"message":"Aborted: required dependency `npm` is missing; cannot continue."}}
|
||||
{"type":"error","message":"codex exec exited non-zero after turn.failed"}
|
||||
|
||||
+50
@@ -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
@@ -11,11 +11,43 @@ from takopi.runners.claude import (
|
||||
ENGINE,
|
||||
translate_claude_event,
|
||||
)
|
||||
from takopi.schemas import claude as claude_schema
|
||||
|
||||
|
||||
def _load_fixture(name: str) -> list[dict]:
|
||||
def _load_fixture(
|
||||
name: str, *, session_id: str | None = None
|
||||
) -> list[claude_schema.StreamJsonMessage]:
|
||||
path = Path(__file__).parent / "fixtures" / name
|
||||
return [json.loads(line) for line in path.read_text().splitlines() if line.strip()]
|
||||
events = [
|
||||
claude_schema.decode_stream_json_line(line)
|
||||
for line in path.read_bytes().splitlines()
|
||||
if line.strip()
|
||||
]
|
||||
if session_id is None:
|
||||
return events
|
||||
return [
|
||||
event for event in events if getattr(event, "session_id", None) == session_id
|
||||
]
|
||||
|
||||
|
||||
def _decode_event(payload: dict) -> claude_schema.StreamJsonMessage:
|
||||
data_payload = dict(payload)
|
||||
data_payload.setdefault("uuid", "uuid")
|
||||
data_payload.setdefault("session_id", "session")
|
||||
match data_payload.get("type"):
|
||||
case "assistant":
|
||||
message = dict(data_payload.get("message", {}))
|
||||
message.setdefault("role", "assistant")
|
||||
message.setdefault("content", [])
|
||||
message.setdefault("model", "claude")
|
||||
data_payload["message"] = message
|
||||
case "user":
|
||||
message = dict(data_payload.get("message", {}))
|
||||
message.setdefault("role", "user")
|
||||
message.setdefault("content", [])
|
||||
data_payload["message"] = message
|
||||
data = json.dumps(data_payload).encode("utf-8")
|
||||
return claude_schema.decode_stream_json_line(data)
|
||||
|
||||
|
||||
def test_claude_resume_format_and_extract() -> None:
|
||||
@@ -33,8 +65,18 @@ def test_claude_resume_format_and_extract() -> None:
|
||||
def test_translate_success_fixture() -> None:
|
||||
state = ClaudeStreamState()
|
||||
events: list = []
|
||||
for event in _load_fixture("claude_stream_success.jsonl"):
|
||||
events.extend(translate_claude_event(event, title="claude", state=state))
|
||||
for event in _load_fixture(
|
||||
"claude_streamjson_session.jsonl",
|
||||
session_id="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
):
|
||||
events.extend(
|
||||
translate_claude_event(
|
||||
event,
|
||||
title="claude",
|
||||
state=state,
|
||||
factory=state.factory,
|
||||
)
|
||||
)
|
||||
|
||||
assert isinstance(events[0], StartedEvent)
|
||||
started = next(evt for evt in events if isinstance(evt, StartedEvent))
|
||||
@@ -47,8 +89,10 @@ def test_translate_success_fixture() -> None:
|
||||
for evt in action_events
|
||||
if evt.phase == "started"
|
||||
}
|
||||
assert started_actions[("toolu_1", "started")].action.kind == "command"
|
||||
write_action = started_actions[("toolu_2", "started")].action
|
||||
assert (
|
||||
started_actions[("toolu_01BASH_LS_EXAMPLE", "started")].action.kind == "command"
|
||||
)
|
||||
write_action = started_actions[("toolu_02", "started")].action
|
||||
assert write_action.kind == "file_change"
|
||||
assert write_action.detail["changes"][0]["path"] == "notes.md"
|
||||
|
||||
@@ -57,34 +101,37 @@ def test_translate_success_fixture() -> None:
|
||||
for evt in action_events
|
||||
if evt.phase == "completed"
|
||||
}
|
||||
assert completed_actions[("toolu_1", "completed")].ok is True
|
||||
assert completed_actions[("toolu_2", "completed")].ok is True
|
||||
assert completed_actions[("toolu_01BASH_LS_EXAMPLE", "completed")].ok is True
|
||||
assert completed_actions[("toolu_02", "completed")].ok is True
|
||||
|
||||
completed = next(evt for evt in events if isinstance(evt, CompletedEvent))
|
||||
assert events[-1] == completed
|
||||
assert completed.ok is True
|
||||
assert completed.resume == started.resume
|
||||
assert completed.answer == "Done. Added notes.md."
|
||||
assert completed.answer == "I see README.md, pyproject.toml, and src/."
|
||||
|
||||
|
||||
def test_translate_error_fixture_permission_denials() -> None:
|
||||
state = ClaudeStreamState()
|
||||
events: list = []
|
||||
for event in _load_fixture("claude_stream_error.jsonl"):
|
||||
events.extend(translate_claude_event(event, title="claude", state=state))
|
||||
for event in _load_fixture(
|
||||
"claude_streamjson_session.jsonl",
|
||||
session_id="bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
||||
):
|
||||
events.extend(
|
||||
translate_claude_event(
|
||||
event,
|
||||
title="claude",
|
||||
state=state,
|
||||
factory=state.factory,
|
||||
)
|
||||
)
|
||||
|
||||
started = next(evt for evt in events if isinstance(evt, StartedEvent))
|
||||
completed = next(evt for evt in events if isinstance(evt, CompletedEvent))
|
||||
warnings = [
|
||||
evt
|
||||
for evt in events
|
||||
if isinstance(evt, ActionEvent) and evt.action.kind == "warning"
|
||||
]
|
||||
|
||||
assert warnings
|
||||
assert events.index(warnings[0]) < events.index(completed)
|
||||
assert completed.ok is False
|
||||
assert completed.error == "Permission denied"
|
||||
assert completed.error is not None
|
||||
assert "claude run failed" in completed.error
|
||||
assert completed.resume == started.resume
|
||||
|
||||
|
||||
@@ -120,13 +167,54 @@ def test_tool_results_pop_pending_actions() -> None:
|
||||
},
|
||||
}
|
||||
|
||||
translate_claude_event(tool_use_event, title="claude", state=state)
|
||||
translate_claude_event(
|
||||
_decode_event(tool_use_event),
|
||||
title="claude",
|
||||
state=state,
|
||||
factory=state.factory,
|
||||
)
|
||||
assert "toolu_1" in state.pending_actions
|
||||
|
||||
translate_claude_event(tool_result_event, title="claude", state=state)
|
||||
translate_claude_event(
|
||||
_decode_event(tool_result_event),
|
||||
title="claude",
|
||||
state=state,
|
||||
factory=state.factory,
|
||||
)
|
||||
assert not state.pending_actions
|
||||
|
||||
|
||||
def test_translate_thinking_block() -> None:
|
||||
state = ClaudeStreamState()
|
||||
event = {
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"id": "msg_1",
|
||||
"content": [
|
||||
{
|
||||
"type": "thinking",
|
||||
"thinking": "Consider the options.",
|
||||
"signature": "sig",
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
events = translate_claude_event(
|
||||
_decode_event(event),
|
||||
title="claude",
|
||||
state=state,
|
||||
factory=state.factory,
|
||||
)
|
||||
|
||||
assert len(events) == 1
|
||||
assert isinstance(events[0], ActionEvent)
|
||||
assert events[0].phase == "completed"
|
||||
assert events[0].action.kind == "note"
|
||||
assert events[0].action.title == "Consider the options."
|
||||
assert events[0].ok is True
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_run_serializes_same_session() -> None:
|
||||
runner = ClaudeRunner(claude_cmd="claude")
|
||||
@@ -184,15 +272,30 @@ async def test_run_serializes_new_session_after_session_is_known(
|
||||
"resume_marker = os.environ['CLAUDE_TEST_RESUME_MARKER']\n"
|
||||
"session_id = os.environ['CLAUDE_TEST_SESSION_ID']\n"
|
||||
"\n"
|
||||
"init = {\n"
|
||||
" 'type': 'system',\n"
|
||||
" 'subtype': 'init',\n"
|
||||
" 'uuid': 'uuid',\n"
|
||||
" 'session_id': session_id,\n"
|
||||
" 'apiKeySource': 'env',\n"
|
||||
" 'cwd': '.',\n"
|
||||
" 'tools': [],\n"
|
||||
" 'mcp_servers': [],\n"
|
||||
" 'model': 'claude',\n"
|
||||
" 'permissionMode': 'default',\n"
|
||||
" 'slash_commands': [],\n"
|
||||
" 'output_style': 'default',\n"
|
||||
"}\n"
|
||||
"\n"
|
||||
"args = sys.argv[1:]\n"
|
||||
"if '--resume' in args or '-r' in args:\n"
|
||||
" print(json.dumps({'type': 'system', 'subtype': 'init', 'session_id': session_id}), flush=True)\n"
|
||||
" print(json.dumps(init), flush=True)\n"
|
||||
" with open(resume_marker, 'w', encoding='utf-8') as f:\n"
|
||||
" f.write('started')\n"
|
||||
" f.flush()\n"
|
||||
" sys.exit(0)\n"
|
||||
"\n"
|
||||
"print(json.dumps({'type': 'system', 'subtype': 'init', 'session_id': session_id}), flush=True)\n"
|
||||
"print(json.dumps(init), flush=True)\n"
|
||||
"while not os.path.exists(gate):\n"
|
||||
" time.sleep(0.001)\n"
|
||||
"sys.exit(0)\n",
|
||||
@@ -252,8 +355,37 @@ async def test_run_strips_anthropic_api_key_by_default(tmp_path, monkeypatch) ->
|
||||
"\n"
|
||||
"session_id = 'session_01'\n"
|
||||
"status = 'set' if os.environ.get('ANTHROPIC_API_KEY') else 'unset'\n"
|
||||
"print(json.dumps({'type': 'system', 'subtype': 'init', 'session_id': session_id}), flush=True)\n"
|
||||
"print(json.dumps({'type': 'result', 'subtype': 'success', 'is_error': False, 'result': f'api={status}', 'session_id': session_id}), flush=True)\n"
|
||||
"init = {\n"
|
||||
" 'type': 'system',\n"
|
||||
" 'subtype': 'init',\n"
|
||||
" 'uuid': 'uuid',\n"
|
||||
" 'session_id': session_id,\n"
|
||||
" 'apiKeySource': 'env',\n"
|
||||
" 'cwd': '.',\n"
|
||||
" 'tools': [],\n"
|
||||
" 'mcp_servers': [],\n"
|
||||
" 'model': 'claude',\n"
|
||||
" 'permissionMode': 'default',\n"
|
||||
" 'slash_commands': [],\n"
|
||||
" 'output_style': 'default',\n"
|
||||
"}\n"
|
||||
"print(json.dumps(init), flush=True)\n"
|
||||
"result = {\n"
|
||||
" 'type': 'result',\n"
|
||||
" 'subtype': 'success',\n"
|
||||
" 'uuid': 'uuid',\n"
|
||||
" 'session_id': session_id,\n"
|
||||
" 'duration_ms': 0,\n"
|
||||
" 'duration_api_ms': 0,\n"
|
||||
" 'is_error': False,\n"
|
||||
" 'num_turns': 1,\n"
|
||||
" 'result': f'api={status}',\n"
|
||||
" 'total_cost_usd': 0.0,\n"
|
||||
" 'usage': {'input_tokens': 0, 'output_tokens': 0},\n"
|
||||
" 'modelUsage': {},\n"
|
||||
" 'permission_denials': [],\n"
|
||||
"}\n"
|
||||
"print(json.dumps(result), flush=True)\n"
|
||||
"raise SystemExit(0)\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
@@ -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])
|
||||
@@ -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])
|
||||
@@ -1,5 +1,21 @@
|
||||
import json
|
||||
|
||||
from takopi.events import EventFactory
|
||||
from takopi.model import ActionEvent
|
||||
from takopi.runners.codex import translate_codex_event
|
||||
from takopi.schemas import codex as codex_schema
|
||||
|
||||
|
||||
def _decode_event(payload: dict) -> codex_schema.ThreadEvent:
|
||||
return codex_schema.decode_event(json.dumps(payload))
|
||||
|
||||
|
||||
def _translate_event(payload: dict) -> list:
|
||||
return translate_codex_event(
|
||||
_decode_event(payload),
|
||||
title="Codex",
|
||||
factory=EventFactory("codex"),
|
||||
)
|
||||
|
||||
|
||||
def test_translate_mcp_tool_call_summarizes_structured_content() -> None:
|
||||
@@ -20,7 +36,7 @@ def test_translate_mcp_tool_call_summarizes_structured_content() -> None:
|
||||
},
|
||||
}
|
||||
|
||||
out = translate_codex_event(evt, title="Codex")
|
||||
out = _translate_event(evt)
|
||||
assert len(out) == 1
|
||||
assert isinstance(out[0], ActionEvent)
|
||||
summary = out[0].action.detail["result_summary"]
|
||||
@@ -36,38 +52,19 @@ def test_translate_mcp_tool_call_summarizes_null_structured_content() -> None:
|
||||
"type": "mcp_tool_call",
|
||||
"server": "docs",
|
||||
"tool": "search",
|
||||
"arguments": None,
|
||||
"result": {"content": [], "structured_content": None},
|
||||
"error": None,
|
||||
"status": "completed",
|
||||
},
|
||||
}
|
||||
|
||||
out = translate_codex_event(evt, title="Codex")
|
||||
out = _translate_event(evt)
|
||||
assert len(out) == 1
|
||||
assert isinstance(out[0], ActionEvent)
|
||||
assert out[0].action.detail["result_summary"]["has_structured"] is False
|
||||
|
||||
|
||||
def test_translate_mcp_tool_call_summarizes_legacy_structured_key() -> None:
|
||||
evt = {
|
||||
"type": "item.completed",
|
||||
"item": {
|
||||
"id": "item_3",
|
||||
"type": "mcp_tool_call",
|
||||
"server": "docs",
|
||||
"tool": "search",
|
||||
"result": {"structured": {"matches": 3}},
|
||||
"error": None,
|
||||
"status": "completed",
|
||||
},
|
||||
}
|
||||
|
||||
out = translate_codex_event(evt, title="Codex")
|
||||
assert len(out) == 1
|
||||
assert isinstance(out[0], ActionEvent)
|
||||
assert out[0].action.detail["result_summary"]["has_structured"] is True
|
||||
|
||||
|
||||
def test_translate_mcp_tool_call_missing_error_is_ok() -> None:
|
||||
evt = {
|
||||
"type": "item.completed",
|
||||
@@ -76,18 +73,20 @@ def test_translate_mcp_tool_call_missing_error_is_ok() -> None:
|
||||
"type": "mcp_tool_call",
|
||||
"server": "docs",
|
||||
"tool": "search",
|
||||
"arguments": None,
|
||||
"status": "completed",
|
||||
"result": {"content": []},
|
||||
"result": {"content": [], "structured_content": None},
|
||||
"error": None,
|
||||
},
|
||||
}
|
||||
|
||||
out = translate_codex_event(evt, title="Codex")
|
||||
out = _translate_event(evt)
|
||||
assert len(out) == 1
|
||||
assert isinstance(out[0], ActionEvent)
|
||||
assert out[0].ok is True
|
||||
|
||||
|
||||
def test_translate_command_execution_allows_missing_exit_code() -> None:
|
||||
def test_translate_command_execution_allows_null_exit_code() -> None:
|
||||
evt = {
|
||||
"type": "item.completed",
|
||||
"item": {
|
||||
@@ -95,11 +94,12 @@ def test_translate_command_execution_allows_missing_exit_code() -> None:
|
||||
"type": "command_execution",
|
||||
"command": "ls -la",
|
||||
"aggregated_output": "",
|
||||
"exit_code": None,
|
||||
"status": "completed",
|
||||
},
|
||||
}
|
||||
|
||||
out = translate_codex_event(evt, title="Codex")
|
||||
out = _translate_event(evt)
|
||||
assert len(out) == 1
|
||||
assert isinstance(out[0], ActionEvent)
|
||||
assert out[0].ok is True
|
||||
|
||||
@@ -218,7 +218,7 @@ async def test_codex_runner_preserves_warning_order(tmp_path) -> None:
|
||||
"import sys\n"
|
||||
"\n"
|
||||
"sys.stdin.read()\n"
|
||||
"print(json.dumps({'type': 'error', 'message': 'warning one', 'fatal': False}), flush=True)\n"
|
||||
"print(json.dumps({'type': 'error', 'message': 'warning one'}), flush=True)\n"
|
||||
f"print(json.dumps({{'type': 'thread.started', 'thread_id': '{thread_id}'}}), flush=True)\n"
|
||||
"print(json.dumps({'type': 'item.completed', 'item': {'id': 'item_0', 'type': 'agent_message', 'text': 'ok'}}), flush=True)\n",
|
||||
encoding="utf-8",
|
||||
@@ -335,7 +335,7 @@ async def test_codex_runner_includes_stderr_reason(tmp_path) -> None:
|
||||
assert completed.ok is False
|
||||
assert completed.error is not None
|
||||
assert "codex exec failed (rc=1)." in completed.error
|
||||
assert "\n\nNot inside a trusted directory" in completed.error
|
||||
assert "Not inside a trusted directory" not in completed.error
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -11,11 +11,26 @@ from takopi.runners.opencode import (
|
||||
ENGINE,
|
||||
translate_opencode_event,
|
||||
)
|
||||
from takopi.schemas import opencode as opencode_schema
|
||||
|
||||
|
||||
def _load_fixture(name: str) -> list[dict]:
|
||||
def _load_fixture(name: str) -> list[opencode_schema.OpenCodeEvent]:
|
||||
path = Path(__file__).parent / "fixtures" / name
|
||||
return [json.loads(line) for line in path.read_text().splitlines() if line.strip()]
|
||||
events: list[opencode_schema.OpenCodeEvent] = []
|
||||
for line in path.read_bytes().splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
events.append(opencode_schema.decode_event(line))
|
||||
except Exception as exc:
|
||||
raise AssertionError(
|
||||
f"{name} contained unparseable line: {line!r}"
|
||||
) from exc
|
||||
return events
|
||||
|
||||
|
||||
def _decode_event(payload: dict) -> opencode_schema.OpenCodeEvent:
|
||||
return opencode_schema.decode_event(json.dumps(payload).encode("utf-8"))
|
||||
|
||||
|
||||
def test_opencode_resume_format_and_extract() -> None:
|
||||
@@ -59,9 +74,6 @@ def test_translate_success_fixture() -> None:
|
||||
assert completed.resume == started.resume
|
||||
assert completed.answer == "```\nhello\n```"
|
||||
|
||||
assert completed.usage is not None
|
||||
assert "tokens" in completed.usage
|
||||
|
||||
|
||||
def test_translate_missing_reason_success() -> None:
|
||||
state = OpenCodeStreamState()
|
||||
@@ -74,7 +86,6 @@ def test_translate_missing_reason_success() -> None:
|
||||
fallback = runner.stream_end_events(
|
||||
resume=None,
|
||||
found_session=started.resume,
|
||||
stderr_tail="",
|
||||
state=state,
|
||||
)
|
||||
|
||||
@@ -82,14 +93,13 @@ def test_translate_missing_reason_success() -> None:
|
||||
assert completed.ok is True
|
||||
assert completed.resume == started.resume
|
||||
assert completed.answer == "All done."
|
||||
assert completed.usage is not None
|
||||
|
||||
|
||||
def test_translate_accumulates_text() -> None:
|
||||
state = OpenCodeStreamState()
|
||||
|
||||
events = translate_opencode_event(
|
||||
{"type": "step_start", "sessionID": "ses_test123", "part": {}},
|
||||
_decode_event({"type": "step_start", "sessionID": "ses_test123", "part": {}}),
|
||||
title="opencode",
|
||||
state=state,
|
||||
)
|
||||
@@ -97,20 +107,24 @@ def test_translate_accumulates_text() -> None:
|
||||
assert isinstance(events[0], StartedEvent)
|
||||
|
||||
translate_opencode_event(
|
||||
_decode_event(
|
||||
{
|
||||
"type": "text",
|
||||
"sessionID": "ses_test123",
|
||||
"part": {"type": "text", "text": "Hello "},
|
||||
},
|
||||
}
|
||||
),
|
||||
title="opencode",
|
||||
state=state,
|
||||
)
|
||||
translate_opencode_event(
|
||||
_decode_event(
|
||||
{
|
||||
"type": "text",
|
||||
"sessionID": "ses_test123",
|
||||
"part": {"type": "text", "text": "World"},
|
||||
},
|
||||
}
|
||||
),
|
||||
title="opencode",
|
||||
state=state,
|
||||
)
|
||||
@@ -118,11 +132,13 @@ def test_translate_accumulates_text() -> None:
|
||||
assert state.last_text == "Hello World"
|
||||
|
||||
events = translate_opencode_event(
|
||||
_decode_event(
|
||||
{
|
||||
"type": "step_finish",
|
||||
"sessionID": "ses_test123",
|
||||
"part": {"reason": "stop", "tokens": {"input": 100, "output": 10}},
|
||||
},
|
||||
}
|
||||
),
|
||||
title="opencode",
|
||||
state=state,
|
||||
)
|
||||
@@ -140,6 +156,7 @@ def test_translate_tool_use_completed() -> None:
|
||||
state.emitted_started = True
|
||||
|
||||
events = translate_opencode_event(
|
||||
_decode_event(
|
||||
{
|
||||
"type": "tool_use",
|
||||
"sessionID": "ses_test123",
|
||||
@@ -155,7 +172,8 @@ def test_translate_tool_use_completed() -> None:
|
||||
"metadata": {"exit": 0},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
),
|
||||
title="opencode",
|
||||
state=state,
|
||||
)
|
||||
@@ -175,6 +193,7 @@ def test_translate_tool_use_with_error() -> None:
|
||||
state.emitted_started = True
|
||||
|
||||
events = translate_opencode_event(
|
||||
_decode_event(
|
||||
{
|
||||
"type": "tool_use",
|
||||
"sessionID": "ses_test123",
|
||||
@@ -190,7 +209,8 @@ def test_translate_tool_use_with_error() -> None:
|
||||
"metadata": {"exit": 1},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
),
|
||||
title="opencode",
|
||||
state=state,
|
||||
)
|
||||
@@ -209,6 +229,7 @@ def test_translate_tool_use_read_title_wraps_path() -> None:
|
||||
path = Path.cwd() / "src" / "takopi" / "runners" / "opencode.py"
|
||||
|
||||
events = translate_opencode_event(
|
||||
_decode_event(
|
||||
{
|
||||
"type": "tool_use",
|
||||
"sessionID": "ses_test123",
|
||||
@@ -223,7 +244,8 @@ def test_translate_tool_use_read_title_wraps_path() -> None:
|
||||
"title": "src/takopi/runners/opencode.py",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
),
|
||||
title="opencode",
|
||||
state=state,
|
||||
)
|
||||
@@ -255,11 +277,16 @@ def test_step_finish_tool_calls_does_not_complete() -> None:
|
||||
state.emitted_started = True
|
||||
|
||||
events = translate_opencode_event(
|
||||
_decode_event(
|
||||
{
|
||||
"type": "step_finish",
|
||||
"sessionID": "ses_test123",
|
||||
"part": {"reason": "tool-calls", "tokens": {"input": 100, "output": 10}},
|
||||
"part": {
|
||||
"reason": "tool-calls",
|
||||
"tokens": {"input": 100, "output": 10},
|
||||
},
|
||||
}
|
||||
),
|
||||
title="opencode",
|
||||
state=state,
|
||||
)
|
||||
|
||||
@@ -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
@@ -1,4 +1,3 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import anyio
|
||||
@@ -6,11 +5,21 @@ import pytest
|
||||
|
||||
from takopi.model import ActionEvent, CompletedEvent, ResumeToken, StartedEvent
|
||||
from takopi.runners.pi import ENGINE, PiRunner, PiStreamState, translate_pi_event
|
||||
from takopi.schemas import pi as pi_schema
|
||||
|
||||
|
||||
def _load_fixture(name: str) -> list[dict]:
|
||||
def _load_fixture(name: str) -> list[pi_schema.PiEvent]:
|
||||
path = Path(__file__).parent / "fixtures" / name
|
||||
return [json.loads(line) for line in path.read_text().splitlines() if line.strip()]
|
||||
events: list[pi_schema.PiEvent] = []
|
||||
for line in path.read_text().splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
decoded = pi_schema.decode_event(line)
|
||||
except Exception as exc:
|
||||
raise AssertionError(f"{name} contained unparseable line: {line}") from exc
|
||||
events.append(decoded)
|
||||
return events
|
||||
|
||||
|
||||
def test_pi_resume_format_and_extract() -> None:
|
||||
|
||||
@@ -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])
|
||||
@@ -212,6 +212,30 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "msgspec"
|
||||
version = "0.20.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ea/9c/bfbd12955a49180cbd234c5d29ec6f74fe641698f0cd9df154a854fc8a15/msgspec-0.20.0.tar.gz", hash = "sha256:692349e588fde322875f8d3025ac01689fead5901e7fb18d6870a44519d62a29", size = 317862, upload-time = "2025-11-24T03:56:28.934Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/18/62dc13ab0260c7d741dda8dc7f481495b93ac9168cd887dda5929880eef8/msgspec-0.20.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:eead16538db1b3f7ec6e3ed1f6f7c5dec67e90f76e76b610e1ffb5671815633a", size = 196407, upload-time = "2025-11-24T03:55:55.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/1d/b9949e4ad6953e9f9a142c7997b2f7390c81e03e93570c7c33caf65d27e1/msgspec-0.20.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:703c3bb47bf47801627fb1438f106adbfa2998fe586696d1324586a375fca238", size = 188889, upload-time = "2025-11-24T03:55:56.311Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/19/f8bb2dc0f1bfe46cc7d2b6b61c5e9b5a46c62298e8f4d03bbe499c926180/msgspec-0.20.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6cdb227dc585fb109305cee0fd304c2896f02af93ecf50a9c84ee54ee67dbb42", size = 219691, upload-time = "2025-11-24T03:55:57.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/8e/6b17e43f6eb9369d9858ee32c97959fcd515628a1df376af96c11606cf70/msgspec-0.20.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27d35044dd8818ac1bd0fedb2feb4fbdff4e3508dd7c5d14316a12a2d96a0de0", size = 224918, upload-time = "2025-11-24T03:55:59.322Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/db/0e833a177db1a4484797adba7f429d4242585980b90882cc38709e1b62df/msgspec-0.20.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b4296393a29ee42dd25947981c65506fd4ad39beaf816f614146fa0c5a6c91ae", size = 223436, upload-time = "2025-11-24T03:56:00.716Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/30/d2ee787f4c918fd2b123441d49a7707ae9015e0e8e1ab51aa7967a97b90e/msgspec-0.20.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:205fbdadd0d8d861d71c8f3399fe1a82a2caf4467bc8ff9a626df34c12176980", size = 227190, upload-time = "2025-11-24T03:56:02.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/37/9c4b58ff11d890d788e700b827db2366f4d11b3313bf136780da7017278b/msgspec-0.20.0-cp314-cp314-win_amd64.whl", hash = "sha256:7dfebc94fe7d3feec6bc6c9df4f7e9eccc1160bb5b811fbf3e3a56899e398a6b", size = 193950, upload-time = "2025-11-24T03:56:03.668Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/4e/cab707bf2fa57408e2934e5197fc3560079db34a1e3cd2675ff2e47e07de/msgspec-0.20.0-cp314-cp314-win_arm64.whl", hash = "sha256:2ad6ae36e4a602b24b4bf4eaf8ab5a441fec03e1f1b5931beca8ebda68f53fc0", size = 179018, upload-time = "2025-11-24T03:56:05.038Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/06/3da3fc9aaa55618a8f43eb9052453cfe01f82930bca3af8cea63a89f3a11/msgspec-0.20.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f84703e0e6ef025663dd1de828ca028774797b8155e070e795c548f76dde65d5", size = 200389, upload-time = "2025-11-24T03:56:06.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/3b/cc4270a5ceab40dfe1d1745856951b0a24fd16ac8539a66ed3004a60c91e/msgspec-0.20.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7c83fc24dd09cf1275934ff300e3951b3adc5573f0657a643515cc16c7dee131", size = 193198, upload-time = "2025-11-24T03:56:07.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/ae/4c7905ac53830c8e3c06fdd60e3cdcfedc0bbc993872d1549b84ea21a1bd/msgspec-0.20.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f13ccb1c335a124e80c4562573b9b90f01ea9521a1a87f7576c2e281d547f56", size = 225973, upload-time = "2025-11-24T03:56:09.18Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/da/032abac1de4d0678d99eaeadb1323bd9d247f4711c012404ba77ed6f15ca/msgspec-0.20.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17c2b5ca19f19306fc83c96d85e606d2cc107e0caeea85066b5389f664e04846", size = 229509, upload-time = "2025-11-24T03:56:10.898Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/52/fdc7bdb7057a166f309e0b44929e584319e625aaba4771b60912a9321ccd/msgspec-0.20.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d931709355edabf66c2dd1a756b2d658593e79882bc81aae5964969d5a291b63", size = 230434, upload-time = "2025-11-24T03:56:12.48Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/fe/1dfd5f512b26b53043884e4f34710c73e294e7cc54278c3fe28380e42c37/msgspec-0.20.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:565f915d2e540e8a0c93a01ff67f50aebe1f7e22798c6a25873f9fda8d1325f8", size = 231758, upload-time = "2025-11-24T03:56:13.765Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/f6/9ba7121b8e0c4e0beee49575d1dbc804e2e72467692f0428cf39ceba1ea5/msgspec-0.20.0-cp314-cp314t-win_amd64.whl", hash = "sha256:726f3e6c3c323f283f6021ebb6c8ccf58d7cd7baa67b93d73bfbe9a15c34ab8d", size = 206540, upload-time = "2025-11-24T03:56:15.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/3e/c5187de84bb2c2ca334ab163fcacf19a23ebb1d876c837f81a1b324a15bf/msgspec-0.20.0-cp314-cp314t-win_arm64.whl", hash = "sha256:93f23528edc51d9f686808a361728e903d6f2be55c901d6f5c92e44c6d546bfc", size = 183011, upload-time = "2025-11-24T03:56:16.442Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "25.0"
|
||||
@@ -384,6 +408,7 @@ dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "httpx" },
|
||||
{ name = "markdown-it-py" },
|
||||
{ name = "msgspec" },
|
||||
{ name = "questionary" },
|
||||
{ name = "rich" },
|
||||
{ name = "sulguk" },
|
||||
@@ -404,6 +429,7 @@ requires-dist = [
|
||||
{ name = "anyio", specifier = ">=4.12.0" },
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
{ name = "markdown-it-py" },
|
||||
{ name = "msgspec", specifier = ">=0.20.0" },
|
||||
{ name = "questionary", specifier = ">=2.1.1" },
|
||||
{ name = "rich", specifier = ">=14.2.0" },
|
||||
{ name = "sulguk", specifier = ">=0.11.1" },
|
||||
|
||||
Reference in New Issue
Block a user