agentlink-cli 0.1.0__tar.gz → 0.1.2__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.0 → agentlink_cli-0.1.2}/PKG-INFO +1 -1
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/capabilities.py +20 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/codex/adapter.py +87 -2
- agentlink_cli-0.1.2/connector/codex/collaboration.py +67 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/codex/reducer.py +107 -1
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/runtime.py +2 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/version.py +1 -1
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/pyproject.toml +1 -1
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/tests/test_codex_adapter.py +260 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/tests/test_version.py +1 -1
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/uv.lock +1 -1
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/.gitignore +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/README.md +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/__init__.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/acp/__init__.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/acp/adapter.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/acp/config_options.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/acp/discovery.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/acp/manifest.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/acp/manifests/__init__.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/acp/manifests/codebuddy.json +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/acp/manifests/cursor.json +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/acp/manifests/gemini.json +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/acp/manifests/grok_build.json +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/acp/reducer.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/acp/rpc.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/adapter.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/attachments.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/claude/__init__.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/claude/history_adapter.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/claude/normalized.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/claude/normalizers.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/claude/path_utils.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/claude/preferences.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/claude/sdk_adapter.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/claude/timeline_identity.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/claude/timeline_reducer.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/claude/trust.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/cli.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/codex/__init__.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/codex/history.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/codex/rpc.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/control.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/json_rpc.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/launch.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/local/__init__.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/local/common.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/local/file_ops.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/local/ops.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/local/shell.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/local/terminal.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/local_ops.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/local_runtime.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/logging.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/perf.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/protocol.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/registry.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/sync_state.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/time.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/run.sh +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/tests/__init__.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/tests/acp/__init__.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/tests/acp/fake_agent/__init__.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/tests/acp/fake_agent/__main__.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/tests/acp/test_acp_adapter_fake.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/tests/acp/test_acp_reducer_golden.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/tests/acp/test_config_options.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/tests/contract/__init__.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/tests/contract/helpers.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/tests/test_claude_history_adapter.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/tests/test_claude_preferences.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/tests/test_claude_sdk_adapter.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/tests/test_claude_timeline_parity.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/tests/test_claude_trust.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/tests/test_connector_capabilities.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/tests/test_connector_cli.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/tests/test_connector_control.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/tests/test_connector_json_rpc.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/tests/test_connector_logging.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/tests/test_connector_runtime.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/tests/test_launch_command.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/tests/test_local_runtime.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/tests/test_perf.py +0 -0
- {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/tests/test_terminal_backend.py +0 -0
|
@@ -13,6 +13,7 @@ from typing import Any
|
|
|
13
13
|
|
|
14
14
|
from connector.acp.discovery import discover_acp_manifest
|
|
15
15
|
from connector.acp.manifest import AgentManifest, load_builtin_manifests
|
|
16
|
+
from connector.codex.collaboration import normalize_collaboration_modes
|
|
16
17
|
from connector.codex.rpc import JsonRpcStdioClient, codex_candidate_paths
|
|
17
18
|
from connector.launch import (
|
|
18
19
|
LaunchCommand,
|
|
@@ -257,6 +258,10 @@ async def _check_codex_candidate(candidate: dict[str, str] | LaunchTarget | Laun
|
|
|
257
258
|
_read_codex_model_options(client),
|
|
258
259
|
timeout=_CODEX_CHECK_TIMEOUT_S,
|
|
259
260
|
)
|
|
261
|
+
collaboration_mode_options = await asyncio.wait_for(
|
|
262
|
+
_read_codex_collaboration_mode_options(client),
|
|
263
|
+
timeout=_CODEX_CHECK_TIMEOUT_S,
|
|
264
|
+
)
|
|
260
265
|
except Exception as exc:
|
|
261
266
|
stage = "model-list" if "model_options" in locals() or _is_model_list_error(exc) else "app-server"
|
|
262
267
|
return {
|
|
@@ -278,6 +283,7 @@ async def _check_codex_candidate(candidate: dict[str, str] | LaunchTarget | Laun
|
|
|
278
283
|
"version": version.get("stdout"),
|
|
279
284
|
"threadListKeys": sorted(list_result.keys()),
|
|
280
285
|
"modelOptions": model_options,
|
|
286
|
+
"collaborationModeOptions": collaboration_mode_options,
|
|
281
287
|
}
|
|
282
288
|
|
|
283
289
|
|
|
@@ -303,6 +309,9 @@ async def prepare_codex_launch(
|
|
|
303
309
|
model_options = await asyncio.wait_for(
|
|
304
310
|
_read_codex_model_options(client), timeout=_CODEX_CHECK_TIMEOUT_S
|
|
305
311
|
)
|
|
312
|
+
collaboration_mode_options = await asyncio.wait_for(
|
|
313
|
+
_read_codex_collaboration_mode_options(client), timeout=_CODEX_CHECK_TIMEOUT_S
|
|
314
|
+
)
|
|
306
315
|
except Exception:
|
|
307
316
|
await client.close()
|
|
308
317
|
raise
|
|
@@ -313,6 +322,7 @@ async def prepare_codex_launch(
|
|
|
313
322
|
"version": version.get("stdout"),
|
|
314
323
|
"threadListKeys": sorted(list_result.keys()),
|
|
315
324
|
"modelOptions": model_options,
|
|
325
|
+
"collaborationModeOptions": collaboration_mode_options,
|
|
316
326
|
}
|
|
317
327
|
return _codex_report_from_check(result, command), client
|
|
318
328
|
|
|
@@ -332,6 +342,8 @@ def _codex_report_from_check(
|
|
|
332
342
|
}
|
|
333
343
|
if isinstance(result.get("modelOptions"), list):
|
|
334
344
|
report["modelOptions"] = result["modelOptions"]
|
|
345
|
+
if isinstance(result.get("collaborationModeOptions"), list):
|
|
346
|
+
report["collaborationModeOptions"] = result["collaborationModeOptions"]
|
|
335
347
|
return report
|
|
336
348
|
|
|
337
349
|
|
|
@@ -444,6 +456,14 @@ def _is_model_list_error(exc: BaseException) -> bool:
|
|
|
444
456
|
return "model/list" in str(exc)
|
|
445
457
|
|
|
446
458
|
|
|
459
|
+
async def _read_codex_collaboration_mode_options(client: JsonRpcStdioClient) -> list[dict[str, Any]]:
|
|
460
|
+
try:
|
|
461
|
+
result = await client.request("collaborationMode/list", {})
|
|
462
|
+
except Exception:
|
|
463
|
+
return []
|
|
464
|
+
return normalize_collaboration_modes(result)
|
|
465
|
+
|
|
466
|
+
|
|
447
467
|
async def _check_claude_candidate(candidate: dict[str, str] | LaunchTarget) -> dict[str, Any]:
|
|
448
468
|
target = _target_from_candidate(candidate)
|
|
449
469
|
path = target.path
|
|
@@ -13,6 +13,7 @@ from connector.logging import logger
|
|
|
13
13
|
|
|
14
14
|
from connector.attachments import attachment_target
|
|
15
15
|
from connector.codex.reducer import CODEX_APPROVAL_METHODS, ReductionResult, TimelineReducer
|
|
16
|
+
from connector.codex.collaboration import collaboration_mode_payload, normalize_collaboration_modes
|
|
16
17
|
from connector.codex.rpc import JsonRpcStdioClient
|
|
17
18
|
from connector.sync_state import SyncStateStore
|
|
18
19
|
from connector.time import utc_now
|
|
@@ -93,6 +94,7 @@ class CodexAdapter:
|
|
|
93
94
|
_existing_thread_sync_markers: dict[str, str] = field(default_factory=dict)
|
|
94
95
|
_existing_thread_names: dict[str, str | None] = field(default_factory=dict)
|
|
95
96
|
_token_usage_by_thread: dict[str, dict[str, Any]] = field(default_factory=dict)
|
|
97
|
+
_collaboration_modes: list[dict[str, Any]] = field(default_factory=list)
|
|
96
98
|
_start_lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False, repr=False)
|
|
97
99
|
|
|
98
100
|
def __post_init__(self) -> None:
|
|
@@ -433,9 +435,47 @@ class CodexAdapter:
|
|
|
433
435
|
"effort": params.get("effort"),
|
|
434
436
|
"approvalsReviewer": params.get("approvalsReviewer"),
|
|
435
437
|
}
|
|
438
|
+
requested_collaboration_mode = _optional_string(params.get("collaborationMode"))
|
|
439
|
+
if requested_collaboration_mode is not None and not self._collaboration_modes:
|
|
440
|
+
try:
|
|
441
|
+
self._collaboration_modes = normalize_collaboration_modes(
|
|
442
|
+
await self.rpc.request("collaborationMode/list", {})
|
|
443
|
+
)
|
|
444
|
+
except Exception:
|
|
445
|
+
self._collaboration_modes = []
|
|
446
|
+
collaboration = (
|
|
447
|
+
collaboration_mode_payload(
|
|
448
|
+
requested_collaboration_mode,
|
|
449
|
+
self._collaboration_modes,
|
|
450
|
+
model=_optional_string(params.get("model")) or "gpt-5.6-sol",
|
|
451
|
+
effort=_optional_string(params.get("effort")),
|
|
452
|
+
)
|
|
453
|
+
if requested_collaboration_mode is not None
|
|
454
|
+
else None
|
|
455
|
+
)
|
|
456
|
+
if collaboration is None and requested_collaboration_mode == "plan":
|
|
457
|
+
raise ValueError("Plan mode is not supported by this Codex installation")
|
|
458
|
+
if collaboration is not None:
|
|
459
|
+
await self.rpc.request(
|
|
460
|
+
"thread/settings/update",
|
|
461
|
+
{"threadId": thread_id, "collaborationMode": collaboration},
|
|
462
|
+
)
|
|
463
|
+
settings = collaboration["settings"]
|
|
464
|
+
turn_params["model"] = settings["model"]
|
|
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
|
+
)
|
|
436
472
|
try:
|
|
437
473
|
result = await self.rpc.request("turn/start", turn_params)
|
|
438
474
|
except RuntimeError as exc:
|
|
475
|
+
self.reducer.cancel_pending_collaboration_mode(
|
|
476
|
+
session_id=session_id,
|
|
477
|
+
thread_id=thread_id,
|
|
478
|
+
)
|
|
439
479
|
recovered = await self._recover_missing_thread_for_turn_start(
|
|
440
480
|
params,
|
|
441
481
|
session_id=session_id,
|
|
@@ -448,6 +488,17 @@ class CodexAdapter:
|
|
|
448
488
|
backend_notifications.extend(extra_notifications)
|
|
449
489
|
self.reducer.bind_session(session_id, thread_id)
|
|
450
490
|
turn_params["threadId"] = thread_id
|
|
491
|
+
if collaboration is not None:
|
|
492
|
+
await self.rpc.request(
|
|
493
|
+
"thread/settings/update",
|
|
494
|
+
{"threadId": thread_id, "collaborationMode": collaboration},
|
|
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
|
+
)
|
|
451
502
|
if client_message_id:
|
|
452
503
|
self.reducer.register_client_message(
|
|
453
504
|
session_id=session_id,
|
|
@@ -456,8 +507,24 @@ class CodexAdapter:
|
|
|
456
507
|
text=text_content,
|
|
457
508
|
attachments=timeline_attachments,
|
|
458
509
|
)
|
|
459
|
-
|
|
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
|
+
)
|
|
460
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
|
+
)
|
|
461
528
|
if client_message_id and turn_id:
|
|
462
529
|
self.reducer.register_client_message(
|
|
463
530
|
session_id=session_id,
|
|
@@ -675,6 +742,24 @@ class CodexAdapter:
|
|
|
675
742
|
)
|
|
676
743
|
return {"resolved": True}
|
|
677
744
|
|
|
745
|
+
async def respond_to_input(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
746
|
+
await self.start()
|
|
747
|
+
assert self.rpc is not None
|
|
748
|
+
request_id = params.get("requestId")
|
|
749
|
+
if request_id is None:
|
|
750
|
+
raise ValueError("requestId is required to respond to Codex user input")
|
|
751
|
+
answers = params.get("answers")
|
|
752
|
+
if not isinstance(answers, dict):
|
|
753
|
+
raise ValueError("answers are required to respond to Codex user input")
|
|
754
|
+
await self.rpc.respond(request_id, {"answers": answers})
|
|
755
|
+
logger.info(
|
|
756
|
+
"codex user input resolved request_id={} approval_id={} question_count={}",
|
|
757
|
+
request_id,
|
|
758
|
+
params.get("approvalId"),
|
|
759
|
+
len(answers),
|
|
760
|
+
)
|
|
761
|
+
return {"resolved": True}
|
|
762
|
+
|
|
678
763
|
async def handle_notification(self, message: dict[str, Any]) -> None:
|
|
679
764
|
if message.get("method") == "thread/tokenUsage/updated":
|
|
680
765
|
params = message.get("params") if isinstance(message.get("params"), dict) else {}
|
|
@@ -801,7 +886,7 @@ class CodexAdapter:
|
|
|
801
886
|
("thread/loaded/list", None),
|
|
802
887
|
):
|
|
803
888
|
try:
|
|
804
|
-
await self.rpc.request(method, params)
|
|
889
|
+
result = await self.rpc.request(method, params)
|
|
805
890
|
except Exception as exc: # pragma: no cover - defensive against version drift
|
|
806
891
|
logger.debug("codex bootstrap read failed method={} error={}", method, exc)
|
|
807
892
|
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
PLAN_MODE_DEVELOPER_INSTRUCTIONS = """# Plan Mode (Conversational)
|
|
7
|
+
|
|
8
|
+
Work with the user to produce a decision-complete implementation plan before any implementation.
|
|
9
|
+
|
|
10
|
+
- Explore the repository and other available context before asking questions.
|
|
11
|
+
- Do not edit files or perform other implementation mutations while in Plan Mode.
|
|
12
|
+
- Ask only questions that materially change the plan and cannot be answered by inspection.
|
|
13
|
+
- Use request_user_input for meaningful choices when it is available.
|
|
14
|
+
- Cover interfaces, data flow, edge cases, compatibility, testing, and acceptance criteria.
|
|
15
|
+
- When the plan is complete, return exactly one self-contained Markdown plan wrapped in
|
|
16
|
+
`<proposed_plan>` and `</proposed_plan>` tags. Do not ask whether to proceed.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def normalize_collaboration_modes(value: Any) -> list[dict[str, Any]]:
|
|
21
|
+
if not isinstance(value, dict) or not isinstance(value.get("data"), list):
|
|
22
|
+
return []
|
|
23
|
+
result: list[dict[str, Any]] = []
|
|
24
|
+
seen: set[str] = set()
|
|
25
|
+
for raw in value["data"]:
|
|
26
|
+
if not isinstance(raw, dict):
|
|
27
|
+
continue
|
|
28
|
+
mode = raw.get("mode")
|
|
29
|
+
if mode not in {"default", "plan"} or mode in seen:
|
|
30
|
+
continue
|
|
31
|
+
seen.add(mode)
|
|
32
|
+
item: dict[str, Any] = {
|
|
33
|
+
"value": mode,
|
|
34
|
+
"label": str(raw.get("name") or mode.title()),
|
|
35
|
+
}
|
|
36
|
+
if isinstance(raw.get("model"), str) and raw["model"]:
|
|
37
|
+
item["model"] = raw["model"]
|
|
38
|
+
effort = raw.get("reasoning_effort") or raw.get("reasoningEffort")
|
|
39
|
+
if isinstance(effort, str) and effort:
|
|
40
|
+
item["reasoningEffort"] = effort
|
|
41
|
+
result.append(item)
|
|
42
|
+
result.sort(key=lambda item: 0 if item["value"] == "default" else 1)
|
|
43
|
+
return result
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def collaboration_mode_payload(
|
|
47
|
+
mode: str,
|
|
48
|
+
options: list[dict[str, Any]],
|
|
49
|
+
*,
|
|
50
|
+
model: str,
|
|
51
|
+
effort: str | None,
|
|
52
|
+
) -> dict[str, Any] | None:
|
|
53
|
+
option = next((item for item in options if item.get("value") == mode), None)
|
|
54
|
+
if option is None:
|
|
55
|
+
return None
|
|
56
|
+
selected_model = option.get("model") if isinstance(option.get("model"), str) else model
|
|
57
|
+
selected_effort = option.get("reasoningEffort")
|
|
58
|
+
if not isinstance(selected_effort, str):
|
|
59
|
+
selected_effort = effort
|
|
60
|
+
return {
|
|
61
|
+
"mode": mode,
|
|
62
|
+
"settings": {
|
|
63
|
+
"model": selected_model,
|
|
64
|
+
"reasoning_effort": selected_effort,
|
|
65
|
+
"developer_instructions": PLAN_MODE_DEVELOPER_INSTRUCTIONS if mode == "plan" else None,
|
|
66
|
+
},
|
|
67
|
+
}
|
|
@@ -2,19 +2,21 @@ 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",
|
|
14
14
|
"item/permissions/requestApproval",
|
|
15
15
|
}
|
|
16
|
+
CODEX_INPUT_REQUEST_METHOD = "item/tool/requestUserInput"
|
|
16
17
|
|
|
17
18
|
OUTPUT_PREVIEW_CHARS = 4000
|
|
19
|
+
COLLABORATION_TURN_CACHE_SIZE = 512
|
|
18
20
|
|
|
19
21
|
|
|
20
22
|
@dataclass(slots=True)
|
|
@@ -34,6 +36,8 @@ class TimelineReducer:
|
|
|
34
36
|
self._client_message_by_turn: dict[tuple[str, str | None, str], dict[str, Any]] = {}
|
|
35
37
|
self._pending_client_messages: dict[tuple[str, str | None], list[dict[str, Any]]] = {}
|
|
36
38
|
self._reasoning_index_by_turn: dict[tuple[str, str], int] = {}
|
|
39
|
+
self._pending_collaboration_mode: dict[tuple[str, str], str] = {}
|
|
40
|
+
self._collaboration_mode_by_turn: OrderedDict[tuple[str, str, str], str] = OrderedDict()
|
|
37
41
|
self._next_order = 1
|
|
38
42
|
|
|
39
43
|
def bind_session(self, session_id: str, thread_id: str) -> None:
|
|
@@ -46,6 +50,52 @@ class TimelineReducer:
|
|
|
46
50
|
def session_for_thread(self, thread_id: str) -> str | None:
|
|
47
51
|
return self._session_by_thread.get(thread_id)
|
|
48
52
|
|
|
53
|
+
def register_collaboration_mode(
|
|
54
|
+
self,
|
|
55
|
+
*,
|
|
56
|
+
session_id: str,
|
|
57
|
+
thread_id: str,
|
|
58
|
+
mode: str,
|
|
59
|
+
turn_id: str | None = None,
|
|
60
|
+
) -> None:
|
|
61
|
+
if turn_id is None:
|
|
62
|
+
self._pending_collaboration_mode[(session_id, thread_id)] = mode
|
|
63
|
+
return
|
|
64
|
+
self._pending_collaboration_mode.pop((session_id, thread_id), None)
|
|
65
|
+
self._bind_collaboration_turn(session_id, thread_id, turn_id, mode)
|
|
66
|
+
|
|
67
|
+
def cancel_pending_collaboration_mode(self, *, session_id: str, thread_id: str) -> None:
|
|
68
|
+
self._pending_collaboration_mode.pop((session_id, thread_id), None)
|
|
69
|
+
|
|
70
|
+
def _bind_pending_collaboration_turn(
|
|
71
|
+
self,
|
|
72
|
+
session_id: str,
|
|
73
|
+
thread_id: str | None,
|
|
74
|
+
turn_id: str | None,
|
|
75
|
+
) -> None:
|
|
76
|
+
if thread_id is None or turn_id is None:
|
|
77
|
+
return
|
|
78
|
+
mode = self._pending_collaboration_mode.pop((session_id, thread_id), None)
|
|
79
|
+
if mode is not None:
|
|
80
|
+
self._bind_collaboration_turn(session_id, thread_id, turn_id, mode)
|
|
81
|
+
|
|
82
|
+
def _bind_collaboration_turn(self, session_id: str, thread_id: str, turn_id: str, mode: str) -> None:
|
|
83
|
+
key = (session_id, thread_id, turn_id)
|
|
84
|
+
self._collaboration_mode_by_turn[key] = mode
|
|
85
|
+
self._collaboration_mode_by_turn.move_to_end(key)
|
|
86
|
+
while len(self._collaboration_mode_by_turn) > COLLABORATION_TURN_CACHE_SIZE:
|
|
87
|
+
self._collaboration_mode_by_turn.popitem(last=False)
|
|
88
|
+
|
|
89
|
+
def _collaboration_mode_for_turn(
|
|
90
|
+
self,
|
|
91
|
+
session_id: str,
|
|
92
|
+
thread_id: str | None,
|
|
93
|
+
turn_id: str | None,
|
|
94
|
+
) -> str | None:
|
|
95
|
+
if thread_id is None or turn_id is None:
|
|
96
|
+
return None
|
|
97
|
+
return self._collaboration_mode_by_turn.get((session_id, thread_id, turn_id))
|
|
98
|
+
|
|
49
99
|
def _session_update(
|
|
50
100
|
self,
|
|
51
101
|
*,
|
|
@@ -263,6 +313,10 @@ class TimelineReducer:
|
|
|
263
313
|
return ReductionResult()
|
|
264
314
|
if thread_id:
|
|
265
315
|
self.bind_session(session_id, thread_id)
|
|
316
|
+
# Codex does not guarantee that turn/started is the first event for a
|
|
317
|
+
# turn. Bind on the first notification carrying the real turn id so an
|
|
318
|
+
# early item or turn/completed event receives the requested mode too.
|
|
319
|
+
self._bind_pending_collaboration_turn(session_id, thread_id, turn_id)
|
|
266
320
|
|
|
267
321
|
if method == "thread/name/updated":
|
|
268
322
|
return ReductionResult(
|
|
@@ -343,6 +397,18 @@ class TimelineReducer:
|
|
|
343
397
|
approvals=[approval],
|
|
344
398
|
)
|
|
345
399
|
|
|
400
|
+
if method == CODEX_INPUT_REQUEST_METHOD:
|
|
401
|
+
approval = self._input_request_from_request(message, params, session_id, thread_id, turn_id)
|
|
402
|
+
blocking = params.get("isBlocking") is not False
|
|
403
|
+
return ReductionResult(
|
|
404
|
+
session_update=self._session_update(
|
|
405
|
+
session_id=session_id,
|
|
406
|
+
thread_id=thread_id,
|
|
407
|
+
status="waiting_approval" if blocking else "running",
|
|
408
|
+
),
|
|
409
|
+
approvals=[approval],
|
|
410
|
+
)
|
|
411
|
+
|
|
346
412
|
if method == "item/agentMessage/delta":
|
|
347
413
|
item_id = _string_value(params.get("itemId")) or _nested_string(params, "item", "id")
|
|
348
414
|
item = self._append_text_item(
|
|
@@ -836,6 +902,39 @@ class TimelineReducer:
|
|
|
836
902
|
event=method,
|
|
837
903
|
)
|
|
838
904
|
|
|
905
|
+
def _input_request_from_request(
|
|
906
|
+
self,
|
|
907
|
+
message: dict[str, Any],
|
|
908
|
+
params: dict[str, Any],
|
|
909
|
+
session_id: str,
|
|
910
|
+
thread_id: str | None,
|
|
911
|
+
turn_id: str | None,
|
|
912
|
+
) -> dict[str, Any]:
|
|
913
|
+
item_id = _string_value(params.get("itemId"))
|
|
914
|
+
questions = [item for item in _list_value(params.get("questions")) if isinstance(item, dict)]
|
|
915
|
+
first = questions[0] if questions else {}
|
|
916
|
+
approval_id = f"appr_{_short_hash([session_id, thread_id, turn_id, item_id, CODEX_INPUT_REQUEST_METHOD, message.get('id')])}"
|
|
917
|
+
return {
|
|
918
|
+
"id": approval_id,
|
|
919
|
+
"sessionId": session_id,
|
|
920
|
+
"turnId": turn_id,
|
|
921
|
+
"status": "pending",
|
|
922
|
+
"kind": "input_request",
|
|
923
|
+
"targetItemId": None,
|
|
924
|
+
"title": _string_value(first.get("header")) or "Codex needs your input",
|
|
925
|
+
"description": _string_value(first.get("question")),
|
|
926
|
+
"payload": params,
|
|
927
|
+
"choices": [],
|
|
928
|
+
"source": {
|
|
929
|
+
"runtime": "codex",
|
|
930
|
+
"requestId": message.get("id"),
|
|
931
|
+
"sessionId": thread_id,
|
|
932
|
+
"turnId": turn_id,
|
|
933
|
+
"itemId": item_id,
|
|
934
|
+
"method": CODEX_INPUT_REQUEST_METHOD,
|
|
935
|
+
},
|
|
936
|
+
}
|
|
937
|
+
|
|
839
938
|
def _upsert_item(
|
|
840
939
|
self,
|
|
841
940
|
*,
|
|
@@ -870,6 +969,13 @@ class TimelineReducer:
|
|
|
870
969
|
}
|
|
871
970
|
if source_extra:
|
|
872
971
|
source.update(source_extra)
|
|
972
|
+
collaboration_mode = self._collaboration_mode_for_turn(
|
|
973
|
+
session_id,
|
|
974
|
+
source_session_id,
|
|
975
|
+
turn_id,
|
|
976
|
+
)
|
|
977
|
+
if collaboration_mode is not None:
|
|
978
|
+
source.setdefault("collaborationMode", collaboration_mode)
|
|
873
979
|
source = {key: value for key, value in source.items() if value is not None}
|
|
874
980
|
content_hash = _content_hash(item_type, status, role, content, source)
|
|
875
981
|
if existing and existing.get("contentHash") == content_hash:
|
|
@@ -422,6 +422,8 @@ class BackendRpcClient:
|
|
|
422
422
|
return await self._resolve_adapter(params).interrupt_turn(params)
|
|
423
423
|
if method == "approval.resolve":
|
|
424
424
|
return await self._resolve_adapter(params).resolve_approval(params)
|
|
425
|
+
if method == "input.respond":
|
|
426
|
+
return await self._resolve_adapter(params).respond_to_input(params)
|
|
425
427
|
if method == "fs.prepareDownload":
|
|
426
428
|
return await self.local_ops.prepare_download(params)
|
|
427
429
|
if method == "fs.uploadPreparedDownload":
|
|
@@ -11,6 +11,7 @@ from connector.codex.adapter import (
|
|
|
11
11
|
CodexAdapter,
|
|
12
12
|
stable_session_id,
|
|
13
13
|
)
|
|
14
|
+
from connector.codex.collaboration import collaboration_mode_payload, normalize_collaboration_modes
|
|
14
15
|
from connector.codex.history import read_timeline_history, read_tool_history
|
|
15
16
|
from connector.codex.reducer import TimelineReducer
|
|
16
17
|
from connector.codex.rpc import APP_SERVER_STREAM_LIMIT, JsonRpcStdioClient
|
|
@@ -77,6 +78,19 @@ class FakeCodexRpc:
|
|
|
77
78
|
self.responses.append((request_id, result))
|
|
78
79
|
|
|
79
80
|
|
|
81
|
+
class CollaborationCodexRpc(FakeCodexRpc):
|
|
82
|
+
async def request(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
83
|
+
if method == "collaborationMode/list":
|
|
84
|
+
self.requests.append((method, params))
|
|
85
|
+
return {
|
|
86
|
+
"data": [
|
|
87
|
+
{"name": "Plan", "mode": "plan", "model": None, "reasoning_effort": "medium"},
|
|
88
|
+
{"name": "Default", "mode": "default", "model": None, "reasoning_effort": None},
|
|
89
|
+
]
|
|
90
|
+
}
|
|
91
|
+
return await super().request(method, params)
|
|
92
|
+
|
|
93
|
+
|
|
80
94
|
class InterruptThreadNotFoundRpc(FakeCodexRpc):
|
|
81
95
|
async def request(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
82
96
|
if method == "turn/interrupt":
|
|
@@ -168,6 +182,17 @@ class MissingAfterUnloadedThreadStartRpc(FakeCodexRpc):
|
|
|
168
182
|
return {}
|
|
169
183
|
|
|
170
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
|
+
|
|
171
196
|
class MissingModelProviderResumeRpc(FakeCodexRpc):
|
|
172
197
|
async def request(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
173
198
|
self.requests.append((method, params))
|
|
@@ -187,6 +212,87 @@ def test_stdio_client_stream_limit_is_large_enough_for_codex_jsonl() -> None:
|
|
|
187
212
|
assert APP_SERVER_STREAM_LIMIT >= 64 * 1024 * 1024
|
|
188
213
|
|
|
189
214
|
|
|
215
|
+
def test_collaboration_modes_normalize_and_build_plan_settings() -> None:
|
|
216
|
+
options = normalize_collaboration_modes(
|
|
217
|
+
{
|
|
218
|
+
"data": [
|
|
219
|
+
{"name": "Plan", "mode": "plan", "model": None, "reasoning_effort": "medium"},
|
|
220
|
+
{"name": "Default", "mode": "default", "model": None, "reasoning_effort": None},
|
|
221
|
+
]
|
|
222
|
+
}
|
|
223
|
+
)
|
|
224
|
+
assert [option["value"] for option in options] == ["default", "plan"]
|
|
225
|
+
plan = collaboration_mode_payload("plan", options, model="gpt-test", effort="high")
|
|
226
|
+
assert plan is not None
|
|
227
|
+
assert plan["mode"] == "plan"
|
|
228
|
+
assert plan["settings"]["model"] == "gpt-test"
|
|
229
|
+
assert plan["settings"]["reasoning_effort"] == "medium"
|
|
230
|
+
assert "Plan Mode" in plan["settings"]["developer_instructions"]
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def test_reducer_maps_codex_input_request() -> None:
|
|
234
|
+
reducer = TimelineReducer()
|
|
235
|
+
reducer.bind_session("sess_1", "thr_1")
|
|
236
|
+
params = {
|
|
237
|
+
"threadId": "thr_1",
|
|
238
|
+
"turnId": "turn_1",
|
|
239
|
+
"itemId": "item_1",
|
|
240
|
+
"questions": [
|
|
241
|
+
{
|
|
242
|
+
"id": "strategy",
|
|
243
|
+
"header": "Strategy",
|
|
244
|
+
"question": "Which strategy?",
|
|
245
|
+
"options": [{"label": "Safe", "description": "Prefer compatibility"}],
|
|
246
|
+
"isOther": True,
|
|
247
|
+
"isSecret": False,
|
|
248
|
+
}
|
|
249
|
+
],
|
|
250
|
+
"isBlocking": True,
|
|
251
|
+
}
|
|
252
|
+
reduced = reducer.reduce_notification(
|
|
253
|
+
{"id": 42, "method": "item/tool/requestUserInput", "params": params}
|
|
254
|
+
)
|
|
255
|
+
assert reduced.session_update["status"] == "waiting_approval"
|
|
256
|
+
assert reduced.approvals[0]["kind"] == "input_request"
|
|
257
|
+
assert reduced.approvals[0]["payload"] == params
|
|
258
|
+
assert reduced.approvals[0]["source"]["requestId"] == 42
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def test_adapter_applies_plan_mode_and_responds_to_input() -> None:
|
|
262
|
+
async def exercise() -> None:
|
|
263
|
+
async def sink(_method: str, _params: dict[str, Any]) -> None:
|
|
264
|
+
return None
|
|
265
|
+
|
|
266
|
+
rpc = CollaborationCodexRpc()
|
|
267
|
+
adapter = CodexAdapter(rpc=rpc, notification_sink=sink) # type: ignore[arg-type]
|
|
268
|
+
adapter.reducer.bind_session("sess_1", "thr_1")
|
|
269
|
+
await adapter.start_turn(
|
|
270
|
+
{
|
|
271
|
+
"sessionId": "sess_1",
|
|
272
|
+
"externalSessionId": "thr_1",
|
|
273
|
+
"content": "plan this",
|
|
274
|
+
"model": "gpt-test",
|
|
275
|
+
"effort": "high",
|
|
276
|
+
"collaborationMode": "plan",
|
|
277
|
+
}
|
|
278
|
+
)
|
|
279
|
+
settings_request = next(
|
|
280
|
+
params for method, params in rpc.requests if method == "thread/settings/update"
|
|
281
|
+
)
|
|
282
|
+
assert settings_request["collaborationMode"]["mode"] == "plan"
|
|
283
|
+
assert settings_request["collaborationMode"]["settings"]["reasoning_effort"] == "medium"
|
|
284
|
+
turn_request = next(params for method, params in rpc.requests if method == "turn/start")
|
|
285
|
+
assert turn_request["model"] == "gpt-test"
|
|
286
|
+
assert turn_request["effort"] == "medium"
|
|
287
|
+
|
|
288
|
+
await adapter.respond_to_input(
|
|
289
|
+
{"requestId": 42, "answers": {"strategy": {"answers": ["Safe"]}}}
|
|
290
|
+
)
|
|
291
|
+
assert rpc.responses[-1] == (42, {"answers": {"strategy": {"answers": ["Safe"]}}})
|
|
292
|
+
|
|
293
|
+
asyncio.run(exercise())
|
|
294
|
+
|
|
295
|
+
|
|
190
296
|
def test_stdio_client_preserves_numeric_server_request_ids() -> None:
|
|
191
297
|
client = JsonRpcStdioClient(command=["codex"])
|
|
192
298
|
client._server_request_ids.add(0) # noqa: SLF001
|
|
@@ -334,6 +440,122 @@ def test_reducer_maps_codex_turn_and_message_notifications() -> None:
|
|
|
334
440
|
assert [item["type"] for item in completed.timeline_items] == ["turn.start", "turn.end"]
|
|
335
441
|
|
|
336
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
|
+
|
|
337
559
|
def test_reducer_keeps_agent_delta_items_separate_by_item_id() -> None:
|
|
338
560
|
reducer = TimelineReducer()
|
|
339
561
|
reducer.bind_session("sess_1", "thr_1")
|
|
@@ -856,6 +1078,10 @@ def test_adapter_replaces_thread_when_unloaded_codex_thread_rollout_is_missing()
|
|
|
856
1078
|
asyncio.run(_exercise_unloaded_thread_missing_rollout_replacement())
|
|
857
1079
|
|
|
858
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
|
+
|
|
859
1085
|
def test_codex_commands_use_native_app_server_methods() -> None:
|
|
860
1086
|
async def exercise() -> None:
|
|
861
1087
|
rpc = FakeCodexRpc()
|
|
@@ -1123,6 +1349,40 @@ async def _exercise_unloaded_thread_missing_rollout_replacement() -> None:
|
|
|
1123
1349
|
]
|
|
1124
1350
|
|
|
1125
1351
|
|
|
1352
|
+
async def _exercise_failed_recovered_turn_start_clears_pending_mode() -> None:
|
|
1353
|
+
rpc = FailingRecoveredTurnStartRpc()
|
|
1354
|
+
adapter = CodexAdapter(rpc=rpc) # type: ignore[arg-type]
|
|
1355
|
+
adapter.reducer.bind_session("sess_1", "thr_1")
|
|
1356
|
+
adapter._loaded_thread_ids.add("thr_1")
|
|
1357
|
+
|
|
1358
|
+
try:
|
|
1359
|
+
await adapter.start_turn(
|
|
1360
|
+
{
|
|
1361
|
+
"sessionId": "sess_1",
|
|
1362
|
+
"externalSessionId": "thr_1",
|
|
1363
|
+
"content": "make a plan",
|
|
1364
|
+
"collaborationMode": "plan",
|
|
1365
|
+
}
|
|
1366
|
+
)
|
|
1367
|
+
except RuntimeError:
|
|
1368
|
+
pass
|
|
1369
|
+
else:
|
|
1370
|
+
raise AssertionError("expected recovered turn/start retry to fail")
|
|
1371
|
+
|
|
1372
|
+
later = adapter.reducer.reduce_notification(
|
|
1373
|
+
{
|
|
1374
|
+
"method": "item/agentMessage/delta",
|
|
1375
|
+
"params": {
|
|
1376
|
+
"threadId": "thr_1",
|
|
1377
|
+
"turnId": "turn_later",
|
|
1378
|
+
"itemId": "msg_later",
|
|
1379
|
+
"delta": "normal answer",
|
|
1380
|
+
},
|
|
1381
|
+
}
|
|
1382
|
+
).timeline_items[0]
|
|
1383
|
+
assert "collaborationMode" not in later["source"]
|
|
1384
|
+
|
|
1385
|
+
|
|
1126
1386
|
def test_adapter_sync_session_refreshes_loaded_codex_thread() -> None:
|
|
1127
1387
|
async def exercise() -> None:
|
|
1128
1388
|
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
|