agentlink-cli 0.1.1__tar.gz → 0.1.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 (84) hide show
  1. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/.gitignore +8 -0
  2. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/PKG-INFO +4 -4
  3. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/README.md +3 -3
  4. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/codex/adapter.py +33 -1
  5. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/codex/reducer.py +93 -2
  6. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/version.py +1 -1
  7. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/pyproject.toml +1 -1
  8. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/tests/test_codex_adapter.py +265 -0
  9. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/tests/test_version.py +1 -1
  10. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/uv.lock +1 -1
  11. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/__init__.py +0 -0
  12. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/acp/__init__.py +0 -0
  13. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/acp/adapter.py +0 -0
  14. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/acp/config_options.py +0 -0
  15. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/acp/discovery.py +0 -0
  16. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/acp/manifest.py +0 -0
  17. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/acp/manifests/__init__.py +0 -0
  18. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/acp/manifests/codebuddy.json +0 -0
  19. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/acp/manifests/cursor.json +0 -0
  20. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/acp/manifests/gemini.json +0 -0
  21. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/acp/manifests/grok_build.json +0 -0
  22. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/acp/reducer.py +0 -0
  23. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/acp/rpc.py +0 -0
  24. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/adapter.py +0 -0
  25. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/attachments.py +0 -0
  26. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/capabilities.py +0 -0
  27. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/claude/__init__.py +0 -0
  28. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/claude/history_adapter.py +0 -0
  29. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/claude/normalized.py +0 -0
  30. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/claude/normalizers.py +0 -0
  31. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/claude/path_utils.py +0 -0
  32. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/claude/preferences.py +0 -0
  33. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/claude/sdk_adapter.py +0 -0
  34. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/claude/timeline_identity.py +0 -0
  35. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/claude/timeline_reducer.py +0 -0
  36. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/claude/trust.py +0 -0
  37. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/cli.py +0 -0
  38. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/codex/__init__.py +0 -0
  39. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/codex/collaboration.py +0 -0
  40. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/codex/history.py +0 -0
  41. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/codex/rpc.py +0 -0
  42. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/control.py +0 -0
  43. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/json_rpc.py +0 -0
  44. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/launch.py +0 -0
  45. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/local/__init__.py +0 -0
  46. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/local/common.py +0 -0
  47. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/local/file_ops.py +0 -0
  48. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/local/ops.py +0 -0
  49. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/local/shell.py +0 -0
  50. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/local/terminal.py +0 -0
  51. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/local_ops.py +0 -0
  52. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/local_runtime.py +0 -0
  53. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/logging.py +0 -0
  54. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/perf.py +0 -0
  55. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/protocol.py +0 -0
  56. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/registry.py +0 -0
  57. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/runtime.py +0 -0
  58. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/sync_state.py +0 -0
  59. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/time.py +0 -0
  60. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/run.sh +0 -0
  61. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/tests/__init__.py +0 -0
  62. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/tests/acp/__init__.py +0 -0
  63. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/tests/acp/fake_agent/__init__.py +0 -0
  64. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/tests/acp/fake_agent/__main__.py +0 -0
  65. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/tests/acp/test_acp_adapter_fake.py +0 -0
  66. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/tests/acp/test_acp_reducer_golden.py +0 -0
  67. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/tests/acp/test_config_options.py +0 -0
  68. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/tests/contract/__init__.py +0 -0
  69. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/tests/contract/helpers.py +0 -0
  70. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/tests/test_claude_history_adapter.py +0 -0
  71. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/tests/test_claude_preferences.py +0 -0
  72. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/tests/test_claude_sdk_adapter.py +0 -0
  73. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/tests/test_claude_timeline_parity.py +0 -0
  74. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/tests/test_claude_trust.py +0 -0
  75. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/tests/test_connector_capabilities.py +0 -0
  76. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/tests/test_connector_cli.py +0 -0
  77. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/tests/test_connector_control.py +0 -0
  78. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/tests/test_connector_json_rpc.py +0 -0
  79. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/tests/test_connector_logging.py +0 -0
  80. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/tests/test_connector_runtime.py +0 -0
  81. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/tests/test_launch_command.py +0 -0
  82. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/tests/test_local_runtime.py +0 -0
  83. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/tests/test_perf.py +0 -0
  84. {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/tests/test_terminal_backend.py +0 -0
@@ -10,6 +10,14 @@ __pycache__/
10
10
  web-next/out/
11
11
  .env.local
12
12
  .env.*.local
13
+ .env
14
+ !.env.example
15
+
16
+ # Android SDK and release signing secrets
17
+ android/local.properties
18
+ android/keystore.properties
19
+ *.jks
20
+ *.keystore
13
21
 
14
22
  # Local Claude tooling (launch.json, transcripts, worktree metadata)
15
23
  .claude/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentlink-cli
3
- Version: 0.1.1
3
+ Version: 0.1.3
4
4
  Summary: Local runtime connector for AgentLink
5
5
  Requires-Python: >=3.12
6
6
  Requires-Dist: claude-agent-sdk
@@ -47,7 +47,7 @@ uv sync
47
47
  Start with explicit credentials from the web pairing flow:
48
48
 
49
49
  ```bash
50
- uvx agentlink-cli start \
50
+ uvx agentlink-cli@latest start \
51
51
  --server-url http://127.0.0.1:8000 \
52
52
  --connector-id conn_xxx \
53
53
  --connector-token cxt_xxx
@@ -60,12 +60,12 @@ listed below.
60
60
  Or save the config locally and start without arguments:
61
61
 
62
62
  ```bash
63
- uvx agentlink-cli configure \
63
+ uvx agentlink-cli@latest configure \
64
64
  --server-url http://127.0.0.1:8000 \
65
65
  --connector-id conn_xxx \
66
66
  --connector-token cxt_xxx
67
67
 
68
- uvx agentlink-cli start
68
+ uvx agentlink-cli@latest start
69
69
  ```
70
70
 
71
71
  The default config path is `~/.agent-server/connector.json`. Override it with
@@ -30,7 +30,7 @@ uv sync
30
30
  Start with explicit credentials from the web pairing flow:
31
31
 
32
32
  ```bash
33
- uvx agentlink-cli start \
33
+ uvx agentlink-cli@latest start \
34
34
  --server-url http://127.0.0.1:8000 \
35
35
  --connector-id conn_xxx \
36
36
  --connector-token cxt_xxx
@@ -43,12 +43,12 @@ listed below.
43
43
  Or save the config locally and start without arguments:
44
44
 
45
45
  ```bash
46
- uvx agentlink-cli configure \
46
+ uvx agentlink-cli@latest configure \
47
47
  --server-url http://127.0.0.1:8000 \
48
48
  --connector-id conn_xxx \
49
49
  --connector-token cxt_xxx
50
50
 
51
- uvx agentlink-cli start
51
+ uvx agentlink-cli@latest start
52
52
  ```
53
53
 
54
54
  The default config path is `~/.agent-server/connector.json`. Override it with
@@ -463,9 +463,19 @@ class CodexAdapter:
463
463
  settings = collaboration["settings"]
464
464
  turn_params["model"] = settings["model"]
465
465
  turn_params["effort"] = settings["reasoning_effort"]
466
+ if requested_collaboration_mode is not None:
467
+ self.reducer.register_collaboration_mode(
468
+ session_id=session_id,
469
+ thread_id=thread_id,
470
+ mode=requested_collaboration_mode,
471
+ )
466
472
  try:
467
473
  result = await self.rpc.request("turn/start", turn_params)
468
474
  except RuntimeError as exc:
475
+ self.reducer.cancel_pending_collaboration_mode(
476
+ session_id=session_id,
477
+ thread_id=thread_id,
478
+ )
469
479
  recovered = await self._recover_missing_thread_for_turn_start(
470
480
  params,
471
481
  session_id=session_id,
@@ -483,6 +493,12 @@ class CodexAdapter:
483
493
  "thread/settings/update",
484
494
  {"threadId": thread_id, "collaborationMode": collaboration},
485
495
  )
496
+ if requested_collaboration_mode is not None:
497
+ self.reducer.register_collaboration_mode(
498
+ session_id=session_id,
499
+ thread_id=thread_id,
500
+ mode=requested_collaboration_mode,
501
+ )
486
502
  if client_message_id:
487
503
  self.reducer.register_client_message(
488
504
  session_id=session_id,
@@ -491,8 +507,24 @@ class CodexAdapter:
491
507
  text=text_content,
492
508
  attachments=timeline_attachments,
493
509
  )
494
- result = await self.rpc.request("turn/start", turn_params)
510
+ retry_succeeded = False
511
+ try:
512
+ result = await self.rpc.request("turn/start", turn_params)
513
+ retry_succeeded = True
514
+ finally:
515
+ if not retry_succeeded:
516
+ self.reducer.cancel_pending_collaboration_mode(
517
+ session_id=session_id,
518
+ thread_id=thread_id,
519
+ )
495
520
  turn_id = _turn_id_from_result(result)
521
+ if requested_collaboration_mode is not None and turn_id:
522
+ self.reducer.register_collaboration_mode(
523
+ session_id=session_id,
524
+ thread_id=thread_id,
525
+ turn_id=turn_id,
526
+ mode=requested_collaboration_mode,
527
+ )
496
528
  if client_message_id and turn_id:
497
529
  self.reducer.register_client_message(
498
530
  session_id=session_id,
@@ -2,12 +2,12 @@ from __future__ import annotations
2
2
 
3
3
  import hashlib
4
4
  import json
5
+ from collections import OrderedDict
5
6
  from dataclasses import dataclass, field
6
7
  from typing import Any
7
8
 
8
9
  from connector.time import utc_now
9
10
 
10
-
11
11
  CODEX_APPROVAL_METHODS = {
12
12
  "item/commandExecution/requestApproval",
13
13
  "item/fileChange/requestApproval",
@@ -16,6 +16,7 @@ CODEX_APPROVAL_METHODS = {
16
16
  CODEX_INPUT_REQUEST_METHOD = "item/tool/requestUserInput"
17
17
 
18
18
  OUTPUT_PREVIEW_CHARS = 4000
19
+ COLLABORATION_TURN_CACHE_SIZE = 512
19
20
 
20
21
 
21
22
  @dataclass(slots=True)
@@ -35,6 +36,10 @@ class TimelineReducer:
35
36
  self._client_message_by_turn: dict[tuple[str, str | None, str], dict[str, Any]] = {}
36
37
  self._pending_client_messages: dict[tuple[str, str | None], list[dict[str, Any]]] = {}
37
38
  self._reasoning_index_by_turn: dict[tuple[str, str], int] = {}
39
+ self._active_review_key: dict[tuple[str, str | None], str] = {}
40
+ self._review_key_by_item: dict[tuple[str, str | None, str], str] = {}
41
+ self._pending_collaboration_mode: dict[tuple[str, str], str] = {}
42
+ self._collaboration_mode_by_turn: OrderedDict[tuple[str, str, str], str] = OrderedDict()
38
43
  self._next_order = 1
39
44
 
40
45
  def bind_session(self, session_id: str, thread_id: str) -> None:
@@ -47,6 +52,52 @@ class TimelineReducer:
47
52
  def session_for_thread(self, thread_id: str) -> str | None:
48
53
  return self._session_by_thread.get(thread_id)
49
54
 
55
+ def register_collaboration_mode(
56
+ self,
57
+ *,
58
+ session_id: str,
59
+ thread_id: str,
60
+ mode: str,
61
+ turn_id: str | None = None,
62
+ ) -> None:
63
+ if turn_id is None:
64
+ self._pending_collaboration_mode[(session_id, thread_id)] = mode
65
+ return
66
+ self._pending_collaboration_mode.pop((session_id, thread_id), None)
67
+ self._bind_collaboration_turn(session_id, thread_id, turn_id, mode)
68
+
69
+ def cancel_pending_collaboration_mode(self, *, session_id: str, thread_id: str) -> None:
70
+ self._pending_collaboration_mode.pop((session_id, thread_id), None)
71
+
72
+ def _bind_pending_collaboration_turn(
73
+ self,
74
+ session_id: str,
75
+ thread_id: str | None,
76
+ turn_id: str | None,
77
+ ) -> None:
78
+ if thread_id is None or turn_id is None:
79
+ return
80
+ mode = self._pending_collaboration_mode.pop((session_id, thread_id), None)
81
+ if mode is not None:
82
+ self._bind_collaboration_turn(session_id, thread_id, turn_id, mode)
83
+
84
+ def _bind_collaboration_turn(self, session_id: str, thread_id: str, turn_id: str, mode: str) -> None:
85
+ key = (session_id, thread_id, turn_id)
86
+ self._collaboration_mode_by_turn[key] = mode
87
+ self._collaboration_mode_by_turn.move_to_end(key)
88
+ while len(self._collaboration_mode_by_turn) > COLLABORATION_TURN_CACHE_SIZE:
89
+ self._collaboration_mode_by_turn.popitem(last=False)
90
+
91
+ def _collaboration_mode_for_turn(
92
+ self,
93
+ session_id: str,
94
+ thread_id: str | None,
95
+ turn_id: str | None,
96
+ ) -> str | None:
97
+ if thread_id is None or turn_id is None:
98
+ return None
99
+ return self._collaboration_mode_by_turn.get((session_id, thread_id, turn_id))
100
+
50
101
  def _session_update(
51
102
  self,
52
103
  *,
@@ -264,6 +315,10 @@ class TimelineReducer:
264
315
  return ReductionResult()
265
316
  if thread_id:
266
317
  self.bind_session(session_id, thread_id)
318
+ # Codex does not guarantee that turn/started is the first event for a
319
+ # turn. Bind on the first notification carrying the real turn id so an
320
+ # early item or turn/completed event receives the requested mode too.
321
+ self._bind_pending_collaboration_turn(session_id, thread_id, turn_id)
267
322
 
268
323
  if method == "thread/name/updated":
269
324
  return ReductionResult(
@@ -453,7 +508,11 @@ class TimelineReducer:
453
508
  ) -> dict[str, Any] | None:
454
509
  codex_type = _string_value(item.get("type")) or "unknown"
455
510
  item_id = _string_value(item.get("id")) or _string_value(item.get("itemId")) or _string_value(item.get("call_id")) or _short_hash(item)
456
- derived_key = _stable_item_key(item)
511
+ derived_key = (
512
+ self._review_item_key(session_id, thread_id, turn_id, item_id, codex_type)
513
+ if codex_type in {"enteredReviewMode", "exitedReviewMode"}
514
+ else _stable_item_key(item)
515
+ )
457
516
  source_item_id = _string_value(item.get("_eventItemId")) or item_id
458
517
  status = _timeline_status(item.get("status")) or "done"
459
518
  role: str | None = None
@@ -586,6 +645,31 @@ class TimelineReducer:
586
645
  source_extra=source_extra,
587
646
  )
588
647
 
648
+ def _review_item_key(
649
+ self,
650
+ session_id: str,
651
+ thread_id: str | None,
652
+ turn_id: str | None,
653
+ item_id: str,
654
+ codex_type: str,
655
+ ) -> str:
656
+ if turn_id is not None:
657
+ return "review"
658
+
659
+ item_key = (session_id, thread_id, item_id)
660
+ known_key = self._review_key_by_item.get(item_key)
661
+ if known_key is not None:
662
+ return known_key
663
+
664
+ scope = (session_id, thread_id)
665
+ if codex_type == "enteredReviewMode":
666
+ review_key = f"review-{item_id}"
667
+ self._active_review_key[scope] = review_key
668
+ else:
669
+ review_key = self._active_review_key.pop(scope, None) or f"review-{item_id}"
670
+ self._review_key_by_item[item_key] = review_key
671
+ return review_key
672
+
589
673
  def _client_message_for_user_message(
590
674
  self,
591
675
  session_id: str,
@@ -916,6 +1000,13 @@ class TimelineReducer:
916
1000
  }
917
1001
  if source_extra:
918
1002
  source.update(source_extra)
1003
+ collaboration_mode = self._collaboration_mode_for_turn(
1004
+ session_id,
1005
+ source_session_id,
1006
+ turn_id,
1007
+ )
1008
+ if collaboration_mode is not None:
1009
+ source.setdefault("collaborationMode", collaboration_mode)
919
1010
  source = {key: value for key, value in source.items() if value is not None}
920
1011
  content_hash = _content_hash(item_type, status, role, content, source)
921
1012
  if existing and existing.get("contentHash") == content_hash:
@@ -2,7 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  from importlib.metadata import PackageNotFoundError, version
4
4
 
5
- FALLBACK_VERSION = "0.1.0"
5
+ FALLBACK_VERSION = "0.1.3"
6
6
 
7
7
 
8
8
  def connector_version() -> str:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agentlink-cli"
3
- version = "0.1.1"
3
+ version = "0.1.3"
4
4
  description = "Local runtime connector for AgentLink"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -182,6 +182,17 @@ class MissingAfterUnloadedThreadStartRpc(FakeCodexRpc):
182
182
  return {}
183
183
 
184
184
 
185
+ class FailingRecoveredTurnStartRpc(CollaborationCodexRpc):
186
+ async def request(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
187
+ if method == "thread/resume":
188
+ self.requests.append((method, params))
189
+ return {"thread": {"id": (params or {}).get("threadId"), "status": {"type": "loaded"}}}
190
+ if method == "turn/start":
191
+ self.requests.append((method, params))
192
+ raise RuntimeError(json.dumps({"code": -32600, "message": "id not found"}))
193
+ return await super().request(method, params)
194
+
195
+
185
196
  class MissingModelProviderResumeRpc(FakeCodexRpc):
186
197
  async def request(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
187
198
  self.requests.append((method, params))
@@ -429,6 +440,122 @@ def test_reducer_maps_codex_turn_and_message_notifications() -> None:
429
440
  assert [item["type"] for item in completed.timeline_items] == ["turn.start", "turn.end"]
430
441
 
431
442
 
443
+ def test_reducer_persists_collaboration_mode_across_live_and_snapshot_items() -> None:
444
+ reducer = TimelineReducer()
445
+ reducer.bind_session("sess_1", "thr_1")
446
+ reducer.register_collaboration_mode(
447
+ session_id="sess_1",
448
+ thread_id="thr_1",
449
+ mode="plan",
450
+ )
451
+
452
+ started = reducer.reduce_notification(
453
+ {
454
+ "method": "turn/started",
455
+ "params": {"threadId": "thr_1", "turnId": "turn_plan", "turn": {"input": "plan"}},
456
+ }
457
+ ).timeline_items[0]
458
+ message = reducer.reduce_notification(
459
+ {
460
+ "method": "item/agentMessage/delta",
461
+ "params": {
462
+ "threadId": "thr_1",
463
+ "turnId": "turn_plan",
464
+ "itemId": "msg_plan",
465
+ "delta": "# Final plan",
466
+ },
467
+ }
468
+ ).timeline_items[0]
469
+ completed = reducer.reduce_notification(
470
+ {
471
+ "method": "turn/completed",
472
+ "params": {
473
+ "threadId": "thr_1",
474
+ "turnId": "turn_plan",
475
+ "turn": {"status": "completed"},
476
+ },
477
+ }
478
+ ).timeline_items
479
+ snapshot = reducer.reduce_thread_snapshot(
480
+ "sess_1",
481
+ {
482
+ "id": "thr_1",
483
+ "status": {"type": "idle"},
484
+ "turns": [
485
+ {
486
+ "id": "turn_plan",
487
+ "status": "completed",
488
+ "items": [
489
+ {
490
+ "id": "msg_plan",
491
+ "type": "agentMessage",
492
+ "text": "# Final plan",
493
+ "status": "completed",
494
+ }
495
+ ],
496
+ }
497
+ ],
498
+ },
499
+ ).timeline_items
500
+
501
+ for item in [started, message, *completed, *snapshot]:
502
+ assert item["source"]["collaborationMode"] == "plan"
503
+
504
+ reducer.register_collaboration_mode(
505
+ session_id="sess_1",
506
+ thread_id="thr_1",
507
+ turn_id="turn_default",
508
+ mode="default",
509
+ )
510
+ default_message = reducer.reduce_notification(
511
+ {
512
+ "method": "item/agentMessage/delta",
513
+ "params": {
514
+ "threadId": "thr_1",
515
+ "turnId": "turn_default",
516
+ "itemId": "msg_default",
517
+ "delta": "Implemented",
518
+ },
519
+ }
520
+ ).timeline_items[0]
521
+ assert default_message["source"]["collaborationMode"] == "default"
522
+
523
+
524
+ def test_reducer_binds_pending_collaboration_mode_on_first_turn_event() -> None:
525
+ reducer = TimelineReducer()
526
+ reducer.bind_session("sess_1", "thr_1")
527
+ reducer.register_collaboration_mode(
528
+ session_id="sess_1",
529
+ thread_id="thr_1",
530
+ mode="plan",
531
+ )
532
+
533
+ message = reducer.reduce_notification(
534
+ {
535
+ "method": "item/agentMessage/delta",
536
+ "params": {
537
+ "threadId": "thr_1",
538
+ "turnId": "turn_out_of_order",
539
+ "itemId": "msg_1",
540
+ "delta": "# Final plan",
541
+ },
542
+ }
543
+ ).timeline_items[0]
544
+ completed = reducer.reduce_notification(
545
+ {
546
+ "method": "turn/completed",
547
+ "params": {
548
+ "threadId": "thr_1",
549
+ "turnId": "turn_out_of_order",
550
+ "turn": {"status": "completed"},
551
+ },
552
+ }
553
+ ).timeline_items
554
+
555
+ assert message["source"]["collaborationMode"] == "plan"
556
+ assert all(item["source"]["collaborationMode"] == "plan" for item in completed)
557
+
558
+
432
559
  def test_reducer_keeps_agent_delta_items_separate_by_item_id() -> None:
433
560
  reducer = TimelineReducer()
434
561
  reducer.bind_session("sess_1", "thr_1")
@@ -951,6 +1078,10 @@ def test_adapter_replaces_thread_when_unloaded_codex_thread_rollout_is_missing()
951
1078
  asyncio.run(_exercise_unloaded_thread_missing_rollout_replacement())
952
1079
 
953
1080
 
1081
+ def test_adapter_clears_pending_mode_when_recovered_turn_start_retry_fails() -> None:
1082
+ asyncio.run(_exercise_failed_recovered_turn_start_clears_pending_mode())
1083
+
1084
+
954
1085
  def test_codex_commands_use_native_app_server_methods() -> None:
955
1086
  async def exercise() -> None:
956
1087
  rpc = FakeCodexRpc()
@@ -1042,9 +1173,109 @@ def test_reducer_normalizes_review_and_compaction_items() -> None:
1042
1173
  "phase": "completed",
1043
1174
  "text": "Looks good.",
1044
1175
  }
1176
+ assert review.timeline_items[0]["turnId"] == "turn_1"
1045
1177
  assert compact.timeline_items[0]["content"]["kind"] == "codex_compaction"
1046
1178
 
1047
1179
 
1180
+ def test_reducer_keeps_unscoped_review_lifecycle_on_stable_derived_key() -> None:
1181
+ reducer = TimelineReducer()
1182
+ reducer.bind_session("sess_1", "thr_1")
1183
+
1184
+ user_message = reducer.reduce_notification(
1185
+ {
1186
+ "method": "item/completed",
1187
+ "params": {
1188
+ "threadId": "thr_1",
1189
+ "turnId": "turn_review_1",
1190
+ "item": {
1191
+ "id": "review_request_1",
1192
+ "type": "userMessage",
1193
+ "text": "Review the current changes",
1194
+ },
1195
+ },
1196
+ }
1197
+ ).timeline_items[0]
1198
+ entered = reducer.reduce_notification(
1199
+ {
1200
+ "method": "item/completed",
1201
+ "params": {
1202
+ "threadId": "thr_1",
1203
+ "item": {
1204
+ "id": "review_entered_1",
1205
+ "type": "enteredReviewMode",
1206
+ "review": "current changes",
1207
+ },
1208
+ },
1209
+ }
1210
+ ).timeline_items[0]
1211
+ completed = reducer.reduce_notification(
1212
+ {
1213
+ "method": "item/completed",
1214
+ "params": {
1215
+ "threadId": "thr_1",
1216
+ "item": {
1217
+ "id": "review_exited_1",
1218
+ "type": "exitedReviewMode",
1219
+ "review": "Looks good.",
1220
+ },
1221
+ },
1222
+ }
1223
+ ).timeline_items[0]
1224
+ ordinary_message = reducer.reduce_notification(
1225
+ {
1226
+ "method": "item/completed",
1227
+ "params": {
1228
+ "threadId": "thr_1",
1229
+ "item": {
1230
+ "id": "msg_1",
1231
+ "type": "agentMessage",
1232
+ "text": "Ordinary reply",
1233
+ },
1234
+ },
1235
+ }
1236
+ ).timeline_items[0]
1237
+ next_entered = reducer.reduce_notification(
1238
+ {
1239
+ "method": "item/completed",
1240
+ "params": {
1241
+ "threadId": "thr_1",
1242
+ "item": {
1243
+ "id": "review_entered_2",
1244
+ "type": "enteredReviewMode",
1245
+ "review": "next changes",
1246
+ },
1247
+ },
1248
+ }
1249
+ ).timeline_items[0]
1250
+ next_completed = reducer.reduce_notification(
1251
+ {
1252
+ "method": "item/completed",
1253
+ "params": {
1254
+ "threadId": "thr_1",
1255
+ "item": {
1256
+ "id": "review_exited_2",
1257
+ "type": "exitedReviewMode",
1258
+ "review": "Second review.",
1259
+ },
1260
+ },
1261
+ }
1262
+ ).timeline_items[0]
1263
+
1264
+ assert entered.get("turnId") is None
1265
+ assert completed.get("turnId") is None
1266
+ assert completed["source"].get("turnId") is None
1267
+ assert entered["id"] == completed["id"]
1268
+ assert user_message["id"] != completed["id"]
1269
+ assert completed["source"]["derivedKey"] == "review-review_entered_1"
1270
+ assert entered["source"]["itemId"] == "review_entered_1"
1271
+ assert completed["source"]["itemId"] == "review_exited_1"
1272
+ assert next_entered["id"] == next_completed["id"]
1273
+ assert next_completed["id"] != completed["id"]
1274
+ assert next_completed["source"]["derivedKey"] == "review-review_entered_2"
1275
+ assert ordinary_message.get("turnId") is None
1276
+ assert ordinary_message["source"].get("turnId") is None
1277
+
1278
+
1048
1279
  async def _exercise_adapter() -> None:
1049
1280
  notifications: list[tuple[str, dict[str, Any]]] = []
1050
1281
 
@@ -1218,6 +1449,40 @@ async def _exercise_unloaded_thread_missing_rollout_replacement() -> None:
1218
1449
  ]
1219
1450
 
1220
1451
 
1452
+ async def _exercise_failed_recovered_turn_start_clears_pending_mode() -> None:
1453
+ rpc = FailingRecoveredTurnStartRpc()
1454
+ adapter = CodexAdapter(rpc=rpc) # type: ignore[arg-type]
1455
+ adapter.reducer.bind_session("sess_1", "thr_1")
1456
+ adapter._loaded_thread_ids.add("thr_1")
1457
+
1458
+ try:
1459
+ await adapter.start_turn(
1460
+ {
1461
+ "sessionId": "sess_1",
1462
+ "externalSessionId": "thr_1",
1463
+ "content": "make a plan",
1464
+ "collaborationMode": "plan",
1465
+ }
1466
+ )
1467
+ except RuntimeError:
1468
+ pass
1469
+ else:
1470
+ raise AssertionError("expected recovered turn/start retry to fail")
1471
+
1472
+ later = adapter.reducer.reduce_notification(
1473
+ {
1474
+ "method": "item/agentMessage/delta",
1475
+ "params": {
1476
+ "threadId": "thr_1",
1477
+ "turnId": "turn_later",
1478
+ "itemId": "msg_later",
1479
+ "delta": "normal answer",
1480
+ },
1481
+ }
1482
+ ).timeline_items[0]
1483
+ assert "collaborationMode" not in later["source"]
1484
+
1485
+
1221
1486
  def test_adapter_sync_session_refreshes_loaded_codex_thread() -> None:
1222
1487
  async def exercise() -> None:
1223
1488
  rpc = FakeCodexRpc()
@@ -17,4 +17,4 @@ def test_connector_version_falls_back_in_source_tree(monkeypatch) -> None:
17
17
 
18
18
  monkeypatch.setattr(version_module, "version", missing)
19
19
 
20
- assert version_module.connector_version() == "0.1.0"
20
+ assert version_module.connector_version() == "0.1.3"
@@ -8,7 +8,7 @@ resolution-markers = [
8
8
 
9
9
  [[package]]
10
10
  name = "agentlink-cli"
11
- version = "0.1.1"
11
+ version = "0.1.3"
12
12
  source = { editable = "." }
13
13
  dependencies = [
14
14
  { name = "claude-agent-sdk" },
File without changes