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.
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/.gitignore +8 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/PKG-INFO +4 -4
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/README.md +3 -3
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/codex/adapter.py +33 -1
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/codex/reducer.py +93 -2
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/version.py +1 -1
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/pyproject.toml +1 -1
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/tests/test_codex_adapter.py +265 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/tests/test_version.py +1 -1
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/uv.lock +1 -1
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/__init__.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/acp/__init__.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/acp/adapter.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/acp/config_options.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/acp/discovery.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/acp/manifest.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/acp/manifests/__init__.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/acp/manifests/codebuddy.json +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/acp/manifests/cursor.json +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/acp/manifests/gemini.json +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/acp/manifests/grok_build.json +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/acp/reducer.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/acp/rpc.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/adapter.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/attachments.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/capabilities.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/claude/__init__.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/claude/history_adapter.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/claude/normalized.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/claude/normalizers.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/claude/path_utils.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/claude/preferences.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/claude/sdk_adapter.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/claude/timeline_identity.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/claude/timeline_reducer.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/claude/trust.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/cli.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/codex/__init__.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/codex/collaboration.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/codex/history.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/codex/rpc.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/control.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/json_rpc.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/launch.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/local/__init__.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/local/common.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/local/file_ops.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/local/ops.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/local/shell.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/local/terminal.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/local_ops.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/local_runtime.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/logging.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/perf.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/protocol.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/registry.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/runtime.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/sync_state.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/connector/time.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/run.sh +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/tests/__init__.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/tests/acp/__init__.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/tests/acp/fake_agent/__init__.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/tests/acp/fake_agent/__main__.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/tests/acp/test_acp_adapter_fake.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/tests/acp/test_acp_reducer_golden.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/tests/acp/test_config_options.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/tests/contract/__init__.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/tests/contract/helpers.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/tests/test_claude_history_adapter.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/tests/test_claude_preferences.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/tests/test_claude_sdk_adapter.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/tests/test_claude_timeline_parity.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/tests/test_claude_trust.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/tests/test_connector_capabilities.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/tests/test_connector_cli.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/tests/test_connector_control.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/tests/test_connector_json_rpc.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/tests/test_connector_logging.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/tests/test_connector_runtime.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/tests/test_launch_command.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/tests/test_local_runtime.py +0 -0
- {agentlink_cli-0.1.1 → agentlink_cli-0.1.3}/tests/test_perf.py +0 -0
- {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.
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
@@ -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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|