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.
Files changed (84) hide show
  1. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/PKG-INFO +1 -1
  2. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/capabilities.py +20 -0
  3. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/codex/adapter.py +87 -2
  4. agentlink_cli-0.1.2/connector/codex/collaboration.py +67 -0
  5. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/codex/reducer.py +107 -1
  6. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/runtime.py +2 -0
  7. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/version.py +1 -1
  8. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/pyproject.toml +1 -1
  9. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/tests/test_codex_adapter.py +260 -0
  10. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/tests/test_version.py +1 -1
  11. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/uv.lock +1 -1
  12. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/.gitignore +0 -0
  13. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/README.md +0 -0
  14. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/__init__.py +0 -0
  15. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/acp/__init__.py +0 -0
  16. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/acp/adapter.py +0 -0
  17. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/acp/config_options.py +0 -0
  18. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/acp/discovery.py +0 -0
  19. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/acp/manifest.py +0 -0
  20. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/acp/manifests/__init__.py +0 -0
  21. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/acp/manifests/codebuddy.json +0 -0
  22. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/acp/manifests/cursor.json +0 -0
  23. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/acp/manifests/gemini.json +0 -0
  24. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/acp/manifests/grok_build.json +0 -0
  25. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/acp/reducer.py +0 -0
  26. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/acp/rpc.py +0 -0
  27. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/adapter.py +0 -0
  28. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/attachments.py +0 -0
  29. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/claude/__init__.py +0 -0
  30. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/claude/history_adapter.py +0 -0
  31. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/claude/normalized.py +0 -0
  32. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/claude/normalizers.py +0 -0
  33. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/claude/path_utils.py +0 -0
  34. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/claude/preferences.py +0 -0
  35. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/claude/sdk_adapter.py +0 -0
  36. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/claude/timeline_identity.py +0 -0
  37. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/claude/timeline_reducer.py +0 -0
  38. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/claude/trust.py +0 -0
  39. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/cli.py +0 -0
  40. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/codex/__init__.py +0 -0
  41. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/codex/history.py +0 -0
  42. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/codex/rpc.py +0 -0
  43. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/control.py +0 -0
  44. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/json_rpc.py +0 -0
  45. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/launch.py +0 -0
  46. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/local/__init__.py +0 -0
  47. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/local/common.py +0 -0
  48. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/local/file_ops.py +0 -0
  49. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/local/ops.py +0 -0
  50. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/local/shell.py +0 -0
  51. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/local/terminal.py +0 -0
  52. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/local_ops.py +0 -0
  53. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/local_runtime.py +0 -0
  54. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/logging.py +0 -0
  55. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/perf.py +0 -0
  56. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/protocol.py +0 -0
  57. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/registry.py +0 -0
  58. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/sync_state.py +0 -0
  59. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/connector/time.py +0 -0
  60. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/run.sh +0 -0
  61. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/tests/__init__.py +0 -0
  62. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/tests/acp/__init__.py +0 -0
  63. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/tests/acp/fake_agent/__init__.py +0 -0
  64. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/tests/acp/fake_agent/__main__.py +0 -0
  65. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/tests/acp/test_acp_adapter_fake.py +0 -0
  66. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/tests/acp/test_acp_reducer_golden.py +0 -0
  67. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/tests/acp/test_config_options.py +0 -0
  68. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/tests/contract/__init__.py +0 -0
  69. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/tests/contract/helpers.py +0 -0
  70. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/tests/test_claude_history_adapter.py +0 -0
  71. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/tests/test_claude_preferences.py +0 -0
  72. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/tests/test_claude_sdk_adapter.py +0 -0
  73. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/tests/test_claude_timeline_parity.py +0 -0
  74. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/tests/test_claude_trust.py +0 -0
  75. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/tests/test_connector_capabilities.py +0 -0
  76. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/tests/test_connector_cli.py +0 -0
  77. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/tests/test_connector_control.py +0 -0
  78. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/tests/test_connector_json_rpc.py +0 -0
  79. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/tests/test_connector_logging.py +0 -0
  80. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/tests/test_connector_runtime.py +0 -0
  81. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/tests/test_launch_command.py +0 -0
  82. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/tests/test_local_runtime.py +0 -0
  83. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/tests/test_perf.py +0 -0
  84. {agentlink_cli-0.1.0 → agentlink_cli-0.1.2}/tests/test_terminal_backend.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentlink-cli
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Local runtime connector for AgentLink
5
5
  Requires-Python: >=3.12
6
6
  Requires-Dist: claude-agent-sdk
@@ -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
- 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
+ )
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":
@@ -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.2"
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.0"
3
+ version = "0.1.2"
4
4
  description = "Local runtime connector for AgentLink"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -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()
@@ -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.2"
@@ -8,7 +8,7 @@ resolution-markers = [
8
8
 
9
9
  [[package]]
10
10
  name = "agentlink-cli"
11
- version = "0.1.0"
11
+ version = "0.1.2"
12
12
  source = { editable = "." }
13
13
  dependencies = [
14
14
  { name = "claude-agent-sdk" },
File without changes
File without changes
File without changes