diff --git a/docs/adding-a-runner.md b/docs/adding-a-runner.md index 9f940da..5d6ac0d 100644 --- a/docs/adding-a-runner.md +++ b/docs/adding-a-runner.md @@ -6,7 +6,8 @@ A *runner* is the adapter between an engine-specific CLI (Codex, Claude Code, **normalized event model** (`StartedEvent`, `ActionEvent`, `CompletedEvent`). Takopi is designed so that adding a runner usually means **adding one new module** under -`src/takopi/runners/`—no changes to the bridge, renderer, or CLI. +`src/takopi/runners/` plus a small **msgspec schema** module under `src/takopi/schemas/`— +no changes to the bridge, renderer, or CLI. The walkthrough below uses an **imaginary engine** named **Pi** (`pi`) and intentionally mirrors the patterns used in `runners/claude.py`. @@ -97,16 +98,20 @@ Why this shape? --- -### Step 2 — Create `src/takopi/runners/pi.py` +### Step 2 — Create `src/takopi/schemas/pi.py` + `src/takopi/runners/pi.py` -Create a new module next to the existing runners: +Create a new schema module and a runner module: ``` +src/takopi/schemas/ + codex.py + pi.py # ← new + src/takopi/runners/ codex.py claude.py mock.py - pi.py # ← new + pi.py # ← new ``` Takopi discovers engines by importing modules in `takopi.runners` and looking for a @@ -121,9 +126,9 @@ Most CLIs we integrate are JSONL-streaming processes. Takopi provides `JsonlSubprocessRunner`, which: - spawns the CLI -- drains stderr into a bounded tail -- reads stdout line-by-line as JSONL -- calls your `translate(...)` method to convert each JSON object into Takopi events +- drains stderr and logs it +- reads stdout line-by-line as JSONL bytes +- calls your `decode_jsonl(...)` and then `translate(...)` to convert each event into Takopi events - guarantees “exactly one CompletedEvent” behavior - provides safe fallbacks for rc != 0 or stream ending without a completion event @@ -147,6 +152,55 @@ class PiStreamState: note_seq: int = 0 ``` +#### Define a msgspec schema (recommended path) + +Codex now decodes JSONL with **msgspec**, and new runners should follow that pattern. +Create a small schema module under `src/takopi/schemas/` and expose a `decode_event(...)` +function. Only include the event shapes your CLI actually emits. + +Minimal example: + +```py +from __future__ import annotations + +from typing import Any, Literal, TypeAlias + +import msgspec + + +class SessionStart(msgspec.Struct, tag="session.start", kw_only=True): + session_id: str + model: str | None = None + + +class ToolUse(msgspec.Struct, tag="tool.use", kw_only=True): + id: str + name: str + input: dict[str, Any] | None = None + + +class ToolResult(msgspec.Struct, tag="tool.result", kw_only=True): + tool_use_id: str + content: Any + is_error: bool | None = None + + +class Final(msgspec.Struct, tag="final", kw_only=True): + session_id: str + ok: bool + answer: str | None = None + error: str | None = None + + +PiEvent: TypeAlias = SessionStart | ToolUse | ToolResult | Final + +_DECODER = msgspec.json.Decoder(PiEvent) + + +def decode_event(data: bytes | str) -> PiEvent: + return _DECODER.decode(data) +``` + #### Decide what Pi emits For this guide, assume Pi outputs events like: @@ -323,7 +377,7 @@ import logging import re from dataclasses import dataclass from pathlib import Path -from typing import Any +from typing import Any, cast from ..backends import EngineBackend, EngineConfig from ..model import ( @@ -333,13 +387,14 @@ from ..model import ( StartedEvent, TakopiEvent, ) +import msgspec + from ..runner import JsonlSubprocessRunner, ResumeTokenMixin, Runner +from ..schemas import pi as pi_schema logger = logging.getLogger(__name__) ENGINE: EngineId = EngineId("pi") -STDERR_TAIL_LINES = 200 - _RESUME_RE = re.compile( r"(?im)^\s*`?pi\s+--resume\s+(?P[^`\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..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. - diff --git a/pyproject.toml b/pyproject.toml index 64ea7f8..82033e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/src/takopi/events.py b/src/takopi/events.py new file mode 100644 index 0000000..8af3b5d --- /dev/null +++ b/src/takopi/events.py @@ -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, + ) diff --git a/src/takopi/logging.py b/src/takopi/logging.py index 7b06b0c..1792461 100644 --- a/src/takopi/logging.py +++ b/src/takopi/logging.py @@ -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) diff --git a/src/takopi/model.py b/src/takopi/model.py index 345e89b..b19de09 100644 --- a/src/takopi/model.py +++ b/src/takopi/model.py @@ -12,6 +12,7 @@ ActionKind: TypeAlias = Literal[ "tool", "file_change", "web_search", + "subagent", "note", "turn", "warning", diff --git a/src/takopi/render.py b/src/takopi/render.py index 42a4fcb..8d66fe7 100644 --- a/src/takopi/render.py +++ b/src/takopi/render.py @@ -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"}: diff --git a/src/takopi/runner.py b/src/takopi/runner.py index 3073983..2c652ae 100644 --- a/src/takopi/runner.py +++ b/src/takopi/runner.py @@ -2,13 +2,13 @@ from __future__ import annotations +import json import logging import re import subprocess -from collections import deque from collections.abc import AsyncIterator, Callable from dataclasses import dataclass -from typing import Any, Protocol +from typing import Any, Protocol, cast from weakref import WeakValueDictionary import anyio @@ -22,7 +22,7 @@ from .model import ( StartedEvent, TakopiEvent, ) -from .utils.streams import drain_stderr, iter_jsonl +from .utils.streams import drain_stderr, iter_bytes_lines from .utils.subprocess import manage_subprocess @@ -131,8 +131,6 @@ class JsonlRunState: class JsonlSubprocessRunner(BaseRunner): - stderr_tail_lines: int = 200 - def get_logger(self) -> logging.Logger: return getattr(self, "logger", logging.getLogger(__name__)) @@ -222,19 +220,66 @@ class JsonlSubprocessRunner(BaseRunner): message = f"invalid JSON from {self.tag()}; ignoring line" return [self.note_event(message, state=state, detail={"line": line})] + def decode_jsonl(self, *, line: bytes) -> Any | None: + text = line.decode("utf-8", errors="replace") + try: + return cast(dict[str, Any], json.loads(text)) + except json.JSONDecodeError: + return None + + async def iter_json_lines( + self, + stream: Any, + *, + logger: logging.Logger, + tag: str, + ) -> AsyncIterator[bytes]: + async for raw_line in iter_bytes_lines(stream): + raw = raw_line.rstrip(b"\n") + text = raw.decode("utf-8", errors="replace") + logger.debug("[%s][jsonl] %s", tag, text) + yield raw + + def decode_error_events( + self, + *, + raw: str, + line: str, + error: Exception, + state: Any, + ) -> list[TakopiEvent]: + message = f"invalid event from {self.tag()}; ignoring line" + detail = {"line": line, "error": str(error)} + return [self.note_event(message, state=state, detail=detail)] + + def translate_error_events( + self, + *, + data: Any, + error: Exception, + state: Any, + ) -> list[TakopiEvent]: + message = f"{self.tag()} translation error; ignoring event" + detail: dict[str, Any] = {"error": str(error)} + if isinstance(data, dict): + detail["type"] = data.get("type") + item = data.get("item") + if isinstance(item, dict): + detail["item_type"] = item.get("type") or item.get("item_type") + return [self.note_event(message, state=state, detail=detail)] + def process_error_events( self, rc: int, *, resume: ResumeToken | None, found_session: ResumeToken | None, - stderr_tail: str, state: Any, ) -> list[TakopiEvent]: message = f"{self.tag()} failed (rc={rc})." resume_for_completed = found_session or resume return [ - self.note_event(message, state=state, detail={"stderr_tail": stderr_tail}), + self.note_event(message, state=state), CompletedEvent( engine=self.engine, ok=False, @@ -249,7 +294,6 @@ class JsonlSubprocessRunner(BaseRunner): *, resume: ResumeToken | None, found_session: ResumeToken | None, - stderr_tail: str, state: Any, ) -> list[TakopiEvent]: message = f"{self.tag()} finished without a result event" @@ -266,7 +310,7 @@ class JsonlSubprocessRunner(BaseRunner): def translate( self, - data: dict[str, Any], + data: Any, *, state: Any, resume: ResumeToken | None, @@ -334,7 +378,6 @@ class JsonlSubprocessRunner(BaseRunner): elif proc.stdin is not None: await proc.stdin.aclose() - stderr_chunks: deque[str] = deque(maxlen=self.stderr_tail_lines) rc: int | None = None expected_session: ResumeToken | None = resume found_session: ResumeToken | None = None @@ -344,26 +387,49 @@ class JsonlSubprocessRunner(BaseRunner): tg.start_soon( drain_stderr, proc.stderr, - stderr_chunks, logger, tag, ) - async for json_line in iter_jsonl(proc.stdout, logger=logger, tag=tag): + async for raw_line in self.iter_json_lines( + proc.stdout, logger=logger, tag=tag + ): if did_emit_completed: continue - if json_line.data is None: - events = self.invalid_json_events( - raw=json_line.raw, - line=json_line.line, + line = raw_line.strip() + if not line: + continue + raw_text = raw_line.decode("utf-8", errors="replace") + line_text = line.decode("utf-8", errors="replace") + try: + decoded = self.decode_jsonl(line=line) + except Exception as exc: + events = self.decode_error_events( + raw=raw_text, + line=line_text, + error=exc, state=state, ) else: - events = self.translate( - json_line.data, - state=state, - resume=resume, - found_session=found_session, - ) + if decoded is None: + events = self.invalid_json_events( + raw=raw_text, + line=line_text, + state=state, + ) + else: + try: + events = self.translate( + decoded, + state=state, + resume=resume, + found_session=found_session, + ) + except Exception as exc: + events = self.translate_error_events( + data=decoded, + error=exc, + state=state, + ) for evt in events: if isinstance(evt, StartedEvent): @@ -385,13 +451,11 @@ class JsonlSubprocessRunner(BaseRunner): logger.debug("[%s] process exit pid=%s rc=%s", tag, proc.pid, rc) if did_emit_completed: return - stderr_tail = "".join(stderr_chunks) if rc is not None and rc != 0: events = self.process_error_events( rc, resume=resume, found_session=found_session, - stderr_tail=stderr_tail, state=state, ) for evt in events: @@ -401,7 +465,6 @@ class JsonlSubprocessRunner(BaseRunner): events = self.stream_end_events( resume=resume, found_session=found_session, - stderr_tail=stderr_tail, state=state, ) for evt in events: diff --git a/src/takopi/runners/claude.py b/src/takopi/runners/claude.py index 1dcb2c7..5b57453 100644 --- a/src/takopi/runners/claude.py +++ b/src/takopi/runners/claude.py @@ -5,26 +5,20 @@ import os import re from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Literal +from typing import Any + +import msgspec from ..backends import EngineBackend, EngineConfig -from ..model import ( - Action, - ActionEvent, - ActionKind, - CompletedEvent, - EngineId, - ResumeToken, - StartedEvent, - TakopiEvent, -) +from ..events import EventFactory +from ..model import Action, ActionKind, EngineId, ResumeToken, TakopiEvent from ..runner import JsonlSubprocessRunner, ResumeTokenMixin, Runner +from ..schemas import claude as claude_schema from ..utils.paths import relativize_command, relativize_path logger = logging.getLogger(__name__) ENGINE: EngineId = EngineId("claude") -STDERR_TAIL_LINES = 200 DEFAULT_ALLOWED_TOOLS = ["Bash", "Read", "Edit", "Write"] _RESUME_RE = re.compile( @@ -34,45 +28,31 @@ _RESUME_RE = re.compile( @dataclass(slots=True) class ClaudeStreamState: + factory: EventFactory = field(default_factory=lambda: EventFactory(ENGINE)) pending_actions: dict[str, Action] = field(default_factory=dict) last_assistant_text: str | None = None note_seq: int = 0 -def _action_event( - *, - phase: Literal["started", "updated", "completed"], - action: Action, - ok: bool | None = None, - message: str | None = None, - level: Literal["debug", "info", "warning", "error"] | None = None, -) -> ActionEvent: - return ActionEvent( - engine=ENGINE, - action=action, - phase=phase, - ok=ok, - message=message, - level=level, - ) - - def _normalize_tool_result(content: Any) -> str: - if isinstance(content, list): - parts: list[str] = [] - for item in content: - if isinstance(item, dict): - if item.get("type") == "text" and isinstance(item.get("text"), str): - parts.append(item["text"]) - elif isinstance(item.get("text"), str): - parts.append(item["text"]) - elif isinstance(item, str): - parts.append(item) - return "\n".join(part for part in parts if part) if content is None: return "" if isinstance(content, str): return content + if isinstance(content, list): + parts: list[str] = [] + for item in content: + if isinstance(item, dict): + text = item.get("text") + if isinstance(text, str) and text: + parts.append(text) + elif isinstance(item, str): + parts.append(item) + return "\n".join(part for part in parts if part) + if isinstance(content, dict): + text = content.get("text") + if isinstance(text, str): + return text return str(content) @@ -134,19 +114,18 @@ def _tool_kind_and_title( return "note", "ask user" if name in {"Task", "Agent"}: desc = tool_input.get("description") or tool_input.get("prompt") - return "tool", str(desc or name) + return "subagent", str(desc or name) return "tool", name def _tool_action( - content: dict[str, Any], + content: claude_schema.StreamToolUseBlock, *, - message_id: str | None, parent_tool_use_id: str | None, ) -> Action: - tool_id = content["id"] - tool_name = str(content.get("name") or "tool") - tool_input = content["input"] + tool_id = content.id + tool_name = str(content.name or "tool") + tool_input = content.input kind, title = _tool_kind_and_title(tool_name, tool_input) @@ -154,8 +133,6 @@ def _tool_action( "name": tool_name, "input": tool_input, } - if message_id: - detail["message_id"] = message_id if parent_tool_use_id: detail["parent_tool_use_id"] = parent_tool_use_id @@ -168,59 +145,46 @@ def _tool_action( def _tool_result_event( - content: dict[str, Any], + content: claude_schema.StreamToolResultBlock, *, action: Action, - message_id: str | None, -) -> ActionEvent: - is_error = content.get("is_error") is True - raw_result = content.get("content") + factory: EventFactory, +) -> TakopiEvent: + is_error = content.is_error is True + raw_result = content.content normalized = _normalize_tool_result(raw_result) preview = normalized detail = dict(action.detail) detail.update( { - "tool_use_id": content.get("tool_use_id"), + "tool_use_id": content.tool_use_id, "result_preview": preview, "result_len": len(normalized), "is_error": is_error, } ) - if message_id: - detail["message_id"] = message_id - - return _action_event( - phase="completed", - action=Action( - id=action.id, - kind=action.kind, - title=action.title, - detail=detail, - ), + return factory.action_completed( + action_id=action.id, + kind=action.kind, + title=action.title, ok=not is_error, + detail=detail, ) -def _extract_error(event: dict[str, Any]) -> str | None: - error = event.get("error") - if isinstance(error, str) and error: - return error - errors = event.get("errors") - if isinstance(errors, list): - for item in errors: - if isinstance(item, dict): - message = item.get("message") or item.get("error") - if isinstance(message, str) and message: - return message - elif isinstance(item, str) and item: - return item - if event.get("is_error"): +def _extract_error(event: claude_schema.StreamResultMessage) -> str | None: + if event.is_error: + if isinstance(event.result, str) and event.result: + return event.result + subtype = event.subtype + if subtype: + return f"claude run failed ({subtype})" return "claude run failed" return None -def _usage_payload(event: dict[str, Any]) -> dict[str, Any]: +def _usage_payload(event: claude_schema.StreamResultMessage) -> dict[str, Any]: usage: dict[str, Any] = {} for key in ( "total_cost_usd", @@ -228,28 +192,28 @@ def _usage_payload(event: dict[str, Any]) -> dict[str, Any]: "duration_api_ms", "num_turns", ): - value = event.get(key) - if value is not None: - usage[key] = value - for key in ("usage", "modelUsage"): - value = event.get(key) + value = getattr(event, key, None) if value is not None: usage[key] = value + if event.usage is not None: + usage["usage"] = event.usage return usage def translate_claude_event( - event: dict[str, Any], + event: claude_schema.StreamJsonMessage, *, title: str, state: ClaudeStreamState, + factory: EventFactory, ) -> list[TakopiEvent]: - etype = event["type"] - match etype: - case "system" if event.get("subtype") == "init": - session_id = event["session_id"] - model = event.get("model") - event_title = str(model) if model else title + match event: + case claude_schema.StreamSystemMessage(subtype=subtype): + if subtype != "init": + return [] + session_id = event.session_id + if not session_id: + return [] meta: dict[str, Any] = {} for key in ( "cwd", @@ -257,52 +221,70 @@ def translate_claude_event( "permissionMode", "output_style", "apiKeySource", + "mcp_servers", ): - if key in event: - meta[key] = event.get(key) - if "mcp_servers" in event: - meta["mcp_servers"] = event.get("mcp_servers") - - return [ - StartedEvent( - engine=ENGINE, - resume=ResumeToken(engine=ENGINE, value=str(session_id)), - title=event_title, - meta=meta or None, - ) - ] - case "assistant": - message = event["message"] - message_id = message.get("id") - parent_tool_use_id = event.get("parent_tool_use_id") - content_blocks = message["content"] + value = getattr(event, key, None) + if value is not None: + meta[key] = value + model = event.model + token = ResumeToken(engine=ENGINE, value=session_id) + event_title = str(model) if isinstance(model, str) and model else title + return [factory.started(token, title=event_title, meta=meta or None)] + case claude_schema.StreamAssistantMessage( + message=message, parent_tool_use_id=parent_tool_use_id + ): out: list[TakopiEvent] = [] - for content in content_blocks: - match content["type"]: - case "tool_use": + for content in message.content: + match content: + case claude_schema.StreamToolUseBlock(): action = _tool_action( content, - message_id=message_id, parent_tool_use_id=parent_tool_use_id, ) state.pending_actions[action.id] = action - out.append(_action_event(phase="started", action=action)) - case "text": - text = content["text"] + out.append( + factory.action_started( + action_id=action.id, + kind=action.kind, + title=action.title, + detail=action.detail, + ) + ) + case claude_schema.StreamThinkingBlock( + thinking=thinking, signature=signature + ): + if not thinking: + continue + state.note_seq += 1 + action_id = f"claude.thinking.{state.note_seq}" + detail: dict[str, Any] = {} + if parent_tool_use_id: + detail["parent_tool_use_id"] = parent_tool_use_id + if signature: + detail["signature"] = signature + out.append( + factory.action_completed( + action_id=action_id, + kind="note", + title=thinking, + ok=True, + detail=detail, + ) + ) + case claude_schema.StreamTextBlock(text=text): if text: state.last_assistant_text = text case _: continue return out - case "user": - message = event["message"] - message_id = message.get("id") - content_blocks = message["content"] + case claude_schema.StreamUserMessage(message=message): + if not isinstance(message.content, list): + return [] out: list[TakopiEvent] = [] - for content in content_blocks: - if content["type"] != "tool_result": + for content in message.content: + if not isinstance(content, claude_schema.StreamToolResultBlock): continue - tool_use_id = content["tool_use_id"] + tool_use_id = content.tool_use_id action = state.pending_actions.pop(tool_use_id, None) if action is None: action = Action( @@ -312,56 +294,32 @@ def translate_claude_event( detail={}, ) out.append( - _tool_result_event(content, action=action, message_id=message_id) - ) - return out - case "result": - out: list[TakopiEvent] = [] - for idx, denial in enumerate(event.get("permission_denials", [])): - tool_name = denial.get("tool_name") - denial_title = "permission denied" - if tool_name: - denial_title = f"permission denied: {tool_name}" - tool_use_id = denial.get("tool_use_id") - action_id = ( - f"claude.permission.{tool_use_id}" - if tool_use_id - else f"claude.permission.{idx}" - ) - out.append( - _action_event( - phase="completed", - action=Action( - id=action_id, - kind="warning", - title=denial_title, - detail=denial, - ), - ok=False, - level="warning", + _tool_result_event( + content, + action=action, + factory=factory, ) ) - - ok = not event.get("is_error", False) - result_text = event["result"] + return out + case claude_schema.StreamResultMessage(): + ok = not event.is_error + result_text = event.result or "" if ok and not result_text and state.last_assistant_text: result_text = state.last_assistant_text - resume = ResumeToken(engine=ENGINE, value=str(event["session_id"])) + resume = ResumeToken(engine=ENGINE, value=event.session_id) error = None if ok else _extract_error(event) usage = _usage_payload(event) - out.append( - CompletedEvent( - engine=ENGINE, + return [ + factory.completed( ok=ok, answer=result_text, resume=resume, error=error, usage=usage or None, ) - ) - return out + ] case _: return [] @@ -377,7 +335,6 @@ class ClaudeRunner(ResumeTokenMixin, JsonlSubprocessRunner): dangerously_skip_permissions: bool = False use_api_billing: bool = False session_title: str = "claude" - stderr_tail_lines = STDERR_TAIL_LINES logger = logger def format_resume(self, token: ResumeToken) -> str: @@ -449,6 +406,34 @@ class ClaudeRunner(ResumeTokenMixin, JsonlSubprocessRunner): ) logger.debug("[claude] prompt: %s", prompt) + def decode_jsonl( + self, + *, + line: bytes, + ) -> claude_schema.StreamJsonMessage: + return claude_schema.decode_stream_json_line(line) + + def decode_error_events( + self, + *, + raw: str, + line: str, + error: Exception, + state: ClaudeStreamState, + ) -> list[TakopiEvent]: + _ = raw, line, state + if isinstance(error, msgspec.DecodeError): + self.get_logger().warning( + "[%s] invalid msgspec event: %s", self.tag(), error + ) + return [] + return super().decode_error_events( + raw=raw, + line=line, + error=error, + state=state, + ) + def invalid_json_events( self, *, @@ -456,13 +441,12 @@ class ClaudeRunner(ResumeTokenMixin, JsonlSubprocessRunner): line: str, state: ClaudeStreamState, ) -> list[TakopiEvent]: - _ = line - message = "invalid JSON from claude; ignoring line" - return [self.note_event(message, state=state, detail={"line": raw})] + _ = raw, line, state + return [] def translate( self, - data: dict[str, Any], + data: claude_schema.StreamJsonMessage, *, state: ClaudeStreamState, resume: ResumeToken | None, @@ -473,6 +457,7 @@ class ClaudeRunner(ResumeTokenMixin, JsonlSubprocessRunner): data, title=self.session_title, state=state, + factory=state.factory, ) def process_error_events( @@ -481,24 +466,15 @@ class ClaudeRunner(ResumeTokenMixin, JsonlSubprocessRunner): *, resume: ResumeToken | None, found_session: ResumeToken | None, - stderr_tail: str, state: ClaudeStreamState, ) -> list[TakopiEvent]: message = f"claude failed (rc={rc})." resume_for_completed = found_session or resume return [ - self.note_event( - message, - state=state, - ok=False, - detail={"stderr_tail": stderr_tail}, - ), - CompletedEvent( - engine=ENGINE, - ok=False, - answer="", - resume=resume_for_completed, + self.note_event(message, state=state, ok=False), + state.factory.completed_error( error=message, + resume=resume_for_completed, ), ] @@ -507,31 +483,24 @@ class ClaudeRunner(ResumeTokenMixin, JsonlSubprocessRunner): *, resume: ResumeToken | None, found_session: ResumeToken | None, - stderr_tail: str, state: ClaudeStreamState, ) -> list[TakopiEvent]: - _ = stderr_tail if not found_session: message = "claude finished but no session_id was captured" resume_for_completed = resume return [ - CompletedEvent( - engine=ENGINE, - ok=False, - answer="", - resume=resume_for_completed, + state.factory.completed_error( error=message, + resume=resume_for_completed, ) ] message = "claude finished without a result event" return [ - CompletedEvent( - engine=ENGINE, - ok=False, + state.factory.completed_error( + error=message, answer=state.last_assistant_text or "", resume=found_session, - error=message, ) ] diff --git a/src/takopi/runners/codex.py b/src/takopi/runners/codex.py index 93dcf23..5f0c825 100644 --- a/src/takopi/runners/codex.py +++ b/src/takopi/runners/codex.py @@ -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[^`\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\d+)/(?P\d+)\s*$", re.IGNORECASE, ) -def _strip_ansi(text: str) -> str: - return _ANSI_ESCAPE_RE.sub("", text) - - -def _extract_stderr_reason(stderr_tail: str) -> str | None: - if not stderr_tail: - return None - cleaned = _strip_ansi(stderr_tail) - lines = [line.strip() for line in cleaned.splitlines() if line.strip()] - if not lines: - return None - for line in lines: - if _TRUSTED_DIR_RE.search(line): - return line - return lines[-1] - - def _parse_reconnect_message(message: str) -> tuple[int, int] | None: match = _RECONNECTING_RE.match(message) if not match: @@ -76,84 +39,54 @@ def _parse_reconnect_message(message: str) -> tuple[int, int] | None: return (attempt, max_attempts) -def _started_event(token: ResumeToken, *, title: str) -> StartedEvent: - return StartedEvent(engine=token.engine, resume=token, title=title) - - -def _completed_event( - *, - resume: ResumeToken | None, - ok: bool, - answer: str, - error: str | None = None, - usage: dict[str, Any] | None = None, -) -> TakopiEvent: - return CompletedEvent( - engine=ENGINE, - ok=ok, - answer=answer, - resume=resume, - error=error, - usage=usage, - ) - - -def _action_event( - *, - phase: ActionPhase, - action_id: str, - kind: ActionKind, - title: str, - detail: dict[str, Any] | None = None, - ok: bool | None = None, - message: str | None = None, - level: ActionLevel | None = None, -) -> TakopiEvent: - action = Action( - id=action_id, - kind=kind, - title=title, - detail=detail or {}, - ) - return ActionEvent( - engine=ENGINE, - action=action, - phase=phase, - ok=ok, - message=message, - level=level, - ) - - -def _short_tool_name(item: dict[str, Any]) -> str: - name = ".".join(part for part in (item.get("server"), item.get("tool")) if part) +def _short_tool_name(server: str | None, tool: str | None) -> str: + name = ".".join(part for part in (server, tool) if part) return name or "tool" def _summarize_tool_result(result: Any) -> dict[str, Any] | None: - if not isinstance(result, dict): - return None - summary: dict[str, Any] = {} - content = result.get("content") - if isinstance(content, list): - summary["content_blocks"] = len(content) - elif content is not None: - summary["content_blocks"] = 1 + if isinstance(result, codex_schema.McpToolCallItemResult): + summary: dict[str, Any] = {} + content = result.content + if isinstance(content, list): + summary["content_blocks"] = len(content) + elif content is not None: + summary["content_blocks"] = 1 + summary["has_structured"] = result.structured_content is not None + return summary or None - structured_key: str | None = None - if "structured_content" in result: - structured_key = "structured_content" - elif "structured" in result: - structured_key = "structured" + if isinstance(result, dict): + summary = {} + content = result.get("content") + if isinstance(content, list): + summary["content_blocks"] = len(content) + elif content is not None: + summary["content_blocks"] = 1 - if structured_key is not None: - summary["has_structured"] = result.get(structured_key) is not None - return summary or None + structured_key: str | None = None + if "structured_content" in result: + structured_key = "structured_content" + elif "structured" in result: + structured_key = "structured" + + if structured_key is not None: + summary["has_structured"] = result.get(structured_key) is not None + return summary or None + + return None -def _format_change_summary(item: dict[str, Any]) -> str: - changes = item.get("changes") or [] - paths = [c.get("path") for c in changes if c.get("path")] +def _format_change_summary(changes: list[Any]) -> str: + paths: list[str] = [] + for change in changes: + if isinstance(change, codex_schema.FileUpdateChange): + if change.path: + paths.append(change.path) + continue + if isinstance(change, dict): + path = change.get("path") + if isinstance(path, str) and path: + paths.append(path) if not paths: total = len(changes) if total <= 0: @@ -178,6 +111,14 @@ def _summarize_todo_list(items: Any) -> _TodoSummary: next_text: str | None = None for raw_item in items: + if isinstance(raw_item, codex_schema.TodoItem): + total += 1 + if raw_item.completed: + done += 1 + continue + if next_text is None: + next_text = raw_item.text + continue if not isinstance(raw_item, dict): continue total += 1 @@ -200,217 +141,209 @@ def _todo_title(summary: _TodoSummary) -> str: return f"todo {summary.done}/{summary.total}: done" -def _translate_item_event(etype: str, item: dict[str, Any]) -> list[TakopiEvent]: - item_type = cast(str, item.get("type") or item.get("item_type")) - if item_type == "assistant_message": - item_type = "agent_message" - - if item_type == "agent_message": - return [] - - action_id = str(item["id"]) - - phase = cast(ActionPhase, etype.split(".")[-1]) - - if item_type == "error": - if phase != "completed": +def _translate_item_event( + phase: ActionPhase, item: codex_schema.ThreadItem, *, factory: EventFactory +) -> list[TakopiEvent]: + match item: + case codex_schema.AgentMessageItem(): return [] - message = str(item["message"]) - return [ - _action_event( - phase="completed", - action_id=action_id, - kind="warning", - title=message, - detail={"message": message}, - ok=False, - message=message, - level="warning", - ) - ] - - kind = _ACTION_KIND_MAP.get(item_type) - if kind is None: - return [] - - if kind == "command": - title = relativize_command(str(item["command"])) - if phase in {"started", "updated"}: + case codex_schema.ErrorItem(id=action_id, message=message): + if phase != "completed": + return [] return [ - _action_event( - phase=phase, + factory.action_completed( action_id=action_id, - kind=kind, - title=title, - ) + kind="warning", + title=message, + detail={"message": message}, + ok=False, + message=message, + level="warning", + ), ] - if phase == "completed": - status = item["status"] - exit_code = item.get("exit_code") - ok = status == "completed" - if isinstance(exit_code, int): - ok = ok and exit_code == 0 - detail = { - "exit_code": exit_code, + case codex_schema.CommandExecutionItem( + id=action_id, + command=command, + exit_code=exit_code, + status=status, + ): + title = relativize_command(command) + if phase in {"started", "updated"}: + return [ + factory.action( + phase=phase, + action_id=action_id, + kind="command", + title=title, + ) + ] + if phase == "completed": + ok = status == "completed" + if isinstance(exit_code, int): + ok = ok and exit_code == 0 + detail = {"exit_code": exit_code, "status": status} + return [ + factory.action_completed( + action_id=action_id, + kind="command", + title=title, + detail=detail, + ok=ok, + ), + ] + case codex_schema.McpToolCallItem( + id=action_id, + server=server, + tool=tool, + arguments=arguments, + status=status, + result=result, + error=error, + ): + title = _short_tool_name(server, tool) + detail: dict[str, Any] = { + "server": server, + "tool": tool, "status": status, + "arguments": arguments, } + + if phase in {"started", "updated"}: + return [ + factory.action( + phase=phase, + action_id=action_id, + kind="tool", + title=title, + detail=detail, + ) + ] + if phase == "completed": + ok = status == "completed" and error is None + if error is not None: + detail["error_message"] = str(error.message) + result_summary = _summarize_tool_result(result) + if result_summary is not None: + detail["result_summary"] = result_summary + return [ + factory.action_completed( + action_id=action_id, + kind="tool", + title=title, + detail=detail, + ok=ok, + ), + ] + case codex_schema.WebSearchItem(id=action_id, query=query): + detail = {"query": query} + if phase in {"started", "updated"}: + return [ + factory.action( + phase=phase, + action_id=action_id, + kind="web_search", + title=query, + detail=detail, + ) + ] + if phase == "completed": + return [ + factory.action_completed( + action_id=action_id, + kind="web_search", + title=query, + detail=detail, + ok=True, + ) + ] + case codex_schema.FileChangeItem(id=action_id, changes=changes, status=status): + if phase != "completed": + return [] + title = _format_change_summary(changes) + detail = { + "changes": changes, + "status": status, + "error": None, + } + ok = status == "completed" return [ - _action_event( - phase="completed", + factory.action_completed( action_id=action_id, - kind=kind, + kind="file_change", title=title, detail=detail, ok=ok, ) ] - - if kind == "tool": - if item_type == "tool_call": - name = item["name"] - title = str(name) if name else "tool" - detail = { - "name": name, - "status": item.get("status"), - "arguments": item.get("arguments"), - } - else: - tool_name = _short_tool_name(item) - title = tool_name - detail = { - "server": item["server"], - "tool": item["tool"], - "status": item.get("status"), - "arguments": item.get("arguments"), - } - - if phase in {"started", "updated"}: - return [ - _action_event( - phase=phase, - action_id=action_id, - kind=kind, - title=title, - detail=detail, - ) - ] - if phase == "completed": - status = item.get("status") - error = item.get("error") - ok = status == "completed" and not error - if error: - if isinstance(error, dict): - detail["error_message"] = str(error.get("message") or error) - else: - detail["error_message"] = str(error) - result_summary = _summarize_tool_result(item.get("result")) - if result_summary is not None: - detail["result_summary"] = result_summary - return [ - _action_event( - phase="completed", - action_id=action_id, - kind=kind, - title=title, - detail=detail, - ok=ok, - ) - ] - - if kind == "web_search": - title = str(item["query"]) - detail = {"query": item["query"]} - if phase in {"started", "updated"}: - return [ - _action_event( - phase=phase, - action_id=action_id, - kind=kind, - title=title, - detail=detail, - ) - ] - if phase == "completed": - return [ - _action_event( - phase="completed", - action_id=action_id, - kind=kind, - title=title, - detail=detail, - ok=True, - ) - ] - - if kind == "file_change": - if phase != "completed": - return [] - title = _format_change_summary(item) - detail = { - "changes": item.get("changes", []), - "status": item.get("status"), - "error": item.get("error"), - } - ok = item.get("status") == "completed" - return [ - _action_event( - phase="completed", - action_id=action_id, - kind=kind, - title=title, - detail=detail, - ok=ok, - ) - ] - - if kind == "note": - if item_type == "todo_list": - summary = _summarize_todo_list(item["items"]) + case codex_schema.TodoListItem(id=action_id, items=items): + summary = _summarize_todo_list(items) title = _todo_title(summary) detail = {"done": summary.done, "total": summary.total} - else: - title = str(item["text"]) - detail = None - - if phase in {"started", "updated"}: - return [ - _action_event( - phase=phase, - action_id=action_id, - kind=kind, - title=title, - detail=detail, - ) - ] - if phase == "completed": - return [ - _action_event( - phase="completed", - action_id=action_id, - kind=kind, - title=title, - detail=detail, - ok=True, - ) - ] - + if phase in {"started", "updated"}: + return [ + factory.action( + phase=phase, + action_id=action_id, + kind="note", + title=title, + detail=detail, + ) + ] + if phase == "completed": + return [ + factory.action_completed( + action_id=action_id, + kind="note", + title=title, + detail=detail, + ok=True, + ) + ] + case codex_schema.ReasoningItem(id=action_id, text=text): + if phase in {"started", "updated"}: + return [ + factory.action( + phase=phase, + action_id=action_id, + kind="note", + title=text, + ) + ] + if phase == "completed": + return [ + factory.action_completed( + action_id=action_id, + kind="note", + title=text, + ok=True, + ) + ] return [] -def translate_codex_event(event: dict[str, Any], *, title: str) -> list[TakopiEvent]: - etype = event["type"] - match etype: - case "thread.started": - token = ResumeToken(engine=ENGINE, value=str(event["thread_id"])) - return [_started_event(token, title=title)] - case "item.started" | "item.updated" | "item.completed": - return _translate_item_event(etype, event["item"]) +def translate_codex_event( + event: codex_schema.ThreadEvent, + *, + title: str, + factory: EventFactory, +) -> list[TakopiEvent]: + match event: + case codex_schema.ThreadStarted(thread_id=thread_id): + token = ResumeToken(engine=ENGINE, value=thread_id) + return [factory.started(token, title=title)] + case codex_schema.ItemStarted(item=item): + return _translate_item_event("started", item, factory=factory) + case codex_schema.ItemUpdated(item=item): + return _translate_item_event("updated", item, factory=factory) + case codex_schema.ItemCompleted(item=item): + return _translate_item_event("completed", item, factory=factory) case _: return [] @dataclass(slots=True) class CodexRunState: + factory: EventFactory note_seq: int = 0 final_answer: str | None = None turn_index: int = 0 @@ -419,7 +352,6 @@ class CodexRunState: class CodexRunner(ResumeTokenMixin, JsonlSubprocessRunner): engine: EngineId = ENGINE resume_re = _RESUME_RE - stderr_tail_lines = STDERR_TAIL_LINES logger = logger def __init__( @@ -453,7 +385,7 @@ class CodexRunner(ResumeTokenMixin, JsonlSubprocessRunner): def new_state(self, prompt: str, resume: ResumeToken | None) -> CodexRunState: _ = prompt, resume - return CodexRunState() + return CodexRunState(factory=EventFactory(ENGINE)) def start_run( self, @@ -466,27 +398,50 @@ class CodexRunner(ResumeTokenMixin, JsonlSubprocessRunner): logger.info("[codex] start run resume=%r", resume.value if resume else None) logger.debug("[codex] prompt: %s", prompt) + def decode_jsonl(self, *, line: bytes) -> codex_schema.ThreadEvent: + return codex_schema.decode_event(line) + + def decode_error_events( + self, + *, + raw: str, + line: str, + error: Exception, + state: CodexRunState, + ) -> list[TakopiEvent]: + _ = raw, line + if isinstance(error, msgspec.DecodeError): + self.get_logger().warning( + "[%s] invalid msgspec event: %s", self.tag(), error + ) + return [] + return super().decode_error_events( + raw=raw, + line=line, + error=error, + state=state, + ) + def pipes_error_message(self) -> str: return "codex exec failed to open subprocess pipes" def translate( self, - data: dict[str, Any], + data: codex_schema.ThreadEvent, *, state: CodexRunState, resume: ResumeToken | None, found_session: ResumeToken | None, ) -> list[TakopiEvent]: - etype = data["type"] - match etype: - case "error": - message = str(data.get("message") or "") + factory = state.factory + match data: + case codex_schema.StreamError(message=message): reconnect = _parse_reconnect_message(message) if reconnect is not None: attempt, max_attempts = reconnect phase: ActionPhase = "started" if attempt <= 1 else "updated" return [ - _action_event( + factory.action( phase=phase, action_id="codex.reconnect", kind="note", @@ -495,83 +450,53 @@ class CodexRunner(ResumeTokenMixin, JsonlSubprocessRunner): level="info", ) ] - - fatal_flag = data.get("fatal") - fatal = fatal_flag is True or fatal_flag is None - if fatal: - resume_for_completed = found_session or resume - return [ - _completed_event( - resume=resume_for_completed, - ok=False, - answer=state.final_answer or "", - error=message, - ) - ] - return [ - self.note_event( - message, - state=state, - ok=False, - detail={"code": data.get("code"), "fatal": data.get("fatal")}, - ) - ] - case "turn.failed": - error = data["error"] - message = str(error["message"]) + return [self.note_event(message, state=state, ok=False)] + case codex_schema.TurnFailed(error=error): resume_for_completed = found_session or resume return [ - _completed_event( - resume=resume_for_completed, - ok=False, + factory.completed_error( + error=error.message, answer=state.final_answer or "", - error=message, + resume=resume_for_completed, ) ] - case "turn.rate_limited": - retry_ms = data.get("retry_after_ms") - message = "rate limited" - if isinstance(retry_ms, int): - message = f"rate limited (retry after {retry_ms}ms)" - return [self.note_event(message, state=state, ok=False)] - case "turn.started": + case codex_schema.TurnStarted(): action_id = f"turn_{state.turn_index}" state.turn_index += 1 return [ - _action_event( - phase="started", + factory.action_started( action_id=action_id, kind="turn", title="turn started", ) ] - case "turn.completed": + case codex_schema.TurnCompleted(usage=usage): resume_for_completed = found_session or resume return [ - _completed_event( - resume=resume_for_completed, - ok=True, + factory.completed_ok( answer=state.final_answer or "", - usage=data.get("usage"), + resume=resume_for_completed, + usage=msgspec.to_builtins(usage), ) ] - case "item.completed": - item = data["item"] - item_type = cast(str, item.get("type") or item.get("item_type")) - if item_type == "assistant_message": - item_type = "agent_message" - if item_type == "agent_message": - if state.final_answer is None: - state.final_answer = item["text"] - else: - logger.debug( - "[codex] emitted multiple agent messages; using the last one" - ) - state.final_answer = item["text"] + case codex_schema.ItemCompleted( + item=codex_schema.AgentMessageItem(text=text) + ): + if state.final_answer is None: + state.final_answer = text + else: + logger.debug( + "[codex] emitted multiple agent messages; using the last one" + ) + state.final_answer = text case _: pass - return translate_codex_event(data, title=self.session_title) + return translate_codex_event( + data, + title=self.session_title, + factory=factory, + ) def process_error_events( self, @@ -579,27 +504,20 @@ class CodexRunner(ResumeTokenMixin, JsonlSubprocessRunner): *, resume: ResumeToken | None, found_session: ResumeToken | None, - stderr_tail: str, state: CodexRunState, ) -> list[TakopiEvent]: - reason = _extract_stderr_reason(stderr_tail) - if reason: - message = f"codex exec failed (rc={rc}).\n\n{reason}" - else: - message = f"codex exec failed (rc={rc})." + message = f"codex exec failed (rc={rc})." resume_for_completed = found_session or resume return [ self.note_event( message, state=state, ok=False, - detail={"stderr_tail": stderr_tail}, ), - _completed_event( - resume=resume_for_completed, - ok=False, - answer=state.final_answer or "", + state.factory.completed_error( error=message, + answer=state.final_answer or "", + resume=resume_for_completed, ), ] @@ -608,27 +526,23 @@ class CodexRunner(ResumeTokenMixin, JsonlSubprocessRunner): *, resume: ResumeToken | None, found_session: ResumeToken | None, - stderr_tail: str, state: CodexRunState, ) -> list[TakopiEvent]: - _ = stderr_tail if not found_session: message = "codex exec finished but no session_id/thread_id was captured" resume_for_completed = resume return [ - _completed_event( - resume=resume_for_completed, - ok=False, - answer=state.final_answer or "", + state.factory.completed_error( error=message, + answer=state.final_answer or "", + resume=resume_for_completed, ) ] logger.info("[codex] done run session=%s", found_session.value) return [ - _completed_event( - resume=found_session, - ok=True, + state.factory.completed_ok( answer=state.final_answer or "", + resume=found_session, ) ] diff --git a/src/takopi/runners/opencode.py b/src/takopi/runners/opencode.py index 00c81e5..7dcea54 100644 --- a/src/takopi/runners/opencode.py +++ b/src/takopi/runners/opencode.py @@ -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+(?Pses_[A-Za-z0-9]+)`?\s*$" @@ -54,8 +56,6 @@ class OpenCodeStreamState: session_id: str | None = None emitted_started: bool = False saw_step_finish: bool = False - total_cost: float = 0.0 - total_tokens: dict[str, int] = field(default_factory=dict) def _action_event( @@ -146,9 +146,8 @@ def _normalize_tool_title( return title -def _extract_tool_action(event: dict[str, Any]) -> Action | None: - """Extract an Action from an OpenCode tool_use event.""" - part = event.get("part") or {} +def _extract_tool_action(part: dict[str, Any]) -> Action | None: + """Extract an Action from an OpenCode tool_use part.""" state = part.get("state") or {} call_id = part.get("callID") @@ -182,196 +181,163 @@ def _extract_tool_action(event: dict[str, Any]) -> Action | None: return Action(id=call_id, kind=kind, title=title, detail=detail) -def _usage_from_tokens(tokens: dict[str, int], cost: float) -> dict[str, Any]: - """Build usage payload from accumulated token counts.""" - usage: dict[str, Any] = {} - if cost > 0: - usage["total_cost_usd"] = cost - if tokens: - usage["tokens"] = tokens - return usage - - def translate_opencode_event( - event: dict[str, Any], + event: opencode_schema.OpenCodeEvent, *, title: str, state: OpenCodeStreamState, ) -> list[TakopiEvent]: """Translate an OpenCode JSON event into Takopi events.""" - etype = event.get("type") - session_id = event.get("sessionID") + session_id = event.sessionID if isinstance(session_id, str) and session_id: if state.session_id is None: state.session_id = session_id - if etype == "step_start": - if not state.emitted_started and state.session_id: - state.emitted_started = True - return [ - StartedEvent( - engine=ENGINE, - resume=ResumeToken(engine=ENGINE, value=state.session_id), - title=title, - ) - ] - return [] - - if etype == "tool_use": - part = event.get("part") or {} - tool_state = part.get("state") or {} - status = tool_state.get("status") - - action = _extract_tool_action(event) - if action is None: + match event: + case opencode_schema.StepStart(): + if not state.emitted_started and state.session_id: + state.emitted_started = True + return [ + StartedEvent( + engine=ENGINE, + resume=ResumeToken(engine=ENGINE, value=state.session_id), + title=title, + ) + ] return [] - if status == "completed": - output = tool_state.get("output") - metadata = tool_state.get("metadata") or {} - exit_code = metadata.get("exit") + case opencode_schema.ToolUse(part=part): + part = part or {} + tool_state = part.get("state") or {} + status = tool_state.get("status") - is_error = False - if isinstance(exit_code, int) and exit_code != 0: - is_error = True + action = _extract_tool_action(part) + if action is None: + return [] - detail = dict(action.detail) - if output is not None: - detail["output_preview"] = ( - str(output)[:500] if len(str(output)) > 500 else str(output) - ) - detail["exit_code"] = exit_code + if status == "completed": + output = tool_state.get("output") + metadata = tool_state.get("metadata") or {} + exit_code = metadata.get("exit") - state.pending_actions.pop(action.id, None) + is_error = False + if isinstance(exit_code, int) and exit_code != 0: + is_error = True - return [ - _action_event( - phase="completed", - action=Action( - id=action.id, - kind=action.kind, - title=action.title, - detail=detail, - ), - ok=not is_error, - ) - ] - if status == "error": - error = tool_state.get("error") - metadata = tool_state.get("metadata") or {} - exit_code = metadata.get("exit") - - detail = dict(action.detail) - if error is not None: - detail["error"] = error - detail["exit_code"] = exit_code - - state.pending_actions.pop(action.id, None) - - return [ - _action_event( - phase="completed", - action=Action( - id=action.id, - kind=action.kind, - title=action.title, - detail=detail, - ), - ok=False, - message=str(error) if error is not None else None, - ) - ] - else: - state.pending_actions[action.id] = action - return [_action_event(phase="started", action=action)] - - if etype == "text": - part = event.get("part") or {} - text = part.get("text") - if isinstance(text, str) and text: - if state.last_text is None: - state.last_text = text - else: - state.last_text += text - return [] - - if etype == "step_finish": - part = event.get("part") or {} - reason = part.get("reason") - state.saw_step_finish = True - - tokens = part.get("tokens") or {} - if isinstance(tokens, dict): - for key in ("input", "output", "reasoning"): - value = tokens.get(key) - if isinstance(value, int): - state.total_tokens[key] = state.total_tokens.get(key, 0) + value - cache = tokens.get("cache") or {} - if isinstance(cache, dict): - for key in ("read", "write"): - value = cache.get(key) - if not isinstance(value, int): - continue - cache_key = f"cache_{key}" - state.total_tokens[cache_key] = ( - state.total_tokens.get(cache_key, 0) + value + detail = dict(action.detail) + if output is not None: + detail["output_preview"] = ( + str(output)[:500] if len(str(output)) > 500 else str(output) ) + detail["exit_code"] = exit_code - cost = part.get("cost") - if isinstance(cost, (int, float)): - state.total_cost += cost + state.pending_actions.pop(action.id, None) + + return [ + _action_event( + phase="completed", + action=Action( + id=action.id, + kind=action.kind, + title=action.title, + detail=detail, + ), + ok=not is_error, + ) + ] + if status == "error": + error = tool_state.get("error") + metadata = tool_state.get("metadata") or {} + exit_code = metadata.get("exit") + + detail = dict(action.detail) + if error is not None: + detail["error"] = error + detail["exit_code"] = exit_code + + state.pending_actions.pop(action.id, None) + + return [ + _action_event( + phase="completed", + action=Action( + id=action.id, + kind=action.kind, + title=action.title, + detail=detail, + ), + ok=False, + message=str(error) if error is not None else None, + ) + ] + else: + state.pending_actions[action.id] = action + return [_action_event(phase="started", action=action)] + + case opencode_schema.Text(part=part): + part = part or {} + text = part.get("text") + if isinstance(text, str) and text: + if state.last_text is None: + state.last_text = text + else: + state.last_text += text + return [] + + case opencode_schema.StepFinish(part=part): + part = part or {} + reason = part.get("reason") + state.saw_step_finish = True + + if reason == "stop": + resume = None + if state.session_id: + resume = ResumeToken(engine=ENGINE, value=state.session_id) + + return [ + CompletedEvent( + engine=ENGINE, + ok=True, + answer=state.last_text or "", + resume=resume, + ) + ] + return [] + + case opencode_schema.Error(error=error_value, message=message_value): + raw_message = message_value if message_value is not None else error_value + + message = raw_message + if isinstance(message, dict): + data = message.get("data") + if isinstance(data, dict) and data.get("message"): + message = data.get("message") + else: + message = ( + message.get("message") + or message.get("name") + or "opencode error" + ) + elif message is None: + message = "opencode error" - if reason == "stop": resume = None if state.session_id: resume = ResumeToken(engine=ENGINE, value=state.session_id) - usage = _usage_from_tokens(state.total_tokens, state.total_cost) - return [ CompletedEvent( engine=ENGINE, - ok=True, + ok=False, answer=state.last_text or "", resume=resume, - usage=usage or None, + error=str(message), ) ] - return [] - if etype == "error": - raw_message = event.get("message") - if raw_message is None: - raw_message = event.get("error") - - message = raw_message - if isinstance(message, dict): - data = message.get("data") - if isinstance(data, dict) and data.get("message"): - message = data.get("message") - else: - message = ( - message.get("message") or message.get("name") or "opencode error" - ) - elif message is None: - message = "opencode error" - - resume = None - if state.session_id: - resume = ResumeToken(engine=ENGINE, value=state.session_id) - - return [ - CompletedEvent( - engine=ENGINE, - ok=False, - answer=state.last_text or "", - resume=resume, - error=str(message), - ) - ] - - return [] + case _: + return [] @dataclass @@ -384,7 +350,6 @@ class OpenCodeRunner(ResumeTokenMixin, JsonlSubprocessRunner): opencode_cmd: str = "opencode" model: str | None = None session_title: str = "opencode" - stderr_tail_lines: int = STDERR_TAIL_LINES logger: logging.Logger = logger def format_resume(self, token: ResumeToken) -> str: @@ -452,7 +417,7 @@ class OpenCodeRunner(ResumeTokenMixin, JsonlSubprocessRunner): def translate( self, - data: dict[str, Any], + data: opencode_schema.OpenCodeEvent, *, state: OpenCodeStreamState, resume: ResumeToken | None, @@ -465,13 +430,36 @@ class OpenCodeRunner(ResumeTokenMixin, JsonlSubprocessRunner): state=state, ) + def decode_jsonl(self, *, line: bytes) -> opencode_schema.OpenCodeEvent: + return opencode_schema.decode_event(line) + + def decode_error_events( + self, + *, + raw: str, + line: str, + error: Exception, + state: OpenCodeStreamState, + ) -> list[TakopiEvent]: + _ = raw, line, state + if isinstance(error, msgspec.DecodeError): + self.get_logger().warning( + "[%s] invalid msgspec event: %s", self.tag(), error + ) + return [] + return super().decode_error_events( + raw=raw, + line=line, + error=error, + state=state, + ) + def process_error_events( self, rc: int, *, resume: ResumeToken | None, found_session: ResumeToken | None, - stderr_tail: str, state: OpenCodeStreamState, ) -> list[TakopiEvent]: message = f"opencode failed (rc={rc})." @@ -481,7 +469,6 @@ class OpenCodeRunner(ResumeTokenMixin, JsonlSubprocessRunner): message, state=state, ok=False, - detail={"stderr_tail": stderr_tail}, ), CompletedEvent( engine=ENGINE, @@ -497,10 +484,8 @@ class OpenCodeRunner(ResumeTokenMixin, JsonlSubprocessRunner): *, resume: ResumeToken | None, found_session: ResumeToken | None, - stderr_tail: str, state: OpenCodeStreamState, ) -> list[TakopiEvent]: - _ = stderr_tail if not found_session: message = "opencode finished but no session_id was captured" resume_for_completed = resume @@ -515,14 +500,12 @@ class OpenCodeRunner(ResumeTokenMixin, JsonlSubprocessRunner): ] if state.saw_step_finish: - usage = _usage_from_tokens(state.total_tokens, state.total_cost) return [ CompletedEvent( engine=ENGINE, ok=True, answer=state.last_text or "", resume=found_session, - usage=usage or None, ) ] diff --git a/src/takopi/runners/pi.py b/src/takopi/runners/pi.py index b235ca9..d0d6ec5 100644 --- a/src/takopi/runners/pi.py +++ b/src/takopi/runners/pi.py @@ -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.+?)`?\s*$") @@ -132,7 +134,7 @@ def _last_assistant_message(messages: Any) -> dict[str, Any] | None: def translate_pi_event( - event: dict[str, Any], + event: pi_schema.PiEvent, *, title: str, meta: dict[str, Any] | None, @@ -150,103 +152,99 @@ def translate_pi_event( ) state.started = True - etype = event.get("type") + match event: + case pi_schema.ToolExecutionStart( + toolCallId=tool_id, toolName=tool_name, args=args + ): + if not isinstance(args, dict): + args = {} + if isinstance(tool_id, str) and tool_id: + name = str(tool_name or "tool") + kind, title_str = _tool_kind_and_title(name, args) + detail: dict[str, Any] = {"tool_name": name, "args": args} + if kind == "file_change": + path = args.get("path") + if path: + detail["changes"] = [{"path": str(path), "kind": "update"}] + action = Action(id=tool_id, kind=kind, title=title_str, detail=detail) + state.pending_actions[action.id] = action + out.append(_action_event(phase="started", action=action)) + return out - if etype == "tool_execution_start": - tool_id = event.get("toolCallId") - tool_name = event.get("toolName") - args = event.get("args") or {} - if not isinstance(args, dict): - args = {} - if isinstance(tool_id, str) and tool_id: - name = str(tool_name or "tool") - kind, title_str = _tool_kind_and_title(name, args) - detail: dict[str, Any] = {"tool_name": name, "args": args} - if kind == "file_change": - path = args.get("path") - if path: - detail["changes"] = [{"path": str(path), "kind": "update"}] - action = Action(id=tool_id, kind=kind, title=title_str, detail=detail) - state.pending_actions[action.id] = action - out.append(_action_event(phase="started", action=action)) - return out + case pi_schema.ToolExecutionEnd( + toolCallId=tool_id, toolName=tool_name, result=result, isError=is_error + ): + if isinstance(tool_id, str) and tool_id: + action = state.pending_actions.pop(tool_id, None) + name = str(tool_name or "tool") + if action is None: + action = Action(id=tool_id, kind="tool", title=name, detail={}) + detail = dict(action.detail) + detail["result"] = result + detail["is_error"] = is_error + out.append( + _action_event( + phase="completed", + action=Action( + id=action.id, + kind=action.kind, + title=action.title, + detail=detail, + ), + ok=not is_error, + ) + ) + return out + + case pi_schema.MessageEnd(message=message): + if isinstance(message, dict) and message.get("role") == "assistant": + text = _extract_text_blocks(message.get("content")) + if text: + state.last_assistant_text = text + usage = message.get("usage") + if isinstance(usage, dict): + state.last_usage = usage + error = _assistant_error(message) + if error: + state.last_assistant_error = error + return out + + case pi_schema.AgentEnd(messages=messages): + assistant = _last_assistant_message(messages) + if assistant: + text = _extract_text_blocks(assistant.get("content")) + if text: + state.last_assistant_text = text + usage = assistant.get("usage") + if isinstance(usage, dict): + state.last_usage = usage + error = _assistant_error(assistant) + if error: + state.last_assistant_error = error + + ok = state.last_assistant_error is None + error = state.last_assistant_error + answer = state.last_assistant_text or "" - if etype == "tool_execution_end": - tool_id = event.get("toolCallId") - tool_name = event.get("toolName") - if isinstance(tool_id, str) and tool_id: - action = state.pending_actions.pop(tool_id, None) - name = str(tool_name or "tool") - if action is None: - action = Action(id=tool_id, kind="tool", title=name, detail={}) - detail = dict(action.detail) - detail["result"] = event.get("result") - detail["is_error"] = event.get("isError") - is_error = event.get("isError") is True out.append( - _action_event( - phase="completed", - action=Action( - id=action.id, - kind=action.kind, - title=action.title, - detail=detail, - ), - ok=not is_error, + CompletedEvent( + engine=ENGINE, + ok=ok, + answer=answer, + resume=state.resume, + error=error, + usage=state.last_usage, ) ) - return out + return out - if etype == "message_end": - message = event.get("message") - if isinstance(message, dict) and message.get("role") == "assistant": - text = _extract_text_blocks(message.get("content")) - if text: - state.last_assistant_text = text - usage = message.get("usage") - if isinstance(usage, dict): - state.last_usage = usage - error = _assistant_error(message) - if error: - state.last_assistant_error = error - return out - - if etype == "agent_end": - assistant = _last_assistant_message(event.get("messages")) - if assistant: - text = _extract_text_blocks(assistant.get("content")) - if text: - state.last_assistant_text = text - usage = assistant.get("usage") - if isinstance(usage, dict): - state.last_usage = usage - error = _assistant_error(assistant) - if error: - state.last_assistant_error = error - - ok = state.last_assistant_error is None - error = state.last_assistant_error - answer = state.last_assistant_text or "" - - out.append( - CompletedEvent( - engine=ENGINE, - ok=ok, - answer=answer, - resume=state.resume, - error=error, - usage=state.last_usage, - ) - ) - return out - - return out + case _: + return out class PiRunner(ResumeTokenMixin, JsonlSubprocessRunner): engine: EngineId = ENGINE resume_re: re.Pattern[str] = _RESUME_RE - stderr_tail_lines = STDERR_TAIL_LINES logger = logger def __init__( @@ -335,7 +333,7 @@ class PiRunner(ResumeTokenMixin, JsonlSubprocessRunner): def translate( self, - data: dict[str, Any], + data: pi_schema.PiEvent, *, state: PiStreamState, resume: ResumeToken | None, @@ -354,19 +352,46 @@ class PiRunner(ResumeTokenMixin, JsonlSubprocessRunner): state=state, ) + def decode_jsonl( + self, + *, + line: bytes, + ) -> pi_schema.PiEvent: + return pi_schema.decode_event(line) + + def decode_error_events( + self, + *, + raw: str, + line: str, + error: Exception, + state: PiStreamState, + ) -> list[TakopiEvent]: + _ = raw, line, state + if isinstance(error, msgspec.DecodeError): + self.get_logger().warning( + "[%s] invalid msgspec event: %s", self.tag(), error + ) + return [] + return super().decode_error_events( + raw=raw, + line=line, + error=error, + state=state, + ) + def process_error_events( self, rc: int, *, resume: ResumeToken | None, found_session: ResumeToken | None, - stderr_tail: str, state: PiStreamState, ) -> list[TakopiEvent]: message = f"pi failed (rc={rc})." resume_for_completed = found_session or resume or state.resume return [ - self.note_event(message, state=state, detail={"stderr_tail": stderr_tail}), + self.note_event(message, state=state), CompletedEvent( engine=ENGINE, ok=False, @@ -382,10 +407,8 @@ class PiRunner(ResumeTokenMixin, JsonlSubprocessRunner): *, resume: ResumeToken | None, found_session: ResumeToken | None, - stderr_tail: str, state: PiStreamState, ) -> list[TakopiEvent]: - _ = stderr_tail resume_for_completed = found_session or resume or state.resume message = "pi finished without an agent_end event" return [ diff --git a/src/takopi/schemas/__init__.py b/src/takopi/schemas/__init__.py new file mode 100644 index 0000000..72e8553 --- /dev/null +++ b/src/takopi/schemas/__init__.py @@ -0,0 +1 @@ +"""Event schemas for runner JSONL streams.""" diff --git a/src/takopi/schemas/claude.py b/src/takopi/schemas/claude.py new file mode 100644 index 0000000..76b3812 --- /dev/null +++ b/src/takopi/schemas/claude.py @@ -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) diff --git a/src/takopi/schemas/codex.py b/src/takopi/schemas/codex.py new file mode 100644 index 0000000..b17e0e7 --- /dev/null +++ b/src/takopi/schemas/codex.py @@ -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) diff --git a/src/takopi/schemas/opencode.py b/src/takopi/schemas/opencode.py new file mode 100644 index 0000000..777a066 --- /dev/null +++ b/src/takopi/schemas/opencode.py @@ -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) diff --git a/src/takopi/schemas/pi.py b/src/takopi/schemas/pi.py new file mode 100644 index 0000000..52eb6fe --- /dev/null +++ b/src/takopi/schemas/pi.py @@ -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) diff --git a/src/takopi/utils/streams.py b/src/takopi/utils/streams.py index dfbf611..cca5d2e 100644 --- a/src/takopi/utils/streams.py +++ b/src/takopi/utils/streams.py @@ -1,73 +1,32 @@ from __future__ import annotations -from collections import deque from collections.abc import AsyncIterator -from dataclasses import dataclass -import json import logging -from typing import Any +import sys import anyio from anyio.abc import ByteReceiveStream -from anyio.streams.text import TextReceiveStream +from anyio.streams.buffered import BufferedByteReceiveStream -async def iter_text_lines(stream: ByteReceiveStream) -> AsyncIterator[str]: - text_stream = TextReceiveStream(stream, errors="replace") - buffer = "" +async def iter_bytes_lines(stream: ByteReceiveStream) -> AsyncIterator[bytes]: + buffered = BufferedByteReceiveStream(stream) while True: try: - chunk = await text_stream.receive() - except anyio.EndOfStream: - if buffer: - yield buffer + line = await buffered.receive_until(b"\n", sys.maxsize) + except anyio.IncompleteRead: return - buffer += chunk - while True: - split_at = buffer.find("\n") - if split_at < 0: - break - line = buffer[: split_at + 1] - buffer = buffer[split_at + 1 :] - yield line - - -@dataclass(frozen=True, slots=True) -class JsonLine: - raw: str - line: str - data: dict[str, Any] | None - - -async def iter_jsonl( - stream: ByteReceiveStream, - *, - logger: logging.Logger, - tag: str, -) -> AsyncIterator[JsonLine]: - async for raw_line in iter_text_lines(stream): - raw = raw_line.rstrip("\n") - logger.debug("[%s][jsonl] %s", tag, raw) - line = raw.strip() - if not line: - continue - try: - data = json.loads(line) - except json.JSONDecodeError: - logger.debug("[%s] invalid json line: %s", tag, line) - data = None - yield JsonLine(raw=raw, line=line, data=data) + yield line async def drain_stderr( stream: ByteReceiveStream, - chunks: deque[str], logger: logging.Logger, tag: str, ) -> None: try: - async for line in iter_text_lines(stream): - logger.debug("[%s][stderr] %s", tag, line.rstrip()) - chunks.append(line) + async for line in iter_bytes_lines(stream): + text = line.decode("utf-8", errors="replace") + logger.debug("[%s][stderr] %s", tag, text) except Exception as e: logger.debug("[%s][stderr] drain error: %s", tag, e) diff --git a/tests/fixtures/claude_stream_error.jsonl b/tests/fixtures/claude_stream_error.jsonl deleted file mode 100644 index 5d2d398..0000000 --- a/tests/fixtures/claude_stream_error.jsonl +++ /dev/null @@ -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"}}]} diff --git a/tests/fixtures/claude_stream_success.jsonl b/tests/fixtures/claude_stream_success.jsonl deleted file mode 100644 index 77b2bd3..0000000 --- a/tests/fixtures/claude_stream_success.jsonl +++ /dev/null @@ -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}}} diff --git a/tests/fixtures/claude_streamjson_session.jsonl b/tests/fixtures/claude_streamjson_session.jsonl new file mode 100644 index 0000000..7ed6649 --- /dev/null +++ b/tests/fixtures/claude_streamjson_session.jsonl @@ -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":""} diff --git a/tests/fixtures/codex.jsonl b/tests/fixtures/codex.jsonl deleted file mode 100644 index 850214f..0000000 --- a/tests/fixtures/codex.jsonl +++ /dev/null @@ -1,37 +0,0 @@ -{"type":"thread.started","thread_id":"019b693a-7253-76f3-9513-0d504995011e"} -{"type":"turn.started"} -{"type":"item.started","item":{"id":"item_0","type":"command_execution","command":"/bin/zsh -lc 'cd /Users/banteg/dev/banteg/agents/takopi && ls'","aggregated_output":"","exit_code":null,"status":"in_progress"}} -{"type":"item.completed","item":{"id":"item_0","type":"command_execution","command":"/bin/zsh -lc 'cd /Users/banteg/dev/banteg/agents/takopi && ls'","aggregated_output":"__pycache__\t\t\treadme.md\r\nbridge.zip\t\t\tsrc\r\ncodex.jsonl\t\t\ttests\r\ncodex_exec_explore_bridge.jsonl\tuv.lock\r\nexec_bridge.log\t\t\twork.md\r\npyproject.toml\r\n","exit_code":0,"status":"completed"}} -{"type":"item.started","item":{"id":"item_1","type":"command_execution","command":"/bin/zsh -lc 'cd /Users/banteg/dev/banteg/agents/takopi && find .. -maxdepth 2 -name AGENTS.md -print'","aggregated_output":"","exit_code":null,"status":"in_progress"}} -{"type":"item.completed","item":{"id":"item_1","type":"command_execution","command":"/bin/zsh -lc 'cd /Users/banteg/dev/banteg/agents/takopi && find .. -maxdepth 2 -name AGENTS.md -print'","aggregated_output":"../.codex/AGENTS.md\r\n","exit_code":0,"status":"completed"}} -{"type":"item.started","item":{"id":"item_2","type":"command_execution","command":"/bin/zsh -lc 'cd /Users/banteg/dev/banteg/agents/takopi && rg -n \"telegram|bridge|bot|webhook|polling|openai|codex\" -S . || true'","aggregated_output":"","exit_code":null,"status":"in_progress"}} -{"type":"item.completed","item":{"id":"item_2","type":"command_execution","command":"/bin/zsh -lc 'cd /Users/banteg/dev/banteg/agents/takopi && rg -n \"telegram|bridge|bot|webhook|polling|openai|codex\" -S . || true'","aggregated_output":"Total output lines: 376\n\n./src/takopi/telegram_client.py\r\n11:class TelegramClient:\r\n13: Minimal Telegram Bot API client.\r\n18: raise ValueError(\"Telegram token is empty\")\r\n19: self._base = f\"https://api.telegram.org/bot{token}\"\r\n31: raise RuntimeError(f\"Telegram API error: {payload}\")\r\n34: logger.error(\"Telegram network error: %s\", e)\r\n\r\n./src/takopi/__init__.py\r\n1:\"\"\"Telegram Codex bridge package.\"\"\"\r\n\r\n./codex.jsonl\r\n3:{\"type\":\"item.started\",\"item\":{\"id\":\"item_0\",\"type\":\"command_execution\",\"command\":\"/bin/zsh -lc 'cd /Users/banteg/dev/banteg/agents/takopi && ls'\",\"aggregated_output\":\"\",\"exit_code\":null,\"status\":\"in_progress\"}}\r\n4:{\"type\":\"item.completed\",\"item\":{\"id\":\"item_0\",\"type\":\"command_execution\",\"command\":\"/bin/zsh -lc 'cd /Users/banteg/dev/banteg/agents/takopi && ls'\",\"aggregated_output\":\"__pycache__\\t\\t\\treadme.md\\r\\nbridge.zip\\t\\t\\tsrc\\r\\ncodex.jsonl\\t\\t\\ttests\\r\\ncodex_exec_explore_bridge.jsonl\\tuv.lock\\r\\nexec_bridge.log\\t\\t\\twork.md\\r\\npyproject.toml\\r\\n\",\"exit_code\":0,\"status\":\"completed\"}}\r\n5:{\"type\":\"item.started\",\"item\":{\"id\":\"item_1\",\"type\":\"command_execution\",\"command\":\"/bin/zsh -lc 'cd /Users/banteg/dev/banteg/agents/takopi && find .. -maxdepth 2 -name AGENTS.md -print'\",\"aggregated_output\":\"\",\"exit_code\":null,\"status\":\"in_progress\"}}\r\n6:{\"type\":\"item.completed\",\"item\":{\"id\":\"item_1\",\"type\":\"command_execution\",\"command\":\"/bin/zsh -lc 'cd /Users/banteg/dev/banteg/agents/takopi && find .. -maxdepth 2 -name AGENTS.md -print'\",\"aggregated_output\":\"../.codex/AGENTS.md\\r\\n\",\"exit_code\":0,\"status\":\"completed\"}}\r\n7:{\"type\":\"item.started\",\"item\":{\"id\":\"item_2\",\"type\":\"command_execution\",\"command\":\"/bin/zsh -lc 'cd /Users/banteg/dev/banteg/agents/takopi && rg -n \\\"telegram|bridge|bot|webhook|polling|openai|codex\\\" -S . || true'\",\"aggregated_output\":\"\",\"exit_code\":null,\"status\":\"in_progress\"}}\r\n\r\n./work.md\r\n5:1. **Switch to `asyncio`**: The current code heavily uses `threading`, `ThreadPoolExecutor`, and `subprocess.Popen` with blocking I/O. This is the primary source of complexity (locks, daemon threads, joining). A Telegram bot + Subprocess manager is a textbook use case for `asyncio`.\r\n10:### 1. `src/takopi/telegram_client.py`\r\n20:class TelegramClient:\r\n22: self._base = f\"https://api.telegram.org/bot{token}\"\r\n34: raise RuntimeError(f\"Telegram API Error: {data}\")\r\n37: logger.error(f\"Telegram Network Error: {e}\")\r\n57:### 2. `src/takopi/rendering.py`\r\n59:**Change:** Remove `sulguk`. Telegram accepts a subset of HTML. We can use `markdown-it-py` to generate HTML and let Telegram parse it. This removes the manual entity construction logic.\r\n64:# Telegram only supports specific tags. \r\n70: Renders Markdown to Telegram-supported HTML.\r\n73: but MarkdownIt default output is generally safe for this bridge.\r\n76: # MarkdownIt wraps everything in

, Telegram doesn't like nested block tags sometimes,\r\n78: # A simple improvement for Telegram: replace

with nothing and use
\r\n83:### 3. `src/takopi/exec_bridge.py`\r\n101:from .config import load_telegram_config\r\n102:from .constants import TELEGRAM_HARD_LIMIT\r\n105:from .telegram_client import TelegramClient\r\n107:logger = logging.getLogger(\"exec_bridge\")\r\n111:TELEGRAM_TEXT_LIMIT = 4000 # Leave buffer for HTML tags\r\n126: if len(text) <= TELEGRAM_TEXT_LIMIT:\r\n128: return text[:TELEGRAM_TEXT_LIMIT - 100] + \"\\n...(truncated)\"\r\n132:class AsyncCodexRunner:\r\n198: raise RuntimeError(f\"Codex failed (rc={rc}): {stderr_data.decode()[-200:]}\")\r\n201: raise RuntimeError(\"No session_id captured from codex stream.\")\r\n208: bot: TelegramClient, \r\n212: runner: AsyncCodexRunner,\r\n224: sent_msg = await bot.send_message(\r\n245: await bot.edit_message_text(\r\n271: await bot.send_message(chat_id=chat_id, text=final_html, parse_mode=\"HTML\")\r\n272: await bot.delete_message(chat_id=chat_id, message_id=progress_msg_id)\r\n275: await bot.edit_message_text(chat_id=chat_id, message_id=progress_msg_id, text=final_html, parse_mode=\"HTML\")\r\n279: await bot.edit_message_text(chat_id=chat_id, message_id=progress_msg_id, text=err_text, parse_mode=\"HTML\")\r\n283:async def bridge_loop(config: dict, cli_args: dict):\r\n284: token = config[\"bot_token\"]\r\n285: bot = TelegramClient(token)\r\n288: codex_cmd = config.get(\"codex_cmd\", \"codex\")\r\n289: workspace = cli_args.get(\"workdir\") or config.get(\"codex_workspace\")\r\n290: extra_args = shlex.split(str(config.get(\"codex_exec_args\", \"\")))\r\n294: runner = AsyncCodexRunner(codex_cmd, extra_args, workspace)\r\n297: logger.info(\"Bridge started (Async).\")\r\n303: updates = await bot.get_updates(offset=offset, timeout=20, allowed_updates=[\"message\"])\r\n311: bot, \r\n324: await bot.close()\r\n330: log_file: str = \"exec_bridge.log\",\r\n335: config = load_telegram_config()\r\n346: asyncio.run(bridge_loop(config, args))\r\n\r\n./src/takopi/rendering.py\r\n18: # FIX: Telegram requires MessageEntity.language (if present) to be a String.\r\n\r\n./src/takopi/config.py\r\n4:from .constants import TELEGRAM_CONFIG_PATH\r\n7:def load_telegram_config(path=None):\r\n8: cfg_path = Path(path) if path else TELEGRAM_CONFIG_PATH\r\n\r\n./exec_bridge.log\r\n1:2025-12-29 11:32:12,510 [debug] file logger initialized path='exec_bridge.log'\r\n4:2025-12-29 11:32:30,656 [codex] start run session_id=None workspace=None\r\n5:2025-12-29 11:32:30,670 [codex] spawn pid=38871 args=['/opt/homebrew/bin/codex', 'exec', '--json', '-c', 'notify=[]', '-']\r\n6:2025-12-29 11:32:30,852 [codex] thread started\r\n7:2025-12-29 11:32:30,852 [codex] turn started\r\n8:2025-12-29 11:32:38,967 [codex] [0] **Assessing user request**\r\n10:I need to address the user's request about not needing the \"progress silent option.\" First, I'll check the repo takopi to find where this might be used. It looks like I should remove this option from the CLI or code that references progress. I'll also make sure to follow the AGENTS instructions, especially for git and Python commands, using conventional commits style and preferring uv for testing. I need to look more into the repository structure.\r\n11:2025-12-29 11:32:39,230 [codex] [1] ▸ running: `/bin/zsh -lc ls`\r\n12:2025-12-29 11:32:39,331 [codex] [1] ✓ ran: `/bin/zsh -lc ls` (exit 0)\r\n13:2025-12-29 11:32:44,335 [codex] [2] **Searching Python project options**\r\n16:2025-12-29 11:32:44,566 [codex] [3] ▸ running: `/bin/zsh -lc 'rg -n…`\r\n17:2025-12-29 11:32:44,696 [codex] [3] ✓ ran: `/bin/zsh -lc 'rg -n…` (exit 0)\r\n18:2025-12-29 11:32:51,849 [codex] [4] I'm looking at the progress_silent option in exec_bridge.py and its documentation. It seems I need to remove it, so my next step is to open the relevant file and check the lines surrounding where this option is defined. I might use sed to view specific lines or nl for numbering the lines. Definitely, I'll start inspecting exec_bridge.py around the definition of that option to assess what to remove.\r\n19:2025-12-29 11:32:52,193 [codex] [5] ▸ running: `/bin/zsh -lc \"sed -n '520,660p'…`\r\n20:2025-12-29 11:32:52,314 [codex] [5] ✓ ran: `/bin/zsh -lc \"sed -n '520,660p'…` (exit 0)\r\n21:2025-12-29 11:32:55,967 [codex][stderr] 2025-12-29T07:32:55.966504Z ERROR codex_core::codex: failed to send tool call event: sending into a closed channel\r\n22:2025-12-29 11:48:21,020 [debug] file logger initialized path='exec_bridge.log'\r\n25:2025-12-29 11:48:29,010 [codex] start run session_id=None workspace=None\r\n26:2025-12-29 11:48:29,027 [codex] spawn pid=51835 args=['/opt/homebrew/bin/codex', 'exec', '--json', '-c', 'notify=[]', '-']\r\n27:2025-12-29 11:48:32,221 [codex] thread started\r\n28:2025-12-29 11:48:32,222 [codex] turn started\r\n29:2025-12-29 11:48:42,625 [codex] [0] **Modifying code for notification fix**\r\n32:2025-12-29 11:48:42,627 [codex] [1] **Assessing the use of planning**\r\n35:2025-12-29 11:48:42,947 [codex] [2] ▸ running: `/bin/zsh -lc 'cd…`\r\n36:2025-12-29 11:48:43,037 [codex] [2] ✓ ran: `/bin/zsh -lc 'cd…` (exit 0)\r\n37:2025-12-29 11:48:46,641 [codex] [3] ▸ running: `/bin/zsh -lc \"cd…`\r\n38:2025-12-29 11:48:46,762 [codex] [3] ✓ ran: `/bin/zsh -lc \"cd…` (exit 0)\r\n39:2025-12-29 11:48:50,023 [codex] [4] ▸ running: `/bin/zsh -lc \"cd…`\r\n40:2025-12-29 11:48:50,117 [codex] [4] ✓ ran: `/bin/zsh -lc \"cd…` (exit 0)\r\n41:2025-12-29 11:48:52,525 [codex] [5] ▸ running: `/bin/zsh -lc \"cd…`\r\n42:2025-12-29 11:48:52,623 [codex] [5] ✓ ran: `/bin/zsh -lc \"cd…` (exit 0)\r\n43:2025-12-29 11:49:03,967 [codex] [6] ▸ running: `/bin/zsh -lc 'cd…`\r\n44:2025-12-29 11:49:04,065 [codex] [6] ✓ ran: `/bin/zsh -lc 'cd…` (exit 0)\r\n45:2025-12-29 11:49:06,388 [codex] [7] ▸ running: `/bin/zsh -lc \"cd…`\r\n46:2025-12-29 11:49:06,478 [codex] [7] ✓ ran: `/bin/zsh -lc \"cd…` (exit 0)\r\n47:2025-12-29 11:49:08,998 [codex] [8] ▸ running: `/bin/zsh -lc \"cd…`\r\n48:2025-12-29 11:49:09,096 [codex] [8] ✓ ran: `/bin/zsh -lc \"cd…` (exit 0)\r\n49:2025-12-29 11:49:11,585 [codex] [9] ▸ running: `/bin/zsh -lc \"cd…`\r\n50:2025-12-29 11:49:11,691 [codex] [9] ✓ ran: `/bin/zsh -lc \"cd…` (exit 0)\r\n51:2025-12-29 11:49:16,302 [codex] [10] **Examining messaging tests**\r\n53:I’m looking into the messaging tests. It seems that we should add tests for _handle_message, especially to confirm that it uses disable_notification correctly. Since _handle_message is async and interacts with TelegramClient and runner, I'm thinking about whether we can create stub objects or if we can just use the existing ones. I’ll check out the beginning of src/takopi/exec_bridge.py to understand the types and functions involved.\r\n54:2025-12-29 11:49:16,886 [codex] [11] ▸ running: `/bin/zsh -lc \"cd…`\r\n55:2025-12-29 11:49:17,004 [codex] [11] ✓ ran: `/bin/zsh -lc \"cd…` (exit 0)\r\n56:2025-12-29 11:49:19,624 [codex] [12] ▸ running: `/bin/zsh -lc \"cd…`\r\n57:2025-12-29 11:49:19,725 [codex] [12] ✓ ran: `/bin/zsh -lc \"cd…` (exit 2)\r\n58:2025-12-29 11:49:22,286 [codex] [13] ▸ running: `/bin/zsh -lc 'cd…`\r\n59:2025-12-29 11:49:22,387 [codex] [13] ✓ ran: `/bin/zsh -lc 'cd…` (exit 0)\r\n60:2025-12-29 11:49:25,494 [codex] [14] ▸ running: `/bin/zsh -lc \"cd…`\r\n61:2025-12-29 11:49:25,598 [codex] [14] ✓ ran: `/bin/zsh -lc \"cd…` (exit 0)\r\n62:2025-12-29 11:49:39,923 [codex] [15] **Fixing notification settings**\r\n64:I’m reviewing the BridgeConfig setup, which only has progress_silent and final_notify. The fix involves changing disable_notification for the final message when sending a new message. This means wherever cfg.final_notify is applicable or can_edit_final, I'll call _send_markdown with disable_notification set to False. Currently, the final message uses _send_markdown, where the default for disable_notification is False, but passing cfg.progress_silent makes it default to True. I think it’s best to explicitly set disable_notification to False for the final message.\r\n65:2025-12-29 11:49:39,923 [codex] [16] **Considering error notifications**\r\n68:2025-12-29 11:49:39,924 [codex] [17] **Exploring notification behavior**\r\n71:2025-12-29 11:49:40,784 [codex] [18] ▸ running: `/bin/zsh -lc 'cd…`\r\n72:2025-12-29 11:49:40,881 [codex] [18] ✓ ran: `/bin/zsh -lc 'cd…` (exit 0)\r\n73:2025-12-29 11:49:42,917 [codex] [19] ▸ running: `/bin/zsh -lc \"cd…`\r\n74:2025-12-29 11:49:43,014 [codex] [19] ✓ ran: `/bin/zsh -lc \"cd…` (exit 0)\r\n75:2025-12-29 11:49:52,232 [codex] [20] ▸ running: `/bin/zsh -lc 'cd…`\r\n76:2025-12-29 11:49:52,324 [codex] [20] ✓ ran: `/bin/zsh -lc 'cd…` (exit 0)\r\n77:2025-12-29 11:50:16,927 [codex] [21] **Considering async tests**\r\n79:Currently, I'm using synchronous tests, but adding async tests might mean introducing a new dependency like pytest-asyncio, which I want to avoid. Instead,…3301 tokens truncated… tests passed after importing exec_bridge earlier, maybe there was a misunderstanding, possibly due to an outdated file. Even though Python checks timestamps and usually uses the source file, could it be sticking with a cached version?\r\n203:2025-12-29 11:56:14,108 [codex] [17] **Examining Python compiled files**\r\n205:Python uses `.pyc` files when the source file's modification time matches the stored time and size. In our repo, we have compiled files in `src/takopi/__pycache__/exec_bridge.cpython-314.pyc`. If Python opts for the `.pyc` over the source, it's possible that a previous version contained different imports or definitions. Although our tests run quickly and may be using the `.pyc`, changing exec_bridge.py should update its timestamp. But if a patch preserves that timestamp, would the new changes affect the use of the cached version?\r\n206:2025-12-29 11:56:14,108 [codex] [18] **Testing Python imports**\r\n208:The import should read the source and recompile if necessary. If it’s invalid, it should still fail, but it didn't. Maybe the functions in exec_bridge.py aren't actually from `.rendering`; instead, they might come from a different module. Our Python command imported `takopi.rendering` from a local file, which seems to be missing. So I’m going to try: `uv run python -c \"from takopi.rendering import render_to_html\"` to see what happens. Let’s test that out!\r\n209:2025-12-29 11:56:14,382 [codex] [19] ▸ running: `/bin/zsh -lc 'cd…`\r\n210:2025-12-29 11:56:14,571 [codex] [19] ✓ ran: `/bin/zsh -lc 'cd…` (exit 1)\r\n211:2025-12-29 11:56:31,318 [codex] [20] **Investigating import issues**\r\n213:It seems there's a contradiction regarding the exec_bridge module and the related tests. Although the tests earlier indicated that exec_bridge imported successfully, the test results are confusing because they passed yet seem to rely on imports that should fail. I wonder if an earlier patch might have altered an import, but I didn't change any imports. Perhaps there is an alternative rendering module earlier in the path, but it should prioritize local modules. This is quite puzzling!\r\n214:2025-12-29 11:56:31,322 [codex] [21] **Clarifying import behavior**\r\n216:I’m trying to understand why tests passed despite the discrepancies with the `exec_bridge` module. It’s possible the tests didn’t run from scratch, but every `uv run pytest` should initiate a fresh Python process. We ran it after the patch, and it passed, indicating that `takopi.rendering` had the necessary functions. It’s puzzling that our file claims otherwise. I wonder if the tests might be using a different `sys.path`. The conftest inserts the right `src` directory, so that seems correct.\r\n217:2025-12-29 11:56:31,322 [codex] [22] **Inspecting import statements**\r\n219:I’m wondering if there’s another file, like `src/takopi/rendering.py`, that we missed. It seems crucial. I might have misread the import statement in `exec_bridge.py`; maybe it imports `render_markdown` as `render_to_html` instead of the functions I expected. To clarify, I should check the import lines at the top of `exec_bridge.py`. I’ll take a closer look at lines 1-40 to get a better understanding of what's really happening there.\r\n220:2025-12-29 11:56:31,618 [codex] [23] ▸ running: `/bin/zsh -lc \"cd…`\r\n221:2025-12-29 11:56:31,717 [codex] [23] ✓ ran: `/bin/zsh -lc \"cd…` (exit 0)\r\n222:2025-12-29 11:56:41,307 [codex] [24] ▸ running: `/bin/zsh -lc 'cd…`\r\n223:2025-12-29 11:56:41,418 [codex] [24] ✓ ran: `/bin/zsh -lc 'cd…` (exit 1)\r\n224:2025-12-29 11:56:52,544 [codex] [25] **Investigating missing imports**\r\n227:2025-12-29 11:56:52,769 [codex] [26] ▸ running: `/bin/zsh -lc \"cd…`\r\n228:2025-12-29 11:56:52,870 [codex] [26] ✓ ran: `/bin/zsh -lc \"cd…` (exit 0)\r\n229:2025-12-29 11:56:58,977 [codex] [27] ▸ running: `/bin/zsh -lc \"cd…`\r\n230:2025-12-29 11:56:59,081 [codex] [27] ✓ ran: `/bin/zsh -lc \"cd…` (exit 0)\r\n231:2025-12-29 11:57:15,994 [debug] file logger initialized path='exec_bridge.log'\r\n\r\n./readme.md\r\n1:# Telegram Codex Bridge (Codex)\r\n3:Route Telegram replies back into Codex sessions using non-interactive\r\n4:`codex exec` + `codex exec resume`.\r\n6:The bridge stores a mapping from `(chat_id, bot_message_id)` to a route so\r\n13:3. Put your Telegram credentials in `~/.codex/telegram.toml`.\r\n15:Example `~/.codex/telegram.toml`:\r\n18:bot_token = \"123:abc\"\r\n22:`chat_id` is used both for allowed messages and startup notifications.\r\n26:- exec/resume: `cd`, `codex_exec_args`\r\n33:uv run takopi\r\n42:- `--cd PATH` (pass through to `codex --cd`)\r\n43:- `--model NAME` (pass through to `codex exec`)\r\n45:To resume an existing thread without a database, reply with (or include) the session id shown at the end of the bot response:\r\n50:- `src/takopi/constants.py`: limits and config path constants\r\n51:- `src/takopi/config.py`: config loading and chat-id parsing helpers\r\n52:- `src/takopi/exec_render.py`: renderers for codex exec JSONL events\r\n53:- `src/takopi/rendering.py`: markdown rendering\r\n54:- `src/takopi/telegram_client.py`: Telegram Bot API client\r\n55:- `src/takopi/exec_bridge.py`: codex exec + resume bridge\r\n\r\n./pyproject.toml\r\n2:name = \"takopi\"\r\n4:description = \"Telegram bridge tools for Codex.\"\r\n15:takopi = \"takopi.exec_bridge:main\"\r\n22:packages = [\"src/takopi\"]\r\n25:include = [\"src/takopi\", \"readme.md\"]\r\n\r\n./src/takopi/constants.py\r\n5:TELEGRAM_HARD_LIMIT = 4096\r\n6:TELEGRAM_CONFIG_PATH = Path.home() / \".codex\" / \"telegram.toml\"\r\n\r\n./tests/test_exec_render.py\r\n3:from takopi.exec_render import ExecProgressRenderer, render_event_cli\r\n15:{\"type\":\"item.completed\",\"item\":{\"id\":\"item_1\",\"type\":\"command_execution\",\"command\":\"bash -lc ls\",\"aggregated_output\":\"2025-09-11\\\\nAGENTS.md\\\\nCHANGELOG.md\\\\ncliff.toml\\\\ncodex-cli\\\\ncodex-rs\\\\ndocs\\\\nexamples\\\\nflake.lock\\\\nflake.nix\\\\nLICENSE\\\\nnode_modules\\\\nNOTICE\\\\npackage.json\\\\npnpm-lock.yaml\\\\npnpm-workspace.yaml\\\\nPNPM.md\\\\nREADME.md\\\\nscripts\\\\nsdk\\\\ntmp\\\\n\",\"exit_code\":0,\"status\":\"completed\"}}\r\n\r\n./tests/test_exec_bridge.py\r\n6:from takopi.exec_bridge import extract_session_id, truncate_for_telegram\r\n16:def test_truncate_for_telegram_preserves_resume_line() -> None:\r\n20: out = truncate_for_telegram(md, 400)\r\n27:class _FakeBot:\r\n91: from takopi.exec_bridge import BridgeConfig, _handle_message\r\n93: bot = _FakeBot()\r\n95: cfg = BridgeConfig(\r\n96: bot=bot, # type: ignore[arg-type]\r\n118: assert len(bot.send_calls) == 2\r\n119: assert bot.send_calls[0][\"disable_notification\"] is True\r\n120: assert bot.send_calls[1][\"disable_notification\"] is False\r\n124: from takopi.exec_bridge import BridgeConfig, _handle_message\r\n126: bot = _FakeBot()\r\n128: cfg = BridgeConfig(\r\n129: bot=bot, # type: ignore[arg-type]\r\n151: assert len(bot.send_calls) == 2\r\n152: assert bot.send_calls[0][\"disable_notification\"] is True\r\n153: assert bot.send_calls[1][\"disable_notification\"] is False\r\n156:def test_codex_runner_cancellation_terminates_subprocess(tmp_path, monkeypatch) -> None:\r\n157: from takopi.exec_bridge import CodexExecRunner\r\n160: codex_path = tmp_path / \"codex\"\r\n161: codex_path.write_text(\r\n166: \"pid_file = os.environ.get('CODEX_FAKE_PID_FILE')\\n\"\r\n175: codex_path.chmod(0o755)\r\n176: monkeypatch.setenv(\"CODEX_FAKE_PID_FILE\", str(pid_file))\r\n178: runner = CodexExecRunner(codex_cmd=str(codex_path), workspace=None, extra_args=[])\r\n202: raise AssertionError(\"cancelled codex subprocess is still running\")\r\n\r\n./src/takopi/exec_bridge.py\r\n21:from .config import load_telegram_config\r\n22:from .constants import TELEGRAM_HARD_LIMIT\r\n25:from .telegram_client import TelegramClient\r\n50: logger.info(\"[codex][stderr] %s\", decoded.rstrip())\r\n53: logger.debug(\"[codex][stderr] drain error: %s\", e)\r\n83:TELEGRAM_TEXT_LIMIT = TELEGRAM_HARD_LIMIT\r\n84:TELEGRAM_MARKDOWN_LIMIT = 3500\r\n87:def _clamp_tg_text(text: str, limit: int = TELEGRAM_TEXT_LIMIT) -> str:\r\n93:def truncate_for_telegram(text: str, limit: int) -> str:\r\n95: Truncate text to fit Telegram limits while preserving the trailing `resume: ...`\r\n132:def render_for_telegram(\r\n137: rendered = truncate_for_telegram(rendered, limit)\r\n143: bot: TelegramClient,\r\n150: rendered, entities = render_for_telegram(text, limit=TELEGRAM_MARKDOWN_LIMIT)\r\n152: return await bot.send_message(\r\n164:class CodexExecRunner:\r\n166: Runs Codex in non-interactive mode:\r\n167: - new: codex exec --json ... -\r\n168: - resume: codex exec --json ... resume -\r\n172: self, codex_cmd: str, workspace: str | None, extra_args: list[str]\r\n174: self.codex_cmd = codex_cmd\r\n197: \"[codex] start run session_id=%r workspace=%r\", session_id, self.workspace\r\n199: args = [self.codex_cmd, \"exec\", \"--json\"]\r\n217: logger.debug(\"[codex] spawn pid=%s args=%r\", proc.pid, args)\r\n246: logger.info(\"[codex] %s\", out)\r\n254: logger.info(\"[codex][on_event] callback error: %s\", e)\r\n281: \"[codex] terminate timed out pid=%s, sending kill\", proc.pid\r\n294: logger.debug(\"[codex] process exit pid=%s rc=%s\", proc.pid, rc)\r\n297: raise RuntimeError(f\"codex exec failed (rc={rc}). stderr tail:\\n{tail}\")\r\n301: \"codex exec finished but no session_id/thread_id was captured\"\r\n304: logger.info(\"[codex] done run session_id=%r\", found_session)\r\n328:class BridgeConfig:\r\n329: bot: TelegramClient\r\n330: runner: CodexExecRunner\r\n340:def _parse_bridge_config(\r\n348:) -> BridgeConfig:\r\n349: config = load_telegram_config()\r\n350: token = config[\"bot_token\"]\r\n354: startup_msg = f\"codex exec bridge has started\\npwd: {startup_pwd}\"\r\n356: codex_cmd = shutil.which(\"codex\")\r\n357: if not codex_cmd:\r\n358: raise RuntimeError(\"codex not found on PATH\")\r\n361: raw_exec_args = config.get(\"codex_exec_args\", \"\")\r\n385: bot = TelegramClient(token)\r\n386: runner = CodexExecRunner(codex_cmd=codex_cmd, workspace=workspace, extra_args=extra_args)\r\n388: return BridgeConfig(\r\n389: bot=bot,\r\n401:async def _send_startup(cfg: BridgeConfig) -> None:\r\n403: await cfg.bot.send_message(chat_id=cfg.chat_id, text=cfg.startup_msg)\r\n411:async def _drain_backlog(cfg: BridgeConfig, offset: int | None) -> int | None:\r\n415: updates = await cfg.bot.get_updates(\r\n428: cfg: BridgeConfig,\r\n447: rendered, entities = render_for_telegram(md, limit=TELEGRAM_TEXT_LIMIT)\r\n449: await cfg.bot.edit_message_text(\r\n465: initial_rendered, initial_entities = render_for_telegram(\r\n466: initial_md, limit=TELEGRAM_TEXT_LIMIT\r\n468: progress_msg = await cfg.bot.send_message(\r\n510: if progress_id is not None and len(err) <= TELEGRAM_TEXT_LIMIT:\r\n512: await cfg.bot.edit_message_text(\r\n519: cfg.bot,\r\n538: can_edit_final = progress_id is not None and len(final_rendered) <= TELEGRAM_TEXT_LIMIT\r\n542: cfg.bot,\r\n550: await cfg.bot.delete_message(chat_id=chat_id, message_id=progress_id)\r\n554: await cfg.bot.edit_message_text(\r\n562:async def _run_main_loop(cfg: BridgeConfig) -> None:\r\n583: updates = await cfg.bot.get_updates(\r\n599: if msg.get(\"from\", {}).get(\"is_bot\"):\r\n624: await cfg.bot.close()\r\n647: help=\"Skip pending Telegram updates that arrived before startup.\",\r\n650: \"exec_bridge.log\",\r\n657: help=\"Pass through to `codex --cd` (defaults to `cd` in ~/.codex/telegram.toml).\",\r\n662: help=\"Codex model to pass to `codex exec`.\",\r\n666: cfg = _parse_bridge_config(\r\n","exit_code":0,"status":"completed"}} -{"type":"item.started","item":{"id":"item_3","type":"command_execution","command":"/bin/zsh -lc \"cd /Users/banteg/dev/banteg/agents/takopi && (ls -la && echo \\\"---\\\" && (test -f README.md && sed -n '1,200p' README.md || true))\"","aggregated_output":"","exit_code":null,"status":"in_progress"}} -{"type":"item.completed","item":{"id":"item_3","type":"command_execution","command":"/bin/zsh -lc \"cd /Users/banteg/dev/banteg/agents/takopi && (ls -la && echo \\\"---\\\" && (test -f README.md && sed -n '1,200p' README.md || true))\"","aggregated_output":"total 304\r\ndrwxr-xr-x@ 21 banteg staff 672 Dec 29 12:29 .\r\ndrwxr-xr-x@ 11 banteg staff 352 Dec 29 02:10 ..\r\n-rw-r--r--@ 1 banteg staff 8196 Dec 29 12:04 .DS_Store\r\ndrwx------@ 3 banteg staff 96 Dec 29 00:16 .claude\r\ndrwxr-xr-x@ 4 banteg staff 128 Dec 29 12:28 .codex_tmp\r\ndrwxr-xr-x@ 6 banteg staff 192 Dec 29 03:04 .pytest_cache\r\ndrwxr-xr-x@ 5 banteg staff 160 Dec 28 23:33 .ruff_cache\r\ndrwx------@ 2 banteg staff 64 Dec 29 02:10 .tmp_fakecodex.FRqd1E\r\ndrwxr-xr-x@ 7 banteg staff 224 Dec 29 00:03 .uv-cache\r\ndrwxr-xr-x@ 8 banteg staff 256 Dec 29 01:30 .venv\r\ndrwxr-xr-x@ 3 banteg staff 96 Dec 28 21:21 __pycache__\r\n-rw-r--r--@ 1 banteg staff 14219 Dec 29 11:36 bridge.zip\r\n-rw-r--r--@ 1 banteg staff 27248 Dec 29 12:30 codex.jsonl\r\n-rw-r--r--@ 1 banteg staff 0 Dec 29 12:28 codex_exec_explore_bridge.jsonl\r\n-rw-r--r--@ 1 banteg staff 32392 Dec 29 11:57 exec_bridge.log\r\n-rw-r--r--@ 1 banteg staff 608 Dec 29 11:54 pyproject.toml\r\n-rw-r--r--@ 1 banteg staff 1724 Dec 29 03:34 readme.md\r\ndrwxr-xr-x@ 3 banteg staff 96 Dec 28 21:24 src\r\ndrwxr-xr-x@ 6 banteg staff 192 Dec 29 03:42 tests\r\n-rw-r--r--@ 1 banteg staff 40936 Dec 29 11:54 uv.lock\r\n-rw-r--r--@ 1 banteg staff 13497 Dec 29 03:47 work.md\r\n---\r\n# Telegram Codex Bridge (Codex)\r\n\r\nRoute Telegram replies back into Codex sessions using non-interactive\r\n`codex exec` + `codex exec resume`.\r\n\r\nThe bridge stores a mapping from `(chat_id, bot_message_id)` to a route so\r\nreplies can be routed correctly.\r\n\r\n## Install\r\n\r\n1. Ensure `uv` is installed.\r\n2. From this folder, run the entrypoints with `uv run` (uses `pyproject.toml` deps).\r\n3. Put your Telegram credentials in `~/.codex/telegram.toml`.\r\n\r\nExample `~/.codex/telegram.toml`:\r\n\r\n```toml\r\nbot_token = \"123:abc\"\r\nchat_id = 123456789\r\n```\r\n\r\n`chat_id` is used both for allowed messages and startup notifications.\r\n\r\nOptional keys:\r\n\r\n- exec/resume: `cd`, `codex_exec_args`\r\n\r\n## Option 1: exec/resume\r\n\r\nRun:\r\n\r\n```bash\r\nuv run takopi\r\n```\r\n\r\nOptional flags:\r\n\r\n- `--progress-edit-every FLOAT` (default `2.0`)\r\n- `--progress-silent/--no-progress-silent` (default silent)\r\n- `--final-notify/--no-final-notify` (default notify via new message)\r\n- `--ignore-backlog/--process-backlog` (default ignore pending updates)\r\n- `--cd PATH` (pass through to `codex --cd`)\r\n- `--model NAME` (pass through to `codex exec`)\r\n\r\nTo resume an existing thread without a database, reply with (or include) the session id shown at the end of the bot response:\r\n\r\n`resume: \\`019b66fc-64c2-7a71-81cd-081c504cfeb2\\``\r\n\r\n## Files\r\n- `src/takopi/constants.py`: limits and config path constants\r\n- `src/takopi/config.py`: config loading and chat-id parsing helpers\r\n- `src/takopi/exec_render.py`: renderers for codex exec JSONL events\r\n- `src/takopi/rendering.py`: markdown rendering\r\n- `src/takopi/telegram_client.py`: Telegram Bot API client\r\n- `src/takopi/exec_bridge.py`: codex exec + resume bridge\r\n","exit_code":0,"status":"completed"}} -{"type":"item.started","item":{"id":"item_4","type":"command_execution","command":"/bin/zsh -lc 'cd /Users/banteg/dev/banteg/agents/takopi && find src -maxdepth 3 -type f -print'","aggregated_output":"","exit_code":null,"status":"in_progress"}} -{"type":"item.completed","item":{"id":"item_4","type":"command_execution","command":"/bin/zsh -lc 'cd /Users/banteg/dev/banteg/agents/takopi && find src -maxdepth 3 -type f -print'","aggregated_output":"src/takopi/exec_bridge.py\r\nsrc/takopi/config.py\r\nsrc/takopi/rendering.py\r\nsrc/takopi/constants.py\r\nsrc/takopi/__init__.py\r\nsrc/takopi/__pycache__/config.cpython-314.pyc\r\nsrc/takopi/__pycache__/tmux_reply_bot.cpython-314.pyc\r\nsrc/takopi/__pycache__/exec_render.cpython-314.pyc\r\nsrc/takopi/__pycache__/tmux_notify.cpython-314.pyc\r\nsrc/takopi/__pycache__/mcp_bridge.cpython-314.pyc\r\nsrc/takopi/__pycache__/constants.cpython-314.pyc\r\nsrc/takopi/__pycache__/routes.cpython-314.pyc\r\nsrc/takopi/__pycache__/rendering.cpython-314.pyc\r\nsrc/takopi/__pycache__/exec_bridge.cpython-314.pyc\r\nsrc/takopi/__pycache__/__init__.cpython-314.pyc\r\nsrc/takopi/__pycache__/exec_bridge_patched.cpython-314.pyc\r\nsrc/takopi/__pycache__/telegram_client.cpython-314.pyc\r\nsrc/takopi/__pycache__/bridge_common.cpython-314.pyc\r\nsrc/takopi/exec_render.py\r\nsrc/takopi/telegram_client.py\r\n","exit_code":0,"status":"completed"}} -{"type":"item.started","item":{"id":"item_5","type":"command_execution","command":"/bin/zsh -lc \"cd /Users/banteg/dev/banteg/agents/takopi && sed -n '1,260p' pyproject.toml\"","aggregated_output":"","exit_code":null,"status":"in_progress"}} -{"type":"item.completed","item":{"id":"item_5","type":"command_execution","command":"/bin/zsh -lc \"cd /Users/banteg/dev/banteg/agents/takopi && sed -n '1,260p' pyproject.toml\"","aggregated_output":"[project]\r\nname = \"takopi\"\r\nversion = \"0.1.0\"\r\ndescription = \"Telegram bridge tools for Codex.\"\r\nreadme = \"readme.md\"\r\nrequires-python = \">=3.12\"\r\ndependencies = [\r\n \"httpx>=0.28.1\",\r\n \"markdown-it-py\",\r\n \"sulguk>=0.11.0\",\r\n \"typer\",\r\n]\r\n\r\n[project.scripts]\r\ntakopi = \"takopi.exec_bridge:main\"\r\n\r\n[build-system]\r\nrequires = [\"hatchling\"]\r\nbuild-backend = \"hatchling.build\"\r\n\r\n[tool.hatch.build.targets.wheel]\r\npackages = [\"src/takopi\"]\r\n\r\n[tool.hatch.build.targets.sdist]\r\ninclude = [\"src/takopi\", \"readme.md\"]\r\n\r\n[dependency-groups]\r\ndev = [\r\n \"pytest>=9.0.2\",\r\n]\r\n","exit_code":0,"status":"completed"}} -{"type":"item.started","item":{"id":"item_6","type":"command_execution","command":"/bin/zsh -lc \"cd /Users/banteg/dev/banteg/agents/takopi && sed -n '1,240p' src/takopi/exec_bridge.py\"","aggregated_output":"","exit_code":null,"status":"in_progress"}} -{"type":"item.completed","item":{"id":"item_6","type":"command_execution","command":"/bin/zsh -lc \"cd /Users/banteg/dev/banteg/agents/takopi && sed -n '1,240p' src/takopi/exec_bridge.py\"","aggregated_output":"from __future__ import annotations\r\n\r\nimport asyncio\r\nimport inspect\r\nimport json\r\nimport logging\r\nimport os\r\nimport re\r\nimport shlex\r\nimport shutil\r\nimport sys\r\nimport time\r\nfrom collections import deque\r\nfrom collections.abc import Awaitable, Callable\r\nfrom dataclasses import dataclass\r\nfrom logging.handlers import RotatingFileHandler\r\nfrom typing import Any\r\n\r\nimport typer\r\n\r\nfrom .config import load_telegram_config\r\nfrom .constants import TELEGRAM_HARD_LIMIT\r\nfrom .exec_render import ExecProgressRenderer, render_event_cli\r\nfrom .rendering import render_markdown\r\nfrom .telegram_client import TelegramClient\r\n\r\nlogger = logging.getLogger(__name__)\r\nUUID_PATTERN = re.compile(\r\n r\"(?i)\\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\b\"\r\n)\r\n\r\n\r\ndef extract_session_id(text: str | None) -> str | None:\r\n if not text:\r\n return None\r\n if m := UUID_PATTERN.search(text):\r\n return m.group(0)\r\n return None\r\n\r\n\r\nasync def _drain_stderr(stderr: asyncio.StreamReader | None, tail: deque[str]) -> None:\r\n if stderr is None:\r\n return\r\n try:\r\n while True:\r\n line = await stderr.readline()\r\n if not line:\r\n return\r\n decoded = line.decode(errors=\"replace\")\r\n logger.info(\"[codex][stderr] %s\", decoded.rstrip())\r\n tail.append(decoded)\r\n except Exception as e:\r\n logger.debug(\"[codex][stderr] drain error: %s\", e)\r\n\r\n\r\ndef setup_logging(log_file: str | None) -> None:\r\n root_logger = logging.getLogger()\r\n root_logger.setLevel(logging.DEBUG)\r\n for handler in root_logger.handlers[:]:\r\n root_logger.removeHandler(handler)\r\n handler.close()\r\n\r\n fmt = logging.Formatter(\"%(asctime)s %(levelname)s %(name)s: %(message)s\")\r\n\r\n console = logging.StreamHandler(sys.stdout)\r\n console.setLevel(logging.INFO)\r\n console.setFormatter(fmt)\r\n root_logger.addHandler(console)\r\n\r\n if log_file:\r\n file_handler = RotatingFileHandler(\r\n log_file,\r\n maxBytes=5 * 1024 * 1024,\r\n backupCount=3,\r\n encoding=\"utf-8\",\r\n )\r\n file_handler.setLevel(logging.DEBUG)\r\n file_handler.setFormatter(fmt)\r\n root_logger.addHandler(file_handler)\r\n logger.debug(\"[debug] file logger initialized path=%r\", log_file)\r\n\r\n\r\nTELEGRAM_TEXT_LIMIT = TELEGRAM_HARD_LIMIT\r\nTELEGRAM_MARKDOWN_LIMIT = 3500\r\n\r\n\r\ndef _clamp_tg_text(text: str, limit: int = TELEGRAM_TEXT_LIMIT) -> str:\r\n if len(text) <= limit:\r\n return text\r\n return text[: limit - 20] + \"\\n...(truncated)\"\r\n\r\n\r\ndef truncate_for_telegram(text: str, limit: int) -> str:\r\n \"\"\"\r\n Truncate text to fit Telegram limits while preserving the trailing `resume: ...`\r\n line (if present), otherwise preserving the last non-empty line.\r\n \"\"\"\r\n if len(text) <= limit:\r\n return text\r\n\r\n lines = text.splitlines()\r\n\r\n tail_lines: list[str] | None = None\r\n is_resume_tail = False\r\n for i in range(len(lines) - 1, -1, -1):\r\n line = lines[i]\r\n if \"resume\" in line and UUID_PATTERN.search(line):\r\n tail_lines = lines[i:]\r\n is_resume_tail = True\r\n break\r\n\r\n if tail_lines is None:\r\n for i in range(len(lines) - 1, -1, -1):\r\n if lines[i].strip():\r\n tail_lines = [lines[i]]\r\n break\r\n\r\n tail = \"\\n\".join(tail_lines or []).strip(\"\\n\")\r\n sep = \"\\n…\\n\"\r\n\r\n max_tail = limit if is_resume_tail else (limit // 4)\r\n tail = tail[-max_tail:] if max_tail > 0 else \"\"\r\n\r\n head_budget = limit - len(sep) - len(tail)\r\n if head_budget <= 0:\r\n return tail[-limit:] if tail else text[:limit]\r\n\r\n head = text[:head_budget].rstrip()\r\n return (head + sep + tail)[:limit]\r\n\r\n\r\ndef render_for_telegram(\r\n md: str, *, limit: int\r\n) -> tuple[str, list[dict[str, Any]] | None]:\r\n rendered, entities = render_markdown(md)\r\n if len(rendered) > limit:\r\n rendered = truncate_for_telegram(rendered, limit)\r\n return rendered, None\r\n return rendered, entities or None\r\n\r\n\r\nasync def _send_markdown(\r\n bot: TelegramClient,\r\n *,\r\n chat_id: int,\r\n text: str,\r\n reply_to_message_id: int | None = None,\r\n disable_notification: bool = False,\r\n) -> dict[str, Any]:\r\n rendered, entities = render_for_telegram(text, limit=TELEGRAM_MARKDOWN_LIMIT)\r\n\r\n return await bot.send_message(\r\n chat_id=chat_id,\r\n text=rendered,\r\n entities=entities,\r\n reply_to_message_id=reply_to_message_id,\r\n disable_notification=disable_notification,\r\n )\r\n\r\n\r\nEventCallback = Callable[[dict[str, Any]], Awaitable[None] | None]\r\n\r\n\r\nclass CodexExecRunner:\r\n \"\"\"\r\n Runs Codex in non-interactive mode:\r\n - new: codex exec --json ... -\r\n - resume: codex exec --json ... resume -\r\n \"\"\"\r\n\r\n def __init__(\r\n self, codex_cmd: str, workspace: str | None, extra_args: list[str]\r\n ) -> None:\r\n self.codex_cmd = codex_cmd\r\n self.workspace = workspace\r\n self.extra_args = extra_args\r\n\r\n # Per-session locks to prevent concurrent resumes to the same session_id.\r\n self._session_locks: dict[str, asyncio.Lock] = {}\r\n self._locks_guard = asyncio.Lock()\r\n\r\n async def _lock_for(self, session_id: str) -> asyncio.Lock:\r\n async with self._locks_guard:\r\n lock = self._session_locks.get(session_id)\r\n if lock is None:\r\n lock = asyncio.Lock()\r\n self._session_locks[session_id] = lock\r\n return lock\r\n\r\n async def run(\r\n self,\r\n prompt: str,\r\n session_id: str | None,\r\n on_event: EventCallback | None = None,\r\n ) -> tuple[str, str, bool]:\r\n logger.info(\r\n \"[codex] start run session_id=%r workspace=%r\", session_id, self.workspace\r\n )\r\n args = [self.codex_cmd, \"exec\", \"--json\"]\r\n args.extend(self.extra_args)\r\n if self.workspace:\r\n args.extend([\"--cd\", self.workspace])\r\n\r\n # Always pipe prompt via stdin (\"-\") to avoid quoting issues.\r\n if session_id:\r\n args.extend([\"resume\", session_id, \"-\"])\r\n else:\r\n args.append(\"-\")\r\n\r\n proc = await asyncio.create_subprocess_exec(\r\n *args,\r\n stdin=asyncio.subprocess.PIPE,\r\n stdout=asyncio.subprocess.PIPE,\r\n stderr=asyncio.subprocess.PIPE,\r\n )\r\n assert proc.stdin and proc.stdout and proc.stderr\r\n logger.debug(\"[codex] spawn pid=%s args=%r\", proc.pid, args)\r\n\r\n stderr_tail: deque[str] = deque(maxlen=200)\r\n stderr_task = asyncio.create_task(_drain_stderr(proc.stderr, stderr_tail))\r\n\r\n found_session: str | None = session_id\r\n last_agent_text: str | None = None\r\n saw_agent_message = False\r\n cli_last_turn: int | None = None\r\n\r\n cancelled = False\r\n rc: int | None = None\r\n\r\n try:\r\n proc.stdin.write(prompt.encode())\r\n await proc.stdin.drain()\r\n proc.stdin.close()\r\n\r\n async for raw_line in proc.stdout:\r\n line = raw_line.decode(errors=\"replace\").strip()\r\n if not line:\r\n continue\r\n try:\r\n evt = json.loads(line)\r\n","exit_code":0,"status":"completed"}} -{"type":"item.started","item":{"id":"item_7","type":"command_execution","command":"/bin/zsh -lc \"cd /Users/banteg/dev/banteg/agents/takopi && sed -n '240,520p' src/takopi/exec_bridge.py\"","aggregated_output":"","exit_code":null,"status":"in_progress"}} -{"type":"item.completed","item":{"id":"item_7","type":"command_execution","command":"/bin/zsh -lc \"cd /Users/banteg/dev/banteg/agents/takopi && sed -n '240,520p' src/takopi/exec_bridge.py\"","aggregated_output":" evt = json.loads(line)\r\n except json.JSONDecodeError:\r\n continue\r\n\r\n cli_last_turn, out_lines = render_event_cli(evt, cli_last_turn)\r\n for out in out_lines:\r\n logger.info(\"[codex] %s\", out)\r\n\r\n if on_event is not None:\r\n try:\r\n res = on_event(evt)\r\n if inspect.isawaitable(res):\r\n await res\r\n except Exception as e:\r\n logger.info(\"[codex][on_event] callback error: %s\", e)\r\n\r\n if evt.get(\"type\") == \"thread.started\":\r\n found_session = evt.get(\"thread_id\") or found_session\r\n\r\n if evt.get(\"type\") == \"item.completed\":\r\n item = evt.get(\"item\") or {}\r\n if item.get(\"type\") == \"agent_message\" and isinstance(\r\n item.get(\"text\"), str\r\n ):\r\n last_agent_text = item[\"text\"]\r\n saw_agent_message = True\r\n except asyncio.CancelledError:\r\n cancelled = True\r\n if proc.returncode is None:\r\n proc.terminate()\r\n finally:\r\n if cancelled:\r\n task = asyncio.current_task()\r\n if task is not None:\r\n while task.cancelling():\r\n task.uncancel()\r\n\r\n try:\r\n rc = await asyncio.wait_for(proc.wait(), timeout=2.0)\r\n except asyncio.TimeoutError:\r\n logger.debug(\r\n \"[codex] terminate timed out pid=%s, sending kill\", proc.pid\r\n )\r\n if proc.returncode is None:\r\n proc.kill()\r\n rc = await proc.wait()\r\n else:\r\n rc = await proc.wait()\r\n\r\n await asyncio.gather(stderr_task, return_exceptions=True)\r\n\r\n if cancelled:\r\n raise asyncio.CancelledError\r\n\r\n logger.debug(\"[codex] process exit pid=%s rc=%s\", proc.pid, rc)\r\n if rc != 0:\r\n tail = \"\".join(stderr_tail)\r\n raise RuntimeError(f\"codex exec failed (rc={rc}). stderr tail:\\n{tail}\")\r\n\r\n if not found_session:\r\n raise RuntimeError(\r\n \"codex exec finished but no session_id/thread_id was captured\"\r\n )\r\n\r\n logger.info(\"[codex] done run session_id=%r\", found_session)\r\n return (\r\n found_session,\r\n (last_agent_text or \"(No agent_message captured from JSON stream.)\"),\r\n saw_agent_message,\r\n )\r\n\r\n async def run_serialized(\r\n self,\r\n prompt: str,\r\n session_id: str | None,\r\n on_event: EventCallback | None = None,\r\n ) -> tuple[str, str, bool]:\r\n \"\"\"\r\n If resuming, serialize per-session.\r\n \"\"\"\r\n if not session_id:\r\n return await self.run(prompt, session_id=None, on_event=on_event)\r\n lock = await self._lock_for(session_id)\r\n async with lock:\r\n return await self.run(prompt, session_id=session_id, on_event=on_event)\r\n\r\n\r\n@dataclass(frozen=True)\r\nclass BridgeConfig:\r\n bot: TelegramClient\r\n runner: CodexExecRunner\r\n chat_id: int\r\n ignore_backlog: bool\r\n progress_edit_every_s: float\r\n progress_silent: bool\r\n final_notify: bool\r\n startup_msg: str\r\n max_concurrency: int\r\n\r\n\r\ndef _parse_bridge_config(\r\n *,\r\n progress_edit_every_s: float,\r\n progress_silent: bool,\r\n final_notify: bool,\r\n ignore_backlog: bool,\r\n cd: str | None,\r\n model: str | None,\r\n) -> BridgeConfig:\r\n config = load_telegram_config()\r\n token = config[\"bot_token\"]\r\n chat_id = int(config[\"chat_id\"])\r\n\r\n startup_pwd = os.getcwd()\r\n startup_msg = f\"codex exec bridge has started\\npwd: {startup_pwd}\"\r\n\r\n codex_cmd = shutil.which(\"codex\")\r\n if not codex_cmd:\r\n raise RuntimeError(\"codex not found on PATH\")\r\n\r\n workspace = cd if cd is not None else config.get(\"cd\")\r\n raw_exec_args = config.get(\"codex_exec_args\", \"\")\r\n if isinstance(raw_exec_args, list):\r\n extra_args = [str(v) for v in raw_exec_args]\r\n else:\r\n extra_args = shlex.split(str(raw_exec_args)) # e.g. \"--full-auto --search\"\r\n\r\n if model:\r\n extra_args.extend([\"--model\", model])\r\n\r\n def _has_notify_override(args: list[str]) -> bool:\r\n for i, arg in enumerate(args):\r\n if arg in (\"-c\", \"--config\"):\r\n key = args[i + 1].split(\"=\", 1)[0].strip()\r\n if key == \"notify\" or key.endswith(\".notify\"):\r\n return True\r\n elif arg.startswith((\"--config=\", \"-c=\")):\r\n key = arg.split(\"=\", 1)[1].split(\"=\", 1)[0].strip()\r\n if key == \"notify\" or key.endswith(\".notify\"):\r\n return True\r\n return False\r\n\r\n if not _has_notify_override(extra_args):\r\n extra_args.extend([\"-c\", \"notify=[]\"])\r\n\r\n bot = TelegramClient(token)\r\n runner = CodexExecRunner(codex_cmd=codex_cmd, workspace=workspace, extra_args=extra_args)\r\n\r\n return BridgeConfig(\r\n bot=bot,\r\n runner=runner,\r\n chat_id=chat_id,\r\n ignore_backlog=bool(ignore_backlog),\r\n progress_edit_every_s=progress_edit_every_s,\r\n progress_silent=progress_silent,\r\n final_notify=final_notify,\r\n startup_msg=startup_msg,\r\n max_concurrency=16,\r\n )\r\n\r\n\r\nasync def _send_startup(cfg: BridgeConfig) -> None:\r\n try:\r\n await cfg.bot.send_message(chat_id=cfg.chat_id, text=cfg.startup_msg)\r\n logger.info(\"[startup] sent startup message to chat_id=%s\", cfg.chat_id)\r\n except Exception as e:\r\n logger.info(\r\n \"[startup] failed to send startup message to chat_id=%s: %s\", cfg.chat_id, e\r\n )\r\n\r\n\r\nasync def _drain_backlog(cfg: BridgeConfig, offset: int | None) -> int | None:\r\n if not cfg.ignore_backlog:\r\n return offset\r\n try:\r\n updates = await cfg.bot.get_updates(\r\n offset=offset, timeout_s=0, allowed_updates=[\"message\"]\r\n )\r\n except Exception as e:\r\n logger.info(\"[startup] backlog drain failed: %s\", e)\r\n return offset\r\n if updates:\r\n offset = updates[-1][\"update_id\"] + 1\r\n logger.info(\"[startup] drained %s pending update(s)\", len(updates))\r\n return offset\r\n\r\n\r\nasync def _handle_message(\r\n cfg: BridgeConfig,\r\n *,\r\n semaphore: asyncio.Semaphore,\r\n chat_id: int,\r\n user_msg_id: int,\r\n text: str,\r\n resume_session: str | None,\r\n) -> None:\r\n started_at = time.monotonic()\r\n progress_renderer = ExecProgressRenderer(max_actions=5)\r\n\r\n progress_id: int | None = None\r\n\r\n last_edit_at = 0.0\r\n edit_task: asyncio.Task[None] | None = None\r\n\r\n async def _edit_progress(md: str) -> None:\r\n if progress_id is None:\r\n return\r\n rendered, entities = render_for_telegram(md, limit=TELEGRAM_TEXT_LIMIT)\r\n try:\r\n await cfg.bot.edit_message_text(\r\n chat_id=chat_id,\r\n message_id=progress_id,\r\n text=rendered,\r\n entities=entities,\r\n )\r\n except Exception as e:\r\n logger.info(\r\n \"[progress] edit failed chat_id=%s message_id=%s: %s\",\r\n chat_id,\r\n progress_id,\r\n e,\r\n )\r\n\r\n try:\r\n initial_md = progress_renderer.render_progress(0.0)\r\n initial_rendered, initial_entities = render_for_telegram(\r\n initial_md, limit=TELEGRAM_TEXT_LIMIT\r\n )\r\n progress_msg = await cfg.bot.send_message(\r\n chat_id=chat_id,\r\n text=initial_rendered,\r\n entities=initial_entities,\r\n reply_to_message_id=user_msg_id,\r\n disable_notification=cfg.progress_silent,\r\n )\r\n progress_id = int(progress_msg[\"message_id\"])\r\n last_edit_at = time.monotonic()\r\n logger.debug(\"[progress] sent chat_id=%s message_id=%s\", chat_id, progress_id)\r\n except Exception as e:\r\n logger.info(\r\n \"[handle] failed to send progress message chat_id=%s: %s\", chat_id, e\r\n )\r\n\r\n async def on_event(evt: dict[str, Any]) -> None:\r\n nonlocal last_edit_at, edit_task\r\n if progress_id is None:\r\n return\r\n if not progress_renderer.note_event(evt):\r\n return\r\n now = time.monotonic()\r\n if (now - last_edit_at) < cfg.progress_edit_every_s:\r\n return\r\n if edit_task is not None and not edit_task.done():\r\n return\r\n last_edit_at = now\r\n elapsed = now - started_at\r\n edit_task = asyncio.create_task(\r\n _edit_progress(progress_renderer.render_progress(elapsed))\r\n )\r\n\r\n async with semaphore:\r\n try:\r\n session_id, answer, saw_agent_message = await cfg.runner.run_serialized(\r\n text, resume_session, on_event=on_event\r\n )\r\n except Exception as e:\r\n if edit_task is not None:\r\n await asyncio.gather(edit_task, return_exceptions=True)\r\n\r\n err = _clamp_tg_text(f\"Error:\\n{e}\")\r\n if progress_id is not None and len(err) <= TELEGRAM_TEXT_LIMIT:\r\n try:\r\n await cfg.bot.edit_message_text(\r\n chat_id=chat_id, message_id=progress_id, text=err\r\n )\r\n return\r\n except Exception:\r\n pass\r\n await _send_markdown(\r\n cfg.bot,\r\n chat_id=chat_id,\r\n","exit_code":0,"status":"completed"}} -{"type":"item.started","item":{"id":"item_8","type":"command_execution","command":"/bin/zsh -lc \"cd /Users/banteg/dev/banteg/agents/takopi && sed -n '520,760p' src/takopi/exec_bridge.py\"","aggregated_output":"","exit_code":null,"status":"in_progress"}} -{"type":"item.completed","item":{"id":"item_8","type":"command_execution","command":"/bin/zsh -lc \"cd /Users/banteg/dev/banteg/agents/takopi && sed -n '520,760p' src/takopi/exec_bridge.py\"","aggregated_output":" chat_id=chat_id,\r\n text=err,\r\n reply_to_message_id=user_msg_id,\r\n disable_notification=cfg.progress_silent,\r\n )\r\n return\r\n\r\n if edit_task is not None:\r\n await asyncio.gather(edit_task, return_exceptions=True)\r\n\r\n answer = answer or \"(No agent_message captured from JSON stream.)\"\r\n elapsed = time.monotonic() - started_at\r\n status = \"done\" if saw_agent_message else \"error\"\r\n final_md = (\r\n progress_renderer.render_final(elapsed, answer, status=status)\r\n + f\"\\n\\nresume: `{session_id}`\"\r\n )\r\n final_rendered, final_entities = render_markdown(final_md)\r\n can_edit_final = progress_id is not None and len(final_rendered) <= TELEGRAM_TEXT_LIMIT\r\n\r\n if cfg.final_notify or not can_edit_final:\r\n await _send_markdown(\r\n cfg.bot,\r\n chat_id=chat_id,\r\n text=final_md,\r\n reply_to_message_id=user_msg_id,\r\n disable_notification=False,\r\n )\r\n if progress_id is not None:\r\n try:\r\n await cfg.bot.delete_message(chat_id=chat_id, message_id=progress_id)\r\n except Exception:\r\n pass\r\n else:\r\n await cfg.bot.edit_message_text(\r\n chat_id=chat_id,\r\n message_id=progress_id,\r\n text=final_rendered,\r\n entities=final_entities or None,\r\n )\r\n\r\n\r\nasync def _run_main_loop(cfg: BridgeConfig) -> None:\r\n semaphore = asyncio.Semaphore(cfg.max_concurrency)\r\n\r\n tasks: set[asyncio.Task[None]] = set()\r\n\r\n def _task_done(task: asyncio.Task[None]) -> None:\r\n tasks.discard(task)\r\n try:\r\n task.result()\r\n except asyncio.CancelledError:\r\n pass\r\n except Exception:\r\n logger.exception(\"[handle] task failed\")\r\n\r\n offset: int | None = None\r\n offset = await _drain_backlog(cfg, offset)\r\n await _send_startup(cfg)\r\n\r\n try:\r\n while True:\r\n try:\r\n updates = await cfg.bot.get_updates(\r\n offset=offset, timeout_s=50, allowed_updates=[\"message\"]\r\n )\r\n except Exception as e:\r\n logger.info(\"[loop] getUpdates failed: %s\", e)\r\n await asyncio.sleep(2)\r\n continue\r\n\r\n for upd in updates:\r\n offset = upd[\"update_id\"] + 1\r\n msg = upd.get(\"message\") or {}\r\n msg_chat_id = msg.get(\"chat\", {}).get(\"id\")\r\n if \"text\" not in msg:\r\n continue\r\n if int(msg_chat_id) != cfg.chat_id:\r\n continue\r\n if msg.get(\"from\", {}).get(\"is_bot\"):\r\n continue\r\n\r\n text = msg[\"text\"]\r\n user_msg_id = msg[\"message_id\"]\r\n resume_session = extract_session_id(text)\r\n r = msg.get(\"reply_to_message\") or {}\r\n resume_session = resume_session or extract_session_id(r.get(\"text\"))\r\n\r\n task = asyncio.create_task(\r\n _handle_message(\r\n cfg,\r\n semaphore=semaphore,\r\n chat_id=msg_chat_id,\r\n user_msg_id=user_msg_id,\r\n text=text,\r\n resume_session=resume_session,\r\n )\r\n )\r\n tasks.add(task)\r\n task.add_done_callback(_task_done)\r\n finally:\r\n for task in tasks:\r\n task.cancel()\r\n await asyncio.gather(*tasks, return_exceptions=True)\r\n await cfg.bot.close()\r\n\r\n\r\ndef run(\r\n progress_edit_every_s: float = typer.Option(\r\n 2.0,\r\n \"--progress-edit-every\",\r\n help=\"Minimum seconds between progress message edits.\",\r\n min=1.0,\r\n ),\r\n progress_silent: bool = typer.Option(\r\n True,\r\n \"--progress-silent/--no-progress-silent\",\r\n help=\"Send the progress message without sound/vibration.\",\r\n ),\r\n final_notify: bool = typer.Option(\r\n True,\r\n \"--final-notify/--no-final-notify\",\r\n help=\"Send the final response as a new message (not an edit).\",\r\n ),\r\n ignore_backlog: bool = typer.Option(\r\n True,\r\n \"--ignore-backlog/--process-backlog\",\r\n help=\"Skip pending Telegram updates that arrived before startup.\",\r\n ),\r\n log_file: str | None = typer.Option(\r\n \"exec_bridge.log\",\r\n \"--log-file\",\r\n help=\"Write detailed debug logs to this file (set to empty to disable).\",\r\n ),\r\n cd: str | None = typer.Option(\r\n None,\r\n \"--cd\",\r\n help=\"Pass through to `codex --cd` (defaults to `cd` in ~/.codex/telegram.toml).\",\r\n ),\r\n model: str | None = typer.Option(\r\n None,\r\n \"--model\",\r\n help=\"Codex model to pass to `codex exec`.\",\r\n ),\r\n) -> None:\r\n setup_logging(log_file if log_file else None)\r\n cfg = _parse_bridge_config(\r\n progress_edit_every_s=progress_edit_every_s,\r\n progress_silent=progress_silent,\r\n final_notify=final_notify,\r\n ignore_backlog=ignore_backlog,\r\n cd=cd,\r\n model=model,\r\n )\r\n asyncio.run(_run_main_loop(cfg))\r\n\r\n\r\ndef main() -> None:\r\n typer.run(run)\r\n\r\n\r\nif __name__ == \"__main__\":\r\n main()\r\n","exit_code":0,"status":"completed"}} -{"type":"item.started","item":{"id":"item_9","type":"command_execution","command":"/bin/zsh -lc \"cd /Users/banteg/dev/banteg/agents/takopi && sed -n '1,260p' src/takopi/telegram_client.py\"","aggregated_output":"","exit_code":null,"status":"in_progress"}} -{"type":"item.completed","item":{"id":"item_9","type":"command_execution","command":"/bin/zsh -lc \"cd /Users/banteg/dev/banteg/agents/takopi && sed -n '1,260p' src/takopi/telegram_client.py\"","aggregated_output":"from __future__ import annotations\r\n\r\nimport logging\r\nfrom typing import Any\r\n\r\nimport httpx\r\n\r\nlogger = logging.getLogger(__name__)\r\n\r\n\r\nclass TelegramClient:\r\n \"\"\"\r\n Minimal Telegram Bot API client.\r\n \"\"\"\r\n\r\n def __init__(self, token: str, timeout_s: float = 120) -> None:\r\n if not token:\r\n raise ValueError(\"Telegram token is empty\")\r\n self._base = f\"https://api.telegram.org/bot{token}\"\r\n self._client = httpx.AsyncClient(timeout=timeout_s)\r\n\r\n async def close(self) -> None:\r\n await self._client.aclose()\r\n\r\n async def _post(self, method: str, json_data: dict[str, Any]) -> Any:\r\n try:\r\n resp = await self._client.post(f\"{self._base}/{method}\", json=json_data)\r\n resp.raise_for_status()\r\n payload = resp.json()\r\n if not payload.get(\"ok\"):\r\n raise RuntimeError(f\"Telegram API error: {payload}\")\r\n return payload[\"result\"]\r\n except httpx.HTTPError as e:\r\n logger.error(\"Telegram network error: %s\", e)\r\n raise\r\n\r\n async def get_updates(\r\n self,\r\n offset: int | None,\r\n timeout_s: int = 50,\r\n allowed_updates: list[str] | None = None,\r\n ) -> list[dict]:\r\n params: dict[str, Any] = {\"timeout\": timeout_s}\r\n if offset is not None:\r\n params[\"offset\"] = offset\r\n if allowed_updates is not None:\r\n params[\"allowed_updates\"] = allowed_updates\r\n return await self._post(\"getUpdates\", params) # type: ignore[return-value]\r\n\r\n async def send_message(\r\n self,\r\n chat_id: int,\r\n text: str,\r\n reply_to_message_id: int | None = None,\r\n disable_notification: bool | None = False,\r\n entities: list[dict] | None = None,\r\n parse_mode: str | None = None,\r\n ) -> dict:\r\n params: dict[str, Any] = {\r\n \"chat_id\": chat_id,\r\n \"text\": text,\r\n }\r\n if disable_notification is not None:\r\n params[\"disable_notification\"] = disable_notification\r\n if reply_to_message_id is not None:\r\n params[\"reply_to_message_id\"] = reply_to_message_id\r\n if entities is not None:\r\n params[\"entities\"] = entities\r\n if parse_mode is not None:\r\n params[\"parse_mode\"] = parse_mode\r\n return await self._post(\"sendMessage\", params) # type: ignore[return-value]\r\n\r\n async def edit_message_text(\r\n self,\r\n chat_id: int,\r\n message_id: int,\r\n text: str,\r\n entities: list[dict] | None = None,\r\n parse_mode: str | None = None,\r\n ) -> dict:\r\n params: dict[str, Any] = {\r\n \"chat_id\": chat_id,\r\n \"message_id\": message_id,\r\n \"text\": text,\r\n }\r\n if entities is not None:\r\n params[\"entities\"] = entities\r\n if parse_mode is not None:\r\n params[\"parse_mode\"] = parse_mode\r\n return await self._post(\"editMessageText\", params) # type: ignore[return-value]\r\n\r\n async def delete_message(self, chat_id: int, message_id: int) -> bool:\r\n res = await self._post(\r\n \"deleteMessage\",\r\n {\r\n \"chat_id\": chat_id,\r\n \"message_id\": message_id,\r\n },\r\n )\r\n return bool(res)\r\n","exit_code":0,"status":"completed"}} -{"type":"item.started","item":{"id":"item_10","type":"command_execution","command":"/bin/zsh -lc \"cd /Users/banteg/dev/banteg/agents/takopi && sed -n '1,240p' src/takopi/exec_render.py\"","aggregated_output":"","exit_code":null,"status":"in_progress"}} -{"type":"item.completed","item":{"id":"item_10","type":"command_execution","command":"/bin/zsh -lc \"cd /Users/banteg/dev/banteg/agents/takopi && sed -n '1,240p' src/takopi/exec_render.py\"","aggregated_output":"from __future__ import annotations\r\n\r\nimport re\r\nimport textwrap\r\nfrom collections import deque\r\nfrom textwrap import indent\r\nfrom typing import Any\r\n\r\nSTATUS_RUNNING = \"▸\"\r\nSTATUS_DONE = \"✓\"\r\nHEADER_SEP = \" · \"\r\nHARD_BREAK = \" \\n\"\r\n\r\nMAX_CMD_LEN = 40\r\nMAX_QUERY_LEN = 60\r\nMAX_PATH_LEN = 40\r\nMAX_PROGRESS_CHARS = 300\r\n\r\n\r\ndef format_elapsed(elapsed_s: float) -> str:\r\n total = max(0, int(elapsed_s))\r\n minutes, seconds = divmod(total, 60)\r\n hours, minutes = divmod(minutes, 60)\r\n if hours:\r\n return f\"{hours}h {minutes:02d}m\"\r\n if minutes:\r\n return f\"{minutes}m {seconds:02d}s\"\r\n return f\"{seconds}s\"\r\n\r\n\r\ndef format_header(elapsed_s: float, turn: int | None, item: int | None, label: str) -> str:\r\n elapsed = format_elapsed(elapsed_s)\r\n parts = [label, elapsed]\r\n if turn is not None:\r\n parts.append(f\"turn {turn}\")\r\n if item is not None:\r\n parts.append(f\"item {item}\")\r\n return HEADER_SEP.join(parts)\r\n\r\n\r\ndef is_command_log_line(line: str) -> bool:\r\n return f\"{STATUS_RUNNING} running:\" in line or f\"{STATUS_DONE} ran:\" in line\r\n\r\n\r\ndef extract_numeric_id(item_id: object, fallback: int | None = None) -> int | None:\r\n if isinstance(item_id, int):\r\n return item_id\r\n if isinstance(item_id, str):\r\n match = re.search(r\"(?:item_)?(\\d+)\", item_id)\r\n if match:\r\n return int(match.group(1))\r\n return fallback\r\n\r\n\r\ndef _shorten(text: str, width: int) -> str:\r\n return textwrap.shorten(text, width=width, placeholder=\"…\")\r\n\r\n\r\ndef _shorten_path(path: str, width: int) -> str:\r\n # Encourage word-boundary truncation for paths (since they may have no spaces).\r\n return _shorten(path.replace(\"/\", \" /\"), width).replace(\" /\", \"/\")\r\n\r\n\r\ndef format_event(\r\n event: dict[str, Any],\r\n last_item: int | None,\r\n) -> tuple[int | None, list[str], str | None, str | None]:\r\n \"\"\"\r\n Returns (new_last_item, cli_lines, progress_line, progress_prefix).\r\n progress_prefix is only set when progress_line is set, and is used for\r\n replacing a preceding \"running\" line on completion.\r\n \"\"\"\r\n lines: list[str] = []\r\n\r\n match event[\"type\"]:\r\n case \"thread.started\":\r\n return last_item, [\"thread started\"], None, None\r\n case \"turn.started\":\r\n return last_item, [\"turn started\"], None, None\r\n case \"turn.completed\":\r\n return last_item, [\"turn completed\"], None, None\r\n case \"turn.failed\":\r\n return last_item, [f\"turn failed: {event['error']['message']}\"], None, None\r\n case \"error\":\r\n return last_item, [f\"stream error: {event['message']}\"], None, None\r\n case \"item.started\" | \"item.updated\" | \"item.completed\" as etype:\r\n item = event[\"item\"]\r\n item_num = extract_numeric_id(item[\"id\"], last_item)\r\n last_item = item_num if item_num is not None else last_item\r\n prefix = f\"[{item_num if item_num is not None else '?'}] \"\r\n\r\n match (item[\"type\"], etype):\r\n case (\"agent_message\", \"item.completed\"):\r\n lines.append(\"assistant:\")\r\n lines.extend(indent(item[\"text\"], \" \").splitlines())\r\n return last_item, lines, None, None\r\n case (\"reasoning\", \"item.completed\"):\r\n line = prefix + item[\"text\"]\r\n return last_item, [line], line, prefix\r\n case (\"command_execution\", \"item.started\"):\r\n command = f\"`{_shorten(item['command'], MAX_CMD_LEN)}`\"\r\n line = prefix + f\"{STATUS_RUNNING} running: {command}\"\r\n return last_item, [line], line, prefix\r\n case (\"command_execution\", \"item.completed\"):\r\n command = f\"`{_shorten(item['command'], MAX_CMD_LEN)}`\"\r\n exit_code = item[\"exit_code\"]\r\n exit_part = f\" (exit {exit_code})\" if exit_code is not None else \"\"\r\n line = prefix + f\"{STATUS_DONE} ran: {command}{exit_part}\"\r\n return last_item, [line], line, prefix\r\n case (\"mcp_tool_call\", \"item.started\"):\r\n name = \".\".join(part for part in (item[\"server\"], item[\"tool\"]) if part) or \"tool\"\r\n line = prefix + f\"{STATUS_RUNNING} tool: {name}\"\r\n return last_item, [line], line, prefix\r\n case (\"mcp_tool_call\", \"item.completed\"):\r\n name = \".\".join(part for part in (item[\"server\"], item[\"tool\"]) if part) or \"tool\"\r\n line = prefix + f\"{STATUS_DONE} tool: {name}\"\r\n return last_item, [line], line, prefix\r\n case (\"web_search\", \"item.completed\"):\r\n query = _shorten(item[\"query\"], MAX_QUERY_LEN)\r\n line = prefix + f\"{STATUS_DONE} searched: {query}\"\r\n return last_item, [line], line, prefix\r\n case (\"file_change\", \"item.completed\"):\r\n paths = [change[\"path\"] for change in item[\"changes\"] if change.get(\"path\")]\r\n if not paths:\r\n total = len(item[\"changes\"])\r\n desc = \"updated files\" if total == 0 else f\"updated {total} files\"\r\n elif len(paths) <= 3:\r\n desc = \"updated \" + \", \".join(f\"`{_shorten_path(p, MAX_PATH_LEN)}`\" for p in paths)\r\n else:\r\n desc = f\"updated {len(paths)} files\"\r\n line = prefix + f\"{STATUS_DONE} {desc}\"\r\n return last_item, [line], line, prefix\r\n case (\"error\", \"item.completed\"):\r\n warning = _shorten(item[\"message\"], 120)\r\n line = prefix + f\"{STATUS_DONE} warning: {warning}\"\r\n return last_item, [line], line, prefix\r\n case _:\r\n return last_item, [], None, None\r\n case _:\r\n return last_item, [], None, None\r\n\r\n\r\ndef render_event_cli(\r\n event: dict[str, Any], last_item: int | None = None\r\n) -> tuple[int | None, list[str]]:\r\n last_item, cli_lines, _, _ = format_event(event, last_item)\r\n return last_item, cli_lines\r\n\r\n\r\nclass ExecProgressRenderer:\r\n def __init__(self, max_actions: int = 5, max_chars: int = MAX_PROGRESS_CHARS) -> None:\r\n self.max_actions = max_actions\r\n self.max_chars = max_chars\r\n self.recent_actions: deque[str] = deque(maxlen=max_actions)\r\n self.turn_count: int | None = None\r\n self.last_item: int | None = None\r\n\r\n def note_event(self, event: dict[str, Any]) -> bool:\r\n if event[\"type\"] == \"thread.started\":\r\n return True\r\n if event[\"type\"] == \"turn.started\":\r\n self.turn_count = 1 if self.turn_count is None else self.turn_count + 1\r\n return True\r\n\r\n self.last_item, _, progress_line, progress_prefix = format_event(event, self.last_item)\r\n if progress_line is None:\r\n return False\r\n\r\n # Replace the preceding \"running\" line for the same item on completion.\r\n if event[\"type\"] == \"item.completed\" and progress_prefix and self.recent_actions:\r\n last = self.recent_actions[-1]\r\n if last.startswith(progress_prefix + f\"{STATUS_RUNNING} \"):\r\n self.recent_actions.pop()\r\n\r\n self.recent_actions.append(progress_line)\r\n return True\r\n\r\n def render_progress(self, elapsed_s: float) -> str:\r\n header = format_header(elapsed_s, self.turn_count, self.last_item, label=\"working\")\r\n message = self._assemble(header, list(self.recent_actions))\r\n return message if len(message) <= self.max_chars else header\r\n\r\n def render_final(self, elapsed_s: float, answer: str, status: str = \"done\") -> str:\r\n header = format_header(elapsed_s, self.turn_count, self.last_item, label=status)\r\n lines = list(self.recent_actions)\r\n if status == \"done\":\r\n lines = [line for line in lines if not is_command_log_line(line)]\r\n body = self._assemble(header, lines)\r\n answer = (answer or \"\").strip()\r\n return body + (\"\\n\\n\" + answer if answer else \"\")\r\n\r\n @staticmethod\r\n def _assemble(header: str, lines: list[str]) -> str:\r\n return header if not lines else header + \"\\n\\n\" + HARD_BREAK.join(lines)\r\n","exit_code":0,"status":"completed"}} -{"type":"item.started","item":{"id":"item_11","type":"command_execution","command":"/bin/zsh -lc \"cd /Users/banteg/dev/banteg/agents/takopi && sed -n '1,220p' src/takopi/rendering.py\"","aggregated_output":"","exit_code":null,"status":"in_progress"}} -{"type":"item.completed","item":{"id":"item_11","type":"command_execution","command":"/bin/zsh -lc \"cd /Users/banteg/dev/banteg/agents/takopi && sed -n '1,220p' src/takopi/rendering.py\"","aggregated_output":"from __future__ import annotations\r\n\r\nimport re\r\nfrom typing import Any\r\n\r\nfrom markdown_it import MarkdownIt\r\nfrom sulguk import transform_html\r\n\r\n_md = MarkdownIt(\"commonmark\", {\"html\": False})\r\n\r\n\r\ndef render_markdown(md: str) -> tuple[str, list[dict[str, Any]]]:\r\n html = _md.render(md or \"\")\r\n rendered = transform_html(html)\r\n\r\n text = re.sub(r\"(?m)^(\\s*)•\", r\"\\1-\", rendered.text)\r\n\r\n # FIX: Telegram requires MessageEntity.language (if present) to be a String.\r\n entities: list[dict[str, Any]] = []\r\n for e in rendered.entities:\r\n d = dict(e)\r\n if \"language\" in d and not isinstance(d[\"language\"], str):\r\n d.pop(\"language\", None)\r\n entities.append(d)\r\n return text, entities\r\n\r\n","exit_code":0,"status":"completed"}} -{"type":"item.started","item":{"id":"item_12","type":"command_execution","command":"/bin/zsh -lc \"cd /Users/banteg/dev/banteg/agents/takopi && sed -n '1,220p' src/takopi/config.py\"","aggregated_output":"","exit_code":null,"status":"in_progress"}} -{"type":"item.completed","item":{"id":"item_12","type":"command_execution","command":"/bin/zsh -lc \"cd /Users/banteg/dev/banteg/agents/takopi && sed -n '1,220p' src/takopi/config.py\"","aggregated_output":"import tomllib\r\nfrom pathlib import Path\r\n\r\nfrom .constants import TELEGRAM_CONFIG_PATH\r\n\r\n\r\ndef load_telegram_config(path=None):\r\n cfg_path = Path(path) if path else TELEGRAM_CONFIG_PATH\r\n return tomllib.loads(cfg_path.read_text(encoding=\"utf-8\"))\r\n","exit_code":0,"status":"completed"}} -{"type":"item.started","item":{"id":"item_13","type":"command_execution","command":"/bin/zsh -lc \"cd /Users/banteg/dev/banteg/agents/takopi && sed -n '1,200p' src/takopi/constants.py\"","aggregated_output":"","exit_code":null,"status":"in_progress"}} -{"type":"item.completed","item":{"id":"item_13","type":"command_execution","command":"/bin/zsh -lc \"cd /Users/banteg/dev/banteg/agents/takopi && sed -n '1,200p' src/takopi/constants.py\"","aggregated_output":"from __future__ import annotations\r\n\r\nfrom pathlib import Path\r\n\r\nTELEGRAM_HARD_LIMIT = 4096\r\nTELEGRAM_CONFIG_PATH = Path.home() / \".codex\" / \"telegram.toml\"\r\n","exit_code":0,"status":"completed"}} -{"type":"item.completed","item":{"id":"item_14","type":"reasoning","text":"**Explaining the bridge functionality**\n\nI understand the user's request to explore the codebase and summarize what the bridge does. It's a small Python package that operates a Telegram bot via long polling. It listens for messages in a specified chat and executes commands using Codex CLI. Each message is processed, and updates are sent to Telegram as JSONL events. When necessary, it resumes conversations based on session UUIDs, ensuring that responses are serialized to prevent conflicts."}} -{"type":"item.completed","item":{"id":"item_15","type":"reasoning","text":"**Summarizing bridge functionality**\n\nI need to include some key points about Markdown rendering, which uses markdown-it-py and sulguk to convert text to Telegram entities. Mentioning logs to exec_bridge.log is necessary. Since the user requested a brief overview, I’ll limit my points to around three to six bullet points. The entry point is the `takopi` script. I should clarify that the bridge operates in a non-interactive manner and utilizes a resume ID. Let's refer to the readme for accuracy."}} -{"type":"item.completed","item":{"id":"item_16","type":"reasoning","text":"**Examining session ID management**\n\nI should quickly check the tests for any additional behaviors, like mapping message IDs to session IDs. However, the current code extracts the session ID from the reply-to message content instead of storing a mapping. The readme mentions a mapping from (chat_id, bot_message_id) for routing replies, but the code doesn’t seem to store this; it simply extracts the session ID directly. I'll need to verify this in the tests to clarify the expected behavior."}} -{"type":"item.started","item":{"id":"item_17","type":"command_execution","command":"/bin/zsh -lc \"cd /Users/banteg/dev/banteg/agents/takopi && sed -n '1,240p' tests/test_exec_bridge.py\"","aggregated_output":"","exit_code":null,"status":"in_progress"}} -{"type":"item.completed","item":{"id":"item_17","type":"command_execution","command":"/bin/zsh -lc \"cd /Users/banteg/dev/banteg/agents/takopi && sed -n '1,240p' tests/test_exec_bridge.py\"","aggregated_output":"import asyncio\r\nimport os\r\n\r\nimport pytest\r\n\r\nfrom takopi.exec_bridge import extract_session_id, truncate_for_telegram\r\n\r\n\r\ndef test_extract_session_id_finds_uuid_v7() -> None:\r\n uuid = \"019b66fc-64c2-7a71-81cd-081c504cfeb2\"\r\n text = f\"resume session `{uuid}` please\"\r\n\r\n assert extract_session_id(text) == uuid\r\n\r\n\r\ndef test_truncate_for_telegram_preserves_resume_line() -> None:\r\n uuid = \"019b66fc-64c2-7a71-81cd-081c504cfeb2\"\r\n md = (\"x\" * 10_000) + f\"\\nresume: `{uuid}`\"\r\n\r\n out = truncate_for_telegram(md, 400)\r\n\r\n assert len(out) <= 400\r\n assert uuid in out\r\n assert out.rstrip().endswith(f\"resume: `{uuid}`\")\r\n\r\n\r\nclass _FakeBot:\r\n def __init__(self) -> None:\r\n self._next_id = 1\r\n self.send_calls: list[dict] = []\r\n self.edit_calls: list[dict] = []\r\n self.delete_calls: list[dict] = []\r\n\r\n async def send_message(\r\n self,\r\n chat_id: int,\r\n text: str,\r\n reply_to_message_id: int | None = None,\r\n disable_notification: bool | None = False,\r\n entities: list[dict] | None = None,\r\n parse_mode: str | None = None,\r\n ) -> dict:\r\n self.send_calls.append(\r\n {\r\n \"chat_id\": chat_id,\r\n \"text\": text,\r\n \"reply_to_message_id\": reply_to_message_id,\r\n \"disable_notification\": disable_notification,\r\n \"entities\": entities,\r\n \"parse_mode\": parse_mode,\r\n }\r\n )\r\n msg_id = self._next_id\r\n self._next_id += 1\r\n return {\"message_id\": msg_id}\r\n\r\n async def edit_message_text(\r\n self,\r\n chat_id: int,\r\n message_id: int,\r\n text: str,\r\n entities: list[dict] | None = None,\r\n parse_mode: str | None = None,\r\n ) -> dict:\r\n self.edit_calls.append(\r\n {\r\n \"chat_id\": chat_id,\r\n \"message_id\": message_id,\r\n \"text\": text,\r\n \"entities\": entities,\r\n \"parse_mode\": parse_mode,\r\n }\r\n )\r\n return {\"message_id\": message_id}\r\n\r\n async def delete_message(self, chat_id: int, message_id: int) -> bool:\r\n self.delete_calls.append({\"chat_id\": chat_id, \"message_id\": message_id})\r\n return True\r\n\r\n\r\nclass _FakeRunner:\r\n def __init__(self, *, answer: str, saw_agent_message: bool = True) -> None:\r\n self._answer = answer\r\n self._saw_agent_message = saw_agent_message\r\n\r\n async def run_serialized(self, *_args, **_kwargs) -> tuple[str, str, bool]:\r\n return (\"019b66fc-64c2-7a71-81cd-081c504cfeb2\", self._answer, self._saw_agent_message)\r\n\r\n\r\ndef test_final_notify_sends_loud_final_message() -> None:\r\n from takopi.exec_bridge import BridgeConfig, _handle_message\r\n\r\n bot = _FakeBot()\r\n runner = _FakeRunner(answer=\"ok\")\r\n cfg = BridgeConfig(\r\n bot=bot, # type: ignore[arg-type]\r\n runner=runner, # type: ignore[arg-type]\r\n chat_id=123,\r\n ignore_backlog=True,\r\n progress_edit_every_s=999.0,\r\n progress_silent=True,\r\n final_notify=True,\r\n startup_msg=\"\",\r\n max_concurrency=1,\r\n )\r\n\r\n asyncio.run(\r\n _handle_message(\r\n cfg,\r\n semaphore=asyncio.Semaphore(1),\r\n chat_id=123,\r\n user_msg_id=10,\r\n text=\"hi\",\r\n resume_session=None,\r\n )\r\n )\r\n\r\n assert len(bot.send_calls) == 2\r\n assert bot.send_calls[0][\"disable_notification\"] is True\r\n assert bot.send_calls[1][\"disable_notification\"] is False\r\n\r\n\r\ndef test_new_final_message_forces_notification_when_too_long_to_edit() -> None:\r\n from takopi.exec_bridge import BridgeConfig, _handle_message\r\n\r\n bot = _FakeBot()\r\n runner = _FakeRunner(answer=\"x\" * 10_000)\r\n cfg = BridgeConfig(\r\n bot=bot, # type: ignore[arg-type]\r\n runner=runner, # type: ignore[arg-type]\r\n chat_id=123,\r\n ignore_backlog=True,\r\n progress_edit_every_s=999.0,\r\n progress_silent=True,\r\n final_notify=False,\r\n startup_msg=\"\",\r\n max_concurrency=1,\r\n )\r\n\r\n asyncio.run(\r\n _handle_message(\r\n cfg,\r\n semaphore=asyncio.Semaphore(1),\r\n chat_id=123,\r\n user_msg_id=10,\r\n text=\"hi\",\r\n resume_session=None,\r\n )\r\n )\r\n\r\n assert len(bot.send_calls) == 2\r\n assert bot.send_calls[0][\"disable_notification\"] is True\r\n assert bot.send_calls[1][\"disable_notification\"] is False\r\n\r\n\r\ndef test_codex_runner_cancellation_terminates_subprocess(tmp_path, monkeypatch) -> None:\r\n from takopi.exec_bridge import CodexExecRunner\r\n\r\n pid_file = tmp_path / \"pid\"\r\n codex_path = tmp_path / \"codex\"\r\n codex_path.write_text(\r\n \"#!/usr/bin/env python3\\n\"\r\n \"import os\\n\"\r\n \"import time\\n\"\r\n \"\\n\"\r\n \"pid_file = os.environ.get('CODEX_FAKE_PID_FILE')\\n\"\r\n \"if pid_file:\\n\"\r\n \" with open(pid_file, 'w', encoding='utf-8') as f:\\n\"\r\n \" f.write(str(os.getpid()))\\n\"\r\n \" f.flush()\\n\"\r\n \"\\n\"\r\n \"time.sleep(60)\\n\",\r\n encoding=\"utf-8\",\r\n )\r\n codex_path.chmod(0o755)\r\n monkeypatch.setenv(\"CODEX_FAKE_PID_FILE\", str(pid_file))\r\n\r\n runner = CodexExecRunner(codex_cmd=str(codex_path), workspace=None, extra_args=[])\r\n\r\n async def run_and_cancel() -> None:\r\n task = asyncio.create_task(runner.run(\"hello\", session_id=None))\r\n\r\n for _ in range(100):\r\n if pid_file.exists():\r\n break\r\n await asyncio.sleep(0.01)\r\n assert pid_file.exists()\r\n\r\n pid = int(pid_file.read_text(encoding=\"utf-8\").strip())\r\n\r\n task.cancel()\r\n with pytest.raises(asyncio.CancelledError):\r\n await task\r\n\r\n for _ in range(200):\r\n try:\r\n os.kill(pid, 0)\r\n except ProcessLookupError:\r\n return\r\n await asyncio.sleep(0.01)\r\n\r\n raise AssertionError(\"cancelled codex subprocess is still running\")\r\n\r\n asyncio.run(run_and_cancel())\r\n","exit_code":0,"status":"completed"}} -{"type":"item.completed","item":{"id":"item_18","type":"agent_message","text":"- This is a small Python CLI (`takopi`) that runs a Telegram bot (Telegram Bot API via long‑polling `getUpdates`), scoped to a single allowed `chat_id` from `~/.codex/telegram.toml`.\n- Every incoming text message is treated as a prompt: it spawns `codex exec --json … -` and pipes the message on stdin (optionally with `--cd` and extra `codex_exec_args`).\n- While Codex runs, it sends a “progress” message (replying to the user) and periodically edits it using Codex’s streamed JSONL events; then it posts the final answer.\n- It appends `resume: \\`\\`` to the final response; if you include (or reply to) a UUID, it runs `codex exec --json resume -` to continue that same Codex thread, with per‑session locking to avoid concurrent resumes.\n- It renders Markdown into Telegram message entities (`markdown-it-py` + `sulguk`) and enforces Telegram length limits, truncating while trying to preserve the trailing `resume:` line."}} -{"type":"turn.completed","usage":{"input_tokens":76296,"cached_input_tokens":57728,"output_tokens":2682}} diff --git a/tests/fixtures/codex_exec_json_all_formats.jsonl b/tests/fixtures/codex_exec_json_all_formats.jsonl index 64bad05..e1be2c0 100644 --- a/tests/fixtures/codex_exec_json_all_formats.jsonl +++ b/tests/fixtures/codex_exec_json_all_formats.jsonl @@ -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} \ No newline at end of file +{"type":"turn.failed","error":{"message":"Aborted: required dependency `npm` is missing; cannot continue."}} +{"type":"error","message":"codex exec exited non-zero after turn.failed"} diff --git a/tests/fixtures/pi_print_mode_events.jsonl b/tests/fixtures/pi_print_mode_events.jsonl new file mode 100644 index 0000000..500246b --- /dev/null +++ b/tests/fixtures/pi_print_mode_events.jsonl @@ -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 \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 \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 \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 \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 ` 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 ` for output format and `pi --print` for non-interactive runs.","partial":{"role":"assistant","content":[{"type":"text","text":"Main CLI commands include `pi --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 ` 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 ` 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 \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 ` 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} diff --git a/tests/test_claude_runner.py b/tests/test_claude_runner.py index ee50504..72d80f1 100644 --- a/tests/test_claude_runner.py +++ b/tests/test_claude_runner.py @@ -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", ) diff --git a/tests/test_claude_schema.py b/tests/test_claude_schema.py new file mode 100644 index 0000000..23012e6 --- /dev/null +++ b/tests/test_claude_schema.py @@ -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]) diff --git a/tests/test_codex_schema.py b/tests/test_codex_schema.py new file mode 100644 index 0000000..946e8d8 --- /dev/null +++ b/tests/test_codex_schema.py @@ -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]) diff --git a/tests/test_codex_tool_result_summary.py b/tests/test_codex_tool_result_summary.py index 23d3abd..8fc3cb3 100644 --- a/tests/test_codex_tool_result_summary.py +++ b/tests/test_codex_tool_result_summary.py @@ -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 diff --git a/tests/test_exec_runner.py b/tests/test_exec_runner.py index f26851a..a229e3e 100644 --- a/tests/test_exec_runner.py +++ b/tests/test_exec_runner.py @@ -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 diff --git a/tests/test_opencode_runner.py b/tests/test_opencode_runner.py index 7cdbdd4..de018fa 100644 --- a/tests/test_opencode_runner.py +++ b/tests/test_opencode_runner.py @@ -11,11 +11,26 @@ from takopi.runners.opencode import ( ENGINE, translate_opencode_event, ) +from takopi.schemas import opencode as opencode_schema -def _load_fixture(name: str) -> list[dict]: +def _load_fixture(name: str) -> list[opencode_schema.OpenCodeEvent]: path = Path(__file__).parent / "fixtures" / name - return [json.loads(line) for line in path.read_text().splitlines() if line.strip()] + events: list[opencode_schema.OpenCodeEvent] = [] + for line in path.read_bytes().splitlines(): + if not line.strip(): + continue + try: + events.append(opencode_schema.decode_event(line)) + except Exception as exc: + raise AssertionError( + f"{name} contained unparseable line: {line!r}" + ) from exc + return events + + +def _decode_event(payload: dict) -> opencode_schema.OpenCodeEvent: + return opencode_schema.decode_event(json.dumps(payload).encode("utf-8")) def test_opencode_resume_format_and_extract() -> None: @@ -59,9 +74,6 @@ def test_translate_success_fixture() -> None: assert completed.resume == started.resume assert completed.answer == "```\nhello\n```" - assert completed.usage is not None - assert "tokens" in completed.usage - def test_translate_missing_reason_success() -> None: state = OpenCodeStreamState() @@ -74,7 +86,6 @@ def test_translate_missing_reason_success() -> None: fallback = runner.stream_end_events( resume=None, found_session=started.resume, - stderr_tail="", state=state, ) @@ -82,14 +93,13 @@ def test_translate_missing_reason_success() -> None: assert completed.ok is True assert completed.resume == started.resume assert completed.answer == "All done." - assert completed.usage is not None def test_translate_accumulates_text() -> None: state = OpenCodeStreamState() events = translate_opencode_event( - {"type": "step_start", "sessionID": "ses_test123", "part": {}}, + _decode_event({"type": "step_start", "sessionID": "ses_test123", "part": {}}), title="opencode", state=state, ) @@ -97,20 +107,24 @@ def test_translate_accumulates_text() -> None: assert isinstance(events[0], StartedEvent) translate_opencode_event( - { - "type": "text", - "sessionID": "ses_test123", - "part": {"type": "text", "text": "Hello "}, - }, + _decode_event( + { + "type": "text", + "sessionID": "ses_test123", + "part": {"type": "text", "text": "Hello "}, + } + ), title="opencode", state=state, ) translate_opencode_event( - { - "type": "text", - "sessionID": "ses_test123", - "part": {"type": "text", "text": "World"}, - }, + _decode_event( + { + "type": "text", + "sessionID": "ses_test123", + "part": {"type": "text", "text": "World"}, + } + ), title="opencode", state=state, ) @@ -118,11 +132,13 @@ def test_translate_accumulates_text() -> None: assert state.last_text == "Hello World" events = translate_opencode_event( - { - "type": "step_finish", - "sessionID": "ses_test123", - "part": {"reason": "stop", "tokens": {"input": 100, "output": 10}}, - }, + _decode_event( + { + "type": "step_finish", + "sessionID": "ses_test123", + "part": {"reason": "stop", "tokens": {"input": 100, "output": 10}}, + } + ), title="opencode", state=state, ) @@ -140,22 +156,24 @@ def test_translate_tool_use_completed() -> None: state.emitted_started = True events = translate_opencode_event( - { - "type": "tool_use", - "sessionID": "ses_test123", - "part": { - "id": "prt_123", - "callID": "call_abc", - "tool": "bash", - "state": { - "status": "completed", - "input": {"command": "ls -la"}, - "output": "file1.txt\nfile2.txt", - "title": "List files", - "metadata": {"exit": 0}, + _decode_event( + { + "type": "tool_use", + "sessionID": "ses_test123", + "part": { + "id": "prt_123", + "callID": "call_abc", + "tool": "bash", + "state": { + "status": "completed", + "input": {"command": "ls -la"}, + "output": "file1.txt\nfile2.txt", + "title": "List files", + "metadata": {"exit": 0}, + }, }, - }, - }, + } + ), title="opencode", state=state, ) @@ -175,22 +193,24 @@ def test_translate_tool_use_with_error() -> None: state.emitted_started = True events = translate_opencode_event( - { - "type": "tool_use", - "sessionID": "ses_test123", - "part": { - "id": "prt_123", - "callID": "call_abc", - "tool": "bash", - "state": { - "status": "completed", - "input": {"command": "exit 1"}, - "output": "error", - "title": "Run failing command", - "metadata": {"exit": 1}, + _decode_event( + { + "type": "tool_use", + "sessionID": "ses_test123", + "part": { + "id": "prt_123", + "callID": "call_abc", + "tool": "bash", + "state": { + "status": "completed", + "input": {"command": "exit 1"}, + "output": "error", + "title": "Run failing command", + "metadata": {"exit": 1}, + }, }, - }, - }, + } + ), title="opencode", state=state, ) @@ -209,21 +229,23 @@ def test_translate_tool_use_read_title_wraps_path() -> None: path = Path.cwd() / "src" / "takopi" / "runners" / "opencode.py" events = translate_opencode_event( - { - "type": "tool_use", - "sessionID": "ses_test123", - "part": { - "id": "prt_123", - "callID": "call_abc", - "tool": "read", - "state": { - "status": "completed", - "input": {"filePath": str(path)}, - "output": "file contents", - "title": "src/takopi/runners/opencode.py", + _decode_event( + { + "type": "tool_use", + "sessionID": "ses_test123", + "part": { + "id": "prt_123", + "callID": "call_abc", + "tool": "read", + "state": { + "status": "completed", + "input": {"filePath": str(path)}, + "output": "file contents", + "title": "src/takopi/runners/opencode.py", + }, }, - }, - }, + } + ), title="opencode", state=state, ) @@ -255,11 +277,16 @@ def test_step_finish_tool_calls_does_not_complete() -> None: state.emitted_started = True events = translate_opencode_event( - { - "type": "step_finish", - "sessionID": "ses_test123", - "part": {"reason": "tool-calls", "tokens": {"input": 100, "output": 10}}, - }, + _decode_event( + { + "type": "step_finish", + "sessionID": "ses_test123", + "part": { + "reason": "tool-calls", + "tokens": {"input": 100, "output": 10}, + }, + } + ), title="opencode", state=state, ) diff --git a/tests/test_opencode_schema.py b/tests/test_opencode_schema.py new file mode 100644 index 0000000..7143404 --- /dev/null +++ b/tests/test_opencode_schema.py @@ -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]) diff --git a/tests/test_pi_runner.py b/tests/test_pi_runner.py index 9280934..392ada1 100644 --- a/tests/test_pi_runner.py +++ b/tests/test_pi_runner.py @@ -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: diff --git a/tests/test_pi_schema.py b/tests/test_pi_schema.py new file mode 100644 index 0000000..9bee5ac --- /dev/null +++ b/tests/test_pi_schema.py @@ -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]) diff --git a/uv.lock b/uv.lock index a7769e3..6487c72 100644 --- a/uv.lock +++ b/uv.lock @@ -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" },