codex-ipc 0.1.2__tar.gz → 0.1.4__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 (51) hide show
  1. {codex_ipc-0.1.2 → codex_ipc-0.1.4}/PKG-INFO +1 -1
  2. {codex_ipc-0.1.2 → codex_ipc-0.1.4}/packages/ipc/src/ipc/__init__.py +8 -0
  3. {codex_ipc-0.1.2 → codex_ipc-0.1.4}/packages/ipc/src/ipc/constants.py +8 -3
  4. {codex_ipc-0.1.2 → codex_ipc-0.1.4}/packages/ipc/src/ipc/conversation_state.py +222 -3
  5. {codex_ipc-0.1.2 → codex_ipc-0.1.4}/packages/ipc/src/ipc/owner_follower.py +91 -5
  6. {codex_ipc-0.1.2 → codex_ipc-0.1.4}/pyproject.toml +1 -1
  7. {codex_ipc-0.1.2 → codex_ipc-0.1.4}/src/codex_ipc/__init__.py +2 -1
  8. {codex_ipc-0.1.2 → codex_ipc-0.1.4}/tests/test_plan_mode_user_input.py +321 -28
  9. {codex_ipc-0.1.2 → codex_ipc-0.1.4}/uv.lock +1 -1
  10. {codex_ipc-0.1.2 → codex_ipc-0.1.4}/.github/workflows/publish.yml +0 -0
  11. {codex_ipc-0.1.2 → codex_ipc-0.1.4}/.gitignore +0 -0
  12. {codex_ipc-0.1.2 → codex_ipc-0.1.4}/.python-version +0 -0
  13. {codex_ipc-0.1.2 → codex_ipc-0.1.4}/.vscode/launch.json +0 -0
  14. {codex_ipc-0.1.2 → codex_ipc-0.1.4}/README.md +0 -0
  15. {codex_ipc-0.1.2 → codex_ipc-0.1.4}/examples/demo.py +0 -0
  16. {codex_ipc-0.1.2 → codex_ipc-0.1.4}/examples/tui.py +0 -0
  17. {codex_ipc-0.1.2 → codex_ipc-0.1.4}/packages/codex-sdk/README.md +0 -0
  18. {codex_ipc-0.1.2 → codex_ipc-0.1.4}/packages/codex-sdk/pyproject.toml +0 -0
  19. {codex_ipc-0.1.2 → codex_ipc-0.1.4}/packages/codex-sdk/src/codex_app_server/__init__.py +0 -0
  20. {codex_ipc-0.1.2 → codex_ipc-0.1.4}/packages/codex-sdk/src/codex_app_server/_inputs.py +0 -0
  21. {codex_ipc-0.1.2 → codex_ipc-0.1.4}/packages/codex-sdk/src/codex_app_server/_message_router.py +0 -0
  22. {codex_ipc-0.1.2 → codex_ipc-0.1.4}/packages/codex-sdk/src/codex_app_server/_run.py +0 -0
  23. {codex_ipc-0.1.2 → codex_ipc-0.1.4}/packages/codex-sdk/src/codex_app_server/_version.py +0 -0
  24. {codex_ipc-0.1.2 → codex_ipc-0.1.4}/packages/codex-sdk/src/codex_app_server/api.py +0 -0
  25. {codex_ipc-0.1.2 → codex_ipc-0.1.4}/packages/codex-sdk/src/codex_app_server/async_client.py +0 -0
  26. {codex_ipc-0.1.2 → codex_ipc-0.1.4}/packages/codex-sdk/src/codex_app_server/client.py +0 -0
  27. {codex_ipc-0.1.2 → codex_ipc-0.1.4}/packages/codex-sdk/src/codex_app_server/errors.py +0 -0
  28. {codex_ipc-0.1.2 → codex_ipc-0.1.4}/packages/codex-sdk/src/codex_app_server/generated/__init__.py +0 -0
  29. {codex_ipc-0.1.2 → codex_ipc-0.1.4}/packages/codex-sdk/src/codex_app_server/generated/notification_registry.py +0 -0
  30. {codex_ipc-0.1.2 → codex_ipc-0.1.4}/packages/codex-sdk/src/codex_app_server/generated/v2_all.py +0 -0
  31. {codex_ipc-0.1.2 → codex_ipc-0.1.4}/packages/codex-sdk/src/codex_app_server/models.py +0 -0
  32. {codex_ipc-0.1.2 → codex_ipc-0.1.4}/packages/codex-sdk/src/codex_app_server/py.typed +0 -0
  33. {codex_ipc-0.1.2 → codex_ipc-0.1.4}/packages/codex-sdk/src/codex_app_server/retry.py +0 -0
  34. {codex_ipc-0.1.2 → codex_ipc-0.1.4}/packages/ipc/README.md +0 -0
  35. {codex_ipc-0.1.2 → codex_ipc-0.1.4}/packages/ipc/pyproject.toml +0 -0
  36. {codex_ipc-0.1.2 → codex_ipc-0.1.4}/packages/ipc/src/ipc/client.py +0 -0
  37. {codex_ipc-0.1.2 → codex_ipc-0.1.4}/packages/ipc/src/ipc/frame_protocol.py +0 -0
  38. {codex_ipc-0.1.2 → codex_ipc-0.1.4}/packages/ipc/src/ipc/immer.py +0 -0
  39. {codex_ipc-0.1.2 → codex_ipc-0.1.4}/packages/ipc/src/ipc/py.typed +0 -0
  40. {codex_ipc-0.1.2 → codex_ipc-0.1.4}/packages/ipc/src/ipc/router.py +0 -0
  41. {codex_ipc-0.1.2 → codex_ipc-0.1.4}/src/codex_ipc/codex/__init__.py +0 -0
  42. {codex_ipc-0.1.2 → codex_ipc-0.1.4}/src/codex_ipc/codex/api.py +0 -0
  43. {codex_ipc-0.1.2 → codex_ipc-0.1.4}/src/codex_ipc/codex/async_client.py +0 -0
  44. {codex_ipc-0.1.2 → codex_ipc-0.1.4}/src/codex_ipc/codex/client.py +0 -0
  45. {codex_ipc-0.1.2 → codex_ipc-0.1.4}/src/codex_ipc/codex/generated/__init__.py +0 -0
  46. {codex_ipc-0.1.2 → codex_ipc-0.1.4}/src/codex_ipc/codex/generated/v2_all.py +0 -0
  47. {codex_ipc-0.1.2 → codex_ipc-0.1.4}/src/codex_ipc/config.py +0 -0
  48. {codex_ipc-0.1.2 → codex_ipc-0.1.4}/src/codex_ipc/session.py +0 -0
  49. {codex_ipc-0.1.2 → codex_ipc-0.1.4}/src/codex_ipc/user_input.py +0 -0
  50. {codex_ipc-0.1.2 → codex_ipc-0.1.4}/tests/__init__.py +0 -0
  51. {codex_ipc-0.1.2 → codex_ipc-0.1.4}/tests/test_ssh_websocket_transport.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-ipc
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: Add your description here
5
5
  Requires-Python: >=3.12
6
6
  Requires-Dist: pydantic>=2.12
@@ -1,8 +1,12 @@
1
1
  from ipc.client import CodexIpcClient
2
2
  from ipc.conversation_state import (
3
3
  JsonDict,
4
+ canonicalize_conversation_patches,
5
+ conversation_state_snapshot,
6
+ conversation_turns,
4
7
  conversation_messages,
5
8
  extract_text_input,
9
+ materialize_conversation_state,
6
10
  text_input_payload,
7
11
  )
8
12
  from ipc.immer import apply_patches, produce_with_patches
@@ -15,8 +19,12 @@ __all__ = [
15
19
  "JsonDict",
16
20
  "OwnerFollowerSession",
17
21
  "apply_patches",
22
+ "canonicalize_conversation_patches",
23
+ "conversation_state_snapshot",
24
+ "conversation_turns",
18
25
  "conversation_messages",
19
26
  "extract_text_input",
27
+ "materialize_conversation_state",
20
28
  "produce_with_patches",
21
29
  "text_input_payload",
22
30
  ]
@@ -22,7 +22,10 @@ IPC_DEBUG_TEXT_LIMIT = 500
22
22
 
23
23
  IPC_METHOD_VERSIONS: dict[str, int] = {
24
24
  # broadcast methods
25
- "thread-stream-state-changed": 7,
25
+ "thread-stream-state-changed": 11,
26
+ "thread-stream-following-changed": 1,
27
+ "thread-stream-following-status-requested": 1,
28
+ "ipc-connection-reset": 1,
26
29
  "thread-read-state-changed": 1,
27
30
  "thread-archived": 2,
28
31
  "thread-unarchived": 1,
@@ -31,12 +34,14 @@ IPC_METHOD_VERSIONS: dict[str, int] = {
31
34
  "client-status-changed": 0,
32
35
  # request methods (thread-follower series)
33
36
  "thread-follower-start-turn": 1,
37
+ "thread-follower-load-complete-history": 1,
34
38
  "thread-follower-compact-thread": 1,
35
39
  "thread-follower-steer-turn": 1,
36
- "thread-follower-interrupt-turn": 1,
40
+ "thread-follower-interrupt-turn": 2,
41
+ "thread-follower-update-thread-settings": 1,
37
42
  "thread-follower-set-model-and-reasoning": 1,
38
43
  "thread-follower-set-collaboration-mode": 1,
39
- "thread-follower-edit-last-user-turn": 1,
44
+ "thread-follower-edit-last-user-turn": 2,
40
45
  "thread-follower-command-approval-decision": 1,
41
46
  "thread-follower-file-approval-decision": 1,
42
47
  "thread-follower-permissions-request-approval-response": 1,
@@ -102,6 +102,227 @@ def empty_turns_pagination() -> JsonDict:
102
102
  }
103
103
 
104
104
 
105
+ def conversation_turns(state: JsonDict | None) -> list[JsonDict]:
106
+ if not isinstance(state, dict):
107
+ return []
108
+
109
+ canonical_turns: list[JsonDict] = []
110
+ turn_history = state.get("turnHistory")
111
+ if isinstance(turn_history, dict) and turn_history.get("kind") == "canonical":
112
+ history = turn_history.get("history")
113
+ if isinstance(history, dict):
114
+ entities = history.get("entitiesByKey")
115
+ islands = history.get("islands")
116
+ if isinstance(entities, dict) and isinstance(islands, list):
117
+ for island in islands:
118
+ if not isinstance(island, dict):
119
+ continue
120
+ entries = island.get("entries")
121
+ if not isinstance(entries, list):
122
+ continue
123
+ for entry in entries:
124
+ if not isinstance(entry, dict):
125
+ continue
126
+ entity_key = entry.get("value")
127
+ turn = entities.get(entity_key)
128
+ if isinstance(turn, dict):
129
+ canonical_turns.append(turn)
130
+
131
+ overlay = state.get("turns")
132
+ overlay_turns = [turn for turn in overlay if isinstance(turn, dict)] if isinstance(overlay, list) else []
133
+ if not canonical_turns:
134
+ return overlay_turns
135
+
136
+ merged = list(canonical_turns)
137
+ turn_indexes = {
138
+ turn_id: index
139
+ for index, turn in enumerate(merged)
140
+ if isinstance((turn_id := turn.get("turnId")), str) and turn_id
141
+ }
142
+ for turn in overlay_turns:
143
+ turn_id = turn.get("turnId")
144
+ index = turn_indexes.get(turn_id) if isinstance(turn_id, str) else None
145
+ if index is None:
146
+ merged.append(turn)
147
+ if isinstance(turn_id, str) and turn_id:
148
+ turn_indexes[turn_id] = len(merged) - 1
149
+ continue
150
+ merged[index] = _merge_turn_overlay(merged[index], turn)
151
+ return merged
152
+
153
+
154
+ def materialize_conversation_state(state: JsonDict | None) -> JsonDict | None:
155
+ if not isinstance(state, dict):
156
+ return state
157
+ turn_history = state.get("turnHistory")
158
+ if not isinstance(turn_history, dict) or turn_history.get("kind") != "canonical":
159
+ return state
160
+ return {**state, "turns": conversation_turns(state)}
161
+
162
+
163
+ def conversation_state_snapshot(state: JsonDict) -> JsonDict:
164
+ turns = conversation_turns(state)
165
+ turn_history = state.get("turnHistory")
166
+ history = turn_history.get("history") if isinstance(turn_history, dict) else None
167
+ generation = history.get("generation") if isinstance(history, dict) else None
168
+ next_generation = generation + 1 if isinstance(generation, int) else 0
169
+ tail_id = f"tail:{next_generation}"
170
+ entities: dict[str, JsonDict] = {}
171
+ entries: list[JsonDict] = []
172
+
173
+ for index, turn in enumerate(turns):
174
+ key = _turn_entity_key(turn, index=index, tail_id=tail_id)
175
+ if key in entities:
176
+ entities[key] = _merge_turn_overlay(entities[key], turn)
177
+ continue
178
+ entities[key] = turn
179
+ entries.append({"key": key, "value": key})
180
+
181
+ pagination = state.get("turnsPagination")
182
+ older_cursor = pagination.get("olderCursor") if isinstance(pagination, dict) else None
183
+ oldest_turn_id = (
184
+ pagination.get("oldestLoadedTurnId") if isinstance(pagination, dict) else None
185
+ )
186
+ has_loaded_oldest = (
187
+ pagination.get("hasLoadedOldest", older_cursor is None)
188
+ if isinstance(pagination, dict)
189
+ else True
190
+ )
191
+ if isinstance(older_cursor, str) and older_cursor:
192
+ handle = {"cursor": older_cursor, "oldestLoadedTurnId": oldest_turn_id}
193
+ older_boundary: JsonDict = {
194
+ "status": "available",
195
+ "boundaryId": f"{tail_id}:older",
196
+ "handle": handle,
197
+ "progressKey": json.dumps(
198
+ [older_cursor, oldest_turn_id],
199
+ separators=(",", ":"),
200
+ ),
201
+ }
202
+ else:
203
+ older_boundary = {
204
+ "status": "exhausted",
205
+ "boundaryId": f"{tail_id}:older",
206
+ }
207
+
208
+ return {
209
+ **state,
210
+ "turnHistory": {
211
+ "kind": "canonical",
212
+ "history": {
213
+ "entitiesByKey": entities,
214
+ "generation": next_generation,
215
+ "isComplete": bool(has_loaded_oldest and older_cursor is None),
216
+ "islands": [
217
+ {
218
+ "id": tail_id,
219
+ "entries": entries,
220
+ "olderBoundary": older_boundary,
221
+ "newerBoundary": {
222
+ "status": "exhausted",
223
+ "boundaryId": f"{tail_id}:newer",
224
+ },
225
+ }
226
+ ],
227
+ },
228
+ },
229
+ "turns": [],
230
+ }
231
+
232
+
233
+ def canonicalize_conversation_patches(
234
+ previous_state: JsonDict,
235
+ next_state: JsonDict,
236
+ patches: list[JsonDict],
237
+ ) -> list[JsonDict]:
238
+ previous_turns = conversation_turns(previous_state)
239
+ next_turns = conversation_turns(next_state)
240
+ previous_tail_id = _snapshot_tail_id(previous_state)
241
+ next_tail_id = _snapshot_tail_id(next_state)
242
+ translated: list[JsonDict] = []
243
+ non_turn_patches: list[JsonDict] = []
244
+ has_structural_turn_change = False
245
+
246
+ for patch in patches:
247
+ path = patch.get("path")
248
+ if not isinstance(path, list) or path[:1] != ["turns"]:
249
+ translated.append(patch)
250
+ non_turn_patches.append(patch)
251
+ continue
252
+ if len(path) < 3 or not isinstance(path[1], int):
253
+ has_structural_turn_change = True
254
+ continue
255
+
256
+ turn_index = path[1]
257
+ if not (0 <= turn_index < len(previous_turns)) or not (
258
+ 0 <= turn_index < len(next_turns)
259
+ ):
260
+ has_structural_turn_change = True
261
+ continue
262
+ previous_key = _turn_entity_key(
263
+ previous_turns[turn_index],
264
+ index=turn_index,
265
+ tail_id=previous_tail_id,
266
+ )
267
+ next_key = _turn_entity_key(
268
+ next_turns[turn_index],
269
+ index=turn_index,
270
+ tail_id=next_tail_id,
271
+ )
272
+ if previous_key != next_key:
273
+ has_structural_turn_change = True
274
+ continue
275
+ translated.append(
276
+ {
277
+ **patch,
278
+ "path": [
279
+ "turnHistory",
280
+ "history",
281
+ "entitiesByKey",
282
+ next_key,
283
+ *path[2:],
284
+ ],
285
+ }
286
+ )
287
+
288
+ if not has_structural_turn_change:
289
+ return translated
290
+
291
+ snapshot = conversation_state_snapshot(next_state)
292
+ return [
293
+ *non_turn_patches,
294
+ {
295
+ "op": "replace",
296
+ "path": ["turnHistory"],
297
+ "value": snapshot["turnHistory"],
298
+ },
299
+ ]
300
+
301
+
302
+ def _snapshot_tail_id(state: JsonDict) -> str:
303
+ turn_history = state.get("turnHistory")
304
+ history = turn_history.get("history") if isinstance(turn_history, dict) else None
305
+ generation = history.get("generation") if isinstance(history, dict) else None
306
+ next_generation = generation + 1 if isinstance(generation, int) else 0
307
+ return f"tail:{next_generation}"
308
+
309
+
310
+ def _turn_entity_key(turn: JsonDict, *, index: int, tail_id: str) -> str:
311
+ turn_id = turn.get("turnId")
312
+ if isinstance(turn_id, str) and turn_id:
313
+ return f"turn:{turn_id}"
314
+ return f"{tail_id}:local:{index}"
315
+
316
+
317
+ def _merge_turn_overlay(base: JsonDict, overlay: JsonDict) -> JsonDict:
318
+ merged = {**base, **overlay}
319
+ base_items = base.get("items")
320
+ overlay_items = overlay.get("items")
321
+ if isinstance(base_items, list) and not isinstance(overlay_items, list):
322
+ merged["items"] = base_items
323
+ return merged
324
+
325
+
105
326
  def default_collaboration_mode() -> JsonDict:
106
327
  return {
107
328
  "mode": "default",
@@ -1017,9 +1238,7 @@ def conversation_messages(state: JsonDict | None) -> list[tuple[str, str]]:
1017
1238
  if state is None:
1018
1239
  return []
1019
1240
 
1020
- turns = state.get("turns")
1021
- if not isinstance(turns, list):
1022
- return []
1241
+ turns = conversation_turns(state)
1023
1242
 
1024
1243
  messages: list[tuple[str, str]] = []
1025
1244
  for turn in turns:
@@ -22,6 +22,8 @@ from ipc.conversation_state import (
22
22
  JsonDict,
23
23
  PLAN_IMPLEMENTATION_REQUEST_METHOD,
24
24
  build_conversation_state,
25
+ canonicalize_conversation_patches,
26
+ conversation_state_snapshot,
25
27
  ensure_turn_slot,
26
28
  default_collaboration_mode,
27
29
  extract_text_input,
@@ -149,7 +151,7 @@ class OwnerFollowerSession:
149
151
  self._state: JsonDict | None = None
150
152
  self._stream_role: JsonDict | None = None
151
153
  self._streaming_conversations: set[str] = set()
152
- # Monotonic per-conversation revision (matches Codex extension v7
154
+ # Monotonic per-conversation revision (matches Codex extension v11
153
155
  # streamRevisionByConversationId). Owner increments on every snapshot
154
156
  # and patch it emits; followers track it to guard out-of-order patches.
155
157
  self._stream_revision: int = 0
@@ -206,6 +208,11 @@ class OwnerFollowerSession:
206
208
  self._can_handle_owner_request,
207
209
  self._handle_follower_start_turn,
208
210
  )
211
+ self.ipc_client.add_request_handler(
212
+ "thread-follower-load-complete-history",
213
+ self._can_handle_owner_request,
214
+ self._handle_follower_load_complete_history,
215
+ )
209
216
  self.ipc_client.add_request_handler(
210
217
  "thread-follower-steer-turn",
211
218
  self._can_handle_owner_request,
@@ -216,6 +223,11 @@ class OwnerFollowerSession:
216
223
  self._can_handle_owner_request,
217
224
  self._handle_follower_set_queued_followups_state,
218
225
  )
226
+ self.ipc_client.add_request_handler(
227
+ "thread-follower-update-thread-settings",
228
+ self._can_handle_owner_request,
229
+ self._handle_follower_update_thread_settings,
230
+ )
219
231
  self.ipc_client.add_request_handler(
220
232
  "thread-follower-set-collaboration-mode",
221
233
  self._can_handle_owner_request,
@@ -333,17 +345,17 @@ class OwnerFollowerSession:
333
345
  owner_client_id = self._owner_client_id()
334
346
  if owner_client_id:
335
347
  response = await self.ipc_client.send_request(
336
- "thread-follower-set-collaboration-mode",
348
+ "thread-follower-update-thread-settings",
337
349
  {
338
350
  "conversationId": self.thread_id,
339
- "collaborationMode": collaboration_mode,
351
+ "threadSettings": {"collaborationMode": collaboration_mode},
340
352
  },
341
353
  target_client_id=owner_client_id,
342
354
  )
343
355
  if response.get("resultType") == "success":
344
356
  return
345
357
  raise RuntimeError(
346
- "thread-follower-set-collaboration-mode failed: "
358
+ "thread-follower-update-thread-settings failed: "
347
359
  f"{response.get('error', '')}"
348
360
  )
349
361
 
@@ -1945,6 +1957,11 @@ class OwnerFollowerSession:
1945
1957
  if broadcast and patches and self._stream_role is not None:
1946
1958
  if self._stream_role.get("role") == "owner":
1947
1959
  patches = self._normalize_broadcast_patches(patches)
1960
+ patches = canonicalize_conversation_patches(
1961
+ previous_state,
1962
+ next_state,
1963
+ patches,
1964
+ )
1948
1965
  await self._broadcast_patches(patches)
1949
1966
  self._state_changed()
1950
1967
 
@@ -2606,7 +2623,7 @@ class OwnerFollowerSession:
2606
2623
  "change": {
2607
2624
  "type": "snapshot",
2608
2625
  "revision": revision,
2609
- "conversationState": self._state,
2626
+ "conversationState": conversation_state_snapshot(self._state),
2610
2627
  },
2611
2628
  }
2612
2629
  self._session_event(
@@ -2992,6 +3009,19 @@ class OwnerFollowerSession:
2992
3009
  }
2993
3010
  }
2994
3011
 
3012
+ async def _handle_follower_load_complete_history(
3013
+ self,
3014
+ message: JsonDict,
3015
+ ) -> JsonDict:
3016
+ params = message.get("params")
3017
+ if not isinstance(params, dict):
3018
+ raise RuntimeError("invalid params")
3019
+ if params.get("conversationId") != self.thread_id:
3020
+ raise RuntimeError("invalid conversationId")
3021
+
3022
+ await self.read_thread(include_turns=True, broadcast_snapshot=True)
3023
+ return {"revision": self._stream_revision}
3024
+
2995
3025
  async def _handle_follower_steer_turn(self, message: JsonDict) -> JsonDict:
2996
3026
  params = message.get("params")
2997
3027
  if not isinstance(params, dict):
@@ -3028,6 +3058,62 @@ class OwnerFollowerSession:
3028
3058
  )
3029
3059
  return {"result": {"ok": True}}
3030
3060
 
3061
+ async def _handle_follower_update_thread_settings(
3062
+ self,
3063
+ message: JsonDict,
3064
+ ) -> JsonDict:
3065
+ params = message.get("params")
3066
+ if not isinstance(params, dict):
3067
+ raise RuntimeError("invalid params")
3068
+ if params.get("conversationId") != self.thread_id:
3069
+ raise RuntimeError("invalid conversationId")
3070
+
3071
+ thread_settings = params.get("threadSettings")
3072
+ if not isinstance(thread_settings, dict):
3073
+ raise RuntimeError("invalid threadSettings")
3074
+
3075
+ await self._update_state(
3076
+ lambda draft: self._apply_thread_settings(draft, thread_settings)
3077
+ )
3078
+ return {"result": {"ok": True}}
3079
+
3080
+ def _apply_thread_settings(
3081
+ self,
3082
+ draft: JsonDict,
3083
+ thread_settings: JsonDict,
3084
+ ) -> None:
3085
+ latest_settings = draft.get("latestThreadSettings")
3086
+ merged_settings = (
3087
+ dict(latest_settings) if isinstance(latest_settings, dict) else {}
3088
+ )
3089
+ merged_settings.update(thread_settings)
3090
+
3091
+ collaboration_mode = thread_settings.get("collaborationMode")
3092
+ if isinstance(collaboration_mode, dict):
3093
+ merged_settings["collaborationMode"] = dict(collaboration_mode)
3094
+ draft["latestCollaborationMode"] = dict(collaboration_mode)
3095
+ elif "collaborationMode" in thread_settings:
3096
+ collaboration_mode = default_collaboration_mode()
3097
+ merged_settings["collaborationMode"] = collaboration_mode
3098
+ draft["latestCollaborationMode"] = collaboration_mode
3099
+
3100
+ model = thread_settings.get("model")
3101
+ if not isinstance(model, str) and isinstance(collaboration_mode, dict):
3102
+ settings = collaboration_mode.get("settings")
3103
+ if isinstance(settings, dict) and isinstance(settings.get("model"), str):
3104
+ model = settings["model"]
3105
+ if isinstance(model, str):
3106
+ merged_settings["model"] = model
3107
+ draft["latestModel"] = model
3108
+
3109
+ if "effort" in thread_settings:
3110
+ draft["latestReasoningEffort"] = thread_settings.get("effort")
3111
+ cwd = thread_settings.get("cwd")
3112
+ if isinstance(cwd, str):
3113
+ draft["cwd"] = cwd
3114
+
3115
+ draft["latestThreadSettings"] = merged_settings
3116
+
3031
3117
  async def _handle_follower_submit_user_input(
3032
3118
  self,
3033
3119
  message: JsonDict,
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "codex-ipc"
7
- version = "0.1.2"
7
+ version = "0.1.4"
8
8
  description = "Add your description here"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -4,7 +4,7 @@ from codex_app_server import (
4
4
  SshWebsocketAppServerConfig,
5
5
  TextInput,
6
6
  )
7
- from ipc.conversation_state import JsonDict
7
+ from ipc.conversation_state import JsonDict, materialize_conversation_state
8
8
 
9
9
  from codex_ipc.codex.api import AsyncCodex, AsyncThread
10
10
  from codex_ipc.config import CodexIpcConfig
@@ -26,6 +26,7 @@ __all__ = [
26
26
  "CodexIpcConfig",
27
27
  "CodexIpcSession",
28
28
  "JsonDict",
29
+ "materialize_conversation_state",
29
30
  "TextInput",
30
31
  "USER_INPUT_REQUEST_METHOD",
31
32
  "UserInputOptionRow",
@@ -42,8 +42,13 @@ from codex_ipc.user_input import build_user_input_response
42
42
  from ipc.conversation_state import (
43
43
  PLAN_IMPLEMENTATION_PROMPT_PREFIX,
44
44
  build_conversation_state,
45
+ canonicalize_conversation_patches,
46
+ conversation_state_snapshot,
47
+ conversation_turns,
45
48
  default_collaboration_mode,
49
+ materialize_conversation_state,
46
50
  )
51
+ from ipc.constants import IPC_METHOD_VERSIONS
47
52
  from ipc.owner_follower import OwnerFollowerSession
48
53
 
49
54
 
@@ -570,6 +575,21 @@ class FacadeSessionTests(unittest.IsolatedAsyncioTestCase):
570
575
 
571
576
 
572
577
  class CoreImportTests(unittest.TestCase):
578
+ def test_ipc_versions_match_conversation_state_v11(self) -> None:
579
+ expected_versions = {
580
+ "thread-stream-state-changed": 11,
581
+ "thread-stream-following-changed": 1,
582
+ "thread-stream-following-status-requested": 1,
583
+ "ipc-connection-reset": 1,
584
+ "thread-follower-load-complete-history": 1,
585
+ "thread-follower-interrupt-turn": 2,
586
+ "thread-follower-update-thread-settings": 1,
587
+ "thread-follower-edit-last-user-turn": 2,
588
+ }
589
+
590
+ for method, version in expected_versions.items():
591
+ self.assertEqual(IPC_METHOD_VERSIONS[method], version)
592
+
573
593
  def test_core_import_does_not_require_tui_dependencies(self) -> None:
574
594
  script = """
575
595
  import builtins
@@ -596,7 +616,284 @@ print(codex_ipc.CodexIpcConfig.__name__)
596
616
  self.assertIn("CodexIpcConfig", result.stdout)
597
617
 
598
618
 
619
+ class ConversationStateV11Tests(unittest.TestCase):
620
+ def test_snapshot_canonicalizes_legacy_turns_without_mutating_state(self) -> None:
621
+ turns = [
622
+ {"turnId": "turn-1", "status": "completed", "items": []},
623
+ {"turnId": None, "status": "inProgress", "items": []},
624
+ ]
625
+ state = {
626
+ "id": "thread-1",
627
+ "turns": turns,
628
+ "turnsPagination": {
629
+ "olderCursor": None,
630
+ "oldestLoadedTurnId": "turn-1",
631
+ "isLoadingOlder": False,
632
+ "hasLoadedOldest": True,
633
+ },
634
+ }
635
+
636
+ snapshot = conversation_state_snapshot(state)
637
+
638
+ self.assertIs(state["turns"], turns)
639
+ self.assertEqual(snapshot["turns"], [])
640
+ history = snapshot["turnHistory"]["history"]
641
+ self.assertTrue(history["isComplete"])
642
+ self.assertEqual(
643
+ history["islands"][0]["entries"],
644
+ [
645
+ {"key": "turn:turn-1", "value": "turn:turn-1"},
646
+ {"key": "tail:0:local:1", "value": "tail:0:local:1"},
647
+ ],
648
+ )
649
+ self.assertEqual(conversation_turns(snapshot), turns)
650
+
651
+ def test_materialize_merges_canonical_history_with_live_overlay(self) -> None:
652
+ historical_turn = {
653
+ "turnId": "turn-1",
654
+ "status": "inProgress",
655
+ "items": [{"type": "agentMessage", "id": "agent-1", "text": ""}],
656
+ }
657
+ completed_overlay = {
658
+ "turnId": "turn-1",
659
+ "status": "completed",
660
+ "items": [
661
+ {"type": "agentMessage", "id": "agent-1", "text": "done"}
662
+ ],
663
+ }
664
+ state = {
665
+ "id": "thread-1",
666
+ "turnHistory": {
667
+ "kind": "canonical",
668
+ "history": {
669
+ "entitiesByKey": {"turn:turn-1": historical_turn},
670
+ "generation": 0,
671
+ "isComplete": True,
672
+ "islands": [
673
+ {
674
+ "id": "tail:0",
675
+ "entries": [
676
+ {"key": "turn:turn-1", "value": "turn:turn-1"}
677
+ ],
678
+ "olderBoundary": {"status": "exhausted"},
679
+ "newerBoundary": {"status": "exhausted"},
680
+ }
681
+ ],
682
+ },
683
+ },
684
+ "turns": [completed_overlay],
685
+ }
686
+
687
+ materialized = materialize_conversation_state(state)
688
+
689
+ self.assertIsNot(materialized, state)
690
+ self.assertEqual(materialized["turns"], [completed_overlay])
691
+ self.assertEqual(
692
+ state["turnHistory"]["history"]["entitiesByKey"]["turn:turn-1"],
693
+ historical_turn,
694
+ )
695
+
696
+ def test_translates_turn_delta_to_canonical_entity_patch(self) -> None:
697
+ previous = {
698
+ "id": "thread-1",
699
+ "turns": [
700
+ {
701
+ "turnId": "turn-1",
702
+ "status": "inProgress",
703
+ "items": [{"type": "agentMessage", "id": "agent-1", "text": "a"}],
704
+ }
705
+ ],
706
+ }
707
+ next_state = {
708
+ **previous,
709
+ "turns": [
710
+ {
711
+ "turnId": "turn-1",
712
+ "status": "inProgress",
713
+ "items": [
714
+ {"type": "agentMessage", "id": "agent-1", "text": "ab"}
715
+ ],
716
+ }
717
+ ],
718
+ }
719
+
720
+ patches = canonicalize_conversation_patches(
721
+ previous,
722
+ next_state,
723
+ [
724
+ {
725
+ "op": "replace",
726
+ "path": ["turns", 0, "items", 0, "text"],
727
+ "value": "ab",
728
+ }
729
+ ],
730
+ )
731
+
732
+ self.assertEqual(
733
+ patches,
734
+ [
735
+ {
736
+ "op": "replace",
737
+ "path": [
738
+ "turnHistory",
739
+ "history",
740
+ "entitiesByKey",
741
+ "turn:turn-1",
742
+ "items",
743
+ 0,
744
+ "text",
745
+ ],
746
+ "value": "ab",
747
+ }
748
+ ],
749
+ )
750
+
751
+ def test_replaces_canonical_history_for_structural_turn_change(self) -> None:
752
+ previous = {"id": "thread-1", "turns": []}
753
+ next_state = {
754
+ "id": "thread-1",
755
+ "turns": [
756
+ {"turnId": None, "status": "inProgress", "items": []}
757
+ ],
758
+ }
759
+
760
+ patches = canonicalize_conversation_patches(
761
+ previous,
762
+ next_state,
763
+ [
764
+ {
765
+ "op": "add",
766
+ "path": ["turns", 0],
767
+ "value": next_state["turns"][0],
768
+ }
769
+ ],
770
+ )
771
+
772
+ self.assertEqual(len(patches), 1)
773
+ self.assertEqual(patches[0]["op"], "replace")
774
+ self.assertEqual(patches[0]["path"], ["turnHistory"])
775
+ self.assertEqual(patches[0]["value"]["kind"], "canonical")
776
+
777
+
599
778
  class PlanModeTests(unittest.IsolatedAsyncioTestCase):
779
+ async def test_owner_broadcasts_initial_state_as_canonical_snapshot(self) -> None:
780
+ session = make_session()
781
+ ipc_client = FakeIpcClient()
782
+ session.ipc_client = ipc_client
783
+ session._stream_role = {"role": "owner"}
784
+ session._state = {
785
+ "id": "thread-1",
786
+ "turns": [
787
+ {"turnId": "turn-1", "status": "completed", "items": []}
788
+ ],
789
+ }
790
+
791
+ await session._broadcast_snapshot()
792
+
793
+ _method, params = ipc_client.broadcasts[0]
794
+ change = params["change"]
795
+ self.assertEqual(change["type"], "snapshot")
796
+ self.assertEqual(change["conversationState"]["turns"], [])
797
+ self.assertEqual(
798
+ change["conversationState"]["turnHistory"]["history"]["islands"][0][
799
+ "entries"
800
+ ],
801
+ [{"key": "turn:turn-1", "value": "turn:turn-1"}],
802
+ )
803
+
804
+ async def test_follower_forwards_collaboration_mode_as_thread_settings(self) -> None:
805
+ session = make_session()
806
+ ipc_client = FakeIpcClient()
807
+ session.ipc_client = ipc_client
808
+ session._stream_role = {"role": "follower", "ownerClientId": "owner-1"}
809
+ collaboration_mode = {
810
+ "mode": "plan",
811
+ "settings": {
812
+ "model": "gpt-5.5",
813
+ "reasoning_effort": "high",
814
+ "developer_instructions": None,
815
+ },
816
+ }
817
+
818
+ await session.set_collaboration_mode(collaboration_mode)
819
+
820
+ self.assertEqual(
821
+ ipc_client.requests,
822
+ [
823
+ (
824
+ "thread-follower-update-thread-settings",
825
+ {
826
+ "conversationId": "thread-1",
827
+ "threadSettings": {"collaborationMode": collaboration_mode},
828
+ },
829
+ "owner-1",
830
+ )
831
+ ],
832
+ )
833
+
834
+ async def test_owner_applies_follower_thread_settings(self) -> None:
835
+ session = make_session()
836
+ session.ipc_client = FakeIpcClient()
837
+ session._stream_role = {"role": "owner"}
838
+ session._state = {
839
+ "id": "thread-1",
840
+ "latestModel": "gpt-5.4",
841
+ "latestReasoningEffort": "medium",
842
+ "latestCollaborationMode": default_collaboration_mode(),
843
+ "latestThreadSettings": {"model": "gpt-5.4"},
844
+ "updatedAt": 0,
845
+ }
846
+ collaboration_mode = {
847
+ "mode": "plan",
848
+ "settings": {
849
+ "model": "gpt-5.5",
850
+ "reasoning_effort": "high",
851
+ "developer_instructions": None,
852
+ },
853
+ }
854
+
855
+ result = await session._handle_follower_update_thread_settings(
856
+ {
857
+ "params": {
858
+ "conversationId": "thread-1",
859
+ "threadSettings": {
860
+ "collaborationMode": collaboration_mode,
861
+ "effort": "high",
862
+ "cwd": "/repo",
863
+ },
864
+ }
865
+ }
866
+ )
867
+
868
+ self.assertEqual(result, {"result": {"ok": True}})
869
+ self.assertEqual(session.state["latestCollaborationMode"], collaboration_mode)
870
+ self.assertEqual(session.state["latestModel"], "gpt-5.5")
871
+ self.assertEqual(session.state["latestReasoningEffort"], "high")
872
+ self.assertEqual(session.state["cwd"], "/repo")
873
+
874
+ async def test_owner_loads_complete_history_and_returns_snapshot_revision(self) -> None:
875
+ session = make_session()
876
+ calls: list[tuple[bool, bool]] = []
877
+
878
+ async def read_thread(
879
+ _thread_id: str | None = None,
880
+ *,
881
+ include_turns: bool = False,
882
+ broadcast_snapshot: bool = False,
883
+ ) -> object:
884
+ calls.append((include_turns, broadcast_snapshot))
885
+ session._stream_revision = 8
886
+ return object()
887
+
888
+ session.read_thread = read_thread # type: ignore[method-assign]
889
+
890
+ result = await session._handle_follower_load_complete_history(
891
+ {"params": {"conversationId": "thread-1"}}
892
+ )
893
+
894
+ self.assertEqual(calls, [(True, True)])
895
+ self.assertEqual(result, {"revision": 8})
896
+
600
897
  async def test_turn_start_passes_collaboration_mode(self) -> None:
601
898
  codex = FakeCodex()
602
899
  thread = AsyncThread(codex, "thread-1")
@@ -691,7 +988,7 @@ class PlanModeTests(unittest.IsolatedAsyncioTestCase):
691
988
  )
692
989
  self.assertGreater(session.state["updatedAt"], 0)
693
990
 
694
- async def test_owner_follower_broadcasts_whole_collaboration_mode_patch(self) -> None:
991
+ async def test_owner_broadcasts_collaboration_mode_as_patch(self) -> None:
695
992
  session_events: list[dict] = []
696
993
  session = make_session(session_event=session_events.append)
697
994
  ipc_client = FakeIpcClient()
@@ -728,20 +1025,19 @@ class PlanModeTests(unittest.IsolatedAsyncioTestCase):
728
1025
  self.assertEqual(len(session_events), 1)
729
1026
  self.assertEqual(session_events[0]["method"], "thread-stream-state-changed")
730
1027
  self.assertEqual(session_events[0]["params"], params)
1028
+ change = params["change"]
1029
+ self.assertEqual(change["type"], "patches")
1030
+ self.assertEqual(change["revision"], 1)
1031
+ self.assertEqual(change["baseRevision"], 0)
731
1032
  self.assertEqual(
732
- params["change"],
733
- {
734
- "type": "patches",
735
- "baseRevision": 0,
736
- "revision": 1,
737
- "patches": [
738
- {
739
- "op": "replace",
740
- "path": ["latestCollaborationMode"],
741
- "value": collaboration_mode,
742
- }
743
- ],
744
- },
1033
+ change["patches"],
1034
+ [
1035
+ {
1036
+ "op": "replace",
1037
+ "path": ["latestCollaborationMode"],
1038
+ "value": collaboration_mode,
1039
+ }
1040
+ ],
745
1041
  )
746
1042
 
747
1043
  async def test_facade_session_exposes_session_event_watchers(self) -> None:
@@ -762,7 +1058,7 @@ class PlanModeTests(unittest.IsolatedAsyncioTestCase):
762
1058
  self.assertEqual(listener_events, [event])
763
1059
  await watcher.aclose()
764
1060
 
765
- async def test_owner_follower_broadcasts_whole_default_mode_patch(self) -> None:
1061
+ async def test_owner_broadcasts_default_mode_as_patch(self) -> None:
766
1062
  session = make_session()
767
1063
  ipc_client = FakeIpcClient()
768
1064
  session.ipc_client = ipc_client
@@ -794,20 +1090,17 @@ class PlanModeTests(unittest.IsolatedAsyncioTestCase):
794
1090
 
795
1091
  self.assertEqual(len(ipc_client.broadcasts), 1)
796
1092
  _method, params = ipc_client.broadcasts[0]
1093
+ change = params["change"]
1094
+ self.assertEqual(change["type"], "patches")
797
1095
  self.assertEqual(
798
- params["change"],
799
- {
800
- "type": "patches",
801
- "baseRevision": 0,
802
- "revision": 1,
803
- "patches": [
804
- {
805
- "op": "replace",
806
- "path": ["latestCollaborationMode"],
807
- "value": collaboration_mode,
808
- }
809
- ],
810
- },
1096
+ change["patches"],
1097
+ [
1098
+ {
1099
+ "op": "replace",
1100
+ "path": ["latestCollaborationMode"],
1101
+ "value": collaboration_mode,
1102
+ }
1103
+ ],
811
1104
  )
812
1105
 
813
1106
  async def test_owner_only_accepts_snapshot_not_patches(self) -> None:
@@ -31,7 +31,7 @@ requires-dist = [{ name = "pydantic", specifier = ">=2.12" }]
31
31
 
32
32
  [[package]]
33
33
  name = "codex-ipc"
34
- version = "0.1.2"
34
+ version = "0.1.4"
35
35
  source = { editable = "." }
36
36
  dependencies = [
37
37
  { name = "pydantic" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes