tandem-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,338 @@
1
+ """Claude Code adapter.
2
+
3
+ Session format observed on claude 2.1.220 (docs/formats.md):
4
+ - transcript: ~/.claude/projects/<munged-cwd>/<sessionId>.jsonl
5
+ - entries chained by uuid/parentUuid; types: user, assistant, attachment,
6
+ queue-operation, last-prompt, summary, system
7
+ - assistant API messages are split one content block per JSONL line, sharing
8
+ message.id
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import uuid as uuidlib
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from .. import compat, paths
19
+ from ..events import (
20
+ AssistantMessage,
21
+ NormalizedEvent,
22
+ SessionContext,
23
+ SystemEvent,
24
+ Thinking,
25
+ ToolCall,
26
+ ToolResult,
27
+ UserMessage,
28
+ )
29
+ from ..util import append_jsonl_fsync, git_branch, iso_now_ms
30
+ from .base import HarnessAdapter
31
+
32
+
33
+ def _stringify_block_content(content: Any) -> str:
34
+ """tool_result content is either a string or a list of content blocks."""
35
+ if isinstance(content, str):
36
+ return content
37
+ if isinstance(content, list):
38
+ parts = []
39
+ for c in content:
40
+ if isinstance(c, dict) and c.get("type") == "text":
41
+ parts.append(c.get("text", ""))
42
+ elif isinstance(c, dict):
43
+ parts.append(f"<{c.get('type', 'block')}>")
44
+ return "\n".join(parts)
45
+ return "" if content is None else str(content)
46
+
47
+
48
+ class ClaudeCodeAdapter(HarnessAdapter):
49
+ id = "claude"
50
+ display_name = "Claude Code"
51
+ binary = "claude"
52
+
53
+ # -- environment ---------------------------------------------------------
54
+
55
+ def detect_version(self) -> str | None:
56
+ return compat.detect_cli_version(self.binary)
57
+
58
+ def version_supported(self, version_text: str) -> bool:
59
+ return compat.version_supported("claude", version_text)
60
+
61
+ # -- session files -------------------------------------------------------
62
+
63
+ def transcript_path(self, cwd: str, session_id: str) -> Path | None:
64
+ p = paths.claude_transcript_path(cwd, session_id)
65
+ return p if p.exists() else None
66
+
67
+ def expected_transcript_path(self, cwd: str, session_id: str) -> Path:
68
+ return paths.claude_transcript_path(cwd, session_id)
69
+
70
+ def mint_session_id(self) -> str:
71
+ return str(uuidlib.uuid4())
72
+
73
+ def _base_entry(self, ctx: SessionContext, entry_type: str) -> dict[str, Any]:
74
+ version = "0.0.0"
75
+ raw = self.detect_version()
76
+ if raw:
77
+ parsed = compat.parse_version(raw)
78
+ if parsed:
79
+ version = ".".join(str(x) for x in parsed)
80
+ return {
81
+ "parentUuid": ctx.claude_leaf_uuid,
82
+ "isSidechain": False,
83
+ "userType": "external",
84
+ "cwd": ctx.cwd,
85
+ "sessionId": ctx.claude_session_id,
86
+ "version": version,
87
+ "gitBranch": git_branch(ctx.cwd),
88
+ "type": entry_type,
89
+ "uuid": str(uuidlib.uuid4()),
90
+ "timestamp": iso_now_ms(),
91
+ }
92
+
93
+ def create_shadow_transcript(
94
+ self, cwd: str, session_id: str, ctx: SessionContext, note: str
95
+ ) -> Path:
96
+ path = paths.claude_transcript_path(cwd, session_id)
97
+ entry = self._base_entry(ctx, "user")
98
+ entry["message"] = {"role": "user", "content": note}
99
+ append_jsonl_fsync(path, [entry])
100
+ ctx.claude_leaf_uuid = entry["uuid"]
101
+ return path
102
+
103
+ # -- launching -----------------------------------------------------------
104
+
105
+ def interactive_argv(self, session_id: str | None, fresh: bool) -> list[str]:
106
+ assert session_id, "claude sessions are always minted up front"
107
+ if fresh:
108
+ return [self.binary, "--session-id", session_id]
109
+ return [self.binary, "--resume", session_id]
110
+
111
+ def oneoff_argv(self, session_id: str, prompt: str) -> list[str]:
112
+ return [self.binary, "--resume", session_id, "-p", prompt]
113
+
114
+ def hook_argv_extra(self, sentinel: Path) -> list[str]:
115
+ # Per-invocation settings JSON; Stop fires at each completed turn.
116
+ settings = {
117
+ "hooks": {
118
+ "Stop": [
119
+ {
120
+ "hooks": [
121
+ {"type": "command", "command": f"touch '{sentinel}'"}
122
+ ]
123
+ }
124
+ ]
125
+ }
126
+ }
127
+ return ["--settings", json.dumps(settings)]
128
+
129
+ # -- parsing -------------------------------------------------------------
130
+
131
+ def parse_entry(self, raw: dict[str, Any], ctx: SessionContext) -> list[NormalizedEvent]:
132
+ """One transcript line -> normalized events. Never raises on shape
133
+ surprises; anything unrecognized degrades to a SystemEvent."""
134
+ ts = raw.get("timestamp")
135
+ etype = raw.get("type")
136
+
137
+ def sysev(subtype: str, text: str = "") -> SystemEvent:
138
+ return SystemEvent(
139
+ source="claude", timestamp=ts, turn_index=ctx.turn_index,
140
+ subtype=subtype, text=text,
141
+ )
142
+
143
+ if raw.get("isSidechain"):
144
+ return [sysev("sidechain")]
145
+
146
+ if etype == "user":
147
+ if raw.get("isMeta"):
148
+ return [sysev("meta_user")]
149
+ content = (raw.get("message") or {}).get("content")
150
+ if isinstance(content, str):
151
+ ctx.turn_index += 1
152
+ return [
153
+ UserMessage(
154
+ source="user", timestamp=ts,
155
+ turn_index=ctx.turn_index, text=content,
156
+ )
157
+ ]
158
+ events: list[NormalizedEvent] = []
159
+ structured = raw.get("toolUseResult")
160
+ for block in content or []:
161
+ btype = block.get("type")
162
+ if btype == "tool_result":
163
+ events.append(
164
+ ToolResult(
165
+ source="claude", timestamp=ts,
166
+ turn_index=ctx.turn_index,
167
+ call_id=block.get("tool_use_id"),
168
+ output=_stringify_block_content(block.get("content")),
169
+ is_error=bool(block.get("is_error")),
170
+ structured=structured if isinstance(structured, dict) else None,
171
+ )
172
+ )
173
+ elif btype == "text":
174
+ ctx.turn_index += 1
175
+ events.append(
176
+ UserMessage(
177
+ source="user", timestamp=ts,
178
+ turn_index=ctx.turn_index,
179
+ text=block.get("text", ""),
180
+ )
181
+ )
182
+ else:
183
+ events.append(sysev(f"user_block:{btype}"))
184
+ return events or [sysev("user_empty")]
185
+
186
+ if etype == "assistant":
187
+ msg = raw.get("message") or {}
188
+ events = []
189
+ for block in msg.get("content") or []:
190
+ btype = block.get("type")
191
+ if btype == "text":
192
+ events.append(
193
+ AssistantMessage(
194
+ source="claude", timestamp=ts,
195
+ turn_index=ctx.turn_index,
196
+ text=block.get("text", ""),
197
+ model=msg.get("model"),
198
+ )
199
+ )
200
+ elif btype == "tool_use":
201
+ events.append(
202
+ ToolCall(
203
+ source="claude", timestamp=ts,
204
+ turn_index=ctx.turn_index,
205
+ call_id=block.get("id", ""),
206
+ tool=block.get("name", ""),
207
+ arguments=block.get("input") or {},
208
+ )
209
+ )
210
+ elif btype in ("thinking", "redacted_thinking"):
211
+ events.append(
212
+ Thinking(source="claude", timestamp=ts, turn_index=ctx.turn_index)
213
+ )
214
+ else:
215
+ events.append(sysev(f"assistant_block:{btype}"))
216
+ return events or [sysev("assistant_empty")]
217
+
218
+ if etype == "system" and raw.get("subtype") == "compact_boundary":
219
+ return [sysev("compaction", "conversation was compacted")]
220
+
221
+ # attachment, queue-operation, last-prompt, summary, system, unknown
222
+ return [sysev(f"claude:{etype}")]
223
+
224
+ # -- rendering (shadow append) -------------------------------------------
225
+
226
+ def render_events(
227
+ self, events: list[NormalizedEvent], ctx: SessionContext
228
+ ) -> list[dict[str, Any]]:
229
+ """Normalized events -> native transcript entries, chained onto the
230
+ current leaf. Messages and tool activity both render natively;
231
+ consecutive assistant entries share one message.id."""
232
+ out: list[dict[str, Any]] = []
233
+ for ev in events:
234
+ if ev.kind in ("user_message", "tool_result"):
235
+ ctx.claude_run_msg_id = None
236
+ if ev.kind == "user_message":
237
+ entry = self._base_entry(ctx, "user")
238
+ entry["message"] = {"role": "user", "content": ev.text}
239
+ elif ev.kind == "assistant_message":
240
+ entry = self._base_entry(ctx, "assistant")
241
+ entry["message"] = self._assistant_message(
242
+ ctx, entry, [{"type": "text", "text": ev.text}], "end_turn"
243
+ )
244
+ elif ev.kind == "tool_call":
245
+ entry = self._base_entry(ctx, "assistant")
246
+ inp = ev.arguments if isinstance(ev.arguments, dict) else {"input": ev.arguments}
247
+ entry["message"] = self._assistant_message(
248
+ ctx, entry,
249
+ [{"type": "tool_use", "id": ev.call_id, "name": ev.tool,
250
+ "input": inp}],
251
+ "tool_use",
252
+ )
253
+ elif ev.kind == "tool_result":
254
+ entry = self._base_entry(ctx, "user")
255
+ entry["message"] = {
256
+ "role": "user",
257
+ "content": [{"type": "tool_result", "tool_use_id": ev.call_id,
258
+ "content": ev.output, "is_error": ev.is_error}],
259
+ }
260
+ entry["toolUseResult"] = ev.structured or {"stdout": ev.output}
261
+ else:
262
+ continue
263
+ if ev.timestamp:
264
+ entry["timestamp"] = ev.timestamp
265
+ ctx.claude_leaf_uuid = entry["uuid"]
266
+ out.append(entry)
267
+ return out
268
+
269
+ def _assistant_message(
270
+ self, ctx: SessionContext, entry: dict[str, Any],
271
+ content: list[dict[str, Any]], stop_reason: str,
272
+ ) -> dict[str, Any]:
273
+ if ctx.claude_run_msg_id is None:
274
+ ctx.claude_run_msg_id = f"msg_tandem_{entry['uuid'][:8]}"
275
+ return {
276
+ "role": "assistant",
277
+ "model": ctx.claude_model or "<synced>",
278
+ "id": ctx.claude_run_msg_id,
279
+ "type": "message",
280
+ "content": content,
281
+ "stop_reason": stop_reason,
282
+ "stop_sequence": None,
283
+ }
284
+
285
+ def render_placeholder(self, text: str, ctx: SessionContext) -> list[dict[str, Any]]:
286
+ # a rendered user-side entry, so it ends the assistant run like any
287
+ # other: no message.id may straddle it
288
+ ctx.claude_run_msg_id = None
289
+ entry = self._base_entry(ctx, "user")
290
+ entry["message"] = {"role": "user", "content": text}
291
+ ctx.claude_leaf_uuid = entry["uuid"]
292
+ return [entry]
293
+
294
+ def _tail_objects(self, transcript: Path) -> list[dict[str, Any]]:
295
+ try:
296
+ with open(transcript, "rb") as f:
297
+ f.seek(0, 2)
298
+ size = f.tell()
299
+ f.seek(max(0, size - 262144))
300
+ tail = f.read().decode("utf-8", errors="replace")
301
+ except OSError:
302
+ return []
303
+ out = []
304
+ for line in tail.splitlines():
305
+ line = line.strip()
306
+ if not line:
307
+ continue
308
+ try:
309
+ obj = json.loads(line)
310
+ except json.JSONDecodeError:
311
+ continue
312
+ if isinstance(obj, dict):
313
+ out.append(obj)
314
+ return out
315
+
316
+ def derive_leaf_uuid(self, transcript: Path) -> str | None:
317
+ """Last chainable uuid in an existing transcript (the harness itself
318
+ may have appended since tandem last wrote)."""
319
+ leaf = None
320
+ for obj in self._tail_objects(transcript):
321
+ if obj.get("uuid") and obj.get("type") in (
322
+ "user", "assistant", "attachment", "system",
323
+ ):
324
+ leaf = obj["uuid"]
325
+ return leaf
326
+
327
+ def derive_last_model(self, transcript: Path) -> str | None:
328
+ """Model of the last main-thread assistant entry with a real model
329
+ name. Skips the "<synced>" placeholder (pre-fix tandem appends) and
330
+ sidechain entries (subagents may run on a different model)."""
331
+ model = None
332
+ for obj in self._tail_objects(transcript):
333
+ if obj.get("type") != "assistant" or obj.get("isSidechain"):
334
+ continue
335
+ m = (obj.get("message") or {}).get("model")
336
+ if m and m != "<synced>":
337
+ model = m
338
+ return model
@@ -0,0 +1,340 @@
1
+ """Codex CLI adapter.
2
+
3
+ Session format observed on codex-cli 0.145.0 (docs/formats.md):
4
+ - rollout: ~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<uuidv7>.jsonl
5
+ - each line {timestamp, type, payload}; model-facing history is the
6
+ response_item lines, UI history is the event_msg lines
7
+ - first line is session_meta; ~/.codex/session_index.jsonl indexes threads
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from .. import compat, paths
18
+ from ..events import (
19
+ AssistantMessage,
20
+ NormalizedEvent,
21
+ SessionContext,
22
+ SystemEvent,
23
+ Thinking,
24
+ ToolCall,
25
+ ToolResult,
26
+ UserMessage,
27
+ )
28
+ from ..util import append_jsonl_fsync, iso_now_ms, uuid7
29
+ from .base import HarnessAdapter
30
+
31
+
32
+ class CodexAdapter(HarnessAdapter):
33
+ id = "codex"
34
+ display_name = "Codex CLI"
35
+ binary = "codex"
36
+
37
+ # -- environment ---------------------------------------------------------
38
+
39
+ def detect_version(self) -> str | None:
40
+ return compat.detect_cli_version(self.binary)
41
+
42
+ def version_supported(self, version_text: str) -> bool:
43
+ return compat.version_supported("codex", version_text)
44
+
45
+ # -- session files -------------------------------------------------------
46
+
47
+ def transcript_path(self, cwd: str, session_id: str) -> Path | None:
48
+ return paths.find_codex_rollout(session_id)
49
+
50
+ def mint_session_id(self) -> str:
51
+ return uuid7()
52
+
53
+ def create_shadow_transcript(
54
+ self, cwd: str, session_id: str, ctx: SessionContext, note: str
55
+ ) -> Path:
56
+ """Create a rollout file shaped like codex's own, headed by a
57
+ session_meta whose originator marks it as tandem-created."""
58
+ now = datetime.now(timezone.utc)
59
+ day_dir = paths.codex_sessions_dir() / now.strftime("%Y/%m/%d")
60
+ fname = f"rollout-{now.strftime('%Y-%m-%dT%H-%M-%S')}-{session_id}.jsonl"
61
+ path = day_dir / fname
62
+ version = "0.0.0"
63
+ raw = self.detect_version()
64
+ if raw:
65
+ parsed = compat.parse_version(raw)
66
+ if parsed:
67
+ version = ".".join(str(x) for x in parsed)
68
+ ts = iso_now_ms()
69
+ meta = {
70
+ "timestamp": ts,
71
+ "type": "session_meta",
72
+ "payload": {
73
+ "session_id": session_id,
74
+ "id": session_id,
75
+ "timestamp": ts,
76
+ "cwd": cwd,
77
+ "originator": "tandem",
78
+ "cli_version": version,
79
+ "source": "exec",
80
+ "thread_source": "user",
81
+ # codex >= 0.145 interactive thread/resume rejects rollouts
82
+ # without a provider id; "openai" is codex's built-in default
83
+ "model_provider": "openai",
84
+ "history_mode": "legacy",
85
+ },
86
+ }
87
+ entries = [meta] + self.render_note(note)
88
+ append_jsonl_fsync(path, entries)
89
+ return path
90
+
91
+ def render_note(self, note: str) -> list[dict[str, Any]]:
92
+ """A tandem-authored informational line, as both the model-facing
93
+ response_item and the UI-facing event_msg."""
94
+ ts = iso_now_ms()
95
+ return [
96
+ {
97
+ "timestamp": ts,
98
+ "type": "response_item",
99
+ "payload": {
100
+ "type": "message",
101
+ "role": "user",
102
+ "content": [{"type": "input_text", "text": note}],
103
+ },
104
+ },
105
+ {
106
+ "timestamp": ts,
107
+ "type": "event_msg",
108
+ "payload": {
109
+ "type": "user_message",
110
+ "message": note,
111
+ "images": [],
112
+ "local_images": [],
113
+ "audio": [],
114
+ "local_audio": [],
115
+ "text_elements": [],
116
+ },
117
+ },
118
+ ]
119
+
120
+ # -- launching -----------------------------------------------------------
121
+
122
+ def interactive_argv(self, session_id: str | None, fresh: bool) -> list[str]:
123
+ if fresh and session_id is None:
124
+ # codex mints its own id; tandem captures the new rollout file.
125
+ return [self.binary]
126
+ assert session_id
127
+ return [self.binary, "resume", session_id]
128
+
129
+ def oneoff_argv(self, session_id: str, prompt: str) -> list[str]:
130
+ return [self.binary, "exec", "--skip-git-repo-check", "resume", session_id, prompt]
131
+
132
+ def hook_argv_extra(self, sentinel: Path) -> list[str]:
133
+ # A -c notify=... override would silently replace a notify handler
134
+ # the user already configured (observed in the wild: Codex Computer
135
+ # Use registers one). In that case rely on fs-watching alone.
136
+ try:
137
+ import tomllib
138
+
139
+ with open(paths.codex_home() / "config.toml", "rb") as f:
140
+ if tomllib.load(f).get("notify"):
141
+ return []
142
+ except (OSError, ValueError):
143
+ pass
144
+ # notify appends a JSON payload argument; route through sh -c so it
145
+ # lands in $0 and is ignored.
146
+ return ["-c", f'notify=["/bin/sh","-c","touch \'{sentinel}\'"]']
147
+
148
+ # -- parsing -------------------------------------------------------------
149
+
150
+ def parse_entry(self, raw: dict[str, Any], ctx: SessionContext) -> list[NormalizedEvent]:
151
+ """One rollout line -> normalized events.
152
+
153
+ Sources of truth per event class (to avoid the duplication built into
154
+ the rollout format):
155
+ - user prompts: event_msg/user_message (response_item role=user
156
+ also carries injected env context, so it's skipped)
157
+ - assistant text: response_item/message role=assistant
158
+ - tool activity: response_item function_call / custom_tool_call and
159
+ their *_output lines; event_msg/patch_apply_end
160
+ only enriches the pending call with structured
161
+ change data.
162
+ """
163
+ ts = raw.get("timestamp")
164
+ etype = raw.get("type")
165
+ payload = raw.get("payload") or {}
166
+ ptype = payload.get("type")
167
+
168
+ def sysev(subtype: str, text: str = "") -> SystemEvent:
169
+ return SystemEvent(
170
+ source="codex", timestamp=ts, turn_index=ctx.turn_index,
171
+ subtype=subtype, text=text,
172
+ )
173
+
174
+ if etype == "response_item":
175
+ if ptype == "message":
176
+ role = payload.get("role")
177
+ if role != "assistant":
178
+ return [sysev(f"context_message:{role}")]
179
+ text = "\n".join(
180
+ b.get("text", "")
181
+ for b in payload.get("content") or []
182
+ if isinstance(b, dict) and b.get("type") == "output_text"
183
+ )
184
+ phase = "final" if payload.get("phase") == "final_answer" else "commentary"
185
+ return [
186
+ AssistantMessage(
187
+ source="codex", timestamp=ts, turn_index=ctx.turn_index,
188
+ text=text, phase=phase,
189
+ )
190
+ ]
191
+ if ptype == "reasoning":
192
+ return [Thinking(source="codex", timestamp=ts, turn_index=ctx.turn_index)]
193
+ if ptype in ("function_call", "custom_tool_call"):
194
+ return [
195
+ ToolCall(
196
+ source="codex", timestamp=ts, turn_index=ctx.turn_index,
197
+ call_id=payload.get("call_id", ""),
198
+ tool=payload.get("name", ""),
199
+ arguments=payload.get("arguments", payload.get("input", "")),
200
+ )
201
+ ]
202
+ if ptype in ("function_call_output", "custom_tool_call_output"):
203
+ call_id = payload.get("call_id")
204
+ pending = ctx.pending_calls.get(call_id or "", {})
205
+ structured = pending.pop("_structured", None) if pending else None
206
+ output = payload.get("output", "")
207
+ if not isinstance(output, str):
208
+ output = str(output)
209
+ return [
210
+ ToolResult(
211
+ source="codex", timestamp=ts, turn_index=ctx.turn_index,
212
+ call_id=call_id, output=output, structured=structured,
213
+ )
214
+ ]
215
+ return [sysev(f"response_item:{ptype}")]
216
+
217
+ if etype == "event_msg":
218
+ if ptype == "task_started":
219
+ ctx.turn_index += 1
220
+ return [sysev("turn_start")]
221
+ if ptype == "user_message":
222
+ return [
223
+ UserMessage(
224
+ source="user", timestamp=ts, turn_index=ctx.turn_index,
225
+ text=payload.get("message", ""),
226
+ )
227
+ ]
228
+ if ptype == "patch_apply_end":
229
+ call_id = payload.get("call_id")
230
+ if call_id and call_id in ctx.pending_calls:
231
+ ctx.pending_calls[call_id]["_structured"] = {
232
+ "success": payload.get("success"),
233
+ "changes": payload.get("changes"),
234
+ }
235
+ return [sysev("patch_apply_end")]
236
+ if ptype == "task_complete":
237
+ return [sysev("turn_end", payload.get("last_agent_message") or "")]
238
+ # agent_message duplicates response_item/message; token_count etc.
239
+ return [sysev(f"event_msg:{ptype}")]
240
+
241
+ # session_meta, turn_context, world_state, unknown
242
+ return [sysev(f"codex:{etype}")]
243
+
244
+ # -- rendering (shadow append) -------------------------------------------
245
+
246
+ def render_events(
247
+ self, events: list[NormalizedEvent], ctx: SessionContext
248
+ ) -> list[dict[str, Any]]:
249
+ """Normalized events -> rollout lines. User prompts get both the
250
+ model-facing response_item and the UI-facing event_msg (codex's own
251
+ writer does the same); assistant text gets a response_item so it
252
+ lands in the model's context on resume.
253
+
254
+ Tool activity is rendered as native pairs (response_item only, no
255
+ event_msg): dict arguments -> function_call with JSON-stringified
256
+ arguments, str arguments -> custom_tool_call with input; the matching
257
+ result follows as function_call_output / custom_tool_call_output."""
258
+ out: list[dict[str, Any]] = []
259
+ custom_ids: set[str] = set() # calls rendered as custom_tool_call
260
+ for ev in events:
261
+ ts = ev.timestamp or iso_now_ms()
262
+ if ev.kind == "user_message":
263
+ out.append(
264
+ {
265
+ "timestamp": ts,
266
+ "type": "response_item",
267
+ "payload": {
268
+ "type": "message",
269
+ "role": "user",
270
+ "content": [{"type": "input_text", "text": ev.text}],
271
+ },
272
+ }
273
+ )
274
+ out.append(
275
+ {
276
+ "timestamp": ts,
277
+ "type": "event_msg",
278
+ "payload": {
279
+ "type": "user_message",
280
+ "message": ev.text,
281
+ "images": [],
282
+ "local_images": [],
283
+ "audio": [],
284
+ "local_audio": [],
285
+ "text_elements": [],
286
+ },
287
+ }
288
+ )
289
+ elif ev.kind == "assistant_message":
290
+ phase = "final_answer" if ev.phase == "final" else "commentary"
291
+ out.append(
292
+ {
293
+ "timestamp": ts,
294
+ "type": "response_item",
295
+ "payload": {
296
+ "type": "message",
297
+ "role": "assistant",
298
+ "content": [{"type": "output_text", "text": ev.text}],
299
+ "phase": phase,
300
+ },
301
+ }
302
+ )
303
+ out.append(
304
+ {
305
+ "timestamp": ts,
306
+ "type": "event_msg",
307
+ "payload": {
308
+ "type": "agent_message",
309
+ "message": ev.text,
310
+ "phase": phase,
311
+ "memory_citation": None,
312
+ },
313
+ }
314
+ )
315
+ elif ev.kind == "tool_call":
316
+ if isinstance(ev.arguments, str):
317
+ custom_ids.add(ev.call_id)
318
+ payload: dict[str, Any] = {
319
+ "type": "custom_tool_call", "name": ev.tool,
320
+ "input": ev.arguments, "call_id": ev.call_id,
321
+ }
322
+ else:
323
+ payload = {
324
+ "type": "function_call", "name": ev.tool,
325
+ "arguments": json.dumps(ev.arguments, ensure_ascii=False),
326
+ "call_id": ev.call_id,
327
+ }
328
+ out.append({"timestamp": ts, "type": "response_item", "payload": payload})
329
+ elif ev.kind == "tool_result":
330
+ ptype = ("custom_tool_call_output" if ev.call_id in custom_ids
331
+ else "function_call_output")
332
+ out.append({
333
+ "timestamp": ts, "type": "response_item",
334
+ "payload": {"type": ptype, "call_id": ev.call_id,
335
+ "output": ev.output},
336
+ })
337
+ return out
338
+
339
+ def render_placeholder(self, text: str, ctx: SessionContext) -> list[dict[str, Any]]:
340
+ return self.render_note(text)