@ranger1/dx 0.1.48 → 0.1.50

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.
@@ -8,21 +8,47 @@ Tests cover:
8
8
  3. Edge cases: empty input, malformed data, cross-reviewer matching
9
9
  """
10
10
 
11
- import pytest
12
- from unittest.mock import patch, MagicMock
13
- import sys
11
+ import importlib.util
12
+ import json
13
+ import subprocess
14
+ from collections.abc import Mapping, Sequence
14
15
  from pathlib import Path
16
+ from types import SimpleNamespace
17
+ from typing import Callable, cast
18
+
19
+ import pytest
20
+
21
+
22
+ def _load_pr_review_aggregate_module():
23
+ module_path = Path(__file__).with_name("pr_review_aggregate.py")
24
+ spec = importlib.util.spec_from_file_location("pr_review_aggregate", module_path)
25
+ if spec is None or spec.loader is None:
26
+ raise RuntimeError(f"Failed to load module spec: {module_path}")
27
+ module = importlib.util.module_from_spec(spec)
28
+ spec.loader.exec_module(module)
29
+ return module
15
30
 
16
- # Add parent directory to path for importing pr_review_aggregate
17
- sys.path.insert(0, str(Path(__file__).parent))
18
31
 
19
- # Import functions under test
20
- from pr_review_aggregate import (
21
- _parse_decision_log,
22
- _filter_by_decision_log,
23
- _parse_escalation_groups_json,
24
- _parse_escalation_groups_b64,
32
+ _pr_review_aggregate = _load_pr_review_aggregate_module()
33
+
34
+ _parse_decision_log = cast(Callable[[str], list[dict[str, object]]], getattr(_pr_review_aggregate, "_parse_decision_log"))
35
+ _filter_by_decision_log = cast(
36
+ Callable[[Sequence[Mapping[str, object]], Sequence[Mapping[str, object]], list[list[str]]], list[dict[str, object]]],
37
+ getattr(_pr_review_aggregate, "_filter_by_decision_log"),
38
+ )
39
+ _parse_escalation_groups_json = cast(
40
+ Callable[[str], list[list[str]]],
41
+ getattr(_pr_review_aggregate, "_parse_escalation_groups_json"),
42
+ )
43
+ _parse_escalation_groups_b64 = cast(
44
+ Callable[[str], list[list[str]]],
45
+ getattr(_pr_review_aggregate, "_parse_escalation_groups_b64"),
46
+ )
47
+ _check_existing_comment = cast(
48
+ Callable[[int, str, int, str], bool],
49
+ getattr(_pr_review_aggregate, "_check_existing_comment"),
25
50
  )
51
+ _MARKER = cast(str, getattr(_pr_review_aggregate, "MARKER"))
26
52
 
27
53
 
28
54
  # ============================================================
@@ -30,13 +56,13 @@ from pr_review_aggregate import (
30
56
  # ============================================================
31
57
 
32
58
  @pytest.fixture
33
- def empty_decision_log():
59
+ def empty_decision_log() -> str:
34
60
  """Empty decision log markdown."""
35
61
  return ""
36
62
 
37
63
 
38
64
  @pytest.fixture
39
- def valid_decision_log():
65
+ def valid_decision_log() -> str:
40
66
  """Valid decision log with Fixed and Rejected entries."""
41
67
  return """# Decision Log
42
68
 
@@ -46,20 +72,24 @@ PR: 123
46
72
 
47
73
  ### Fixed
48
74
  - id: CDX-001
75
+ file: apps/backend/src/api.ts
49
76
  commit: abc123
50
77
  essence: JSON.parse 未捕获异常
51
78
 
52
79
  - id: GMN-002
80
+ file: apps/front/src/ErrorBoundary.tsx
53
81
  commit: def456
54
82
  essence: 缺少错误边界处理
55
83
 
56
84
  ### Rejected
57
85
  - id: GMN-004
86
+ file: apps/front/src/Component.tsx
58
87
  priority: P2
59
88
  reason: 需要产品决策,超出 PR 范围
60
89
  essence: 组件拆分建议
61
90
 
62
91
  - id: CLD-003
92
+ file: apps/backend/src/db.ts
63
93
  priority: P3
64
94
  reason: 性能优化非当前优先级
65
95
  essence: 批量查询优化
@@ -67,7 +97,38 @@ PR: 123
67
97
 
68
98
 
69
99
  @pytest.fixture
70
- def malformed_decision_log():
100
+ def valid_decision_log_legacy_no_file() -> str:
101
+ """Legacy decision log fixture without the file: field (backward compat)."""
102
+ return """# Decision Log
103
+
104
+ PR: 123
105
+
106
+ ## Round 1
107
+
108
+ ### Fixed
109
+ - id: CDX-001
110
+ commit: abc123
111
+ essence: JSON.parse 未捕获异常
112
+
113
+ - id: GMN-002
114
+ commit: def456
115
+ essence: 缺少错误边界处理
116
+
117
+ ### Rejected
118
+ - id: GMN-004
119
+ priority: P2
120
+ reason: 需要产品决策,超出 PR 范围
121
+ essence: 组件拆分建议
122
+
123
+ - id: CLD-003
124
+ priority: P3
125
+ reason: 性能优化非当前优先级
126
+ essence: 批量查询优化
127
+ """
128
+
129
+
130
+ @pytest.fixture
131
+ def malformed_decision_log() -> str:
71
132
  """Malformed decision log with missing fields and bad formatting."""
72
133
  return """# Decision Log
73
134
 
@@ -90,7 +151,7 @@ Some random text that should be ignored
90
151
 
91
152
 
92
153
  @pytest.fixture
93
- def sample_findings():
154
+ def sample_findings() -> list[dict[str, object]]:
94
155
  """Sample findings list for filter tests."""
95
156
  return [
96
157
  {
@@ -137,7 +198,7 @@ def sample_findings():
137
198
 
138
199
 
139
200
  @pytest.fixture
140
- def prior_decisions():
201
+ def prior_decisions() -> list[dict[str, object]]:
141
202
  """Sample prior decisions from _parse_decision_log."""
142
203
  return [
143
204
  {
@@ -160,7 +221,7 @@ def prior_decisions():
160
221
  # Test: _parse_decision_log() - Empty Input
161
222
  # ============================================================
162
223
 
163
- def test_parse_decision_log_empty(empty_decision_log):
224
+ def test_parse_decision_log_empty(empty_decision_log: str) -> None:
164
225
  """
165
226
  Test that empty decision log returns empty list.
166
227
 
@@ -177,7 +238,7 @@ def test_parse_decision_log_empty(empty_decision_log):
177
238
  # Test: _parse_decision_log() - Valid Input
178
239
  # ============================================================
179
240
 
180
- def test_parse_decision_log_valid(valid_decision_log):
241
+ def test_parse_decision_log_valid(valid_decision_log: str) -> None:
181
242
  """
182
243
  Test that valid decision log is parsed into structured data.
183
244
 
@@ -194,6 +255,7 @@ def test_parse_decision_log_valid(valid_decision_log):
194
255
  fixed_1 = result[0]
195
256
  assert fixed_1["id"] == "CDX-001"
196
257
  assert fixed_1["status"] == "fixed"
258
+ assert fixed_1["file"] == "apps/backend/src/api.ts"
197
259
  assert fixed_1["commit"] == "abc123"
198
260
  assert fixed_1["essence"] == "JSON.parse 未捕获异常"
199
261
 
@@ -201,6 +263,7 @@ def test_parse_decision_log_valid(valid_decision_log):
201
263
  fixed_2 = result[1]
202
264
  assert fixed_2["id"] == "GMN-002"
203
265
  assert fixed_2["status"] == "fixed"
266
+ assert fixed_2["file"] == "apps/front/src/ErrorBoundary.tsx"
204
267
  assert fixed_2["commit"] == "def456"
205
268
  assert fixed_2["essence"] == "缺少错误边界处理"
206
269
 
@@ -208,6 +271,7 @@ def test_parse_decision_log_valid(valid_decision_log):
208
271
  rejected_1 = result[2]
209
272
  assert rejected_1["id"] == "GMN-004"
210
273
  assert rejected_1["status"] == "rejected"
274
+ assert rejected_1["file"] == "apps/front/src/Component.tsx"
211
275
  assert rejected_1["priority"] == "P2"
212
276
  assert rejected_1["reason"] == "需要产品决策,超出 PR 范围"
213
277
  assert rejected_1["essence"] == "组件拆分建议"
@@ -216,14 +280,31 @@ def test_parse_decision_log_valid(valid_decision_log):
216
280
  rejected_2 = result[3]
217
281
  assert rejected_2["id"] == "CLD-003"
218
282
  assert rejected_2["status"] == "rejected"
283
+ assert rejected_2["file"] == "apps/backend/src/db.ts"
219
284
  assert rejected_2["priority"] == "P3"
220
285
 
221
286
 
287
+ def test_parse_decision_log_legacy_without_file(valid_decision_log_legacy_no_file: str) -> None:
288
+ """Decision log entries without file: should still parse (backward compat)."""
289
+ result = _parse_decision_log(valid_decision_log_legacy_no_file)
290
+
291
+ # Should have 4 entries (2 Fixed, 2 Rejected)
292
+ assert len(result) == 4
293
+
294
+ # Basic shape should still be present
295
+ for entry in result:
296
+ assert "id" in entry
297
+ assert "status" in entry
298
+
299
+ # And file should be optional
300
+ assert all(("file" not in e) or (e["file"] in (None, "")) for e in result)
301
+
302
+
222
303
  # ============================================================
223
304
  # Test: _parse_decision_log() - Malformed Input
224
305
  # ============================================================
225
306
 
226
- def test_parse_decision_log_malformed(malformed_decision_log):
307
+ def test_parse_decision_log_malformed(malformed_decision_log: str) -> None:
227
308
  """
228
309
  Test that malformed decision log degrades gracefully.
229
310
 
@@ -247,7 +328,7 @@ def test_parse_decision_log_malformed(malformed_decision_log):
247
328
  # Test: _filter_by_decision_log() - Fixed Issues
248
329
  # ============================================================
249
330
 
250
- def test_filter_fixed_issues(sample_findings, prior_decisions):
331
+ def test_filter_fixed_issues(sample_findings: list[dict[str, object]], prior_decisions: list[dict[str, object]]) -> None:
251
332
  """
252
333
  Test that findings matching Fixed decisions are filtered out.
253
334
 
@@ -255,7 +336,7 @@ def test_filter_fixed_issues(sample_findings, prior_decisions):
255
336
  When: _filter_by_decision_log() is called with empty escalation_groups
256
337
  Then: CDX-001 is filtered out
257
338
  """
258
- escalation_groups = []
339
+ escalation_groups: list[list[str]] = []
259
340
 
260
341
  result = _filter_by_decision_log(sample_findings, prior_decisions, escalation_groups)
261
342
 
@@ -271,7 +352,7 @@ def test_filter_fixed_issues(sample_findings, prior_decisions):
271
352
  # Test: _filter_by_decision_log() - Rejected Without Escalation
272
353
  # ============================================================
273
354
 
274
- def test_filter_rejected_without_escalation(sample_findings, prior_decisions):
355
+ def test_filter_rejected_without_escalation(sample_findings: list[dict[str, object]], prior_decisions: list[dict[str, object]]) -> None:
275
356
  """
276
357
  Test that findings matching Rejected decisions are filtered out when NOT in escalation_groups.
277
358
 
@@ -280,7 +361,7 @@ def test_filter_rejected_without_escalation(sample_findings, prior_decisions):
280
361
  When: _filter_by_decision_log() is called
281
362
  Then: GMN-004 is filtered out
282
363
  """
283
- escalation_groups = []
364
+ escalation_groups: list[list[str]] = []
284
365
 
285
366
  result = _filter_by_decision_log(sample_findings, prior_decisions, escalation_groups)
286
367
 
@@ -296,7 +377,7 @@ def test_filter_rejected_without_escalation(sample_findings, prior_decisions):
296
377
  # Test: _filter_by_decision_log() - Rejected With Escalation
297
378
  # ============================================================
298
379
 
299
- def test_filter_rejected_with_escalation(sample_findings, prior_decisions):
380
+ def test_filter_rejected_with_escalation(sample_findings: list[dict[str, object]], prior_decisions: list[dict[str, object]]) -> None:
300
381
  """
301
382
  Test that findings matching Rejected decisions are kept when in escalation_groups.
302
383
 
@@ -322,7 +403,7 @@ def test_filter_rejected_with_escalation(sample_findings, prior_decisions):
322
403
  # Test: _filter_by_decision_log() - Cross-Reviewer Match
323
404
  # ============================================================
324
405
 
325
- def test_filter_cross_reviewer_match():
406
+ def test_filter_cross_reviewer_match() -> None:
326
407
  """
327
408
  Test that findings with different reviewer IDs but same essence are filtered.
328
409
 
@@ -381,7 +462,7 @@ def test_filter_cross_reviewer_match():
381
462
  # Test: _parse_escalation_groups_json()
382
463
  # ============================================================
383
464
 
384
- def test_parse_escalation_groups_json_valid():
465
+ def test_parse_escalation_groups_json_valid() -> None:
385
466
  """Test parsing valid escalation groups JSON."""
386
467
  json_str = '{"escalationGroups": [["GMN-004", "CLD-007"], ["CDX-001", "GMN-005"]]}'
387
468
  result = _parse_escalation_groups_json(json_str)
@@ -391,13 +472,13 @@ def test_parse_escalation_groups_json_valid():
391
472
  assert ["CDX-001", "GMN-005"] in result
392
473
 
393
474
 
394
- def test_parse_escalation_groups_json_empty():
475
+ def test_parse_escalation_groups_json_empty() -> None:
395
476
  """Test parsing empty escalation groups JSON."""
396
477
  result = _parse_escalation_groups_json("")
397
478
  assert result == []
398
479
 
399
480
 
400
- def test_parse_escalation_groups_json_malformed():
481
+ def test_parse_escalation_groups_json_malformed() -> None:
401
482
  """Test parsing malformed JSON returns empty list."""
402
483
  result = _parse_escalation_groups_json("not valid json {{{")
403
484
  assert result == []
@@ -407,7 +488,7 @@ def test_parse_escalation_groups_json_malformed():
407
488
  # Test: _parse_escalation_groups_b64()
408
489
  # ============================================================
409
490
 
410
- def test_parse_escalation_groups_b64_valid():
491
+ def test_parse_escalation_groups_b64_valid() -> None:
411
492
  """Test parsing valid base64-encoded escalation groups."""
412
493
  import base64
413
494
  json_str = '{"escalationGroups": [["GMN-004", "CLD-007"]]}'
@@ -419,13 +500,13 @@ def test_parse_escalation_groups_b64_valid():
419
500
  assert ["GMN-004", "CLD-007"] in result
420
501
 
421
502
 
422
- def test_parse_escalation_groups_b64_empty():
503
+ def test_parse_escalation_groups_b64_empty() -> None:
423
504
  """Test parsing empty base64 string."""
424
505
  result = _parse_escalation_groups_b64("")
425
506
  assert result == []
426
507
 
427
508
 
428
- def test_parse_escalation_groups_b64_invalid():
509
+ def test_parse_escalation_groups_b64_invalid() -> None:
429
510
  """Test parsing invalid base64 returns empty list."""
430
511
  result = _parse_escalation_groups_b64("not-valid-base64!!!")
431
512
  assert result == []
@@ -435,7 +516,7 @@ def test_parse_escalation_groups_b64_invalid():
435
516
  # Test: Integration - Full Workflow
436
517
  # ============================================================
437
518
 
438
- def test_integration_full_filter_workflow():
519
+ def test_integration_full_filter_workflow() -> None:
439
520
  """
440
521
  Integration test: parse decision log and filter findings.
441
522
 
@@ -496,5 +577,125 @@ PR: 456
496
577
  assert len(result) == 2
497
578
 
498
579
 
580
+ # ============================================================
581
+ # Test: _check_existing_comment() - PR comment idempotency
582
+ # ============================================================
583
+
584
+
585
+ def _patch_subprocess_run_for_gh_comments(monkeypatch: pytest.MonkeyPatch, comments: list[dict[str, object]], returncode: int = 0) -> None:
586
+ stdout = json.dumps(comments, ensure_ascii=True)
587
+
588
+ def _fake_run(*_args: object, **_kwargs: object) -> SimpleNamespace:
589
+ return SimpleNamespace(returncode=returncode, stdout=stdout)
590
+
591
+ monkeypatch.setattr(subprocess, "run", _fake_run)
592
+
593
+
594
+ @pytest.mark.parametrize(
595
+ "comment_type,round_num,expected_header",
596
+ [
597
+ ("review-summary", 2, "## Review Summary (Round 2)"),
598
+ ("fix-report", 2, "## Fix Report (Round 2)"),
599
+ ("final-report", 2, "## Final Report"),
600
+ ],
601
+ )
602
+ def test_check_existing_comment_true_when_marker_header_and_runid_match(
603
+ monkeypatch: pytest.MonkeyPatch, comment_type: str, round_num: int, expected_header: str
604
+ ) -> None:
605
+ pr_number = 123
606
+ run_id = "run-abc"
607
+ body = "\n".join([_MARKER, "", expected_header, "", f"RunId: {run_id}"])
608
+ _patch_subprocess_run_for_gh_comments(monkeypatch, [{"body": body}])
609
+
610
+ assert _check_existing_comment(pr_number, run_id, round_num, comment_type) is True
611
+
612
+
613
+ @pytest.mark.parametrize(
614
+ "comment_type,round_num,expected_header",
615
+ [
616
+ ("review-summary", 3, "## Review Summary (Round 3)"),
617
+ ("fix-report", 3, "## Fix Report (Round 3)"),
618
+ ("final-report", 3, "## Final Report"),
619
+ ],
620
+ )
621
+ def test_check_existing_comment_false_when_marker_missing(
622
+ monkeypatch: pytest.MonkeyPatch, comment_type: str, round_num: int, expected_header: str
623
+ ) -> None:
624
+ pr_number = 456
625
+ run_id = "run-xyz"
626
+ body = "\n".join(["", expected_header, "", f"RunId: {run_id}"])
627
+ _patch_subprocess_run_for_gh_comments(monkeypatch, [{"body": body}])
628
+
629
+ assert _check_existing_comment(pr_number, run_id, round_num, comment_type) is False
630
+
631
+
632
+ @pytest.mark.parametrize(
633
+ "comment_type,round_num,expected_header,wrong_header",
634
+ [
635
+ (
636
+ "review-summary",
637
+ 2,
638
+ "## Review Summary (Round 2)",
639
+ "## Fix Report (Round 2)",
640
+ ),
641
+ (
642
+ "fix-report",
643
+ 2,
644
+ "## Fix Report (Round 2)",
645
+ "## Review Summary (Round 2)",
646
+ ),
647
+ (
648
+ "final-report",
649
+ 2,
650
+ "## Final Report",
651
+ "## Review Summary (Round 2)",
652
+ ),
653
+ ],
654
+ )
655
+ def test_check_existing_comment_false_when_header_mismatched(
656
+ monkeypatch: pytest.MonkeyPatch, comment_type: str, round_num: int, expected_header: str, wrong_header: str
657
+ ) -> None:
658
+ pr_number = 789
659
+ run_id = "run-123"
660
+
661
+ body = "\n".join([_MARKER, "", wrong_header, "", f"RunId: {run_id}"])
662
+ _patch_subprocess_run_for_gh_comments(monkeypatch, [{"body": body}])
663
+
664
+ assert expected_header not in body
665
+ assert _check_existing_comment(pr_number, run_id, round_num, comment_type) is False
666
+
667
+
668
+ @pytest.mark.parametrize(
669
+ "comment_type,round_num,expected_header",
670
+ [
671
+ ("review-summary", 1, "## Review Summary (Round 1)"),
672
+ ("fix-report", 1, "## Fix Report (Round 1)"),
673
+ ("final-report", 1, "## Final Report"),
674
+ ],
675
+ )
676
+ def test_check_existing_comment_false_when_runid_mismatched(
677
+ monkeypatch: pytest.MonkeyPatch, comment_type: str, round_num: int, expected_header: str
678
+ ) -> None:
679
+ pr_number = 101
680
+ run_id = "run-a"
681
+ other_run_id = "run-b"
682
+
683
+ body = "\n".join([_MARKER, "", expected_header, "", f"RunId: {other_run_id}"])
684
+ _patch_subprocess_run_for_gh_comments(monkeypatch, [{"body": body}])
685
+
686
+ assert _check_existing_comment(pr_number, run_id, round_num, comment_type) is False
687
+
688
+
689
+ def test_check_existing_comment_false_when_subprocess_run_nonzero(monkeypatch: pytest.MonkeyPatch) -> None:
690
+ pr_number = 999
691
+ run_id = "run-nonzero"
692
+ round_num = 2
693
+ comment_type = "review-summary"
694
+ body = "\n".join([_MARKER, "", "## Review Summary (Round 2)", "", f"RunId: {run_id}"])
695
+
696
+ _patch_subprocess_run_for_gh_comments(monkeypatch, [{"body": body}], returncode=1)
697
+ assert _check_existing_comment(pr_number, run_id, round_num, comment_type) is False
698
+
699
+
499
700
  if __name__ == "__main__":
500
- pytest.main([__file__, "-v"])
701
+ _ = pytest.main([__file__, "-v"])
@@ -15,13 +15,13 @@
15
15
  "variant": "high"
16
16
  },
17
17
  "librarian": {
18
- "model": "github-copilot/claude-sonnet-4.5"
18
+ "model": "openai/gpt-5.3-codex-spark"
19
19
  },
20
20
  "explore": {
21
- "model": "github-copilot/claude-sonnet-4.5"
21
+ "model": "openai/gpt-5.3-codex-spark"
22
22
  },
23
23
  "multimodal-looker": {
24
- "model": "github-copilot/gemini-3-flash-preview"
24
+ "model": "openai/gpt-5.3-codex-spark"
25
25
  },
26
26
  "prometheus": {
27
27
  "model": "openai/gpt-5.2",
@@ -44,15 +44,15 @@
44
44
  "temperature": 0.1
45
45
  },
46
46
  "gemini-reviewer": {
47
- "model": "github-copilot/gemini-3-pro-preview",
47
+ "model": "openai/gpt-5.3-codex-spark",
48
48
  "variant": "max"
49
49
  },
50
50
  "claude-reviewer": {
51
- "model": "github-copilot/claude-sonnet-4.5",
51
+ "model": "openai/gpt-5.3-codex-spark",
52
52
  "variant": "high"
53
53
  },
54
54
  "pr-fixer": {
55
- "model": "openai/gpt-5.2-codex",
55
+ "model": "openai/gpt-5.3-codex",
56
56
  "variant": "xhigh",
57
57
  "temperature": 0.1
58
58
  }
@@ -60,7 +60,7 @@
60
60
  "concurrency": 5,
61
61
  "categories": {
62
62
  "visual-engineering": {
63
- "model": "github-copilot/gemini-3-pro-preview",
63
+ "model": "openai/gpt-5.3-codex",
64
64
  "variant": "high"
65
65
  },
66
66
  "ultrabrain": {
@@ -68,17 +68,17 @@
68
68
  "variant": "xhigh"
69
69
  },
70
70
  "artistry": {
71
- "model": "github-copilot/gemini-3-pro-preview",
71
+ "model": "openai/gpt-5.3-codex",
72
72
  "variant": "max"
73
73
  },
74
74
  "quick": {
75
- "model": "openai/gpt-5.1-codex-mini"
75
+ "model": "openai/gpt-5.3-codex-spark"
76
76
  },
77
77
  "middle": {
78
- "model": "github-copilot/claude-sonnet-4.5"
78
+ "model": "openai/gpt-5.3-codex-spark"
79
79
  },
80
80
  "unspecified-low": {
81
- "model": "github-copilot/claude-sonnet-4.5",
81
+ "model": "openai/gpt-5.3-codex-spark",
82
82
  "variant": "medium"
83
83
  },
84
84
  "unspecified-high": {
@@ -86,7 +86,7 @@
86
86
  "variant": "medium"
87
87
  },
88
88
  "writing": {
89
- "model": "github-copilot/gemini-3-flash-preview"
89
+ "model": "openai/gpt-5.3-codex-spark"
90
90
  }
91
91
  }
92
92
  }
@@ -10,13 +10,13 @@
10
10
  ],
11
11
  "agent": {
12
12
  "quick": {
13
- "model": "openai/gpt-5.1-codex-mini"
13
+ "model": "openai/gpt-5.3-codex-spark"
14
14
  },
15
15
  "middle": {
16
- "model": "github-copilot/claude-sonnet-4.5"
16
+ "model": "openai/gpt-5.3-codex"
17
17
  },
18
18
  "documenter": {
19
- "model": "github-copilot/claude-sonnet-4.5"
19
+ "model": "openai/gpt-5.3-codex-spark"
20
20
  }
21
21
  },
22
22
  "permission": {