Repository navigation
Codex compaction on converted routes; mid-conversation developer messages stay in place - #305
Merged
Merged
Conversation
…ages stay in place Compaction. Codex compacts through any provider named "OpenAI" (which is what ThinkWatch's takeover configures) by sending its history plus a `compaction_trigger` item and requiring exactly one `compaction` output item. Converted routes refused that request, so Codex sessions on Claude, Gemini, Bedrock or Chat upstreams could never compact. - A `compaction_trigger` now becomes a handoff-summary request appended to the unchanged history (same tools, reasoning and tool_choice, so the previous turn's prompt cache still applies). - The upstream's text comes back as the single `compaction` item Codex expects, streamed or whole. Its `encrypted_content` is our carried format, `tw1.c.<base64url summary>`; an answer without text fails instead of handing Codex an empty summary. While the summary is collected the stream reports `response.in_progress` so Codex's idle timeout doesn't fire. - A carried compaction item in a later request decodes to the summary, in place, as a system turn. OpenAI's own encrypted compactions are still refused. On passthrough to an OpenAI upstream, `strip_carried` turns a carried item into a developer message with the same text. Mid-conversation system messages. Responses `developer`/`system` items and Chat/Anthropic `system` messages after the conversation has started stay in place as `Role::System` instead of being appended to the system prompt, so the system prefix stays stable from one request to the next. Responses targets get a native developer message; Anthropic, Chat, Gemini and Bedrock get a user turn wrapped in `<system-reminder>`, moved past a tool result if it sat between a call and its result. Leading ones still form the system prompt. The transcript and search readers show carried summaries and skip the gateway's summarisation instruction. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 9, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two follow-ups to #304, both for Codex sessions routed to a non-Responses upstream (Anthropic / Chat / Gemini / Bedrock).
1. Codex compaction on converted routes
The contract Codex uses
Codex compacts remotely whenever the provider is named "OpenAI" (capabilities.rs#L33). ThinkWatch's takeover registers its shadow provider under that name, so every Codex compaction through the gateway takes this path (turn.rs#L1474, tasks/compact.rs):
build_responses_request: the whole history, then{"type": "compaction_trigger"}(compact_remote_v2_attempt.rs#L95). Tools andtool_choiceare as usual.response.output_item.donewhose item is{"type": "compaction", "encrypted_content": "…"}, plus aresponse.completed. Other items are ignored, and zero or two compaction items fail (compact_remote_v2.rs#L430-L490).Converted routes used to refuse
compaction_trigger(#304), so these sessions could never compact.What the gateway does now (
tw_dialect::compaction)compaction::INSTRUCTION). The summary must cover goal and constraints, decisions and failed approaches, files and commands, running state, open tasks, and anything carried over from an earlier summary. Tools, reasoning settings andtool_choiceare unchanged, so the request has the same prefix as the previous turn and its prompt cache still applies. (Changingtool_choicewould invalidate Anthropic's message cache.) A test pins that the system prompt, tools and every message except the last match a normal turn on the same history.Session::is_compaction(). The upstream's text blocks (thinking and stray tool calls excluded) become the singlecompactionitem, withaddedthendonethenresponse.completedincluding usage. This works streamed, whole and stream-collected.encrypted_contentis our own carried format,tw1.c.<base64url(summary)>. Codex treats it as opaque and the gateway can read it back.response.in_progressevery 32 upstream deltas, because Codex treats 300 s without an event as a dropped stream.response.failed. Returning an empty summary would make Codex discard the conversation.compaction/context_compactionitem decodes to the summary in place, as a mid-conversation system turn prefixed withcompaction::SUMMARY_PREFIX. It is a system turn because the upstream wrote it, not the caller, so content filtering and redaction treat it like other system text. OpenAI's own encrypted compactions are still refused with a clear message, and a damaged carried item is refused too.strip_carriedreplaces a carried item with a developer message carrying the same summary. OpenAI can't read our format, and dropping the item would lose the whole earlier context. Bodies without carried items are still left byte for byte.tw-store: the transcript shows a carried summary as text, and search indexing ignores the gateway's summarisation instruction.2. Mid-conversation developer / system messages stay in place
Codex adds developer messages as a session goes on: permission changes, collaboration mode, tool updates, and the rebuilt context after a compaction. #304 still appended every one of them to the system prompt, so the system prefix changed whenever one appeared, which resets any prefix cache that starts at the system prompt.
Role::System. Leading system/developer messages still formRequest::system; any that come after the conversation has started becomeRole::Systemmessages where they appear (ir::system_turn). This applies to the Responsesdeveloper/systemitems and to Chat and Anthropicsystemmessages.developermessage in place.ir::fold_system_turns): the message becomes a user turn wrapped in<system-reminder>…</system-reminder>. Claude Code uses the same convention, so Claude reads it as a harness message, not the user's words. Message count and indices are unchanged, so client cache breakpoints still line up.systemmessage: many non-OpenAI models' chat templates accept a system message only at the start and reject one later.cache_controlbreakpoints today. The IR only has breakpoints that Anthropic or Bedrock clients put there, and Responses clients have none. So this change helps the automatic prefix caches now (OpenAI, DeepSeek, Kimi, GLM, Gemini implicit caching); Anthropic caching for converted requests needs breakpoints first. That is a separate decision, not made here.API (additive for the enterprise edition)
New
ir::Role::System(the enterprise code only compares and constructsRolevalues; it never matches on it exhaustively). Also new:ClientShape.compaction,ir::{system_turn, system_reminder, fold_system_turns}, thecompactionmodule, andSession::is_compaction. Notw-gatewaysource changes.The DeepSeek Harness passthrough cleaner (
harness::clean, Anthropic to a non-DeepSeek Anthropic upstream) still merges dsh'ssystementries intosystem, as before. Its two conversion tests intw-gateway/tests/harness.rsnow expect the in-place reminder.Tests
tw-dialect/tests/codex_compaction.rs(new), built on Codex-shaped Lite requests:strip_carriedon passthrough.fold_system_turnsincluding the tool-result reordering, and refusals.tests/caller.rscross-check: mid-conversation developer/system messages and carried summaries survive caller-text removal and are not caller text.tests/conversion.rs): a compaction through a mock Claude returns one compaction item and records usage (40 in, 17 out); a carried summary sent straight through to OpenAI arrives as a developer message.tw-store: transcript shows the carried summary; search reads the user's last words, not the instruction.Checks:
cargo fmt --all --check,cargo clippy --workspace --all-targets -- -D warnings,cargo test --workspace --no-fail-fast(3139 passed, 0 failed, 8 ignored),scripts/smoke.sh(79 passed, 0 failed).🤖 Generated with Claude Code