@agentsdock/server 1.0.8 → 1.0.10-beta.1

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.
@@ -659,16 +659,36 @@ class _AsyncDeliveryIndex:
659
659
  starts: dict = field(default_factory=dict)
660
660
  ends: dict = field(default_factory=dict)
661
661
  receipts: dict = field(default_factory=dict)
662
+ status_queues: dict = field(default_factory=dict)
663
+ status_body_keys: set = field(default_factory=set)
662
664
  steered: set = field(default_factory=set)
663
665
  count: int = 0
664
666
 
665
667
  def observe(self, event: dict) -> None:
666
668
  event_type = event.get("type")
667
- if (event_type not in {"turn_started", "turn_finished", "turn_steered", "turn_stopped", "turn_queue_run_now",
669
+ if (event_type not in {"turn_queued", "turn_started", "turn_finished", "turn_steered", "turn_stopped", "turn_queue_run_now",
668
670
  "chat_conversation_message_received", "chat_conversation_message_started", "chat_conversation_message_delivered"}
669
671
  or event.get("imported") is True or event.get("forked") is True
670
672
  or event.get("backend") not in (None, "codex")):
671
673
  return
674
+ if event_type == "turn_queued":
675
+ # The private durable request is exact provider input. Public display
676
+ # labels and wrapper-looking text are never evidence by themselves.
677
+ queued_id, body = event.get("queued_id"), event.get("request_prompt")
678
+ if (event.get("purpose") == "cross_chat_handoff_delivery"
679
+ and event.get("cross_chat_exchange_status") is True
680
+ and not _runtime_human_provenance(event)
681
+ and isinstance(queued_id, str) and 0 < len(queued_id) <= 256
682
+ and isinstance(body, str) and 0 < len(body) <= NATIVE_PROOF_LINE_BYTES):
683
+ record = {key: event.get(key) for key in ("seq", "cross_chat_exchange_id",
684
+ "cross_chat_exchange_leg_id", "source_session_id", "target_session_id")}
685
+ record["body_key"] = _text_key(body)
686
+ self.status_queues.setdefault(queued_id, []).append(record)
687
+ self.status_body_keys.add(record["body_key"])
688
+ self.count += 1
689
+ if self.count > MAX_KEYS:
690
+ raise _Unproven()
691
+ return
672
692
  run = (event.get("interrupted_run_id") or event.get("run_id")) if event_type == "turn_queue_run_now" else event.get("run_id")
673
693
  if (event_type in {"turn_steered", "turn_stopped"} or event.get("native_steer") is True):
674
694
  if isinstance(run, str) and 0 < len(run) <= 256 and not run.startswith("import_"):
@@ -686,7 +706,8 @@ class _AsyncDeliveryIndex:
686
706
  "provider_thread_id", "provider_turn_id", "cross_chat_envelope_id", "handoff_id", "message_id",
687
707
  "source_session_id", "target_session_id", "source_title", "target_run_id", "conversation_mode",
688
708
  "kind", "action", "handoff_action", "handoff_body_chars", "handoff_body_sha256",
689
- "message_revision", "message_edited_by_user", "exchange_id", "cross_chat_exchange_id")
709
+ "message_revision", "message_edited_by_user", "exchange_id", "cross_chat_exchange_id",
710
+ "cross_chat_exchange_leg_id", "cross_chat_exchange_status", "queued_id")
690
711
  record = {key: event.get(key) for key in fields
691
712
  if event.get(key) is None or type(event.get(key)) in (int, bool)
692
713
  or isinstance(event.get(key), str) and len(event[key]) <= 512}
@@ -710,11 +731,36 @@ class _AsyncDeliveryIndex:
710
731
  raise _Unproven()
711
732
 
712
733
  def match(self, session_id: str, provider_id: str, run: str, origin: dict,
713
- body: tuple[str, str, int] | None, source_ids: set) -> dict | None:
734
+ body: tuple[str, str, int] | None, source_ids: set, body_key: str = "") -> dict | None:
714
735
  starts, ends = self.starts.get(run, []), self.ends.get(run, [])
715
- if body is None or len(starts) != 1 or len(ends) != 1 or run in self.steered:
736
+ if len(starts) != 1 or len(ends) != 1 or run in self.steered:
716
737
  return None
717
738
  start, end = starts[0], ends[0]
739
+ if start.get("cross_chat_exchange_status") is True:
740
+ queued = self.status_queues.get(start.get("queued_id"), [])
741
+ if len(queued) != 1:
742
+ return None
743
+ receipt = queued[0]
744
+ identity = ("cross_chat_exchange_id", "cross_chat_exchange_leg_id",
745
+ "source_session_id", "target_session_id")
746
+ if (start.get("malformed") or end.get("malformed") or not start.get("body_key")
747
+ or any(not isinstance(start.get(key), str) or not start[key]
748
+ or receipt.get(key) != start[key] or end.get(key) != start[key] for key in identity)
749
+ or start.get("target_session_id") != session_id or start.get("source_session_id") == session_id
750
+ or start.get("backend") != "codex" or end.get("backend") != "codex"
751
+ or end.get("cross_chat_exchange_status") is not True or end.get("transport") != "app-server"
752
+ or end.get("provider_thread_id") != provider_id or end.get("provider_turn_id") != origin.get("turn_id")
753
+ or receipt["body_key"] != body_key or source_ids != {origin.get("event_id")} or not origin.get("event_id")
754
+ or any(type(record.get("seq")) is not int for record in (receipt, start, end))
755
+ or not receipt["seq"] < start["seq"] < end["seq"]
756
+ or not isinstance(start.get("id"), str) or not start["id"]):
757
+ return None
758
+ times = [_delivery_timestamp(value) for value in (start.get("ts"), origin.get("timestamp"), end.get("ts"))]
759
+ if any(value is None for value in times) or not times[0] <= times[1] <= times[2]:
760
+ return None
761
+ return _native_event_identity(start)
762
+ if body is None:
763
+ return None
718
764
  envelope = start.get("cross_chat_envelope_id")
719
765
  if (start.get("malformed") or end.get("malformed")
720
766
  or not isinstance(envelope, str) or not envelope or start.get("backend") != "codex"
@@ -1044,7 +1090,7 @@ def _prove_native_source(thread: str, source: Path, root: Path, batches: dict, c
1044
1090
  native_matches = native.get((native_run, kind, body_key), [])
1045
1091
  if not native_matches and kind == "user":
1046
1092
  delivery = deliveries.match(session_id, thread, native_run, source_origin,
1047
- delivery_bodies.get(key), delivery_source_ids.get(key[0], set()))
1093
+ delivery_bodies.get(key), delivery_source_ids.get(key[0], set()), body_key)
1048
1094
  if delivery is not None:
1049
1095
  native_matches = [delivery]
1050
1096
  if not native_matches and kind == "assistant":
@@ -1155,7 +1201,7 @@ def _prove_pending_async_deliveries(session_id: str, provider_id: str, items: li
1155
1201
  if (item.get("kind") != "user" or not isinstance(item.get("text"), str)
1156
1202
  or not isinstance(origin, dict) or origin.get("provider") != "codex" or origin.get("kind") != "user"
1157
1203
  or origin.get("session_id", provider_id) != provider_id or item.get("source_text_sha256") is not None
1158
- or not _async_delivery_body(item["text"])):
1204
+ or not (_async_delivery_body(item["text"]) or _text_key(item["text"]) in deliveries.status_body_keys)):
1159
1205
  continue
1160
1206
  candidate = _replay_target({"seq": previous_seq + index + 2, "id": f"pending:{index}", "run_id": run,
1161
1207
  "type": "turn_started", "backend": "codex", "imported": True, "prompt": item["text"],
@@ -1205,8 +1251,7 @@ def filter_native_codex_history_items(session_id: str, provider_id: str, events:
1205
1251
  deliveries = _AsyncDeliveryIndex()
1206
1252
  prove_deliveries = (source_path is not None and root is not None
1207
1253
  and isinstance(sync_checkpoint, dict) and callable(parse_item)
1208
- and any(item.get("kind") == "user" and isinstance(item.get("text"), str)
1209
- and _async_delivery_body(item["text"]) for item in items))
1254
+ and any(item.get("kind") == "user" and isinstance(item.get("text"), str) for item in items))
1210
1255
  previous_seq, owner_count = 0, 0
1211
1256
  for event, _offset, _line in _native_records(events, stamp, budget):
1212
1257
  seq = event.get("seq")
@@ -195,6 +195,19 @@ def session_choice(value) -> str:
195
195
  return choice
196
196
 
197
197
 
198
+ RUNTIME_FIELDS = ("codex_provider", "codex_provider_revision", "codex_provider_binding", "model", "effort", "_codex_provider_removed")
199
+
200
+
201
+ def requested_session(session: dict) -> dict:
202
+ """Desired runtime is separate from the owner of an in-flight turn."""
203
+ pending = session.get("_codex_provider_pending")
204
+ return {**session, **pending} if isinstance(pending, dict) else dict(session)
205
+
206
+
207
+ def runtime_selection(session: dict) -> dict:
208
+ return {key: session.get(key) for key in RUNTIME_FIELDS}
209
+
210
+
198
211
  class ProviderStore:
199
212
  """Atomic metadata pointer plus private, endpoint-bound credential records."""
200
213
  def __init__(self, root: Path):
@@ -329,6 +342,8 @@ class ProviderStore:
329
342
  def for_session(self, session: dict, *, include_key=False) -> dict | None:
330
343
  if session_choice(session.get("codex_provider")) == "default":
331
344
  return None
345
+ if session.get("_codex_provider_removed"):
346
+ raise HTTPException(409, "Configure the custom Codex endpoint before using this chat.")
332
347
  revision = session.get("codex_provider_revision")
333
348
  selected = self.selection(include_key=include_key, revision=revision, include_revision=True)
334
349
  if selected is None:
@@ -345,6 +360,30 @@ class ProviderStore:
345
360
  selected["model"] = validate_model(model)
346
361
  return selected
347
362
 
363
+ def control(self, session: dict, *, available=True, summary=False) -> dict:
364
+ requested = requested_session(session)
365
+ pending = isinstance(session.get("_codex_provider_pending"), dict)
366
+ requested_provider = session_choice(requested.get("codex_provider"))
367
+ def endpoint(value):
368
+ if session_choice(value.get("codex_provider")) != "custom" or value.get("_codex_provider_removed"):
369
+ return None
370
+ try:
371
+ selected = self._public_selections.get(value.get("codex_provider_revision")) or self.for_session(value)
372
+ return selected.get("base_url") if selected else None
373
+ except HTTPException:
374
+ return None
375
+ return {"pending": pending,
376
+ "pending_reason": session.get("_codex_provider_pending_reason") if pending else None,
377
+ "requested_provider": requested_provider,
378
+ "active_provider": session_choice(session.get("codex_provider")),
379
+ "requested_model": requested.get("model"), "requested_effort": requested.get("effort"),
380
+ # The next-turn picker must use the requested endpoint's catalog,
381
+ # while active runtime fields continue describing running work.
382
+ # Avoid duplicating the active catalog on ordinary session events.
383
+ "requested_catalog": self.catalog(available=available, session=requested, summary=summary)
384
+ if pending and requested_provider == "custom" else None,
385
+ "requested_base_url": endpoint(requested), "active_base_url": endpoint(session)}
386
+
348
387
  def registration(self, *, include_key=False) -> dict | None:
349
388
  # A damaged optional endpoint must not prevent normal Codex startup.
350
389
  # Custom chat admission and the admin status route still fail closed.
@@ -575,6 +614,17 @@ class ProviderStore:
575
614
  if selected:
576
615
  self._atomic(self._binding_name(thread_id), {"binding": binding(selected),
577
616
  "credential_id": selected.get("credential_id")})
617
+ else:
618
+ self._atomic(self._binding_name(thread_id), {"binding": None, "credential_id": None})
619
+
620
+ def thread_binding(self, thread_id: str) -> dict:
621
+ """Snapshot rollback metadata without reopening revoked credentials."""
622
+ with self.lock:
623
+ return self._read(self._binding_name(thread_id)) or {"binding": None, "credential_id": None}
624
+
625
+ def restore_thread_binding(self, thread_id: str, record: dict) -> None:
626
+ with self.lock:
627
+ self._atomic(self._binding_name(thread_id), record)
578
628
 
579
629
  def for_thread(self, thread_id: str, *, include_key=False) -> dict | None:
580
630
  with self.lock:
@@ -1076,7 +1126,7 @@ def create_router(*, authorize, store: ProviderStore, mutate, probe, available,
1076
1126
  session = session_lookup(session_id) if session_lookup else None
1077
1127
  if not session:
1078
1128
  raise HTTPException(404, "Chat not found.")
1079
- selected = await asyncio.to_thread(store.for_session, session, include_key=True)
1129
+ selected = await asyncio.to_thread(store.for_session, requested_session(session), include_key=True)
1080
1130
  else:
1081
1131
  selected = await asyncio.to_thread(store.selection, include_key=True, include_revision=True)
1082
1132
  if selected is None:
@@ -1141,7 +1191,7 @@ def create_router(*, authorize, store: ProviderStore, mutate, probe, available,
1141
1191
  session = session_lookup(session_id) if session_lookup else None
1142
1192
  if not session:
1143
1193
  raise HTTPException(404, "Chat not found.")
1144
- selected = await asyncio.to_thread(store.for_session, session, include_key=True)
1194
+ selected = await asyncio.to_thread(store.for_session, requested_session(session), include_key=True)
1145
1195
  else:
1146
1196
  selected = await asyncio.to_thread(store.selection, include_key=True, include_revision=True)
1147
1197
  if selected is None:
@@ -0,0 +1,229 @@
1
+ """Keep nonstandard custom Responses errors visible to native Codex.
2
+
3
+ An owned loopback transport forwards requests unchanged to one immutable endpoint.
4
+ Only untyped SSE error packets are normalized; native Codex owns all retries.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ from contextlib import contextmanager, suppress
10
+ import hmac
11
+ import json
12
+ import secrets
13
+ import socket
14
+ from urllib.parse import quote
15
+
16
+ import httpx
17
+ import uvicorn
18
+ from fastapi import FastAPI, Request
19
+ from fastapi.responses import Response, StreamingResponse
20
+
21
+ from codex_app_server import CodexAppServerManager
22
+ from codex_provider import ENV_KEY, PROVIDER_ID, config_args
23
+
24
+ # Larger frames pass through unchanged, rather than buffering arbitrary output.
25
+ # This is a parser memory bound, not a response or content size limit.
26
+ _ERROR_FRAME_BYTES = 64 * 1024
27
+ _HOP_HEADERS = {'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
28
+ 'te', 'trailer', 'transfer-encoding', 'upgrade', 'host', 'content-length'}
29
+
30
+
31
+ class ErrorFrames:
32
+ def __init__(self, secret: str):
33
+ self.secret = secret
34
+ self.pending = bytearray()
35
+ self.passthrough = False
36
+ self.response_id = 'resp_custom_endpoint_error'
37
+
38
+ def frame(self, raw: bytes) -> bytes:
39
+ try:
40
+ data = b'\n'.join(line[5:].lstrip(b' ') for line in raw.splitlines() if line.startswith(b'data:'))
41
+ packet = json.loads(data)
42
+ except (ValueError, UnicodeError):
43
+ return raw
44
+ if not isinstance(packet, dict):
45
+ return raw
46
+ response = packet.get('response')
47
+ if isinstance(response, dict) and isinstance(response.get('id'), str):
48
+ self.response_id = response['id']
49
+ error = packet.get('error')
50
+ if packet.get('type') or not error:
51
+ return raw
52
+ if isinstance(error, dict):
53
+ message = error.get('message')
54
+ if not isinstance(message, str) or not message:
55
+ return raw
56
+ code = str(error.get('code') or error.get('type') or 'server_error')
57
+ elif isinstance(error, str):
58
+ message, code = error, 'server_error'
59
+ else:
60
+ return raw
61
+ code = {'429': 'rate_limit_exceeded', '401': 'invalid_api_key',
62
+ '403': 'permission_denied', '400': 'invalid_request_error',
63
+ '500': 'server_error', '502': 'server_error', '503': 'server_error'}.get(code, code)
64
+ for value in (self.secret, quote(self.secret, safe='')):
65
+ if value:
66
+ message = message.replace(value, '[redacted]')
67
+ failed = {'type': 'response.failed', 'response': {
68
+ 'id': self.response_id, 'object': 'response', 'created_at': 0,
69
+ 'status': 'failed', 'output': [], 'error': {'code': code, 'message': message}}}
70
+ return b'event: response.failed\ndata: ' + json.dumps(failed, ensure_ascii=False).encode() + b'\n\n'
71
+
72
+ async def stream(self, chunks):
73
+ # Split at SSE blank lines, including CRLF boundaries across chunks.
74
+ async for chunk in chunks:
75
+ self.pending.extend(chunk)
76
+ while self.pending:
77
+ lf = self.pending.find(b'\n\n')
78
+ crlf = self.pending.find(b'\r\n\r\n')
79
+ endings = [(i, n) for i, n in ((lf, 2), (crlf, 4)) if i >= 0]
80
+ if endings:
81
+ i, n = min(endings)
82
+ raw = bytes(self.pending[:i+n]); del self.pending[:i+n]
83
+ yield raw if self.passthrough or len(raw) > _ERROR_FRAME_BYTES else self.frame(raw)
84
+ self.passthrough = False
85
+ elif len(self.pending) > _ERROR_FRAME_BYTES:
86
+ # Retain only a delimiter tail; do not truncate a large frame.
87
+ yield bytes(self.pending[:-3]); del self.pending[:-3]
88
+ self.passthrough = True
89
+ else:
90
+ break
91
+ if self.pending:
92
+ raw = bytes(self.pending); self.pending.clear()
93
+ yield raw if self.passthrough else self.frame(raw)
94
+
95
+
96
+ class _OwnedConfig(uvicorn.Config):
97
+ def configure_logging(self):
98
+ # Do not change the host server's process-wide logging configuration.
99
+ pass
100
+
101
+
102
+ class _OwnedServer(uvicorn.Server):
103
+ @contextmanager
104
+ def capture_signals(self):
105
+ # This transport must never take over its host server's signal handlers.
106
+ yield
107
+
108
+
109
+ class ResponseStreamBridge:
110
+ def __init__(self, selected: dict):
111
+ self.upstream = selected['base_url'].rstrip('/')
112
+ self.key = selected['api_key']
113
+ self.token = secrets.token_urlsafe(32)
114
+ self.url = ''
115
+ self.task = self.server = self.client = self.socket = None
116
+ app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
117
+ app.add_api_route('/v1/{path:path}', self.forward, methods=['POST', 'GET', 'DELETE'])
118
+ self.app = app
119
+
120
+ async def start(self):
121
+ if self.task is not None and not self.task.done():
122
+ return
123
+ self.client = httpx.AsyncClient(timeout=None, follow_redirects=False, trust_env=False)
124
+ self.socket = socket.socket()
125
+ self.socket.bind(('127.0.0.1', 0)); self.socket.listen(128); self.socket.setblocking(False)
126
+ self.url = f'http://127.0.0.1:{self.socket.getsockname()[1]}/v1'
127
+ self.server = _OwnedServer(_OwnedConfig(self.app, log_config=None, access_log=False, lifespan='off', log_level='critical'))
128
+ self.task = asyncio.create_task(self.server.serve(sockets=[self.socket]))
129
+ try:
130
+ while not self.server.started:
131
+ if self.task.done():
132
+ await self.task
133
+ raise RuntimeError('Custom response transport could not start')
134
+ await asyncio.sleep(0)
135
+ except BaseException:
136
+ await self.close()
137
+ raise
138
+
139
+ async def forward(self, request: Request, path: str):
140
+ if not hmac.compare_digest(request.headers.get('authorization', ''), 'Bearer ' + self.token):
141
+ return Response(status_code=401)
142
+ # The destination is immutable. A request cannot name a different origin.
143
+ if not (path == 'responses' or path.startswith('responses/')) or '..' in path.split('/'):
144
+ return Response(status_code=404)
145
+ headers = {k: v for k, v in request.headers.items() if k.lower() not in _HOP_HEADERS | {'authorization', 'accept-encoding', 'cookie'}}
146
+ headers.update(authorization='Bearer ' + self.key, **{'accept-encoding': 'identity'})
147
+ if 'content-length' in request.headers:
148
+ headers['content-length'] = request.headers['content-length']
149
+ uploaded = asyncio.Event()
150
+ async def upload():
151
+ try:
152
+ async for chunk in request.stream(): yield chunk
153
+ finally:
154
+ uploaded.set()
155
+ async def disconnected():
156
+ # Wait for the upload reader to finish before reading disconnects.
157
+ # No polling, execution deadline, or competing request-body reader.
158
+ await uploaded.wait()
159
+ while (await request.receive()).get('type') != 'http.disconnect':
160
+ pass
161
+ req = self.client.build_request(request.method, self.upstream + '/' + path,
162
+ params=request.query_params.multi_items(), headers=headers, content=upload())
163
+ sending = asyncio.create_task(self.client.send(req, stream=True))
164
+ disconnect = asyncio.create_task(disconnected())
165
+ try:
166
+ done, _ = await asyncio.wait((sending, disconnect), return_when=asyncio.FIRST_COMPLETED)
167
+ if disconnect in done:
168
+ if sending.done() and not sending.cancelled() and sending.exception() is None:
169
+ await sending.result().aclose()
170
+ return Response(status_code=499)
171
+ upstream = sending.result()
172
+ except httpx.HTTPError:
173
+ return Response('Custom endpoint connection failed', status_code=502)
174
+ finally:
175
+ for task in (sending, disconnect):
176
+ if not task.done(): task.cancel()
177
+ await asyncio.gather(sending, disconnect, return_exceptions=True)
178
+ headers = {k: v for k, v in upstream.headers.items() if k.lower() not in _HOP_HEADERS | {'content-encoding', 'set-cookie'}}
179
+ async def body():
180
+ try:
181
+ chunks = upstream.aiter_bytes()
182
+ if upstream.status_code == 200 and 'text/event-stream' in upstream.headers.get('content-type', '').lower():
183
+ chunks = ErrorFrames(self.key).stream(chunks)
184
+ async for chunk in chunks:
185
+ yield chunk
186
+ finally:
187
+ await upstream.aclose()
188
+ return StreamingResponse(body(), status_code=upstream.status_code, headers=headers)
189
+
190
+ async def close(self):
191
+ if self.server is not None:
192
+ self.server.should_exit = True
193
+ # The owning native process has already closed; cancel only this
194
+ # listener's in-flight transports, including a silent upstream.
195
+ tasks = tuple(self.server.server_state.tasks)
196
+ for task in tasks: task.cancel()
197
+ if tasks: await asyncio.gather(*tasks, return_exceptions=True)
198
+ if self.task is not None:
199
+ # Let Uvicorn close its asyncio listeners and remove their readers.
200
+ # Cancelling serve() skips shutdown and can leave a stale selector
201
+ # entry when the next provider reuses the same socket descriptor.
202
+ with suppress(asyncio.CancelledError): await self.task
203
+ if self.client is not None: await self.client.aclose()
204
+ if self.socket is not None: self.socket.close()
205
+ self.task = self.server = self.client = self.socket = None
206
+
207
+
208
+ class CustomCodexAppServerManager(CodexAppServerManager):
209
+ """Native manager with an owned, credential-scoped stream normalizer."""
210
+ def __init__(self, *args, selected: dict, **kwargs):
211
+ self.response_bridge = ResponseStreamBridge(selected)
212
+ prepare = kwargs.pop('before_start', None)
213
+ environment = kwargs.pop('env_factory')
214
+ original_args = tuple(kwargs.get('app_server_args', ()))
215
+ async def before_start():
216
+ if prepare is not None: await prepare()
217
+ await self.response_bridge.start()
218
+ self.client.app_server_args = (*original_args, *config_args({
219
+ f'model_providers.{PROVIDER_ID}.base_url': self.response_bridge.url}))
220
+ def env_factory():
221
+ return {**environment(), ENV_KEY: self.response_bridge.token}
222
+ kwargs['sensitive_values'] = (*kwargs.get('sensitive_values', ()), self.response_bridge.token)
223
+ super().__init__(*args, before_start=before_start, env_factory=env_factory, **kwargs)
224
+
225
+ async def close(self):
226
+ try:
227
+ await super().close()
228
+ finally:
229
+ await self.response_bridge.close()
@@ -0,0 +1,110 @@
1
+ """User-visible native Codex side-thread activity; never project parent/control input."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ from copy import deepcopy
6
+
7
+
8
+ class CodexSideChatProgress:
9
+ def __init__(self, publish, *, thread_id):
10
+ self.publish = publish
11
+ self.thread_id = thread_id
12
+ self.items = {}
13
+ self.parts = {}
14
+ self.completed = set()
15
+ self.pending = None
16
+ self.dirty = False
17
+
18
+ def _item(self, identifier, kind):
19
+ key = f"{identifier}:{kind}"
20
+ if key not in self.items:
21
+ self.items[key] = {"id": key, "kind": kind, "text": "", "status": "running"}
22
+ return self.items[key]
23
+
24
+ @staticmethod
25
+ def _text(parts):
26
+ if not isinstance(parts, list):
27
+ return ""
28
+ return "\n\n".join(part if isinstance(part, str) else str(part.get("text") or part.get("summary_text") or "")
29
+ for part in parts if isinstance(part, (str, dict)))
30
+
31
+ def receive(self, packet):
32
+ method, data = packet.get("method"), packet.get("params") or {}
33
+ # The transport normally scopes notifications; enforce it here too so
34
+ # descendant activity cannot become the side assistant's own answer.
35
+ if data.get("threadId") not in (None, self.thread_id):
36
+ return
37
+ item = data.get("item") or {}
38
+ identifier = str(data.get("itemId") or item.get("id") or "")
39
+ if not identifier or identifier in self.completed:
40
+ return
41
+ changed = False
42
+ if method in {"item/reasoning/summaryTextDelta", "item/reasoning/textDelta", "item/reasoning/summaryPartAdded"}:
43
+ kind = "reasoning" if method == "item/reasoning/textDelta" else "reasoning_summary"
44
+ field = "contentIndex" if kind == "reasoning" else "summaryIndex"
45
+ index = data.get(field, 0)
46
+ if not isinstance(index, int) or index < 0:
47
+ return
48
+ parts = self.parts.setdefault((identifier, kind), {})
49
+ parts[index] = parts.get(index, "") + str(data.get("delta") or "")
50
+ self._item(identifier, kind)["text"] = "\n\n".join(parts[i] for i in sorted(parts))
51
+ changed = True
52
+ elif method == "item/agentMessage/delta":
53
+ # item/started carries the phase. Older runtimes may emit deltas
54
+ # first; item/completed supplies the authoritative final phase.
55
+ kind = "commentary" if f"{identifier}:commentary" in self.items else "answer"
56
+ self._item(identifier, kind)["text"] += str(data.get("delta") or "")
57
+ changed = True
58
+ elif method in {"item/started", "item/completed"}:
59
+ item_type = item.get("type")
60
+ if item_type == "reasoning":
61
+ for kind, field in (("reasoning_summary", "summary"), ("reasoning", "content")):
62
+ text = self._text(item.get(field))
63
+ if text or (identifier, kind) in self.parts:
64
+ projected = self._item(identifier, kind)
65
+ if text:
66
+ projected["text"] = text
67
+ changed = True
68
+ elif item_type == "agentMessage":
69
+ kind = "commentary" if item.get("phase") == "commentary" else "answer"
70
+ other = f"{identifier}:{'answer' if kind == 'commentary' else 'commentary'}"
71
+ if other in self.items:
72
+ # Keep its chronological slot when an older runtime omits phase at start.
73
+ self.items = {f"{identifier}:{kind}" if key == other else key:
74
+ {**value, "id": f"{identifier}:{kind}", "kind": kind} if key == other else value
75
+ for key, value in self.items.items()}
76
+ projected = self._item(identifier, kind)
77
+ if isinstance(item.get("text"), str) and (item["text"] or method == "item/completed"):
78
+ projected["text"] = item["text"]
79
+ changed = True
80
+ elif item_type in {"commandExecution", "mcpToolCall", "dynamicToolCall", "collabAgentToolCall",
81
+ "collabToolCall", "webSearch", "fileChange", "imageView", "imageGeneration"}:
82
+ projected = self._item(identifier, "tool")
83
+ projected["tool"] = item_type
84
+ text = item.get("command") if item_type == "commandExecution" else item.get("tool") or item.get("query") or item.get("path")
85
+ if not text and item_type == "fileChange":
86
+ text = ", ".join(str(change.get("path") or "") for change in item.get("changes", []) if isinstance(change, dict))
87
+ if text:
88
+ projected["text"] = str(text)
89
+ changed = True
90
+ if method == "item/completed":
91
+ self.completed.add(identifier)
92
+ for key, value in self.items.items():
93
+ if key.startswith(identifier + ":"):
94
+ value["status"] = "completed"
95
+ if changed:
96
+ self.dirty = True
97
+ if self.pending is None:
98
+ self.pending = asyncio.create_task(self._publish_later())
99
+
100
+ async def _publish_later(self):
101
+ # Coalesce token bursts; no per-token disk writes or client history reads.
102
+ await asyncio.sleep(0.1)
103
+ while self.dirty:
104
+ self.dirty = False
105
+ await self.publish(deepcopy(list(self.items.values())))
106
+ self.pending = None
107
+
108
+ async def flush(self):
109
+ if self.pending is not None:
110
+ await asyncio.shield(self.pending)
@@ -179,6 +179,7 @@ class NativeCodexSideChat:
179
179
  self._cleaning: asyncio.Task | None = None
180
180
  self._process_started = asyncio.Event()
181
181
  self._active: asyncio.Task | None = None
182
+ self._progress = None
182
183
  self._lock = asyncio.Lock()
183
184
  self._closed = False
184
185
 
@@ -186,6 +187,12 @@ class NativeCodexSideChat:
186
187
  def closed(self) -> bool:
187
188
  return self._closed
188
189
 
190
+ def snapshot_progress(self, task):
191
+ """Copy exposed native items at Stop without flushing or awaiting IO."""
192
+ if task is None or task.done() or self._active is not task or self._progress is None:
193
+ return None
194
+ return deepcopy(list(self._progress.items.values()))
195
+
189
196
  async def _handle_server_request(self, request_id, method, params):
190
197
  # The callback can present approvals through the existing UI, but only
191
198
  # for this exact child. Never route a parent's inherited tool request.
@@ -284,7 +291,7 @@ class NativeCodexSideChat:
284
291
  elif metadata.get("ephemeral") is not True or metadata.get("path") is not None:
285
292
  raise SideQuestionError(503, "Codex did not confirm an ephemeral side chat")
286
293
 
287
- async def ask(self, question: str) -> str:
294
+ async def ask(self, question: str, *, runtime_settings: dict | None = None, on_progress=None) -> str:
288
295
  if self._closed:
289
296
  raise SideQuestionError(409, "Side chat was closed; open a new side chat")
290
297
  if self._lock.locked():
@@ -292,6 +299,7 @@ class NativeCodexSideChat:
292
299
  async with self._lock:
293
300
  self._active = asyncio.current_task()
294
301
  turn = None
302
+ progress = None
295
303
  try:
296
304
  if self._opening is None:
297
305
  self._opening = asyncio.create_task(self._open())
@@ -300,14 +308,37 @@ class NativeCodexSideChat:
300
308
  await asyncio.shield(self._opening)
301
309
  if self._closed:
302
310
  raise SideQuestionError(409, "Side chat was closed; open a new side chat")
311
+ overrides = {**self.turn_overrides, **self.provider_turn_overrides}
312
+ if runtime_settings is not None:
313
+ model = runtime_settings["model"]
314
+ effort = runtime_settings.get("effort") or ""
315
+ # Explicit default must clear a previous turn's effort;
316
+ # null effort alone would inherit it from the thread.
317
+ overrides.pop("effort", None)
318
+ overrides.update({"model": model, "collaborationMode": {"mode": "default", "settings": {
319
+ "model": model, "reasoning_effort": effort or None, "developer_instructions": None}}})
320
+ if effort:
321
+ overrides["effort"] = effort
322
+ if self.provider_config:
323
+ from codex_provider import turn_overrides
324
+ overrides.update(turn_overrides(model, effort,
325
+ summary=runtime_settings.get("reasoning_summary") or "none"))
303
326
  turn = await self._client.start_turn(
304
327
  self.thread_id, [{"type": "text", "text": question}],
305
- overrides={**self.turn_overrides, **self.provider_turn_overrides},
328
+ overrides=overrides,
306
329
  )
330
+ if on_progress is not None:
331
+ from codex_side_chat_progress import CodexSideChatProgress
332
+ progress = CodexSideChatProgress(on_progress, thread_id=self.thread_id)
333
+ self._progress = progress
307
334
  answers: dict[str, str] = {}
308
335
  while True:
309
336
  packet = await turn.next_notification()
310
337
  method, data = packet.get("method"), packet.get("params", {})
338
+ if data.get("threadId") not in (None, self.thread_id):
339
+ continue
340
+ if progress is not None:
341
+ progress.receive(packet)
311
342
  if method == "item/completed":
312
343
  item = data.get("item", {})
313
344
  if item.get("type") == "agentMessage" and item.get("phase") in (None, "", "final_answer"):
@@ -323,6 +354,8 @@ class NativeCodexSideChat:
323
354
  answer = "\n\n".join(answers.values()).strip()
324
355
  if not answer:
325
356
  raise SideQuestionError(502, "Codex did not return a side question answer")
357
+ if progress is not None:
358
+ await progress.flush()
326
359
  return answer
327
360
  except CodexAppServerError:
328
361
  await self.close()
@@ -331,9 +364,16 @@ class NativeCodexSideChat:
331
364
  await self.close()
332
365
  raise
333
366
  finally:
334
- if turn is not None:
335
- await turn.close()
336
- self._active = None
367
+ try:
368
+ if progress is not None:
369
+ await progress.flush()
370
+ finally:
371
+ try:
372
+ if turn is not None:
373
+ await turn.close()
374
+ finally:
375
+ self._progress = None
376
+ self._active = None
337
377
 
338
378
  async def close(self):
339
379
  self._closed = True