agent-session-bridge 0.2.1__tar.gz → 0.2.3__tar.gz

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.
Files changed (83) hide show
  1. {agent_session_bridge-0.2.1/src/agent_session_bridge.egg-info → agent_session_bridge-0.2.3}/PKG-INFO +2 -2
  2. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/README.md +1 -1
  3. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/pyproject.toml +1 -1
  4. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3/src/agent_session_bridge.egg-info}/PKG-INFO +2 -2
  5. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/src/session_bridge/writers/codex.py +51 -0
  6. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/src/session_bridge/writers/codex_db.py +51 -2
  7. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/tests/test_codex_db.py +80 -0
  8. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/LICENSE +0 -0
  9. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/setup.cfg +0 -0
  10. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/src/agent_session_bridge.egg-info/SOURCES.txt +0 -0
  11. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/src/agent_session_bridge.egg-info/dependency_links.txt +0 -0
  12. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/src/agent_session_bridge.egg-info/entry_points.txt +0 -0
  13. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/src/agent_session_bridge.egg-info/requires.txt +0 -0
  14. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/src/agent_session_bridge.egg-info/top_level.txt +0 -0
  15. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/src/session_bridge/__init__.py +0 -0
  16. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/src/session_bridge/_ids.py +0 -0
  17. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/src/session_bridge/cli.py +0 -0
  18. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/src/session_bridge/convert.py +0 -0
  19. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/src/session_bridge/handshake.py +0 -0
  20. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/src/session_bridge/ir.py +0 -0
  21. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/src/session_bridge/place.py +0 -0
  22. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/src/session_bridge/readers/__init__.py +0 -0
  23. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/src/session_bridge/readers/_content.py +0 -0
  24. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/src/session_bridge/readers/_jsonl.py +0 -0
  25. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/src/session_bridge/readers/_pending.py +0 -0
  26. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/src/session_bridge/readers/claude_code.py +0 -0
  27. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/src/session_bridge/readers/codex.py +0 -0
  28. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/src/session_bridge/readers/hermes.py +0 -0
  29. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/src/session_bridge/skill_install.py +0 -0
  30. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/src/session_bridge/skills/session-handoff/SKILL.md +0 -0
  31. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/src/session_bridge/tui/__init__.py +0 -0
  32. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/src/session_bridge/tui/actions.py +0 -0
  33. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/src/session_bridge/tui/app.py +0 -0
  34. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/src/session_bridge/tui/discovery.py +0 -0
  35. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/src/session_bridge/tui/options.py +0 -0
  36. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/src/session_bridge/tui/register.py +0 -0
  37. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/src/session_bridge/tui/screens.py +0 -0
  38. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/src/session_bridge/tui/summary.py +0 -0
  39. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/src/session_bridge/writers/__init__.py +0 -0
  40. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/src/session_bridge/writers/_common.py +0 -0
  41. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/src/session_bridge/writers/claude_code.py +0 -0
  42. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/src/session_bridge/writers/hermes.py +0 -0
  43. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/src/session_bridge/writers/hermes_db.py +0 -0
  44. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/tests/test_claude_reader.py +0 -0
  45. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/tests/test_cli_tui.py +0 -0
  46. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/tests/test_codex_reader.py +0 -0
  47. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/tests/test_handshake_convert_cli.py +0 -0
  48. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/tests/test_hermes_db.py +0 -0
  49. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/tests/test_hermes_reader.py +0 -0
  50. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/tests/test_ir.py +0 -0
  51. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/tests/test_place.py +0 -0
  52. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/tests/test_review_fixes.py +0 -0
  53. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/tests/test_review_round11_fixes.py +0 -0
  54. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/tests/test_review_round12_fixes.py +0 -0
  55. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/tests/test_review_round13_fixes.py +0 -0
  56. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/tests/test_review_round14_fixes.py +0 -0
  57. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/tests/test_review_round16_fixes.py +0 -0
  58. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/tests/test_review_round17_fixes.py +0 -0
  59. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/tests/test_review_round18_fixes.py +0 -0
  60. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/tests/test_review_round18b_fixes.py +0 -0
  61. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/tests/test_review_round1_fixes.py +0 -0
  62. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/tests/test_review_round21_fixes.py +0 -0
  63. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/tests/test_review_round22_fixes.py +0 -0
  64. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/tests/test_review_round25_fixes.py +0 -0
  65. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/tests/test_review_round27_fixes.py +0 -0
  66. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/tests/test_review_round28_fixes.py +0 -0
  67. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/tests/test_review_round2_fixes.py +0 -0
  68. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/tests/test_review_round3_fixes.py +0 -0
  69. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/tests/test_review_round4_fixes.py +0 -0
  70. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/tests/test_review_round5_fixes.py +0 -0
  71. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/tests/test_review_round6_fixes.py +0 -0
  72. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/tests/test_review_round7_fixes.py +0 -0
  73. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/tests/test_review_round8_fixes.py +0 -0
  74. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/tests/test_review_round9_fixes.py +0 -0
  75. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/tests/test_skill_install.py +0 -0
  76. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/tests/test_stub_open_calls.py +0 -0
  77. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/tests/test_tui_actions.py +0 -0
  78. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/tests/test_tui_discovery.py +0 -0
  79. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/tests/test_tui_options.py +0 -0
  80. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/tests/test_tui_pilot.py +0 -0
  81. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/tests/test_tui_register.py +0 -0
  82. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/tests/test_tui_summary.py +0 -0
  83. {agent_session_bridge-0.2.1 → agent_session_bridge-0.2.3}/tests/test_writers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-session-bridge
3
- Version: 0.2.1
3
+ Version: 0.2.3
4
4
  Summary: Local-first cross-harness agent-session portability: export a live coding-agent session from one harness and resume it in another.
5
5
  Author: Prakash Chaudhary
6
6
  License: MIT
@@ -60,7 +60,7 @@ and fail closed (no write) when it doesn't match.
60
60
  | Harness | Last verified against | Verification |
61
61
  |---|---|---|
62
62
  | Claude Code | 2.1.x transcripts | Round-trip + live `claude --resume` recall of a converted-only fact |
63
- | Codex | Codex CLI 0.145.0 (`state_5.sqlite`) | Live `codex resume` recall of a converted-only sentinel |
63
+ | Codex | Codex CLI 0.145.0 (`state_5.sqlite`) | Live `codex resume` recall of a converted-only sentinel, and the registered session listed in the resume picker |
64
64
  | Hermes | `state.db` schema as of 2026-07 | Live `hermes --resume` replay of a registered session |
65
65
  | Python | 3.11 – 3.13 | CI test matrix |
66
66
 
@@ -29,7 +29,7 @@ and fail closed (no write) when it doesn't match.
29
29
  | Harness | Last verified against | Verification |
30
30
  |---|---|---|
31
31
  | Claude Code | 2.1.x transcripts | Round-trip + live `claude --resume` recall of a converted-only fact |
32
- | Codex | Codex CLI 0.145.0 (`state_5.sqlite`) | Live `codex resume` recall of a converted-only sentinel |
32
+ | Codex | Codex CLI 0.145.0 (`state_5.sqlite`) | Live `codex resume` recall of a converted-only sentinel, and the registered session listed in the resume picker |
33
33
  | Hermes | `state.db` schema as of 2026-07 | Live `hermes --resume` replay of a registered session |
34
34
  | Python | 3.11 – 3.13 | CI test matrix |
35
35
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agent-session-bridge"
7
- version = "0.2.1"
7
+ version = "0.2.3"
8
8
  description = "Local-first cross-harness agent-session portability: export a live coding-agent session from one harness and resume it in another."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-session-bridge
3
- Version: 0.2.1
3
+ Version: 0.2.3
4
4
  Summary: Local-first cross-harness agent-session portability: export a live coding-agent session from one harness and resume it in another.
5
5
  Author: Prakash Chaudhary
6
6
  License: MIT
@@ -60,7 +60,7 @@ and fail closed (no write) when it doesn't match.
60
60
  | Harness | Last verified against | Verification |
61
61
  |---|---|---|
62
62
  | Claude Code | 2.1.x transcripts | Round-trip + live `claude --resume` recall of a converted-only fact |
63
- | Codex | Codex CLI 0.145.0 (`state_5.sqlite`) | Live `codex resume` recall of a converted-only sentinel |
63
+ | Codex | Codex CLI 0.145.0 (`state_5.sqlite`) | Live `codex resume` recall of a converted-only sentinel, and the registered session listed in the resume picker |
64
64
  | Hermes | `state.db` schema as of 2026-07 | Live `hermes --resume` replay of a registered session |
65
65
  | Python | 3.11 – 3.13 | CI test matrix |
66
66
 
@@ -23,6 +23,16 @@ def _codex_role(role: Role) -> str:
23
23
 
24
24
 
25
25
  def _msg_payload(role: str, text: str) -> dict[str, Any]:
26
+ if role == "assistant":
27
+ # Codex's history reconstruction keeps assistant messages by channel
28
+ # phase; without this the resumed model sees only the user turns.
29
+ block_type = "output_text"
30
+ return {
31
+ "type": "message",
32
+ "role": role,
33
+ "content": [{"type": block_type, "text": text}],
34
+ "phase": "final_answer",
35
+ }
26
36
  # Assistant emits output_text; user/system emit input_text.
27
37
  block_type = "output_text" if role == "assistant" else "input_text"
28
38
  return {"type": "message", "role": role, "content": [{"type": block_type, "text": text}]}
@@ -150,5 +160,46 @@ def write_codex(
150
160
  # Preserve an otherwise-empty message so message count survives the round trip.
151
161
  if not emitted and msg.role in (Role.USER, Role.ASSISTANT, Role.SYSTEM):
152
162
  add(_msg_payload(role, ""), ts)
163
+ # Codex writes an event_msg twin for every user turn, and its resume
164
+ # picker only lists sessions whose rollout contains at least one
165
+ # user_message event — without this the imported session resumes by
166
+ # id but never appears in the picker. The reader ignores event_msg
167
+ # as a response_item duplicate, so round-trips are unaffected.
168
+ if msg.role is Role.USER:
169
+ user_text = msg.text()
170
+ if user_text.strip():
171
+ records.append(
172
+ {
173
+ "timestamp": ts,
174
+ "type": "event_msg",
175
+ "payload": {
176
+ "type": "user_message",
177
+ "message": user_text,
178
+ "images": [],
179
+ "local_images": [],
180
+ "audio": [],
181
+ "local_audio": [],
182
+ "text_elements": [],
183
+ },
184
+ }
185
+ )
186
+ elif msg.role is Role.ASSISTANT:
187
+ # Same twin discipline for assistant turns: the transcript view
188
+ # renders from event_msg records, so without agent_message events
189
+ # a resumed session displays as user-messages-only.
190
+ assistant_text = msg.text()
191
+ if assistant_text.strip():
192
+ records.append(
193
+ {
194
+ "timestamp": ts,
195
+ "type": "event_msg",
196
+ "payload": {
197
+ "type": "agent_message",
198
+ "message": assistant_text,
199
+ "phase": "final_answer",
200
+ "memory_citation": None,
201
+ },
202
+ }
203
+ )
153
204
 
154
205
  return records, report
@@ -16,7 +16,9 @@ from __future__ import annotations
16
16
 
17
17
  import json
18
18
  import os
19
+ import shutil
19
20
  import sqlite3
21
+ import subprocess
20
22
  import tempfile
21
23
  import time
22
24
  import uuid
@@ -81,6 +83,43 @@ def validate_codex_store(codex_home: str | Path) -> Path:
81
83
  return db_path
82
84
 
83
85
 
86
+ def infer_codex_cli_version(codex_home: str | Path) -> str:
87
+ """The installed Codex CLI's version, for stamping imported sessions.
88
+
89
+ Asks the binary first (`codex --version` -> "codex-cli 0.145.0"): the
90
+ store's rows are not a trustworthy source because previously imported
91
+ sessions land there too, so inferring from rows can echo an earlier
92
+ import's value back. The newest store row is only the fallback when no
93
+ codex binary is on PATH; empty string for a fresh store, which Codex
94
+ tolerates (the column defaults to '' for pre-column rows).
95
+ """
96
+ exe = shutil.which("codex")
97
+ if exe:
98
+ try:
99
+ out = subprocess.run(
100
+ [exe, "--version"], capture_output=True, text=True, timeout=10
101
+ ).stdout.strip()
102
+ if out:
103
+ return out.split()[-1]
104
+ except (OSError, subprocess.SubprocessError):
105
+ pass
106
+ db_path = validate_codex_store(codex_home)
107
+ conn = sqlite3.connect(db_path.resolve().as_uri() + "?mode=ro", uri=True)
108
+ try:
109
+ row = conn.execute(
110
+ """
111
+ SELECT cli_version
112
+ FROM threads
113
+ WHERE cli_version IS NOT NULL AND cli_version != ''
114
+ ORDER BY recency_at_ms DESC, updated_at_ms DESC, updated_at DESC
115
+ LIMIT 1
116
+ """
117
+ ).fetchone()
118
+ finally:
119
+ conn.close()
120
+ return row[0] if row else ""
121
+
122
+
84
123
  def infer_codex_model(codex_home: str | Path, model_provider: str = "openai") -> str | None:
85
124
  """Return the most recently used model for a configured Codex provider."""
86
125
  db_path = validate_codex_store(codex_home)
@@ -161,6 +200,8 @@ def _thread_values(
161
200
  "tokens_used": 0,
162
201
  "has_user_event": int(bool(first_user_message)),
163
202
  "archived": 0,
203
+ # meta.version here is the TARGET Codex version (the registrar
204
+ # replaces the source harness's with the installed Codex's).
164
205
  "cli_version": session.meta.version or "",
165
206
  "first_user_message": first_user_message,
166
207
  "memory_mode": "enabled",
@@ -221,8 +262,13 @@ def register_codex_session(
221
262
 
222
263
  timestamp = started_at if started_at is not None else time.time()
223
264
  iso_timestamp = datetime.fromtimestamp(timestamp, UTC).strftime("%Y-%m-%dT%H:%M:%S.000Z")
224
- day_path = datetime.fromtimestamp(timestamp, UTC).strftime("%Y/%m/%d")
225
- rollout_path = home / "sessions" / day_path / f"rollout-{iso_timestamp.replace(':', '-')}-{session_id}.jsonl"
265
+ # The resume picker parses rollout filenames strictly: local time,
266
+ # second precision, no timezone suffix matching what Codex itself
267
+ # writes. Record timestamps inside the file stay UTC.
268
+ local = datetime.fromtimestamp(timestamp)
269
+ day_path = local.strftime("%Y/%m/%d")
270
+ file_stamp = local.strftime("%Y-%m-%dT%H-%M-%S")
271
+ rollout_path = home / "sessions" / day_path / f"rollout-{file_stamp}-{session_id}.jsonl"
226
272
  if rollout_path.exists():
227
273
  raise CodexRegistrationError(f"Codex rollout already exists: {rollout_path}")
228
274
 
@@ -232,6 +278,9 @@ def register_codex_session(
232
278
  cwd=resolved_cwd,
233
279
  model=model,
234
280
  model_provider=model_provider,
281
+ # The source harness's version must not masquerade as Codex's;
282
+ # stamp the installed Codex's version observed from its own store.
283
+ version=infer_codex_cli_version(home),
235
284
  )
236
285
  target_session = replace(session, meta=target_meta)
237
286
  transcript_sandbox, index_sandbox = workspace_write_policies(resolved_cwd)
@@ -315,3 +315,83 @@ def test_cli_register_codex_backs_up_and_adds_handshake(tmp_path):
315
315
  ]
316
316
  ) == 0
317
317
  assert len(list(home.glob("state_5.sqlite.session-bridge-backup-*"))) == 2
318
+
319
+
320
+ def test_registration_matches_native_picker_requirements(tmp_path, monkeypatch):
321
+ """The resume picker only lists sessions whose rollout carries an
322
+ event_msg user_message and whose filename matches the native pattern."""
323
+ import re
324
+
325
+ monkeypatch.setattr('session_bridge.writers.codex_db.shutil.which', lambda _: None)
326
+
327
+ home = tmp_path / "codex"
328
+ _make_codex_home(home)
329
+ session = _sample_session()
330
+ rollout = register_codex_session(
331
+ session, home, "1e8400e2-9b1d-4716-a4f6-426655440000",
332
+ cwd=str(tmp_path), title="picker check", model="gpt-5", model_provider="openai",
333
+ )
334
+ # Native filename: local time, no milliseconds/Z suffix.
335
+ assert re.fullmatch(
336
+ r"rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-[0-9a-f-]{36}\.jsonl",
337
+ rollout.name,
338
+ ), rollout.name
339
+ records = [json.loads(line) for line in rollout.read_text().splitlines()]
340
+ user_events = [
341
+ r for r in records
342
+ if r.get("type") == "event_msg" and r["payload"].get("type") == "user_message"
343
+ ]
344
+ assert user_events, "picker requires an event_msg user_message in the rollout"
345
+ assert user_events[0]["payload"]["message"].strip()
346
+ agent_events = [
347
+ r for r in records
348
+ if r.get("type") == "event_msg" and r["payload"].get("type") == "agent_message"
349
+ ]
350
+ assert agent_events, "transcript rendering requires agent_message events"
351
+ assistant_items = [
352
+ r for r in records
353
+ if r.get("type") == "response_item" and r["payload"].get("type") == "message"
354
+ and r["payload"].get("role") == "assistant"
355
+ ]
356
+ assert assistant_items and all(
357
+ i["payload"].get("phase") == "final_answer" for i in assistant_items
358
+ ), "history reconstruction drops assistant messages without a phase"
359
+ conn = sqlite3.connect(home / "state_5.sqlite")
360
+ row = dict(zip([c[0] for c in conn.execute("SELECT model, first_user_message, preview, cli_version FROM threads").description],
361
+ conn.execute("SELECT model, first_user_message, preview, cli_version FROM threads").fetchone()))
362
+ conn.close()
363
+ assert row["model"] == "gpt-5"
364
+ assert row["first_user_message"].strip()
365
+ assert row["preview"].strip()
366
+ # The source harness's version must not masquerade as Codex's
367
+ # cli_version; a fresh isolated store has no native row to infer from.
368
+ assert row["cli_version"] == ""
369
+
370
+
371
+ def test_cli_version_inferred_from_native_rows(tmp_path, monkeypatch):
372
+ monkeypatch.setattr('session_bridge.writers.codex_db.shutil.which', lambda _: None)
373
+ home = tmp_path / "codex"
374
+ _make_codex_home(home)
375
+ conn = sqlite3.connect(home / "state_5.sqlite")
376
+ conn.execute(
377
+ "INSERT INTO threads (id, rollout_path, created_at, updated_at, source, "
378
+ "model_provider, cwd, title, sandbox_policy, approval_mode, cli_version, "
379
+ "recency_at_ms, updated_at_ms) "
380
+ "VALUES (?, ?, 1, 1, 'cli', 'openai', '/tmp', 'native', '{}', 'on-request', "
381
+ "'0.145.0', 1, 1)",
382
+ (str(uuid.uuid4()), "/tmp/native.jsonl"),
383
+ )
384
+ conn.commit()
385
+ conn.close()
386
+ rollout = register_codex_session(
387
+ _sample_session(), home, "2e8400e2-9b1d-4716-a4f6-426655440000",
388
+ cwd=str(tmp_path), title="version check", model="gpt-5", model_provider="openai",
389
+ )
390
+ conn = sqlite3.connect(home / "state_5.sqlite")
391
+ got = conn.execute(
392
+ "SELECT cli_version FROM threads WHERE id='2e8400e2-9b1d-4716-a4f6-426655440000'"
393
+ ).fetchone()[0]
394
+ conn.close()
395
+ assert got == "0.145.0"
396
+ meta = json.loads(rollout.read_text().splitlines()[0])
397
+ assert meta["payload"]["cli_version"] == "0.145.0"