forked from omnigent-ai/omnigent
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest_codex_native_forwarder.py
More file actions
2675 lines (2179 loc) · 95.6 KB
/
Copy pathtest_codex_native_forwarder.py
File metadata and controls
2675 lines (2179 loc) · 95.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
Tests for the codex-native forwarder's model-change sync-back
(:mod:`omnigent.codex_native_forwarder`).
For codex-native, ``config.toml``'s ``model`` key is the cost-policy source
of truth (it is what an in-TUI ``/model`` writes). At subscription and at
each ``turn/started`` the forwarder reads it (``_refresh_model_from_config``,
which delegates to the shared ``read_codex_config_model`` in the bridge
module) onto ``_CodexForwarderState.model`` and mirrors it to the Omnigent server
as an ``external_model_change`` event (→ persisted ``conv.model_override``)
so the cost-budget policy resolves the selected model. The startup/spawn
model IS mirrored (so Omnigent learns the session's model even when unchanged);
only an already-mirrored value is not re-posted.
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
from omnigent import codex_native_forwarder as fwd
from omnigent.codex_native_bridge import (
CodexNativeBridgeState,
codex_home_for_bridge_dir,
read_bridge_state,
write_bridge_state,
)
from omnigent.codex_native_forwarder import _persist_codex_compaction_item
class _RecordingClient:
"""
Async ``httpx`` client stub that records POSTs and returns HTTP 200.
Only ``post`` is exercised by ``_post_session_event``; each call is
recorded so the test can assert exactly what was mirrored.
"""
def __init__(self) -> None:
"""Initialize with an empty record of posts."""
self.posts: list[tuple[str, dict]] = []
async def post(self, url: str, *, json: dict) -> httpx.Response:
"""
Record ``(url, json)`` and return a 200 response.
:param url: Request URL, e.g. ``"/v1/sessions/conv_x/events"``.
:param json: JSON body, e.g.
``{"type": "external_model_change", "data": {"model": "gpt-5.4"}}``.
:returns: A real ``httpx.Response`` with status 200.
"""
self.posts.append((url, json))
return httpx.Response(200, request=httpx.Request("POST", url))
def _state(model: str | None, posted_model: str | None) -> fwd._CodexForwarderState:
"""
Build a forwarder state with the given current + last-mirrored model.
:param model: Current Codex model, e.g. ``"gpt-5.4"`` or ``None``.
:param posted_model: Last-mirrored model baseline, e.g. ``"gpt-5.5"``.
:returns: A ``_CodexForwarderState`` for the sync-back helper.
"""
state = fwd._CodexForwarderState()
state.model = model
state.posted_model = posted_model
return state
@pytest.mark.asyncio
async def test_sync_model_change_posts_on_change() -> None:
"""A model differing from the baseline posts external_model_change.
The in-TUI ``/model`` switch (gpt-5.5 → gpt-5.4) must mirror to Omnigent as
an ``external_model_change`` and advance the baseline so it isn't
re-posted. A missing post here is exactly the bug a user hit: the
terminal model changed but the cost policy kept seeing gpt-5.5.
"""
client = _RecordingClient()
state = _state(model="gpt-5.4", posted_model="gpt-5.5")
await fwd._sync_model_change(client, session_id="conv_x", forwarder_state=state)
# Exactly one mirror post, carrying the new raw codex model id.
assert client.posts == [
(
"/v1/sessions/conv_x/events",
{"type": "external_model_change", "data": {"model": "gpt-5.4"}},
)
]
# Baseline advanced → the same model won't re-post on the next update.
assert state.posted_model == "gpt-5.4"
@pytest.mark.asyncio
async def test_sync_model_change_no_post_when_unchanged() -> None:
"""Model equal to the baseline (seeded spawn default) does not post.
Prevents the spawn/startup model from being echoed back to Omnigent as a
spurious "change" (which would also fire on every settings update).
"""
client = _RecordingClient()
state = _state(model="gpt-5.5", posted_model="gpt-5.5")
await fwd._sync_model_change(client, session_id="conv_x", forwarder_state=state)
assert client.posts == []
@pytest.mark.asyncio
async def test_sync_model_change_no_post_when_model_unknown() -> None:
"""No model observed yet (``None``) → nothing to mirror."""
client = _RecordingClient()
state = _state(model=None, posted_model="gpt-5.5")
await fwd._sync_model_change(client, session_id="conv_x", forwarder_state=state)
assert client.posts == []
def _write_codex_config(bridge_dir: Path, body: str) -> Path:
"""
Write a ``config.toml`` into the session's per-session ``CODEX_HOME``.
:param bridge_dir: The bridge dir whose ``codex-home/config.toml`` is
written (the path the model reader reads).
:param body: Raw TOML body, e.g. ``'model = "gpt-5.4"\\n'``.
:returns: The written ``config.toml`` path.
"""
home = codex_home_for_bridge_dir(bridge_dir)
home.mkdir(parents=True, exist_ok=True)
path = home / "config.toml"
path.write_text(body)
return path
def test_refresh_model_from_config_updates_state(tmp_path: Path) -> None:
"""``config.toml``'s model lands on the forwarder state for mirroring.
This is the exact path the subscription and ``turn/started`` handlers use
to learn the user's ``/model`` selection: read config.toml (via the
shared ``read_codex_config_model``) → set ``forwarder_state.model`` →
``_sync_model_change`` mirrors it to AP. The config.toml parsing itself
is covered in ``tests/test_codex_native_bridge.py``; this asserts the
forwarder wires the read into its state.
"""
_write_codex_config(tmp_path, 'model = "gpt-5.4"\n')
state = _state(model="gpt-5.5", posted_model="gpt-5.5")
fwd._refresh_model_from_config(tmp_path, state)
# The selected model (gpt-5.4) replaces the prior value, ready to mirror.
assert state.model == "gpt-5.4"
def test_refresh_prefers_pushed_settings_model_over_stale_config(tmp_path: Path) -> None:
"""An unchanged config.toml must not roll back a live thread-settings model.
Regression for the routed-model reversion: routing switched the running
thread via ``thread/settings/update`` (notified as
``thread/settings/updated``), but config.toml still held the pinned
launch model; the next ``turn/started`` re-read the stale file and
mirrored the default back over ``model_override`` — reverting the routed
model one turn after it applied.
"""
_write_codex_config(tmp_path, 'model = "databricks-gpt-5-5"\n')
state = fwd._CodexForwarderState()
# Subscription-time read adopts the pinned launch model (baseline).
fwd._refresh_model_from_config(tmp_path, state)
assert state.model == "databricks-gpt-5-5"
# Omnigent pushes a routed model thread-level; the live notification wins.
state.note_thread_settings_updated({"threadSettings": {"model": "databricks-gpt-5-6-luna"}})
# turn/started re-read: config.toml is UNCHANGED — the pushed model holds.
fwd._refresh_model_from_config(tmp_path, state)
assert state.model == "databricks-gpt-5-6-luna"
def test_refresh_adopts_changed_config_over_settings_model(tmp_path: Path) -> None:
"""A config.toml that changed since the last read wins over settings.
An in-TUI ``/model`` (or the executor's mirror write) rewrites the file —
that is the freshest signal and must not be masked by an older
``thread/settings/updated`` value.
"""
_write_codex_config(tmp_path, 'model = "databricks-gpt-5-5"\n')
state = fwd._CodexForwarderState()
fwd._refresh_model_from_config(tmp_path, state)
state.note_thread_settings_updated({"threadSettings": {"model": "databricks-gpt-5-6-luna"}})
# The user picks a third model in the TUI: /model rewrites config.toml.
_write_codex_config(tmp_path, 'model = "gpt-5.6-sol"\n')
fwd._refresh_model_from_config(tmp_path, state)
assert state.model == "gpt-5.6-sol"
def test_refresh_launch_race_ends_on_routed_model(tmp_path: Path) -> None:
"""Launch-race scenario: the pinned default ends up on the routed model.
The terminal launch pins the default into config.toml before first-turn
routing runs. The executor then pushes the routed model thread-level AND
mirrors it into config.toml (``write_codex_config_model``); the next
``turn/started`` re-read must adopt the routed model — with or without
the mirror write having succeeded.
"""
from omnigent.codex_native_bridge import write_codex_config_model
_write_codex_config(tmp_path, 'model = "databricks-gpt-5-5"\n')
state = fwd._CodexForwarderState()
fwd._refresh_model_from_config(tmp_path, state)
# First routed turn: settings push (notification) + executor mirror write.
state.note_thread_settings_updated({"threadSettings": {"model": "databricks-gpt-5-6-luna"}})
assert write_codex_config_model(tmp_path, "databricks-gpt-5-6-luna") is True
fwd._refresh_model_from_config(tmp_path, state)
assert state.model == "databricks-gpt-5-6-luna"
# Later turns stay on the routed model (no reversion churn).
fwd._refresh_model_from_config(tmp_path, state)
assert state.model == "databricks-gpt-5-6-luna"
def test_note_resume_response_records_model_without_seeding_baseline() -> None:
"""The startup/resume model is recorded but the baseline stays unset.
Omnigent must learn the session's ACTUAL model — including the spawn default —
because the cost gate resolves ``conv.model_override or spec.llm.model``
and for codex the spawn model is frequently NOT ``spec.llm.model``. So
``note_resume_response`` records ``model`` but leaves ``posted_model``
``None``, so the next ``_sync_model_change`` mirrors the real model. If
this re-seeded the baseline, an unchanged cheap session would never post
``external_model_change`` and the gate would wrongly DENY it.
"""
state = fwd._CodexForwarderState()
state.note_resume_response({"result": {"model": "gpt-5.4-mini"}})
assert state.model == "gpt-5.4-mini"
# Baseline NOT seeded → the spawn model will be mirrored on the next sync.
assert state.posted_model is None
@pytest.mark.asyncio
async def test_sync_after_resume_posts_spawn_model() -> None:
"""End-to-end: an unchanged spawn model is mirrored to AP.
This is the regression for the wrongly-blocked cheap session: codex
spawned on gpt-5.4-mini, the model never "changed", yet Omnigent must still
receive it as ``model_override`` so the cost gate sees a cheap model
instead of falling back to the spec model and DENYing.
"""
client = _RecordingClient()
state = fwd._CodexForwarderState()
state.note_resume_response({"result": {"model": "gpt-5.4-mini"}})
await fwd._sync_model_change(client, session_id="conv_x", forwarder_state=state)
# The spawn model is mirrored (not suppressed as "unchanged").
assert client.posts == [
(
"/v1/sessions/conv_x/events",
{"type": "external_model_change", "data": {"model": "gpt-5.4-mini"}},
)
]
assert state.posted_model == "gpt-5.4-mini"
def test_thread_settings_updated_records_effort_and_collaboration_mode() -> None:
"""
``thread/settings/updated`` records Codex's live thinking settings.
App-server sends the public ``ThreadSettings`` shape with ``effort`` and
``collaborationMode``. If this parser regresses, the later sync helpers have
no state to mirror, so Omnigent would keep stale ``reasoning_effort`` and
mode metadata even though Codex changed them.
"""
state = fwd._CodexForwarderState()
state.note_thread_settings_updated(
{
"threadSettings": {
"model": "gpt-5.4-codex",
"effort": "medium",
"collaborationMode": {
"mode": "plan",
"settings": {
"model": "gpt-5.4-codex",
"reasoning_effort": "medium",
"developer_instructions": None,
},
},
}
}
)
assert state.model == "gpt-5.4-codex"
assert state.effort == "medium"
assert state.collaboration_mode == "plan"
@pytest.mark.asyncio
async def test_sync_reasoning_effort_change_posts_and_dedupes() -> None:
"""
Codex effort changes mirror to Omnigent exactly once per observed value.
The first sync must POST ``external_reasoning_effort_change`` so the server
persists ``conversation.reasoning_effort``. The second sync with the same
value must not re-post; otherwise every repeated settings notification would
churn the session stream.
"""
client = _RecordingClient()
state = fwd._CodexForwarderState(effort="medium")
await fwd._sync_reasoning_effort_change(
client,
session_id="conv_x",
forwarder_state=state,
)
await fwd._sync_reasoning_effort_change(
client,
session_id="conv_x",
forwarder_state=state,
)
# One post proves the new effort reached AP; no second post proves the
# dedupe baseline advanced after a successful mirror.
assert client.posts == [
(
"/v1/sessions/conv_x/events",
{
"type": "external_reasoning_effort_change",
"data": {"reasoning_effort": "medium"},
},
)
]
assert state.posted_effort == "medium"
assert state.posted_effort_known is True
@pytest.mark.asyncio
async def test_sync_reasoning_effort_change_posts_clear() -> None:
"""
Codex clearing effort mirrors JSON null to Omnigent.
``None`` is a meaningful observed value (model/default effort), so the
forwarder must still post it after a prior explicit effort. If this returned
early on falsey ``None``, Omnigent would keep a stale explicit effort.
"""
client = _RecordingClient()
state = fwd._CodexForwarderState(
effort=None,
posted_effort="high",
posted_effort_known=True,
)
await fwd._sync_reasoning_effort_change(
client,
session_id="conv_x",
forwarder_state=state,
)
assert client.posts == [
(
"/v1/sessions/conv_x/events",
{
"type": "external_reasoning_effort_change",
"data": {"reasoning_effort": None},
},
)
]
assert state.posted_effort is None
assert state.posted_effort_known is True
@pytest.mark.asyncio
async def test_sync_codex_collaboration_mode_change_posts_and_dedupes() -> None:
"""
Codex collaboration mode changes mirror to Omnigent labels once.
The ``mode`` value is the durable "Plan vs Default" signal we can get from
app-server. Missing this POST would leave the session snapshot without the
current Codex mode.
"""
client = _RecordingClient()
state = fwd._CodexForwarderState(collaboration_mode="plan")
await fwd._sync_codex_collaboration_mode_change(
client,
session_id="conv_x",
forwarder_state=state,
)
await fwd._sync_codex_collaboration_mode_change(
client,
session_id="conv_x",
forwarder_state=state,
)
assert client.posts == [
(
"/v1/sessions/conv_x/events",
{
"type": "external_codex_collaboration_mode_change",
"data": {"mode": "plan"},
},
)
]
assert state.posted_collaboration_mode == "plan"
@pytest.mark.asyncio
async def test_sync_codex_approval_mode_change_posts_and_dedupes() -> None:
"""Codex ``/permissions`` changes mirror to terminal_launch_args once."""
client = _RecordingClient()
state = fwd._CodexForwarderState()
state.note_thread_settings_updated(
{
"threadSettings": {
"approvalPolicy": "never",
"approvalsReviewer": "auto_review",
"sandboxPolicy": {"type": "danger-full-access"},
"activePermissionProfile": {"id": "dev", "extends": ":workspace"},
}
}
)
await fwd._sync_codex_approval_mode_change(
client,
session_id="conv_x",
forwarder_state=state,
)
await fwd._sync_codex_approval_mode_change(
client,
session_id="conv_x",
forwarder_state=state,
)
assert client.posts == [
(
"/v1/sessions/conv_x/events",
{
"type": "external_codex_approval_mode_change",
"data": {
"terminal_launch_args": [
"-c",
'default_permissions="dev"',
"-c",
'approval_policy="never"',
"-c",
'approvals_reviewer="auto_review"',
]
},
},
)
]
assert state.posted_terminal_launch_args == [
"-c",
'default_permissions="dev"',
"-c",
'approval_policy="never"',
"-c",
'approvals_reviewer="auto_review"',
]
def test_codex_permission_settings_fall_back_to_legacy_policy_args() -> None:
"""Legacy settings without an active profile keep approval and sandbox."""
assert fwd._codex_terminal_launch_args_from_settings(
{
"approvalPolicy": "on-failure",
"approvalsReviewer": "user",
"sandboxPolicy": {"type": "workspace-write"},
}
) == [
"--sandbox",
"workspace-write",
"--ask-for-approval",
"on-failure",
"-c",
'approvals_reviewer="user"',
]
@pytest.mark.parametrize(
"content,expected",
[
([{"type": "image", "url": "data:image/png;base64,AAAA"}], True),
([{"type": "input_file", "file_data": "data:application/pdf;base64,AAAA"}], True),
([{"type": "text", "text": "hi"}, {"type": "image", "url": "data:x"}], True),
([{"type": "text", "text": "only text"}], False),
([], False),
("not a list", False),
],
)
def test_user_message_has_file_content(content: object, expected: bool) -> None:
"""
Detect a non-text (image/file) block in a Codex ``userMessage``.
Drives the gate that decides whether a text-less ``userMessage`` is a
real image-bearing message that must be persisted. ``True`` for any
block whose ``type`` is not ``"text"``, else ``False``. A wrong result
re-opens the image-only regression (text-less image skipped → dropped
bubble + pending-FIFO bleed) or makes text-only messages post twice.
"""
assert fwd._user_message_has_file_content({"content": content}) is expected
@pytest.mark.asyncio
async def test_post_user_message_image_only_posts_empty_content() -> None:
"""
An image-only ``userMessage`` is posted with EMPTY Omnigent content.
Regression guard for the image-only bleed/ordering bug: the forwarder
must post the user item (so the server drains the pending-input FIFO
entry and folds the image in by file_id). The posted content is empty
— the base64 ``data:`` URL Codex echoes must NOT be written into text.
A bail here would drop the user bubble and leak the pending entry into
the next message.
"""
client = _RecordingClient()
item = {
"type": "userMessage",
"content": [{"type": "image", "url": "data:image/png;base64,AAAA"}],
}
await fwd._post_user_message(client, "conv_x", {"turnId": "t1"}, item)
assert len(client.posts) == 1, "image-only userMessage must still be posted"
_url, body = client.posts[0]
item_data = body["data"]["item_data"]
assert item_data["role"] == "user"
# Empty content: the image is supplied server-side from the pending
# entry; echoing Codex's base64 url here would re-introduce the freeze.
assert item_data["content"] == []
@pytest.mark.asyncio
async def test_post_user_message_text_posts_input_text() -> None:
"""A text ``userMessage`` posts an ``input_text`` block (unchanged path)."""
client = _RecordingClient()
item = {"type": "userMessage", "content": [{"type": "text", "text": "hello"}]}
await fwd._post_user_message(client, "conv_x", {"turnId": "t1"}, item)
assert len(client.posts) == 1
_url, body = client.posts[0]
assert body["data"]["item_data"]["content"] == [{"type": "input_text", "text": "hello"}]
@pytest.mark.asyncio
async def test_post_user_message_truly_empty_is_skipped() -> None:
"""
A ``userMessage`` with neither text nor a file block is not posted.
Without this guard the forwarder would emit spurious empty user
bubbles. A failure (a post recorded) means the empty-skip branch broke.
"""
client = _RecordingClient()
item = {"type": "userMessage", "content": []}
await fwd._post_user_message(client, "conv_x", {"turnId": "t1"}, item)
assert client.posts == []
# ── sub-agent usage pricing: seed the child coalescer's model ──────────
@pytest.mark.asyncio
async def test_usage_coalescer_seeded_model_rides_along_so_child_usage_prices() -> None:
"""A coalescer seeded with a model attaches it to its token post.
Codex sub-agent (child-thread) usage is recorded on a coalescer created on
the child-event path, where ``forwarder_state`` (the usual model source) is
intentionally ``None`` — so ``record()`` receives no model. Without the
constructor seed the token post carries no ``model``, the server leaves the
child's ``total_cost_usd`` unpriced (``None``), and the sub-agent's spend
drops out of the parent's subtree cost — letting it run past the budget.
The seeded model must ride along on every token post so the server can
price the cumulative tokens.
"""
client = _RecordingClient()
coalescer = fwd._SessionUsageCoalescer(client, "conv_child", model="gpt-5.5")
# Mirror the child path exactly: a usage frame with NO model in record().
coalescer.record({"tokenUsage": {"total": {"inputTokens": 46003, "outputTokens": 4141}}})
await coalescer.flush()
assert len(client.posts) == 1 # one external_session_usage post
url, body = client.posts[0]
assert url == "/v1/sessions/conv_child/events"
assert body["type"] == "external_session_usage"
data = body["data"]
# The seeded model rides along — this is what lets the server price the
# tokens into the child's total_cost_usd (the whole point of the fix).
assert data["model"] == "gpt-5.5"
# The cumulative token counts the server prices from are present.
assert data["cumulative_input_tokens"] == 46003
assert data["cumulative_output_tokens"] == 4141
@pytest.mark.asyncio
async def test_usage_coalescer_unseeded_omits_model() -> None:
"""Without a seed (and no model via record), the post carries no model.
This is the pre-fix behavior that left a sub-agent's cost unpriced; the
test pins the contrast so a regression dropping the seed is caught (the
post would silently go back to model-less and the budget gap would return).
"""
client = _RecordingClient()
coalescer = fwd._SessionUsageCoalescer(client, "conv_child") # no model seed
coalescer.record({"tokenUsage": {"total": {"inputTokens": 100, "outputTokens": 5}}})
await coalescer.flush()
assert len(client.posts) == 1
_url, body = client.posts[0]
assert "model" not in body["data"]
class _FlakyElicitationClient:
"""
Elicitation client stub: configurable failures, then HTTP 200.
Real stub (not MagicMock) so unexpected extra calls surface in
:attr:`posts`. Each call records ``(url, json)``. The first
``transport_failures`` calls raise ``httpx.ReadError`` (a severed
long-poll) and the next ``gateway_failures`` calls return HTTP 502
(a proxy gateway error); every later call returns 200 with a
JSON-RPC result body.
:param transport_failures: Calls to fail with ``httpx.ReadError``
before succeeding, e.g. ``1``.
:param gateway_failures: Calls to answer with HTTP 502 after the
transport failures, e.g. ``0``.
"""
def __init__(self, transport_failures: int = 0, gateway_failures: int = 0) -> None:
self.posts: list[tuple[str, dict]] = []
self._transport_failures = transport_failures
self._gateway_failures = gateway_failures
async def post(self, url: str, *, json: dict, timeout: httpx.Timeout) -> httpx.Response:
"""
Record the call and fail/succeed per the configured schedule.
:param url: Request URL, e.g.
``"/v1/sessions/conv_x/hooks/codex-elicitation-request"``.
:param json: Codex JSON-RPC request envelope.
:param timeout: Per-attempt budget (ignored by the stub).
:returns: HTTP 502 during the gateway-failure window, else 200
with a JSON-RPC result body.
:raises httpx.ReadError: During the transport-failure window.
"""
self.posts.append((url, json))
attempt = len(self.posts)
if attempt <= self._transport_failures:
raise httpx.ReadError(
"proxy severed the long-poll",
request=httpx.Request("POST", url),
)
if attempt <= self._transport_failures + self._gateway_failures:
return httpx.Response(502, request=httpx.Request("POST", url))
return httpx.Response(
200,
json={"action": "accept", "content": {}, "_meta": None},
request=httpx.Request("POST", url),
)
async def _instant_retry_sleep(_seconds: float) -> None:
"""
Drop-in for ``_elicitation_retry_sleep`` that returns at once.
:param _seconds: Ignored backoff duration.
:returns: None.
"""
return
_ELICITATION_EVENT: dict = {
"id": 7,
"method": "mcpServer/elicitation/request",
"params": {"mode": "form", "message": "Pick a date"},
}
@pytest.mark.asyncio
async def test_elicitation_post_reposts_after_transport_cut_with_same_envelope(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
A severed elicitation long-poll is re-POSTed with the identical envelope.
This is the invisible-stuck bug for codex sub-agents: one transport
error used to abandon the prompt to the native-TUI path nobody is
watching. The envelope must be byte-identical on the retry — the
server derives the deterministic elicitation id from (session,
method, rpc id), so an identical re-POST re-parks the SAME prompt
and keeps the approval card alive.
"""
monkeypatch.setattr(fwd, "_elicitation_retry_sleep", _instant_retry_sleep)
client = _FlakyElicitationClient(transport_failures=1)
response = await fwd._post_codex_elicitation_request(
client, # type: ignore[arg-type] # stub implements the one used method
"conv_x",
event=_ELICITATION_EVENT,
)
assert response is not None
assert response.status_code == 200
# 2 = one severed attempt + one successful retry. 1 means the
# transport error abandoned the prompt (the production bug).
assert len(client.posts) == 2, f"expected 2 attempts, got {len(client.posts)}"
# Identical (url, envelope) on the retry is the re-park contract.
assert client.posts[0] == client.posts[1]
assert client.posts[0][0] == "/v1/sessions/conv_x/hooks/codex-elicitation-request"
@pytest.mark.asyncio
async def test_elicitation_post_retries_gateway_5xx(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
A 5xx (proxy gateway error on a severed long-poll) is retried.
The Databricks Apps proxy answers a killed upstream long-poll with
502/504 rather than a clean transport error; the verdict may still
be pending server-side, so the forwarder must re-park rather than
treat it as final.
"""
monkeypatch.setattr(fwd, "_elicitation_retry_sleep", _instant_retry_sleep)
client = _FlakyElicitationClient(gateway_failures=1)
response = await fwd._post_codex_elicitation_request(
client, # type: ignore[arg-type]
"conv_x",
event=_ELICITATION_EVENT,
)
assert response is not None
assert response.status_code == 200
# 2 = the 502 attempt + the successful retry; 1 would mean 5xx was
# treated as a final answer and the prompt abandoned.
assert len(client.posts) == 2
@pytest.mark.asyncio
async def test_elicitation_post_4xx_is_final(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
A 4xx is a deliberate server rejection — returned without retry.
Retrying a rejection would hammer the server with a request it
already refused; the caller logs it and leaves the native request
unanswered.
"""
class _RejectingClient:
"""Client stub answering every elicitation POST with HTTP 400."""
def __init__(self) -> None:
self.posts: list[tuple[str, dict]] = []
async def post(self, url: str, *, json: dict, timeout: httpx.Timeout) -> httpx.Response:
"""
Record the call and reject it.
:param url: Request URL.
:param json: Codex JSON-RPC request envelope.
:param timeout: Per-attempt budget (ignored by the stub).
:returns: HTTP 400.
"""
self.posts.append((url, json))
return httpx.Response(400, request=httpx.Request("POST", url))
monkeypatch.setattr(fwd, "_elicitation_retry_sleep", _instant_retry_sleep)
client = _RejectingClient()
response = await fwd._post_codex_elicitation_request(
client, # type: ignore[arg-type]
"conv_x",
event=_ELICITATION_EVENT,
)
assert response is not None
assert response.status_code == 400
# 1 = the rejection was final; 2+ means 4xx is being retried.
assert len(client.posts) == 1
@pytest.mark.asyncio
async def test_elicitation_post_returns_none_when_budget_exhausted(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
An exhausted retry budget returns ``None`` (caller leaves the
native request unanswered, matching the old single-attempt outcome).
"""
# Budget smaller than the first backoff → exactly one attempt.
monkeypatch.setattr(fwd, "_CODEX_ELICITATION_REQUEST_TIMEOUT_SECONDS", 0.5)
monkeypatch.setattr(fwd, "_elicitation_retry_sleep", _instant_retry_sleep)
client = _FlakyElicitationClient(transport_failures=100)
response = await fwd._post_codex_elicitation_request(
client, # type: ignore[arg-type]
"conv_x",
event=_ELICITATION_EVENT,
)
assert response is None
# 1 = the deadline check stopped the loop before a second attempt
# (backoff 1.0s > 0.5s budget); more means the budget is ignored.
assert len(client.posts) == 1
class _StatusClient:
"""httpx client stub whose ``post`` returns a fixed status code."""
def __init__(self, status_code: int) -> None:
""":param status_code: Status to return from every post, e.g. ``400``."""
self.status_code = status_code
self.posts = 0
async def post(self, url: str, *, json: dict) -> httpx.Response:
"""Return the configured status; never raises."""
del json
self.posts += 1
return httpx.Response(self.status_code, request=httpx.Request("POST", url))
def test_forward_failures_escalate_to_degraded_once() -> None:
"""
Sustained forward failures flip the degraded latch exactly once (#1120).
Network drops previously surfaced only as scattered per-item warnings;
the latch turns a real outage into a single loud signal and does not
re-fire per dropped item.
"""
fwd._reset_forward_health()
for _ in range(fwd._FORWARD_DEGRADED_THRESHOLD - 1):
fwd._note_forward_failure("external_output_text_delta")
# Below threshold: not yet degraded.
assert fwd._forward_health.degraded_logged is False
fwd._note_forward_failure("external_output_text_delta") # crosses threshold
assert fwd._forward_health.degraded_logged is True
assert fwd._forward_health.consecutive_failures == fwd._FORWARD_DEGRADED_THRESHOLD
# The latch holds — further failures keep counting but don't re-escalate.
fwd._note_forward_failure("external_output_text_delta")
assert fwd._forward_health.degraded_logged is True
assert fwd._forward_health.consecutive_failures == fwd._FORWARD_DEGRADED_THRESHOLD + 1
def test_forward_success_resets_degraded_state() -> None:
"""
A successful forward clears the failure count and degraded latch.
Recovery must re-arm the indicator so a later outage escalates again.
"""
fwd._reset_forward_health()
for _ in range(fwd._FORWARD_DEGRADED_THRESHOLD):
fwd._note_forward_failure("external_session_usage")
assert fwd._forward_health.degraded_logged is True
fwd._note_forward_success()
assert fwd._forward_health.consecutive_failures == 0
assert fwd._forward_health.degraded_logged is False
@pytest.mark.asyncio
async def test_post_session_event_tracks_success_and_failure() -> None:
"""
_post_session_event classifies each outcome into forward health (#1120).
A 2xx clears the failure run; a permanent 4xx counts as a failure so a
sustained outage can escalate.
"""
fwd._reset_forward_health()
# A permanent 4xx is a failure.
await fwd._post_session_event(
_StatusClient(400), "conv_x", event_type="external_session_status", data={"status": "idle"}
)
assert fwd._forward_health.consecutive_failures == 1
# A 2xx resets the run.
await fwd._post_session_event(
_RecordingClient(), "conv_x", event_type="external_session_status", data={"status": "idle"}
)
assert fwd._forward_health.consecutive_failures == 0
# ── #1108: turn-error "silent success" → surfaced failed ──────────────
#
# A failed Codex turn arrives as ``turn/completed`` (a clean success boundary)
# with ``turn.status == "failed"`` and a ``turn.error`` object. These tests pin
# the surface-only fix: such turns are forced to ``failed``, the reason is
# surfaced as the status output, auth errors (codexErrorInfo / 401-403) carry a
# re-auth hint, the resume path reaches the same verdict, an empty turn is idle
# (+ WARN), and a genuinely clean turn still reports success.
def _seed_active_turn(bridge_dir: Path, turn_id: str) -> None:
"""
Seed bridge state so a terminal turn edge clears the active turn.
``_terminal_turn_status_edge`` only produces an edge when the terminal
event clears the recorded active turn id; without this seed it returns
``None`` as "stale".
:param bridge_dir: Native Codex bridge directory (the test ``tmp_path``).
:param turn_id: Active Codex turn id to record, e.g. ``"turn_123"``.
:returns: None.
"""
write_bridge_state(
bridge_dir,
CodexNativeBridgeState(
session_id="conv_x",
socket_path=str(bridge_dir / "app-server.sock"),
thread_id="thread_123",
codex_home=str(bridge_dir / "codex-home"),
active_turn_id=turn_id,
),
)
def test_classify_codex_error_auth_vs_generic() -> None:
"""The shared classifier flags auth errors and leaves the rest generic.
This is the single classifier reused by both the live and resume paths;
if it regresses, an expired-login failure would surface without the
re-auth hint (or a disk-full error would wrongly demand re-auth). It
prefers ``codexErrorInfo`` (variant / httpStatusCode) and falls back to
the message text.
"""
auth = fwd._CODEX_ERROR_KIND_AUTH
generic = fwd._CODEX_ERROR_KIND_GENERIC
# Structured codexErrorInfo: string variant, tagged object, http status.
assert fwd._classify_codex_error({"codexErrorInfo": "Unauthorized"}, "nope") == auth
assert fwd._classify_codex_error({"codexErrorInfo": {"type": "Unauthorized"}}, "nope") == auth
assert fwd._classify_codex_error({"codexErrorInfo": {"httpStatusCode": 401}}, "nope") == auth
# The real app-server enum serializes lowercase snake_case; it must match
# via the structured path (message "nope" has no auth substring to fall
# back on), case-insensitively.
assert fwd._classify_codex_error({"codexErrorInfo": "unauthorized"}, "nope") == auth
assert fwd._classify_codex_error({"codexErrorInfo": {"type": "unauthorized"}}, "nope") == auth
# Message-text fallback when codexErrorInfo is absent.
assert fwd._classify_codex_error({}, "Please run codex login") == auth
assert fwd._classify_codex_error({}, "ChatGPT session expired") == auth
assert fwd._classify_codex_error({"codexErrorInfo": "Other"}, "disk full") == generic
def test_terminal_error_from_turn_reads_and_classifies_turn_error() -> None:
"""``_terminal_error_from_turn`` returns the classified ``turn.error``.
The helper is the single source of truth for "did this turn fail"; both
edge builders depend on it, so it must read ``turn.error`` and classify it.
"""
params = {
"turn": {
"id": "turn_123",
"status": "failed",
"error": {
"message": "401 Unauthorized: login expired",
"codexErrorInfo": "Unauthorized",
},
}
}
error = fwd._terminal_error_from_turn(params)
assert error is not None
assert error.message == "401 Unauthorized: login expired"
assert error.kind == fwd._CODEX_ERROR_KIND_AUTH
assert error.is_auth is True
def test_terminal_error_from_turn_falls_back_to_error_item() -> None:
"""With no ``turn.error``, an ``error`` ThreadItem in ``turn.items`` is used.
Both shapes exist in the app-server type system; the fallback keeps the fix
correct on the version/path that emits the error as an item rather than as a
``turn.error`` object.
"""
params = {
"turn": {
"id": "turn_123",
"status": "completed",
"items": [
{"type": "agentMessage", "id": "a", "text": "working"},
{"type": "error", "message": "please run codex login"},