agentshim 0.22.0__tar.gz → 0.23.1__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 (137) hide show
  1. {agentshim-0.22.0 → agentshim-0.23.1}/CHANGELOG.md +49 -0
  2. {agentshim-0.22.0 → agentshim-0.23.1}/PKG-INFO +1 -1
  3. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/__init__.py +1 -1
  4. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/agent.py +1 -0
  5. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/core/errors.py +5 -0
  6. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/core/provider.py +3 -0
  7. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/core/session_policy.py +7 -0
  8. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/core/turn.py +9 -0
  9. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/pricing.py +1 -1
  10. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/claude/events.py +16 -1
  11. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/claude/failures.py +26 -5
  12. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/claude/parser.py +34 -0
  13. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/claude/scripted.py +6 -0
  14. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/claude/stream_transport.py +1 -0
  15. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/codex/app_server/errors.py +4 -0
  16. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/codex/app_server/transport.py +5 -0
  17. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/codex/failures.py +33 -1
  18. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/codex/model_data.py +7 -0
  19. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/codex/scripted.py +10 -1
  20. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/opencode/parser.py +9 -0
  21. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/testing/conversation.py +3 -0
  22. {agentshim-0.22.0 → agentshim-0.23.1}/pyproject.toml +1 -1
  23. agentshim-0.23.1/tests/fixtures/claude/README.md +13 -0
  24. {agentshim-0.22.0 → agentshim-0.23.1}/tests/fixtures/codex/README.md +6 -0
  25. {agentshim-0.22.0 → agentshim-0.23.1}/tests/fixtures/codex_app_server/README.md +2 -1
  26. {agentshim-0.22.0 → agentshim-0.23.1}/.gitignore +0 -0
  27. {agentshim-0.22.0 → agentshim-0.23.1}/LICENSE +0 -0
  28. {agentshim-0.22.0 → agentshim-0.23.1}/README.md +0 -0
  29. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/core/__init__.py +0 -0
  30. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/core/_files.py +0 -0
  31. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/core/auth.py +0 -0
  32. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/core/checkpoints.py +0 -0
  33. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/core/clock.py +0 -0
  34. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/core/conversation.py +0 -0
  35. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/core/env.py +0 -0
  36. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/core/events.py +0 -0
  37. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/core/home.py +0 -0
  38. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/core/ids.py +0 -0
  39. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/core/mcp.py +0 -0
  40. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/core/models.py +0 -0
  41. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/core/permissions.py +0 -0
  42. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/core/plugin.py +0 -0
  43. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/core/pricing.py +0 -0
  44. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/core/profile.py +0 -0
  45. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/core/schema.py +0 -0
  46. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/core/skills.py +0 -0
  47. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/core/status.py +0 -0
  48. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/core/stream.py +0 -0
  49. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/core/usage.py +0 -0
  50. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/execution/__init__.py +0 -0
  51. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/execution/confinement.py +0 -0
  52. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/execution/docker.py +0 -0
  53. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/execution/executor.py +0 -0
  54. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/execution/host.py +0 -0
  55. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/execution/process.py +0 -0
  56. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/execution/readiness.py +0 -0
  57. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/execution/transform.py +0 -0
  58. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/models.py +0 -0
  59. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/oneshot.py +0 -0
  60. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/probe.py +0 -0
  61. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/__init__.py +0 -0
  62. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/_testing.py +0 -0
  63. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/catalog.py +0 -0
  64. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/claude/__init__.py +0 -0
  65. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/claude/cost.py +0 -0
  66. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/claude/hooks/__init__.py +0 -0
  67. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/claude/hooks/confine_reads.py +0 -0
  68. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/claude/model_data.py +0 -0
  69. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/claude/plugin.py +0 -0
  70. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/claude/probe.py +0 -0
  71. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/claude/provider.py +0 -0
  72. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/claude/rate_limits.py +0 -0
  73. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/claude/sandbox.py +0 -0
  74. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/claude/stream_argv.py +0 -0
  75. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/claude/testing.py +0 -0
  76. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/claude/tools.py +0 -0
  77. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/claude/user_hooks.py +0 -0
  78. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/codex/__init__.py +0 -0
  79. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/codex/_toml.py +0 -0
  80. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/codex/app_server/__init__.py +0 -0
  81. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/codex/app_server/_wire.py +0 -0
  82. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/codex/app_server/approvals.py +0 -0
  83. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/codex/app_server/channel.py +0 -0
  84. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/codex/app_server/items.py +0 -0
  85. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/codex/app_server/mcp.py +0 -0
  86. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/codex/app_server/permissions.py +0 -0
  87. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/codex/app_server/profile.py +0 -0
  88. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/codex/app_server/protocol.py +0 -0
  89. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/codex/app_server/rate_limits.py +0 -0
  90. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/codex/app_server/usage.py +0 -0
  91. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/codex/events.py +0 -0
  92. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/codex/mcp_scope.py +0 -0
  93. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/codex/parser.py +0 -0
  94. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/codex/plugin.py +0 -0
  95. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/codex/probe.py +0 -0
  96. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/codex/provider.py +0 -0
  97. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/codex/rules.py +0 -0
  98. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/codex/sandbox.py +0 -0
  99. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/codex/skills.py +0 -0
  100. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/codex/testing.py +0 -0
  101. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/copilot/__init__.py +0 -0
  102. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/copilot/events.py +0 -0
  103. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/copilot/parser.py +0 -0
  104. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/copilot/plugin.py +0 -0
  105. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/copilot/probe.py +0 -0
  106. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/copilot/provider.py +0 -0
  107. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/copilot/scripted.py +0 -0
  108. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/copilot/testing.py +0 -0
  109. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/gemini/__init__.py +0 -0
  110. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/gemini/events.py +0 -0
  111. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/gemini/parser.py +0 -0
  112. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/gemini/plugin.py +0 -0
  113. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/gemini/probe.py +0 -0
  114. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/gemini/provider.py +0 -0
  115. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/gemini/scripted.py +0 -0
  116. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/gemini/testing.py +0 -0
  117. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/opencode/__init__.py +0 -0
  118. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/opencode/events.py +0 -0
  119. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/opencode/model_data.py +0 -0
  120. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/opencode/plugin.py +0 -0
  121. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/opencode/probe.py +0 -0
  122. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/opencode/provider.py +0 -0
  123. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/opencode/scripted.py +0 -0
  124. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/opencode/testing.py +0 -0
  125. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/providers/static_plugin.py +0 -0
  126. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/py.typed +0 -0
  127. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/runtime.py +0 -0
  128. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/session.py +0 -0
  129. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/testing/__init__.py +0 -0
  130. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/testing/claude_stream.py +0 -0
  131. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/testing/clock.py +0 -0
  132. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/testing/codex_app_server.py +0 -0
  133. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/testing/confinement.py +0 -0
  134. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/testing/contracts.py +0 -0
  135. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/testing/probe.py +0 -0
  136. {agentshim-0.22.0 → agentshim-0.23.1}/agentshim/testing/process.py +0 -0
  137. {agentshim-0.22.0 → agentshim-0.23.1}/tests/fixtures/claude_stream/README.md +0 -0
@@ -1,5 +1,54 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.23.1 (2026-10-30)
4
+
5
+ ### Fixed
6
+
7
+ - `gpt-6.1-sol` has a price. The catalogue listed it since 0.18.2 but the price table did not, so
8
+ `price_for("codex", "gpt-6.1-sol")` returned `None` and its turns recorded no cost. The rates are
9
+ the standard short-context tier on https://developers.openai.com/api/docs/pricing, read
10
+ 2026-10-30: input 2.00, cache read 0.10, cache write 2.50, output 10.00 USD per million tokens.
11
+ The cache read rate is half of `gpt-6-sol`'s. The table version is `2026-10-30`.
12
+
13
+ ### Added
14
+
15
+ - A test that every model the catalogue or a provider profile lists is priced or named, with its
16
+ reason, in an exclusion list. `gemini-3-pro-preview` is the one exclusion: Google's pricing page
17
+ no longer lists it, so no official number can be checked, and Gemini models are not priced.
18
+
19
+ ## 0.23.0 (2026-10-20)
20
+
21
+ ### Added
22
+
23
+ - `FailureKind.CONTEXT_OVERFLOW`: the provider refused the turn because the conversation and the
24
+ new prompt do not fit the model's context window. Claude is read from the result frame's
25
+ `terminal_reason` (`prompt_too_long`, `blocking_limit`, `rapid_refill_breaker`), and from the
26
+ text `Prompt is too long` on builds without it; this wins over the 503 a failed compaction
27
+ appends, which used to make the overflow look `TRANSIENT`. Codex is read from the app-server's
28
+ `codexErrorInfo: contextWindowExceeded`, from the Responses API error body's `error.code`
29
+ `context_length_exceeded` (which the app-server relays under `other`, and `codex exec` as its
30
+ only report), and from Codex's "ran out of room in the model's context window". Before, every
31
+ one of these was `OTHER` or `TRANSIENT`. Gemini CLI 0.25.2 reports an overflow as a `success`
32
+ with zero tokens, so it cannot be classified; Copilot and opencode have no recorded overflow
33
+ and stay `OTHER`.
34
+ - `TurnResult.context_tokens` (and `ParsedTurn.context_tokens`): how many tokens the
35
+ conversation's context holds after the turn, which is what the next resume starts from. It is
36
+ the turn's last model call, its whole prompt (uncached input, cache reads, cache writes) plus
37
+ its output, not the turn's summed `usage`. Claude: the last main-thread assistant frame's usage,
38
+ skipping subagent frames and the CLI's synthetic error messages; the output count on that frame
39
+ is taken when the reply starts, so it can undercount the final reply. Codex app-server:
40
+ `last.totalTokens` of the turn's last `thread/tokenUsage/updated`. opencode: the last
41
+ `step_finish`, without reasoning. `None` for `codex exec` (thread totals only), Gemini (one sum
42
+ per turn), Copilot (per-request usage is not printed), and a turn with no model call.
43
+ - `FakeTurn(context_tokens=...)` scripts the reading in `agentshim.testing`.
44
+
45
+ ### Changed
46
+
47
+ - A session forgets its conversation after a `CONTEXT_OVERFLOW`, whether or not the turn resumed
48
+ it, since the conversation cannot take another turn. Strict and pinned turns keep it.
49
+ - `scripted_failure("codex", FailureKind.OTHER)` scripts a refused model; its old text was a
50
+ context overflow.
51
+
3
52
  ## 0.22.0 (2026-10-19)
4
53
 
5
54
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentshim
3
- Version: 0.22.0
3
+ Version: 0.23.1
4
4
  Summary: Provider-agnostic coding-agent CLI shims
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -186,7 +186,7 @@ from .providers.opencode import OpencodeProvider
186
186
  from .runtime import Agent
187
187
  from .session import Session, Turn, TurnTicket, map_mcp_servers
188
188
 
189
- __version__ = "0.22.0"
189
+ __version__ = "0.23.1"
190
190
 
191
191
  __all__ = [ # noqa: RUF022 - public API test uses Python's case-sensitive order
192
192
  "Agent",
@@ -415,6 +415,7 @@ class AgentSession:
415
415
  duration_ms=duration_ms,
416
416
  exit_code=result.returncode,
417
417
  skills=skills.summary(),
418
+ context_tokens=parsed.context_tokens,
418
419
  )
419
420
  self.last_result = turn_result
420
421
  return turn_result
@@ -33,6 +33,11 @@ class FailureKind(str, Enum):
33
33
  #: schema. ``detail`` carries the last validation errors it reported, and
34
34
  #: the conversation survives: a correction can be sent as the next turn.
35
35
  SCHEMA = "schema"
36
+ #: The conversation plus the new prompt do not fit the model's context
37
+ #: window, so the provider refused the turn. Sending the same turn to the
38
+ #: same conversation fails again; a fresh conversation, or a shorter
39
+ #: prompt, is the way forward.
40
+ CONTEXT_OVERFLOW = "context_overflow"
36
41
  #: Anything else, including every failure a provider cannot classify.
37
42
  OTHER = "other"
38
43
 
@@ -67,6 +67,9 @@ class ParsedTurn:
67
67
  error: str | None = None
68
68
  #: The parser's classification of ``error``; ``OTHER`` when it cannot tell.
69
69
  error_kind: FailureKind = FailureKind.OTHER
70
+ #: Tokens in the conversation's context after the turn; see
71
+ #: ``TurnResult.context_tokens``. ``None`` when the stream cannot say.
72
+ context_tokens: int | None = None
70
73
 
71
74
 
72
75
  class StreamParser(Protocol):
@@ -16,6 +16,8 @@ What the policy decides, per turn:
16
16
  did not itself resume (the conversation was lost under it) forgets the
17
17
  conversation without replaying the turn.
18
18
  * An unclassified failure of a resumed turn forgets the conversation.
19
+ * A context overflow forgets the conversation, resumed or not: it cannot take
20
+ another turn.
19
21
  * After a successful turn that exhausts the provider's ``RenewalBudget`` the
20
22
  conversation is retired.
21
23
  * An idle conversation is released at prepare time and reopened by id.
@@ -87,6 +89,8 @@ class Outcome(Enum):
87
89
  USAGE_LIMIT = "usage_limit"
88
90
  AUTH = "auth"
89
91
  SCHEMA = "schema"
92
+ #: The conversation no longer fits the model's context window.
93
+ CONTEXT_OVERFLOW = "context_overflow"
90
94
  #: A failure the provider could not classify.
91
95
  OTHER = "other"
92
96
  #: The provider no longer has the conversation the attempt tried to resume.
@@ -103,6 +107,7 @@ _OUTCOME_OF_KIND = {
103
107
  FailureKind.USAGE_LIMIT: Outcome.USAGE_LIMIT,
104
108
  FailureKind.AUTH: Outcome.AUTH,
105
109
  FailureKind.SCHEMA: Outcome.SCHEMA,
110
+ FailureKind.CONTEXT_OVERFLOW: Outcome.CONTEXT_OVERFLOW,
106
111
  FailureKind.OTHER: Outcome.OTHER,
107
112
  }
108
113
 
@@ -536,6 +541,8 @@ class SessionPolicy:
536
541
  return self._on_resume_refused(state, turn, event)
537
542
  if event.outcome is Outcome.OTHER and turn.resumed and not turn.keeps_conversation:
538
543
  return _forget_conversation(state, event.now)
544
+ if event.outcome is Outcome.CONTEXT_OVERFLOW and not turn.keeps_conversation:
545
+ return _forget_conversation(state, event.now)
539
546
  return _give_up(state, event.now)
540
547
 
541
548
  def _on_resume_refused(self, state: SessionState, turn: TurnState, event: Failed) -> Step:
@@ -69,6 +69,15 @@ class TurnResult:
69
69
  #: The turn ended because it was interrupted. The conversation is kept; the
70
70
  #: text and usage are whatever the turn produced before it stopped.
71
71
  interrupted: bool = False
72
+ #: How many tokens the conversation's context holds after this turn: what
73
+ #: the next turn that resumes it starts from. It is the last model call of
74
+ #: the turn, counted as the prompt that call sent (uncached input, cache
75
+ #: reads and cache writes) plus the output it wrote, not ``usage``, which
76
+ #: sums every call of the turn and so counts the prompt once per call.
77
+ #: Calls made by a subagent are not this conversation's and are skipped.
78
+ #: Each provider's source is listed in ``docs/sessions.md``. ``None`` when
79
+ #: the provider does not report a single model call's usage.
80
+ context_tokens: int | None = None
72
81
 
73
82
 
74
83
  def coerce_request(request: TurnRequest | str) -> TurnRequest:
@@ -106,4 +106,4 @@ def default_pricing(*, catalog: ProviderCatalog | None = None) -> PricingTable:
106
106
  by_key[entry.key] = entry
107
107
  entries = tuple(by_key.values())
108
108
  updated = max((entry.checked for entry in entries), default=dt.date(2026, 9, 27))
109
- return PricingTable(version="2026-10-19", last_updated=updated, entries=entries)
109
+ return PricingTable(version="2026-10-30", last_updated=updated, entries=entries)
@@ -67,6 +67,14 @@ class AssistantMessage:
67
67
  #: when a request fails (``overloaded``, ``rate_limit``, ...); ``None`` on
68
68
  #: a normal reply.
69
69
  error: str | None = None
70
+ #: The API message id; the frames of one model call share it.
71
+ message_id: str | None = None
72
+ #: The model that wrote the message: ``<synthetic>`` for a message the CLI
73
+ #: wrote itself (an error) rather than a model call.
74
+ model: str | None = None
75
+ #: The ``Task`` call whose subagent wrote this, or ``None`` for the main
76
+ #: conversation.
77
+ parent_tool_use_id: str | None = None
70
78
 
71
79
 
72
80
  @dataclass(frozen=True)
@@ -157,7 +165,14 @@ def _assistant(data: Mapping[str, Any]) -> AssistantMessage | None:
157
165
  error = _str_or_none(data.get("error"))
158
166
  if not blocks and usage is None and error is None:
159
167
  return None
160
- return AssistantMessage(blocks=tuple(blocks), usage=usage, error=error)
168
+ return AssistantMessage(
169
+ blocks=tuple(blocks),
170
+ usage=usage,
171
+ error=error,
172
+ message_id=_str_or_none(payload.get("id")),
173
+ model=_str_or_none(payload.get("model")),
174
+ parent_tool_use_id=_str_or_none(data.get("parent_tool_use_id")),
175
+ )
161
176
 
162
177
 
163
178
  def _content_block(raw: object) -> ContentBlock | None:
@@ -7,6 +7,11 @@ kind on the synthetic assistant message it writes for the failure, an
7
7
  have the text, so all three are read. A turn whose ``StructuredOutput`` calls
8
8
  never validated ends with the ``result`` subtype
9
9
  ``error_max_structured_output_retries`` instead.
10
+
11
+ A prompt the context window cannot hold is named by the ``result`` frame's
12
+ ``terminal_reason``. Builds without that field write ``Prompt is too long``
13
+ as the message, sometimes followed by why the CLI's own compaction failed
14
+ (which can be an API 503), so the overflow is checked before the status.
10
15
  """
11
16
 
12
17
  from __future__ import annotations
@@ -43,22 +48,38 @@ _TRANSIENT_STATUSES = frozenset({408, 429})
43
48
  _SERVER_ERRORS = range(500, 600)
44
49
  #: The ``result`` subtype of a turn that used up its output-schema retries.
45
50
  SCHEMA_RETRIES_SUBTYPE = "error_max_structured_output_retries"
51
+ #: ``terminal_reason`` values Claude Code groups as a context limit: the API
52
+ #: refused the prompt, the CLI's own estimate was over its blocking limit, or
53
+ #: the context refilled right after a compaction.
54
+ CONTEXT_LIMIT_REASONS = frozenset({"prompt_too_long", "blocking_limit", "rapid_refill_breaker"})
55
+ #: The CLI's message for the same, and the API's (``prompt is too long: N
56
+ #: tokens > M maximum``), for builds that write no ``terminal_reason`` or a
57
+ #: path that sets another one (a compaction that failed on its own error).
58
+ _CONTEXT_LIMIT_TEXT = re.compile(r"\bprompt is too long\b", re.IGNORECASE)
46
59
 
47
60
 
48
61
  def classify_failure(
49
- *, subtype: str | None, api_error: str | None, status: int | None, text: str
62
+ *,
63
+ subtype: str | None,
64
+ api_error: str | None,
65
+ status: int | None,
66
+ text: str,
67
+ terminal_reason: str | None = None,
50
68
  ) -> FailureKind:
51
69
  """Classify one failed turn from what Claude reported about it.
52
70
 
53
71
  ``subtype`` is the error ``result`` frame's subtype, ``api_error`` the
54
72
  assistant frame's ``error`` kind, ``status`` the
55
- result frame's ``api_error_status``, and ``text`` the error text the
56
- stream (or, when the stream had none, stderr) carried. Tool output is
57
- never part of ``text``, so a tool that printed an API error cannot make
58
- the turn look like one.
73
+ result frame's ``api_error_status``, ``text`` the error text the
74
+ stream (or, when the stream had none, stderr) carried, and
75
+ ``terminal_reason`` why the CLI said it stopped. Tool output is never part
76
+ of ``text``, so a tool that printed an API error cannot make the turn
77
+ look like one.
59
78
  """
60
79
  if subtype == SCHEMA_RETRIES_SUBTYPE:
61
80
  return FailureKind.SCHEMA
81
+ if terminal_reason in CONTEXT_LIMIT_REASONS or _CONTEXT_LIMIT_TEXT.search(text):
82
+ return FailureKind.CONTEXT_OVERFLOW
62
83
  return _classify_api_failure(api_error=api_error, status=status, text=text)
63
84
 
64
85
 
@@ -89,6 +89,28 @@ def fold_usage(usage: Mapping[str, Any] | None, turns: int = 0) -> TokenUsage:
89
89
  )
90
90
 
91
91
 
92
+ #: The ``model`` of a message Claude Code wrote itself rather than a model.
93
+ SYNTHETIC_MODEL = "<synthetic>"
94
+
95
+
96
+ def context_tokens(usage: Mapping[str, Any]) -> int:
97
+ """Tokens one model call leaves in the context: its whole prompt plus its output.
98
+
99
+ The prompt is ``input_tokens`` (uncached) plus the cache reads and cache
100
+ writes, which Anthropic reports disjoint from it. The output count is the
101
+ one Claude Code writes on the message's frames. It is taken when the reply
102
+ starts, so it can fall short of the final reply by the tokens generated
103
+ after that; most of those are thinking, which the API drops from the
104
+ prompt of later turns anyway.
105
+ """
106
+ return (
107
+ _int(usage.get("input_tokens"))
108
+ + _int(usage.get("cache_read_input_tokens"))
109
+ + _int(usage.get("cache_creation_input_tokens"))
110
+ + _int(usage.get("output_tokens"))
111
+ )
112
+
113
+
92
114
  def _int(value: object) -> int:
93
115
  if isinstance(value, bool):
94
116
  return 0
@@ -172,9 +194,12 @@ class ClaudeStreamParser:
172
194
  self._api_error_text: str | None = None
173
195
  self._api_error_status: int | None = None
174
196
  self._subtype: str | None = None
197
+ self._terminal_reason: str | None = None
175
198
  # The validation errors of the last rejected StructuredOutput call.
176
199
  self._schema_errors: str | None = None
177
200
  self._stderr: list[str] = []
201
+ # The context the main conversation's last model call left.
202
+ self._context_tokens: int | None = None
178
203
 
179
204
  def feed_stdout(self, line: str) -> None:
180
205
  """Consume one stdout line, emitting the events its frame implies.
@@ -211,6 +236,7 @@ class ClaudeStreamParser:
211
236
  cost_usd=self._cost_usd,
212
237
  error=self._error,
213
238
  error_kind=self._error_kind(),
239
+ context_tokens=self._context_tokens,
214
240
  )
215
241
 
216
242
  def _error_kind(self) -> FailureKind:
@@ -226,6 +252,7 @@ class ClaudeStreamParser:
226
252
  api_error=self._api_error,
227
253
  status=self._api_error_status,
228
254
  text=text,
255
+ terminal_reason=self._terminal_reason,
229
256
  )
230
257
 
231
258
  def _handle(self, frame: object) -> None:
@@ -251,6 +278,12 @@ class ClaudeStreamParser:
251
278
  self._api_error_text = "\n".join(
252
279
  block.text for block in frame.blocks if isinstance(block, TextBlock)
253
280
  )
281
+ if (
282
+ frame.usage is not None
283
+ and frame.parent_tool_use_id is None
284
+ and frame.model != SYNTHETIC_MODEL
285
+ ):
286
+ self._context_tokens = context_tokens(frame.usage)
254
287
  if frame.usage is not None:
255
288
  self._emit(
256
289
  UsageReport(
@@ -313,6 +346,7 @@ class ClaudeStreamParser:
313
346
  if frame.is_error:
314
347
  self._api_error_status = frame.api_error_status
315
348
  self._subtype = frame.subtype
349
+ self._terminal_reason = frame.terminal_reason
316
350
  self._error = self._error_text(frame)
317
351
  self._emit(ProviderError(self._error))
318
352
 
@@ -129,8 +129,12 @@ _FAILURES: dict[FailureKind, tuple[str, int, str]] = {
129
129
  ),
130
130
  FailureKind.USAGE_LIMIT: ("rate_limit", 429, "You've hit your limit \u00b7 resets 3pm (UTC)"),
131
131
  FailureKind.AUTH: ("authentication_failed", 401, "Invalid API key \u00b7 Please run /login"),
132
+ FailureKind.CONTEXT_OVERFLOW: ("invalid_request", 400, "Prompt is too long"),
132
133
  }
133
134
 
135
+ #: Why Claude Code says a turn of each kind stopped, where that is its signal.
136
+ _TERMINAL_REASONS: dict[FailureKind, str] = {FailureKind.CONTEXT_OVERFLOW: "prompt_too_long"}
137
+
134
138
  #: The validation errors Claude's ``StructuredOutput`` tool returns for a
135
139
  #: submission that does not match the schema.
136
140
  SCHEMA_ERRORS = "Output does not match required schema: root: must have required property 'answer'"
@@ -170,6 +174,8 @@ def failure_lines(
170
174
  message = {"role": "assistant", "content": [{"type": "text", "text": text}]}
171
175
  lines.append(_line({"type": "assistant", "message": message, "error": error}))
172
176
  result.update(subtype="success", result=text, api_error_status=status)
177
+ if kind in _TERMINAL_REASONS:
178
+ result["terminal_reason"] = _TERMINAL_REASONS[kind]
173
179
  lines.append(_line(result))
174
180
  return lines, [], 1
175
181
 
@@ -436,6 +436,7 @@ class _StreamConversation:
436
436
  exit_code=0,
437
437
  skills=skills.summary(),
438
438
  interrupted=_interrupted(frame),
439
+ context_tokens=parsed.context_tokens,
439
440
  )
440
441
  if result.interrupted:
441
442
  relay(TurnInterrupted())
@@ -9,6 +9,9 @@ source; when it says only ``other`` (it then carries the raw HTTP body in
9
9
  a lost connection or response stream (unless it ended in a client error
10
10
  other than 408 and 429): ``TRANSIENT``.
11
11
  - ``unauthorized``, or a connection failure with HTTP 401 or 403: ``AUTH``.
12
+ - ``contextWindowExceeded``: ``CONTEXT_OVERFLOW``. A request the Responses API
13
+ refuses for its length arrives as ``other`` with the API's error body, and
14
+ the text classifier reads its ``context_length_exceeded`` code.
12
15
  - anything else: the text classifier, which returns ``OTHER`` for text it does
13
16
  not recognize.
14
17
  """
@@ -35,6 +38,7 @@ _BY_KIND: dict[CodexErrorInfoKind, FailureKind] = {
35
38
  CodexErrorInfoKind.INTERNAL_SERVER_ERROR: FailureKind.TRANSIENT,
36
39
  CodexErrorInfoKind.FLEX_UNAVAILABLE: FailureKind.TRANSIENT,
37
40
  CodexErrorInfoKind.UNAUTHORIZED: FailureKind.AUTH,
41
+ CodexErrorInfoKind.CONTEXT_WINDOW_EXCEEDED: FailureKind.CONTEXT_OVERFLOW,
38
42
  }
39
43
 
40
44
  _AUTH_STATUSES = frozenset({401, 403})
@@ -318,6 +318,9 @@ class _Turn:
318
318
  turn_id: str | None = None
319
319
  items: list[ThreadItem] = field(default_factory=_no_items)
320
320
  total: Totals | None = None
321
+ #: ``last.totalTokens`` of the turn's latest usage report: the context
322
+ #: its last model call left.
323
+ context_tokens: int | None = None
321
324
  completed: Turn | None = None
322
325
  error: TurnError | None = None
323
326
  fail: TurnFailedError | None = None
@@ -752,6 +755,7 @@ class _Conversation:
752
755
  elif isinstance(params, ThreadTokenUsageUpdatedNotification):
753
756
  if self._is_mine(params.thread_id, params.turn_id, turn):
754
757
  turn.total = totals_of(params.token_usage.total)
758
+ turn.context_tokens = params.token_usage.last.total_tokens
755
759
  elif isinstance(params, ErrorNotification):
756
760
  self._on_error(params, turn)
757
761
  elif isinstance(params, TurnCompletedNotification):
@@ -909,6 +913,7 @@ class _Conversation:
909
913
  duration_ms=duration_ms,
910
914
  exit_code=0,
911
915
  interrupted=interrupted,
916
+ context_tokens=turn.context_tokens,
912
917
  )
913
918
  return result if skills is None else replace(result, skills=skills)
914
919
 
@@ -6,14 +6,27 @@ Codex's own: it names a usage limit ("You've hit your usage limit",
6
6
  ``usage_limit_reached``), retries a lost stream ("stream disconnected",
7
7
  "Reconnecting...") and, once its retries run out, reports
8
8
  "exceeded retry limit, last status: <status>".
9
+
10
+ A prompt the context window cannot hold arrives two ways. A request the
11
+ Responses API refuses is relayed as the API's own JSON error body, whose
12
+ ``error.code`` is ``context_length_exceeded``; that code is read from the
13
+ parsed body, never searched for in prose. An overflow Codex's own accounting
14
+ catches is its message "Codex ran out of room in the model's context window".
9
15
  """
10
16
 
11
17
  from __future__ import annotations
12
18
 
19
+ import json
13
20
  import re
21
+ from typing import Any, cast
14
22
 
15
23
  from agentshim.core.errors import FailureKind
16
24
 
25
+ #: The Responses API's ``error.code`` for a prompt longer than the window.
26
+ CONTEXT_LENGTH_EXCEEDED = "context_length_exceeded"
27
+ #: Codex's own message for ``CodexErr::ContextWindowExceeded``.
28
+ _CONTEXT_WINDOW = re.compile(r"ran out of room in the model's context window", re.IGNORECASE)
29
+
17
30
  _USAGE_LIMIT = re.compile(
18
31
  r"hit your usage limit|usage_limit_(?:reached|exceeded)|insufficient_quota", re.IGNORECASE
19
32
  )
@@ -32,8 +45,12 @@ def classify_failure(text: str) -> FailureKind:
32
45
 
33
46
  The order matters: a usage limit is also reported with a 429 status, and
34
47
  an exhausted retry can end on a 401, so the specific kinds are checked
35
- before the generic transient one.
48
+ before the generic transient one. A JSON error body is classified by its
49
+ code before any phrase is looked for.
36
50
  """
51
+ code = api_error_code(text)
52
+ if code == CONTEXT_LENGTH_EXCEEDED or _CONTEXT_WINDOW.search(text):
53
+ return FailureKind.CONTEXT_OVERFLOW
37
54
  if _USAGE_LIMIT.search(text):
38
55
  return FailureKind.USAGE_LIMIT
39
56
  if _AUTH.search(text):
@@ -41,3 +58,18 @@ def classify_failure(text: str) -> FailureKind:
41
58
  if _TRANSIENT.search(text):
42
59
  return FailureKind.TRANSIENT
43
60
  return FailureKind.OTHER
61
+
62
+
63
+ def api_error_code(text: str) -> str | None:
64
+ """The ``error.code`` of *text* when it is an API error body, else ``None``."""
65
+ try:
66
+ body: object = json.loads(text)
67
+ except ValueError:
68
+ return None
69
+ if not isinstance(body, dict):
70
+ return None
71
+ error = cast("dict[str, Any]", body).get("error")
72
+ if not isinstance(error, dict):
73
+ return None
74
+ code = cast("dict[str, Any]", error).get("code")
75
+ return code if isinstance(code, str) else None
@@ -1,6 +1,7 @@
1
1
  """Codex-owned model and price facts."""
2
2
 
3
3
  import datetime as dt
4
+ from dataclasses import replace
4
5
 
5
6
  from agentshim.core.models import ModelSpec
6
7
  from agentshim.core.pricing import ModelPricing
@@ -9,6 +10,8 @@ OPENAI_MODELS_URL = "https://developers.openai.com/api/docs/models"
9
10
  OPENAI_PRICING_URL = "https://developers.openai.com/api/docs/pricing"
10
11
  _MODEL_CHECKED = dt.date(2026, 10, 17)
11
12
  _PRICE_CHECKED = dt.date(2026, 9, 27)
13
+ #: ``gpt-6.1-sol`` was read from the page on its own date, after the rest of the table.
14
+ _SOL_6_1_CHECKED = dt.date(2026, 10, 30)
12
15
 
13
16
 
14
17
  def _openai_model(
@@ -88,6 +91,10 @@ def _openai(
88
91
 
89
92
  PRICING_ENTRIES = (
90
93
  _openai("gpt-6-astra", 10.00, 1.00, 50.00, cache_write_usd=12.50),
94
+ replace(
95
+ _openai("gpt-6.1-sol", 2.00, 0.10, 10.00, cache_write_usd=2.50),
96
+ checked=_SOL_6_1_CHECKED,
97
+ ),
91
98
  _openai("gpt-6-sol", 2.00, 0.20, 10.00, cache_write_usd=2.50),
92
99
  _openai("gpt-6-luna", 0.10, 0.01, 0.50, cache_write_usd=0.125),
93
100
  _openai("gpt-5.6-sol", 4.00, 0.40, 20.00, cache_write_usd=5.00),
@@ -131,7 +131,16 @@ _FAILURES: dict[FailureKind, str] = {
131
131
  "(https://chatgpt.com/explore/plus), or try again in 2 hours."
132
132
  ),
133
133
  FailureKind.AUTH: "unexpected status 401 Unauthorized: Missing bearer authentication",
134
- FailureKind.OTHER: "Codex ran out of room in the model's context window.",
134
+ FailureKind.CONTEXT_OVERFLOW: (
135
+ '{"type":"error","error":{"message":"Your input exceeds the context window of this '
136
+ 'model. Please adjust your input and try again.","type":"invalid_request_error",'
137
+ '"param":"input","code":"context_length_exceeded"},"status":400}'
138
+ ),
139
+ FailureKind.OTHER: (
140
+ '{"type":"error","status":400,"error":{"type":"invalid_request_error","message":'
141
+ "\"The 'no-such-model-xyz' model is not supported when using Codex with a ChatGPT "
142
+ 'account."}}'
143
+ ),
135
144
  }
136
145
 
137
146
 
@@ -114,6 +114,8 @@ class OpencodeStreamParser:
114
114
  self._cost_usd: float | None = None
115
115
  self._raw_tokens: Mapping[str, Any] | None = None
116
116
  self._error: str | None = None
117
+ # The context the last step (one model call) left.
118
+ self._context_tokens: int | None = None
117
119
 
118
120
  def feed_stdout(self, line: str) -> None:
119
121
  """Consume one stdout line."""
@@ -143,6 +145,7 @@ class OpencodeStreamParser:
143
145
  usage=self._usage(),
144
146
  cost_usd=self._cost_usd,
145
147
  error=self._error,
148
+ context_tokens=self._context_tokens,
146
149
  )
147
150
 
148
151
  def _usage(self) -> ProviderUsage:
@@ -201,6 +204,12 @@ class OpencodeStreamParser:
201
204
  def _step_finish(self, frame: StepFinishEvent) -> None:
202
205
  step = fold_usage(frame.tokens)
203
206
  self._tokens = self._tokens + step
207
+ if frame.tokens is not None:
208
+ # One step is one model call: its whole prompt plus the answer it
209
+ # wrote. Reasoning is not sent back in the next step's prompt.
210
+ self._context_tokens = (
211
+ step.input_tokens + step.output_tokens - step.reasoning_output_tokens
212
+ )
204
213
  self._raw_tokens = frame.tokens if frame.tokens is not None else self._raw_tokens
205
214
  if frame.cost is not None:
206
215
  self._cost_usd = (self._cost_usd or 0.0) + frame.cost
@@ -54,6 +54,8 @@ class FakeTurn:
54
54
  duration_ms: int = 0
55
55
  events: Sequence[AgentEvent] = ()
56
56
  during: Callable[[], None] | None = None
57
+ #: The ``TurnResult.context_tokens`` the turn reports.
58
+ context_tokens: int | None = None
57
59
 
58
60
 
59
61
  FakeOutcome = FakeTurn | AgentShimError
@@ -156,6 +158,7 @@ class FakeConversation:
156
158
  duration_ms=outcome.duration_ms,
157
159
  exit_code=0,
158
160
  interrupted=interrupted,
161
+ context_tokens=outcome.context_tokens,
159
162
  )
160
163
 
161
164
  def steer(self, text: str) -> None:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agentshim"
3
- version = "0.22.0"
3
+ version = "0.23.1"
4
4
  description = "Provider-agnostic coding-agent CLI shims"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -0,0 +1,13 @@
1
+ # Claude Code stream-json recordings
2
+
3
+ Real `claude -p --output-format stream-json --verbose` stdout. The `init`
4
+ frame's lists of slash commands, skills, plugins, agents and MCP servers are
5
+ emptied, and local paths are replaced with `/workspace`; every other frame is
6
+ verbatim.
7
+
8
+ | File | Covers |
9
+ | --- | --- |
10
+ | `haiku45_two_model_calls.jsonl` | Claude Code 2.1.304, `claude-haiku-4-5`: a `Read` call, then the answer, so two model calls in one turn |
11
+ | `haiku45_prompt_too_long_blocking_limit.jsonl` | Claude Code 2.1.304, `claude-haiku-4-5`: a prompt over the CLI's own blocking limit; compaction fails, no request is sent, `terminal_reason: blocking_limit` |
12
+ | `haiku45_prompt_too_long_api_400.jsonl` | Claude Code 2.1.304, `claude-haiku-4-5`: a prompt the CLI underestimated (random CJK text), refused by the API with a 400, `terminal_reason: prompt_too_long` |
13
+ | `haiku_task_subagent.jsonl`, `skill_*_haiku.jsonl`, `sonnet_1h_cache.jsonl` | older recordings: a Task subagent, skill loads, one-hour cache writes |
@@ -16,3 +16,9 @@ contained no two-invocation recording.
16
16
  Codex 0.144.4's JSON event processor copies all five fields from the thread
17
17
  usage `total` into `turn.completed.usage`; it does not expose a per-turn field:
18
18
  [provider source](https://github.com/openai/codex/blob/rust-v0.144.4/codex-rs/exec/src/event_processor_with_jsonl_output.rs).
19
+
20
+ `exec_context_overflow_gpt6_luna.jsonl` is recorded `codex exec --json` stdout
21
+ (codex-cli 0.160.0, `gpt-6-luna`, 2026-10-20) for a prompt of 700,000 random
22
+ CJK characters, about 1.4 million tokens. The Responses API refuses it, and
23
+ Codex relays the API's error body, code `context_length_exceeded`, as the
24
+ `error` and `turn.failed` message.
@@ -1,6 +1,6 @@
1
1
  # Codex app-server transcripts
2
2
 
3
- Three recorded sessions of `codex app-server` (codex-cli 0.160.0, model
3
+ Four recorded sessions of `codex app-server` (codex-cli 0.160.0, model
4
4
  `gpt-6-luna`, low effort) over stdio. Each line is
5
5
  `{"dir": "out" | "in", "msg": {...}}`: `out` is what the client wrote, `in` what
6
6
  the server wrote. They are scrubbed: the Codex home is `$CODEX_HOME`, the
@@ -12,6 +12,7 @@ placeholders.
12
12
  | `session_1.jsonl` | handshake, `thread/resume` errors (unknown and malformed id), two threads in one process, a plain turn |
13
13
  | `session_2.jsonl` | `workspace-write` with `untrusted` approvals: a declined command approval, a command that runs, `turn/interrupt` |
14
14
  | `session_3.jsonl` | `thread/resume` across processes, a structured-output turn, per-thread MCP config that fails to start, a failed turn (unknown model) |
15
+ | `session_4.jsonl` | a plain turn whose `last` and `total` usage agree, then a turn of 700,000 random CJK characters that the Responses API refuses with `context_length_exceeded`, relayed under `codexErrorInfo: other`; recorded 2026-10-20, the long prompt is elided |
15
16
 
16
17
  `tests/unit/providers/codex/app_server/test_replay.py` parses every line.
17
18
  Re-record them only when the CLI version in
File without changes
File without changes
File without changes