From 954c1f28fcb6e5d48fdbd6f3aa39103b27029693 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 13:45:25 +0900 Subject: [PATCH 1/4] fix(claude): make harness cache-prefix stabilization explicitly opt-in Reimplement #4052 at 43def4039ba60039df9a2a91fb6352b91ba74d70 with the existing Claude config argument as a default-off operator boundary. Preserve ordinary Messages system roles and cache keys; include actual handler outbound regressions. Local suites NOT RUN, hosted CI pending. Co-authored-by: Warexpor Co-authored-by: Cursor Agent --- .../src/content/docs/fr/guides/claude-code.md | 2 + .../src/content/docs/guides/claude-code.md | 2 + .../src/content/docs/ja/guides/claude-code.md | 2 + .../src/content/docs/ko/guides/claude-code.md | 2 + .../src/content/docs/ru/guides/claude-code.md | 2 + .../src/content/docs/tr/guides/claude-code.md | 2 + .../content/docs/zh-cn/guides/claude-code.md | 2 + .../content/docs/zh-tw/guides/claude-code.md | 2 + scripts/test-layout/layout.json | 1 + src/claude/inbound-cache-stabilize.ts | 149 ++++++ src/claude/inbound.ts | 50 +- src/types/config.ts | 6 + structure/clients/claude-desktop.md | 4 + structure/config.md | 4 + structure/data-planes/inbound-compat.md | 11 + structure/runtime.md | 4 + .../claude-inbound-cache-stabilize.test.ts | 452 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 18 files changed, 693 insertions(+), 5 deletions(-) create mode 100644 src/claude/inbound-cache-stabilize.ts create mode 100644 tests/claude-integration/claude-inbound-cache-stabilize.test.ts diff --git a/docs-site/src/content/docs/fr/guides/claude-code.md b/docs-site/src/content/docs/fr/guides/claude-code.md index e11e6a06d8..65d1a39a45 100644 --- a/docs-site/src/content/docs/fr/guides/claude-code.md +++ b/docs-site/src/content/docs/fr/guides/claude-code.md @@ -623,3 +623,5 @@ par défaut par un contenu minimal (`blockedSkills: ["claude-api"]`). **Les sous-agents sont envoyés au mauvais modèle** — Les agents de la liste (`ocx-*`) utilisent les directives ``, et non l'argument `model` de l'outil Agent. Vérifiez que la directive désigne la route voulue. Utilisez `"haiku"` comme valeur de remplacement pour le modèle. + +Dans `config.json`, `claudeCode.stabilizePromptCache: true` déplace les notices Claude reconnues en fin des instructions système vers un dernier message utilisateur sur les routes traduites. La valeur par défaut est `false`. Activez cette option seulement si ce changement de rôle convient à vos clients. Les exemples dans des blocs de code et le texte non reconnu sont conservés ; le transfert Anthropic natif reste inchangé. Sans métadonnées, la clé de cache suit les instructions stabilisées. Cette option ne crée pas une identité de conversation et ne garantit aucun succès du cache amont. diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 59bac66535..9be9d644df 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -679,3 +679,5 @@ it by default (`blockedSkills: ["claude-api"]`). **Subagent dispatches to wrong model** — Roster agents (`ocx-*`) use `` directives, not the Agent tool's `model` argument. Make sure the directive matches the intended route. Pass `"haiku"` as the model placeholder. + +Set `claudeCode.stabilizePromptCache` to `true` in `config.json` to relocate supported trailing Claude harness notices from system instructions to a trailing user message on translated routes. The default is `false`. Enable it only when this role change is appropriate for your clients. It preserves fenced examples and unmatched text; native Anthropic passthrough is unchanged. The metadata-less prompt-cache key then follows stabilized instructions. This does not create conversation identity or guarantee upstream cache hits. diff --git a/docs-site/src/content/docs/ja/guides/claude-code.md b/docs-site/src/content/docs/ja/guides/claude-code.md index adc8703340..1d3c2da548 100644 --- a/docs-site/src/content/docs/ja/guides/claude-code.md +++ b/docs-site/src/content/docs/ja/guides/claude-code.md @@ -494,3 +494,5 @@ Anthropic バックエンドを明示すると意図的に失敗後停止しま **サブエージェントが誤ったモデルにディスパッチされる** — ロスターエージェント(`ocx-*`)は Agent ツールの `model` 引数ではなく `` ディレクティブを使います。ディレクティブが希望ルートと一致するか確認し、 モデルプレースホルダとして `"haiku"` を渡してください。 + +`config.json` の `claudeCode.stabilizePromptCache` を `true` にすると、変換ルートのシステム指示末尾にある対応済み Claude 通知を最後のユーザーメッセージへ移します。既定値は `false` です。このロール変更が適切なクライアントでのみ有効にしてください。コードフェンス内の例と一致しない本文は保持され、Anthropic のネイティブ転送は変わりません。メタデータがない場合のキャッシュキーは安定化した指示から計算されます。会話 ID の生成やキャッシュヒットの保証は行いません。 diff --git a/docs-site/src/content/docs/ko/guides/claude-code.md b/docs-site/src/content/docs/ko/guides/claude-code.md index 527c810d42..ca3f5dfb33 100644 --- a/docs-site/src/content/docs/ko/guides/claude-code.md +++ b/docs-site/src/content/docs/ko/guides/claude-code.md @@ -559,3 +559,5 @@ Anthropic 백엔드를 명시하면 의도적으로 실패 후 중단해요. **서브에이전트가 잘못된 모델로 디스패치됨** — 로스터 에이전트(`ocx-*`)는 Agent 도구의 `model` 인자가 아니라 `` 지시문을 사용해요. 지시문이 원하는 라우트와 일치하는지 확인하고, 모델 자리 표시자로 `"haiku"`를 전달하세요. + +`config.json`에서 `claudeCode.stabilizePromptCache`를 `true`로 설정하면 번역 경로의 시스템 지시 끝에 붙은 지원 대상 Claude 알림을 마지막 사용자 메시지로 옮깁니다. 기본값은 `false`입니다. 사용하는 클라이언트에서 이 역할 변경을 허용할 때만 켜세요. 코드 펜스 안의 예제와 일치하지 않는 원문은 보존하며, Anthropic 원본 전달 경로는 바꾸지 않습니다. 메타데이터가 없는 요청의 캐시 키는 정리된 지시문을 기준으로 계산합니다. 대화 식별자를 만들거나 상위 서비스의 캐시 적중을 보장하는 기능은 아닙니다. diff --git a/docs-site/src/content/docs/ru/guides/claude-code.md b/docs-site/src/content/docs/ru/guides/claude-code.md index f5504c9dc2..41cefcc951 100644 --- a/docs-site/src/content/docs/ru/guides/claude-code.md +++ b/docs-site/src/content/docs/ru/guides/claude-code.md @@ -525,3 +525,5 @@ Responses `web_search_call` в парные блоки Anthropic `server_tool_us **Подагент отправляется не в ту модель** — агенты из ростера (`ocx-*`) используют директивы ``, а не аргумент `model` инструмента Agent. Убедитесь, что директива соответствует нужному маршруту. В качестве плейсхолдера модели передавайте `"haiku"`. + +Параметр `claudeCode.stabilizePromptCache: true` в `config.json` переносит поддерживаемые уведомления Claude в конце системных инструкций в последнее пользовательское сообщение на маршрутах с преобразованием. По умолчанию он выключен (`false`). Включайте его только когда такое изменение роли допустимо для ваших клиентов. Примеры в блоках кода и нераспознанный текст сохраняются; нативная передача Anthropic не меняется. Без метаданных ключ кэша рассчитывается по стабилизированным инструкциям. Идентификатор разговора не создаётся, попадания в кэш не гарантируются. diff --git a/docs-site/src/content/docs/tr/guides/claude-code.md b/docs-site/src/content/docs/tr/guides/claude-code.md index 497f5e635b..f4846148f3 100644 --- a/docs-site/src/content/docs/tr/guides/claude-code.md +++ b/docs-site/src/content/docs/tr/guides/claude-code.md @@ -732,3 +732,5 @@ modellerde opencodex varsayılan olarak bunu taslakla değiştirir (`blockedSkil aracının `model` argümanını değil, `` yönergelerini kullanır. Yönergenin hedeflenen rotayla eşleştiğinden emin olun. Model yer tutucusu olarak `"haiku"` iletin. + +`config.json` içindeki `claudeCode.stabilizePromptCache: true`, dönüştürülen rotalarda sistem talimatlarının sonundaki desteklenen Claude bildirimlerini son kullanıcı mesajına taşır. Varsayılan değer `false` olur. Yalnızca bu rol değişikliği istemcileriniz için uygunsa etkinleştirin. Kod bloklarındaki örnekler ve eşleşmeyen metin korunur; yerel Anthropic aktarımı değişmez. Meta veri yoksa önbellek anahtarı kararlı talimatlardan hesaplanır. Bu seçenek konuşma kimliği oluşturmaz veya üst hizmette önbellek isabeti garanti etmez. diff --git a/docs-site/src/content/docs/zh-cn/guides/claude-code.md b/docs-site/src/content/docs/zh-cn/guides/claude-code.md index 216766f70c..4c4982908f 100644 --- a/docs-site/src/content/docs/zh-cn/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-cn/guides/claude-code.md @@ -463,3 +463,5 @@ Claude 模型时自动加载。对于原生透传,这是正常现象;对于 **子代理派发到错误模型**——名册代理(`ocx-*`)使用 `` 指令, 而不是 Agent 工具的 `model` 参数。请确保指令与预期路由一致。传入 `"haiku"` 作为模型占位符。 + +在 `config.json` 中设置 `claudeCode.stabilizePromptCache: true`,可在转换路由上将系统指令末尾受支持的 Claude 提示移到最后一条用户消息。默认值为 `false`。仅在客户端允许这种角色变化时启用。代码围栏内的示例和不匹配的文本会保留,Anthropic 原生透传不变。没有元数据时,缓存键按稳定后的指令计算。该选项不会生成会话标识,也不保证上游缓存命中。 diff --git a/docs-site/src/content/docs/zh-tw/guides/claude-code.md b/docs-site/src/content/docs/zh-tw/guides/claude-code.md index 86a3ea0782..6df147ab6a 100644 --- a/docs-site/src/content/docs/zh-tw/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-tw/guides/claude-code.md @@ -533,3 +533,5 @@ Claude 模型時自動載入。對於原生透傳,這是正常現象;對於 **子代理派發到錯誤模型**——名冊代理(`ocx-*`)使用 `` 指令, 而不是 Agent 工具的 `model` 引數。請確保指令與預期路由一致。傳入 `"haiku"` 作為模型佔位符。 + +在 `config.json` 中設定 `claudeCode.stabilizePromptCache: true`,可在轉換路由上將系統指令末尾支援的 Claude 提示移到最後一則使用者訊息。預設值為 `false`。僅在用戶端允許這種角色變更時啟用。程式碼圍欄中的範例和不符合的文字會保留,Anthropic 原生轉送不變。沒有中繼資料時,快取鍵依穩定後的指令計算。此選項不會產生對話識別碼,也不保證上游快取命中。 diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index b23d208200..8f40c0714d 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -310,6 +310,7 @@ "claude-desktop-remote-hub.test.ts": "claude-integration", "claude-dotenv-provenance-transport.test.ts": "claude-integration", "claude-gateway-cache.test.ts": "claude-integration", + "claude-inbound-cache-stabilize.test.ts": "claude-integration", "claude-inbound-debug.test.ts": "claude-integration", "claude-inbound.test.ts": "claude-integration", "claude-management-api.test.ts": "claude-integration", diff --git a/src/claude/inbound-cache-stabilize.ts b/src/claude/inbound-cache-stabilize.ts new file mode 100644 index 0000000000..bb75fc184f --- /dev/null +++ b/src/claude/inbound-cache-stabilize.ts @@ -0,0 +1,149 @@ +/** + * Claude Code appends growing `` footers (and + * occasional TaskCreate nudges) into system text that becomes Responses + * `instructions`. That churn breaks Muse/Go prefix cache on the instructions + * prefix even when tools stay stable. Strip dynamics from instructions; + * surface the latest notice on `input` instead. + * + * Relocation is identified by harness shape: only a trailing, unfenced, + * canonical notice at the end of instructions is moved. An unmatched fence + * opener covers through EOF. No match → the original string is returned + * byte-for-byte. The matcher is content identity only; `translateAnthropicRequest` + * requires `claudeCode.stabilizePromptCache: true` before this helper runs. Claude Code + * writes `N tokens left`. + * + * TaskCreate nudge text has drifted across Claude Code builds; match the + * known exact paragraphs (legacy + 2.1.263 "tracking progress" form) as + * trailing alternatives so a mid-session nudge cannot pin older footers + * above it and flip `instructions` after a previously stable peel. + */ + +const TRAILING_TOTAL_RE = + /(?:^|(?:\r?\n)+)[ \t]*(\d+\s+tokens left<\/total_tokens>)[ \t]*(?:\r?\n)*$/; + +/** Legacy Claude Code TaskCreate reminder (pre-"tracking progress"). */ +const TASKCREATE_NUDGE_LEGACY = + "The task tools haven't been used recently. If you're working on tasks that would benefit from tracking, consider using TaskCreate to add them. Only use these if relevant to the current work. This is just a gentle reminder - ignore if not applicable."; + +/** + * Claude Code 2.1.263+ TaskCreate reminder observed on hitrate S/T4 + * (FREEZE-DIFF + tip0847 pilots). Mentions TaskUpdate and stale-list cleanup. + */ +const TASKCREATE_NUDGE_CC_2_1_263 = + "The task tools haven't been used recently. If you're working on tasks that would benefit from tracking progress, consider using TaskCreate to add new tasks and TaskUpdate to update task status (set to in_progress when starting, completed when done). Also consider cleaning up the task list if it has become stale. Only use these if relevant to the current work. This is just a gentle reminder - ignore if not applicable."; + +const TRAILING_NUDGE_RE = new RegExp( + `(?:^|(?:\\r?\\n)+)[ \\t]*(${escapeRegExp(TASKCREATE_NUDGE_LEGACY)}|${escapeRegExp(TASKCREATE_NUDGE_CC_2_1_263)})[ \\t]*(?:\\r?\\n)*$`, +); + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +interface FenceRange { + start: number; + end: number; +} + +const FENCE_OPEN_RE = /^( {0,3})(`{3,}|~{3,})/; +const FENCE_CLOSE_RE = /^( {0,3})(`{3,}|~{3,})[ \t]*$/; + +/** + * Markdown fence ranges. An unmatched opener covers through EOF: unfinished + * fenced examples are code content, not a harness suffix. A closer must be a + * standalone fence line (no info string) of the same character and at least + * the opener's length. + */ +function fencedRanges(source: string): FenceRange[] { + const ranges: FenceRange[] = []; + let offset = 0; + let openAt: number | null = null; + let openFence = ""; + while (offset <= source.length) { + const nl = source.indexOf("\n", offset); + const lineEnd = nl === -1 ? source.length : nl; + const line = source.slice(offset, lineEnd).replace(/\r$/, ""); + if (openAt === null) { + const open = FENCE_OPEN_RE.exec(line); + if (open) { + openAt = offset; + openFence = open[2]!; + } + } else { + const close = FENCE_CLOSE_RE.exec(line); + if ( + close + && close[2]![0] === openFence[0] + && close[2]!.length >= openFence.length + ) { + ranges.push({ start: openAt, end: lineEnd }); + openAt = null; + openFence = ""; + } + } + if (nl === -1) break; + offset = nl + 1; + } + if (openAt !== null) ranges.push({ start: openAt, end: source.length }); + return ranges; +} + +function isInsideFence(ranges: readonly FenceRange[], index: number): boolean { + return ranges.some(range => index >= range.start && index < range.end); +} + +function peelOne( + rest: string, + ranges: readonly FenceRange[], +): { rest: string; total?: string; nudge?: string } | null { + const total = rest.match(TRAILING_TOTAL_RE); + if (total?.[1]) { + const matchStart = rest.length - total[0].length; + const tagAt = matchStart + total[0].indexOf(total[1]); + if (!isInsideFence(ranges, tagAt)) { + return { rest: rest.slice(0, rest.length - total[0].length), total: total[1] }; + } + } + const nudge = rest.match(TRAILING_NUDGE_RE); + if (nudge?.[1]) { + const matchStart = rest.length - nudge[0].length; + const tagAt = matchStart + nudge[0].indexOf(nudge[1]); + if (!isInsideFence(ranges, tagAt)) { + return { rest: rest.slice(0, rest.length - nudge[0].length), nudge: nudge[1] }; + } + } + return null; +} + +export function stabilizeClaudeInstructionsForPromptCache( + instructions: string, +): { instructions: string; dynamicNotice: string | null } { + if (!instructions) { + return { instructions: "", dynamicNotice: null }; + } + + const ranges = fencedRanges(instructions); + let rest = instructions; + let latestTotal: string | null = null; + let latestNudge: string | null = null; + let peeled = false; + for (;;) { + const next = peelOne(rest, ranges); + if (!next) break; + peeled = true; + rest = next.rest; + if (next.total && latestTotal === null) latestTotal = next.total; + if (next.nudge && latestNudge === null) latestNudge = next.nudge; + } + + if (!peeled) { + return { instructions, dynamicNotice: null }; + } + + const noticeParts: string[] = []; + if (latestTotal) noticeParts.push(latestTotal); + if (latestNudge) noticeParts.push(latestNudge); + const dynamicNotice = noticeParts.length > 0 ? noticeParts.join("\n\n") : null; + + return { instructions: rest, dynamicNotice }; +} diff --git a/src/claude/inbound.ts b/src/claude/inbound.ts index 35f4865b94..e3664bf01c 100644 --- a/src/claude/inbound.ts +++ b/src/claude/inbound.ts @@ -17,6 +17,7 @@ export { resolveInboundModel, effortForThinkingBudget, effortFromOutputConfig, e import { AnthropicRequestError, isRec, type Rec } from "./inbound-records"; import { resolveInboundModel, effortForThinkingBudget, effortFromOutputConfig, formatFromOutputConfig } from "./inbound-model-options"; import { systemToInstructions, toolsToResponses, toolChoiceToResponses } from "./inbound-content-options"; +import { stabilizeClaudeInstructionsForPromptCache } from "./inbound-cache-stabilize"; import { decodeReasoningEnvelope, encodeReasoningEnvelope, OCX_REASONING_PREFIX } from "../responses/reasoning-envelope"; import { createTranslatorBudget, type TranslatorBudget } from "../lib/translator-budget"; @@ -298,7 +299,10 @@ export interface ClaudeInboundTranslation { * Translate an Anthropic Messages request body into a /v1/responses request body. * Throws AnthropicRequestError (-> 400 invalid_request_error) on malformed input. */ -export function anthropicToResponsesBody(raw: unknown, cc?: OcxClaudeCodeConfig): Rec { +export function anthropicToResponsesBody( + raw: unknown, + cc?: OcxClaudeCodeConfig, +): Rec { return anthropicToResponsesTranslation(raw, cc).body; } @@ -307,7 +311,11 @@ export function anthropicToResponsesBody(raw: unknown, cc?: OcxClaudeCodeConfig) * OUT-OF-BODY tuple (audit 133 R3#1 — an in-body marker would leak upstream through * the native Responses forward and 400). */ -export function anthropicToResponsesTranslation(raw: unknown, cc?: OcxClaudeCodeConfig, budget?: TranslatorBudget): ClaudeInboundTranslation { +export function anthropicToResponsesTranslation( + raw: unknown, + cc?: OcxClaudeCodeConfig, + budget?: TranslatorBudget, +): ClaudeInboundTranslation { const activeBudget = budget ?? createTranslatorBudget(); try { return translateAnthropicRequest(raw, cc, activeBudget); @@ -316,7 +324,11 @@ export function anthropicToResponsesTranslation(raw: unknown, cc?: OcxClaudeCode } } -function translateAnthropicRequest(raw: unknown, cc: OcxClaudeCodeConfig | undefined, budget: TranslatorBudget): ClaudeInboundTranslation { +function translateAnthropicRequest( + raw: unknown, + cc: OcxClaudeCodeConfig | undefined, + budget: TranslatorBudget, +): ClaudeInboundTranslation { if (!isRec(raw)) throw new AnthropicRequestError("request body must be a JSON object"); if (typeof raw.model !== "string" || raw.model.length === 0) { throw new AnthropicRequestError("model is required"); @@ -357,7 +369,32 @@ function translateAnthropicRequest(raw: unknown, cc: OcxClaudeCodeConfig | undef stream: raw.stream === true, }; - if (systemParts.length > 0) body.instructions = systemParts.join("\n\n"); + const joinedSystem = systemParts.length > 0 ? systemParts.join("\n\n") : ""; + const stabilizePromptCache = cc?.stabilizePromptCache === true; + // Desktop fallback hashes raw systemParts unless the caller opted into + // harness cleanup. Opt-in then hashes the same string as body.instructions. + let cacheSystem: string | string[] = systemParts; + if (joinedSystem) { + if (stabilizePromptCache) { + // Claude Code appends growing N tokens left + // footers (and occasional TaskCreate nudges) into system text. That churn + // breaks Muse/Go prefix cache on the Responses instructions prefix even + // when tools stay stable. Relocation is caller-opted, not inferred from + // a matching suffix or metadata.user_id. + const stabilized = stabilizeClaudeInstructionsForPromptCache(joinedSystem); + if (stabilized.instructions) body.instructions = stabilized.instructions; + if (stabilized.dynamicNotice) { + input.push({ + type: "message", + role: "user", + content: [{ type: "input_text", text: stabilized.dynamicNotice }], + }); + } + cacheSystem = stabilized.instructions; + } else { + body.instructions = joinedSystem; + } + } const tools = toolsToResponses(raw.tools); if (tools) body.tools = tools; @@ -395,11 +432,14 @@ function translateAnthropicRequest(raw: unknown, cc: OcxClaudeCodeConfig | undef // Exact-prefix matching still isolates content; the key only steers routing // affinity. Callers must NOT synthesize a session_id header from this fallback // (audit 133 R2#3). + // Outside opt-in, hash the raw systemParts array (pre-stabilize Desktop + // key). Opt-in hashes the same string used for body.instructions so the + // key tracks the cacheable prefix after peel. body.prompt_cache_key = createHash("sha256") .update(canonicalJson({ version: 2, model: body.model, - system: systemParts, + system: cacheSystem, tools: Array.isArray(body.tools) ? body.tools : [], })) .digest("hex").slice(0, 32); diff --git a/src/types/config.ts b/src/types/config.ts index acfa35f868..52c4ed8b0e 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -6,6 +6,12 @@ import type { CodexAccount } from "./accounts"; * /v1/messages surface, the `ocx claude` launcher, and the GUI Claude page. */ export interface OcxClaudeCodeConfig { + /** + * Opt-in relocation of supported trailing Claude harness notices from system instructions + * to a user input message on translated routes. Changes the Desktop cache-key prefix. + * Default: false; only literal true enables it. Native passthrough is unchanged. + */ + stabilizePromptCache?: boolean; /** Opt-in translated Messages admission; unset keeps legacy behavior. Native passthrough is exempt. */ compatibility?: "shadow" | "enforce"; /** Kill switch for the /v1/messages inbound (GUI "Claude ON" toggle). Default: enabled. */ diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index f3e5d5f196..2914823958 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -87,3 +87,7 @@ Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.m privately to final dispatch; preliminary route selection does not inject Go-only headers. The explicit sync coordinator also accepts Cline CLI as a separate file integration. Its [paired-file recovery](integrations.md#cline-paired-files) is owned by the generic integration journal, independently of Desktop profile snapshots. + +`claudeCode.stabilizePromptCache` is a default-off operator setting for +[translated instruction stabilization](../data-planes/inbound-compat.md#opt-in-claude-instruction-stabilization). +Config JSON preserves the boolean; only literal true activates the role-changing transform. diff --git a/structure/config.md b/structure/config.md index 2b76e1361a..80bb62bc73 100644 --- a/structure/config.md +++ b/structure/config.md @@ -201,3 +201,7 @@ Codex display-cache expiry, retained main-policy evidence, and reset history fol `src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before artifact changes and compensates detected migration. Failed config restore stops later catalog/history work. See the [history writer contract](codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. The Cline client keeps connection settings and models in a separate native file pair; client path overrides and reversible writes follow [Cline paired files](clients/integrations.md#cline-paired-files). + +`claudeCode.stabilizePromptCache` is a default-off operator setting for +[translated instruction stabilization](data-planes/inbound-compat.md#opt-in-claude-instruction-stabilization). +Config JSON preserves the boolean; only literal true activates the role-changing transform. diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index f49aef115e..c4bd51b46b 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -113,3 +113,14 @@ session or thread header suppresses synthesis. Refresh and alternate-account ret that value. Original request headers stay unchanged so policy fallback cannot promote a generated native identifier into a noncanonical replay. Go preliminary selection does not suppress the final native affinity, and shared-system keys do not provide either conversation value. + +## Opt-in Claude instruction stabilization + +`src/claude/inbound.ts` reads only literal `claudeCode.stabilizePromptCache: true` from +its existing configuration argument. The default is off for every translated Messages caller. +`src/claude/inbound-cache-stabilize.ts` relocates only exact trailing unfenced harness notices +into a trailing user input message; unmatched and fenced text is preserved, including an open +fence through EOF. Native passthrough never enters this translator. Without opt-in the original +system-parts cache-key derivation remains unchanged; with opt-in the metadata-less key uses +stabilized instructions. Metadata-derived keys retain their existing derivation. This configuration +changes prompt roles, not conversation identity, and cannot guarantee upstream cache reuse. diff --git a/structure/runtime.md b/structure/runtime.md index 833d2fe66b..522e5cabb9 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -221,3 +221,7 @@ Claude replay carries [Go conversation affinity](data-planes/inbound-compat.md#c privately to final dispatch; preliminary route selection does not inject Go-only headers. Cline CLI joins the existing export/client integration registries. Explicit CLI sync and POST /api/sync refresh its owned pair; unattended catalog refresh excludes it. See [Cline paired files](clients/integrations.md#cline-paired-files). + +`claudeCode.stabilizePromptCache` is a default-off operator setting for +[translated instruction stabilization](data-planes/inbound-compat.md#opt-in-claude-instruction-stabilization). +Config JSON preserves the boolean; only literal true activates the role-changing transform. diff --git a/tests/claude-integration/claude-inbound-cache-stabilize.test.ts b/tests/claude-integration/claude-inbound-cache-stabilize.test.ts new file mode 100644 index 0000000000..73363578ca --- /dev/null +++ b/tests/claude-integration/claude-inbound-cache-stabilize.test.ts @@ -0,0 +1,452 @@ +import { createHash } from "node:crypto"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadConfig, saveConfig } from "../../src/config"; +import { handleClaudeMessages } from "../../src/server/claude-messages"; +import type { OcxConfig, OcxClaudeCodeConfig } from "../../src/types"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { beforeEach, afterEach, describe, expect, test } from "bun:test"; +import { stabilizeClaudeInstructionsForPromptCache } from "../../src/claude/inbound-cache-stabilize"; +import { anthropicToResponsesTranslation } from "../../src/claude/inbound"; + +const TASKCREATE_NUDGE = [ + "The task tools haven't been used recently. If you're working on tasks that would benefit from tracking, consider using TaskCreate to add them.", + "Only use these if relevant to the current work. This is just a gentle reminder - ignore if not applicable.", +].join(" "); + +/** Claude Code 2.1.263+ wording (hitrate tip0847 / FREEZE-DIFF S/T4). */ +const TASKCREATE_NUDGE_CC_2_1_263 = [ + "The task tools haven't been used recently. If you're working on tasks that would benefit from tracking progress, consider using TaskCreate to add new tasks and TaskUpdate to update task status (set to in_progress when starting, completed when done).", + "Also consider cleaning up the task list if it has become stale.", + "Only use these if relevant to the current work. This is just a gentle reminder - ignore if not applicable.", +].join(" "); + +function footer(used: number): string { + return `${used} tokens left`; +} + +function translate( + system: string, + options?: { user_id?: string; stabilizePromptCache?: boolean }, +) { + return anthropicToResponsesTranslation( + { + model: "m", + max_tokens: 1, + system, + messages: [{ role: "user", content: "hi" }], + ...(options?.user_id ? { metadata: { user_id: options.user_id } } : {}), + }, + options?.stabilizePromptCache === undefined + ? undefined + : { stabilizePromptCache: options.stabilizePromptCache }, + ); +} + +function translateHarness(system: string, metadata?: { user_id: string }) { + return anthropicToResponsesTranslation( + { + model: "m", + max_tokens: 1, + system, + messages: [{ role: "user", content: "hi" }], + ...(metadata ? { metadata } : {}), + }, + { stabilizePromptCache: true }, + ); +} + +function userTurns(body: { input: unknown }) { + return body.input as Array>; +} + +describe("stabilizeClaudeInstructionsForPromptCache", () => { + test("empty input is a no-op", () => { + expect(stabilizeClaudeInstructionsForPromptCache("")).toEqual({ + instructions: "", + dynamicNotice: null, + }); + }); + + test("stable instructions without dynamics pass through", () => { + const instructions = "You are Claude Code.\n\nPrefer terse answers."; + expect(stabilizeClaudeInstructionsForPromptCache(instructions)).toEqual({ + instructions, + dynamicNotice: null, + }); + }); + + test("no-match whitespace is returned byte-for-byte", () => { + const instructions = "You are Claude Code.\n\n\nPrefer terse answers.\n"; + expect(stabilizeClaudeInstructionsForPromptCache(instructions)).toEqual({ + instructions, + dynamicNotice: null, + }); + }); + + test("whitespace-only system without a footer is unchanged", () => { + const instructions = " \n\n "; + expect(stabilizeClaudeInstructionsForPromptCache(instructions)).toEqual({ + instructions, + dynamicNotice: null, + }); + }); + + test("three trailing total_tokens footers keep only the latest in the notice", () => { + const stable = "You are Claude Code."; + const first = footer(1000); + const second = footer(4000); + const third = footer(8000); + const result = stabilizeClaudeInstructionsForPromptCache( + [stable, first, second, third].join("\n\n"), + ); + expect(result.instructions).toBe(stable); + expect(result.instructions).not.toContain(""); + expect(result.dynamicNotice).toBe(third); + }); + + test("real Claude Code 15000000 tokens left trailing footer peels", () => { + const stable = "You are Claude Code."; + const harness = footer(15_000_000); + const result = stabilizeClaudeInstructionsForPromptCache(`${stable}\n\n${harness}`); + expect(result.instructions).toBe(stable); + expect(result.dynamicNotice).toBe("15000000 tokens left"); + }); + + test("bare numeric total_tokens without tokens left is not a harness footer", () => { + const docs = "You are Claude Code.\n\n123"; + const result = stabilizeClaudeInstructionsForPromptCache(docs); + expect(result.instructions).toBe(docs); + expect(result.dynamicNotice).toBeNull(); + }); + + test("mid-document total_tokens stays; only the trailing harness footer relocates", () => { + const result = stabilizeClaudeInstructionsForPromptCache( + `System.\n${footer(1)}\nMore system.\n${footer(3)}`, + ); + expect(result.instructions).toBe(`System.\n${footer(1)}\nMore system.`); + expect(result.dynamicNotice).toBe(footer(3)); + }); + + test("TaskCreate nudge is stripped from instructions and kept in the notice", () => { + const stable = "You are Claude Code."; + const result = stabilizeClaudeInstructionsForPromptCache( + `${stable}\n\n${TASKCREATE_NUDGE}`, + ); + expect(result.instructions).toBe(stable); + expect(result.instructions).not.toContain("TaskCreate"); + expect(result.dynamicNotice).toBe(TASKCREATE_NUDGE); + }); + + test("Claude Code 2.1.263 TaskCreate nudge peels and unpins older tokens-left footers", () => { + const stable = "You are Claude Code."; + const older = footer(15_000_000); + const mid = footer(14_980_071); + const latest = footer(14_997_176); + const result = stabilizeClaudeInstructionsForPromptCache( + [stable, older, mid, TASKCREATE_NUDGE_CC_2_1_263, latest].join("\n\n"), + ); + expect(result.instructions).toBe(stable); + expect(result.instructions).not.toContain(""); + expect(result.instructions).not.toContain("TaskCreate"); + expect(result.dynamicNotice).toBe(`${latest}\n\n${TASKCREATE_NUDGE_CC_2_1_263}`); + }); + + test("latest footer and latest nudge both surface in the notice", () => { + const stable = "Stay stable."; + const older = footer(10); + const latest = footer(50); + const result = stabilizeClaudeInstructionsForPromptCache( + [stable, older, TASKCREATE_NUDGE, latest].join("\n\n"), + ); + expect(result.instructions).toBe(stable); + expect(result.dynamicNotice).toBe(`${latest}\n\n${TASKCREATE_NUDGE}`); + }); + + test("legacy and 2.1.263 nudges both leave the same stable instructions prefix", () => { + const stable = "Stay stable."; + const a = stabilizeClaudeInstructionsForPromptCache( + [stable, footer(1), TASKCREATE_NUDGE, footer(2)].join("\n\n"), + ); + const b = stabilizeClaudeInstructionsForPromptCache( + [stable, footer(1), TASKCREATE_NUDGE_CC_2_1_263, footer(9)].join("\n\n"), + ); + expect(a.instructions).toBe(stable); + expect(b.instructions).toBe(stable); + }); + + test("inline documentation of total_tokens tags stays in instructions", () => { + const docs = "The harness may emit a 123 footer; do not invent one."; + const result = stabilizeClaudeInstructionsForPromptCache(docs); + expect(result.instructions).toBe(docs); + expect(result.dynamicNotice).toBeNull(); + }); + + test("TaskCreate mentioned in docs is not treated as the harness nudge", () => { + const docs = "The task tools haven't been used recently. You may mention TaskCreate in docs without the reminder."; + const result = stabilizeClaudeInstructionsForPromptCache(docs); + expect(result.instructions).toBe(docs); + expect(result.dynamicNotice).toBeNull(); + }); + + test("fenced standalone total_tokens example stays byte-for-byte", () => { + const docs = ["You are a docs bot.", "```", footer(123), "```", ""].join("\n"); + const result = stabilizeClaudeInstructionsForPromptCache(docs); + expect(result.instructions).toBe(docs); + expect(result.dynamicNotice).toBeNull(); + }); + + test("docs plus a real trailing footer keep the docs and move only the latest footer", () => { + const docs = "Describe 0 in the protocol guide."; + const latest = footer(8000); + const result = stabilizeClaudeInstructionsForPromptCache( + [docs, footer(1), latest].join("\n\n"), + ); + expect(result.instructions).toBe(docs); + expect(result.dynamicNotice).toBe(latest); + }); + + test("fenced example plus a trailing harness footer moves only the footer", () => { + const docs = ["Docs:", "```", footer(123), "```"].join("\n"); + const latest = footer(8000); + const result = stabilizeClaudeInstructionsForPromptCache(`${docs}\n\n${latest}`); + expect(result.instructions).toBe(docs); + expect(result.dynamicNotice).toBe(latest); + }); + + test("unclosed fence through EOF is not a harness suffix", () => { + const docs = ["You are a docs bot.", "```", footer(123)].join("\n"); + const result = stabilizeClaudeInstructionsForPromptCache(docs); + expect(result.instructions).toBe(docs); + expect(result.dynamicNotice).toBeNull(); + }); + + test("a fence line with an info string does not close an open fence", () => { + const docs = ["```", footer(123), "```xml"].join("\n"); + const result = stabilizeClaudeInstructionsForPromptCache(docs); + expect(result.instructions).toBe(docs); + expect(result.dynamicNotice).toBeNull(); + }); +}); + +describe("anthropicToResponsesTranslation cache-stabilize wire-in", () => { + test("ordinary caller with the exact unfenced suffix keeps instructions and input unchanged", () => { + const latest = footer(15_000_000); + const system = ["You are Claude Code.", latest].join("\n\n"); + const { body } = translate(system); + expect(body.instructions).toBe(system); + expect(userTurns(body)).toEqual([ + { type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }, + ]); + }); + + test("ordinary caller with the exact TaskCreate paragraph keeps instructions and input unchanged", () => { + const system = ["You are Claude Code.", TASKCREATE_NUDGE].join("\n\n"); + const { body } = translate(system); + expect(body.instructions).toBe(system); + expect(userTurns(body)).toEqual([ + { type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }, + ]); + }); + + test("opted-in harness relocates the latest total_tokens footer onto a trailing input user message", () => { + const first = footer(1000); + const latest = footer(8000); + const { body } = translateHarness(["You are Claude Code.", first, latest].join("\n\n")); + expect(body.instructions).toBe("You are Claude Code."); + expect(String(body.instructions)).not.toContain(""); + const input = userTurns(body); + const last = input[input.length - 1]!; + expect(last).toEqual({ + type: "message", + role: "user", + content: [{ type: "input_text", text: latest }], + }); + expect(input.some(item => item.role === "user" && item !== last)).toBe(true); + }); + + test("opted-in peel does not require metadata.user_id", () => { + const latest = footer(14_980_071); + const { body } = translateHarness(["You are Claude Code.", latest].join("\n\n")); + expect(body.instructions).toBe("You are Claude Code."); + const input = userTurns(body); + expect(input[input.length - 1]).toEqual({ + type: "message", + role: "user", + content: [{ type: "input_text", text: latest }], + }); + }); + + test("fenced standalone total_tokens example is a translator no-op when opted in", () => { + const system = ["You are a docs bot.", "```", footer(123), "```", ""].join("\n"); + const { body } = translateHarness(system); + expect(body.instructions).toBe(system); + expect(userTurns(body)).toEqual([ + { type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }, + ]); + }); + + test("open fence to EOF with a trailing total_tokens tag is not relocated", () => { + const system = ["You are a docs bot.", "```", footer(123)].join("\n"); + const { body } = translateHarness(system); + expect(body.instructions).toBe(system); + expect(userTurns(body)).toEqual([ + { type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }, + ]); + }); + + test("real footer after a closed fence still relocates when opted in", () => { + const docs = ["Docs:", "```", footer(123), "```"].join("\n"); + const latest = footer(8000); + const { body } = translateHarness(`${docs}\n\n${latest}`); + expect(body.instructions).toBe(docs); + const input = userTurns(body); + expect(input[input.length - 1]).toEqual({ + type: "message", + role: "user", + content: [{ type: "input_text", text: latest }], + }); + }); + + test("whitespace-only system without a footer is preserved byte-for-byte", () => { + const system = " \n\n "; + const { body } = translate(system); + expect(body.instructions).toBe(system); + expect(userTurns(body)).toEqual([ + { type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }, + ]); + }); + + test("Claude Code session prompt_cache_key is unchanged across trailing footers", () => { + const stable = "You are Claude Code."; + const keyOf = (system: string) => + translateHarness(system, { user_id: "user-abc" }).body.prompt_cache_key as string; + const stableKey = keyOf(stable); + expect(stableKey).toMatch(/^[0-9a-f]{32}$/); + expect(keyOf([stable, footer(1000), footer(8000)].join("\n\n"))).toBe(stableKey); + expect(keyOf([stable, footer(99999)].join("\n\n"))).toBe(stableKey); + }); + + test("outside opt-in, Desktop prompt_cache_key hashes raw systemParts including footers", () => { + const stable = "You are Claude Code."; + const keyOf = (system: string) => translate(system).body.prompt_cache_key as string; + const stableKey = keyOf(stable); + expect(stableKey).toMatch(/^[0-9a-f]{32}$/); + expect(keyOf(stable)).toBe(stableKey); + expect(keyOf([stable, footer(8000)].join("\n\n"))).not.toBe(stableKey); + }); + + test("opted-in Desktop prompt_cache_key hashes stabilized instructions, not total_tokens footers", () => { + const stable = "You are Claude Code."; + const keyOf = (system: string) => translateHarness(system).body.prompt_cache_key as string; + const stableKey = keyOf(stable); + expect(stableKey).toMatch(/^[0-9a-f]{32}$/); + expect(keyOf([stable, footer(1000), footer(8000)].join("\n\n"))).toBe(stableKey); + expect(keyOf([stable, footer(99999)].join("\n\n"))).toBe(stableKey); + }); + + test("outside opt-in, a no-match Desktop key differs from the opted-in instructions-string key", () => { + const system = "You are Claude Code.\n\nPrefer terse answers."; + const rawKey = translate(system).body.prompt_cache_key as string; + const optedInKey = translateHarness(system).body.prompt_cache_key as string; + expect(rawKey).toMatch(/^[0-9a-f]{32}$/); + expect(optedInKey).toMatch(/^[0-9a-f]{32}$/); + expect(rawKey).not.toBe(optedInKey); + }); + +}); + +describe("Messages operator opt-in at the outbound boundary", () => { + const originalFetch = globalThis.fetch; + let isolatedHome: IsolatedCodexHome | undefined; + let previousHome: string | undefined; + let configHome: string | undefined; + + beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + isolatedHome = installIsolatedCodexHome("ocx-prefix-contract-"); + configHome = mkdtempSync(join(tmpdir(), "ocx-prefix-config-")); + process.env.OPENCODEX_HOME = configHome; + }); + afterEach(() => { + globalThis.fetch = originalFetch; + isolatedHome?.restore(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (configHome) removeTreeWithRetry(configHome); + }); + + async function outbound(system: string, enabled?: boolean) { + const captured: Array> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + expect(String(input)).toBe("https://prefix.example/v1/responses"); + captured.push(JSON.parse(String(init?.body)) as Record); + return Response.json({ id: "resp_prefix", object: "response", status: "completed", + output: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: "ok" }] }], + usage: { input_tokens: 2, output_tokens: 1, total_tokens: 3 } }); + }) as typeof fetch; + const config = { + providers: { prefix: { adapter: "openai-responses", authMode: "key", baseUrl: "https://prefix.example/v1", apiKey: "test-key", models: ["m"] } }, + ...(enabled === undefined ? {} : { claudeCode: { stabilizePromptCache: enabled } }), + } as OcxConfig; + const response = await handleClaudeMessages(new Request("http://localhost/v1/messages", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "prefix/m", max_tokens: 32, system, + messages: [{ role: "user", content: "hi" }], stream: false }), + }), config, { model: "", provider: "" }); + await response.text(); + expect(response.status).toBe(200); + expect(captured).toHaveLength(1); + return captured[0]!; + } + + for (const enabled of [undefined, false]) { + test(`ordinary HTTP caller preserves exact footer with setting ${enabled}`, async () => { + const system = `System.\n\n${footer(15_000_000)}`; + const wire = await outbound(system, enabled); + expect(wire.instructions).toBe(system); + expect(wire.input).toEqual([{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }]); + // Independent historical key vector: canonical JSON keys in sorted order, raw system array. + const expectedKey = createHash("sha256").update(JSON.stringify({ model: "prefix/m", system: [system], tools: [], version: 2 })).digest("hex").slice(0, 32); + expect(wire.prompt_cache_key).toBe(expectedKey); + }); + } + + for (const notice of [footer(15_000_000), TASKCREATE_NUDGE, TASKCREATE_NUDGE_CC_2_1_263]) { + test(`explicit HTTP opt-in relocates supported notice ${notice.slice(0, 35)}`, async () => { + const wire = await outbound(`System.\n\n${notice}`, true); + expect(wire.instructions).toBe("System."); + expect(wire.input).toEqual([ + { type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }, + { type: "message", role: "user", content: [{ type: "input_text", text: notice }] }, + ]); + }); + } + + test("opted-in HTTP caller preserves an open fenced example", async () => { + const system = `Docs:\n\x60\x60\x60xml\n${footer(1)}`; + const wire = await outbound(system, true); + expect(wire.instructions).toBe(system); + expect(wire.input).toHaveLength(1); + }); + + test("explicit setting survives actual config save/load without enabling other configs", () => { + const config = loadConfig(); + config.claudeCode = { ...config.claudeCode, stabilizePromptCache: true }; + saveConfig(config); + expect(loadConfig().claudeCode?.stabilizePromptCache).toBe(true); + config.claudeCode.stabilizePromptCache = false; + saveConfig(config); + expect(loadConfig().claudeCode?.stabilizePromptCache).toBe(false); + }); + + test("malformed truthy configuration never activates relocation", () => { + const system = `System.\n\n${footer(1)}`; + const config = JSON.parse('{"stabilizePromptCache":"true"}') as OcxClaudeCodeConfig; + const { body } = anthropicToResponsesTranslation({ model: "m", system, messages: [{ role: "user", content: "hi" }] }, config); + expect(body.instructions).toBe(system); + expect(body.input).toHaveLength(1); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index ec0792d2c5..f116d70a11 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -145,6 +145,7 @@ "claude-desktop-remote-hub.test.ts": "claude-integration", "claude-dotenv-provenance-transport.test.ts": "claude-integration", "claude-gateway-cache.test.ts": "claude-integration", + "claude-inbound-cache-stabilize.test.ts": "claude-integration", "claude-inbound-debug.test.ts": "claude-integration", "claude-inbound.test.ts": "claude-integration", "claude-management-api.test.ts": "claude-integration", From a3c8ec6b5935d19bddabac2b1eef0cd7eef1a010 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 13:58:32 +0900 Subject: [PATCH 2/4] docs: record prefix current-dev conflict adaptation --- devlog/_plan/260912_cache_lane/035_prefix_adapt.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 devlog/_plan/260912_cache_lane/035_prefix_adapt.md diff --git a/devlog/_plan/260912_cache_lane/035_prefix_adapt.md b/devlog/_plan/260912_cache_lane/035_prefix_adapt.md new file mode 100644 index 0000000000..e48750d0d8 --- /dev/null +++ b/devlog/_plan/260912_cache_lane/035_prefix_adapt.md @@ -0,0 +1,7 @@ +# Prefix current-dev adaptation + +Previous D: reverse native affinity correction reviewed. Parent requested current-dev integration of own prefix branch #4347. Class C2 adaptation; all existing no-local-suites/no-merge restrictions apply. Safe rebase own prefix commit df5853600a onto fetched origin/dev, dropping no product change. Existing roadmap commit already integrated via #4338. Record old/new immutable refs. No other branch/worktree edits. + +MODIFY only conflict resolutions in structure/clients/claude-desktop.md, structure/data-planes/inbound-compat.md, structure/runtime.md: preserve latest dev appended helper contract AND prefix opt-in section/link. Runtime prefix delta remains byte-identical. Test layout maps auto-merge retaining both sides' entries. No new implementation. + +Independent design/audit: confirm union of append-only docs is correct; compare old base..old tip to new base..new tip by file and range-diff, disclose every changed patch. Existing independent source review at df585 remains valid only for unchanged authored bytes, and conflict interdiff needs a separate inherited reviewer. C text diff check and new exact head hosted CI tracking; no local product tests. Push --force-with-lease tied to old prefix head and --no-verify; parent owns merge. From e114bc97b5f662710214f9e47426c6742062b5cb Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 14:18:40 +0900 Subject: [PATCH 3/4] fix(claude): parse canonical cache notices in one linear scan Address public #4347 P1/P2: use decreasing original-string line/fence cursors and commit prefix removal only after a match; match one ASCII space inside the canonical tag. Add 20k-footer/fence and whitespace preservation regressions. Local suites NOT RUN. --- .../260912_cache_lane/036_prefix_linear.md | 13 +++ src/claude/inbound-cache-stabilize.ts | 87 ++++++++----------- structure/data-planes/inbound-compat.md | 6 +- .../claude-inbound-cache-stabilize.test.ts | 41 +++++++++ 4 files changed, 93 insertions(+), 54 deletions(-) create mode 100644 devlog/_plan/260912_cache_lane/036_prefix_linear.md diff --git a/devlog/_plan/260912_cache_lane/036_prefix_linear.md b/devlog/_plan/260912_cache_lane/036_prefix_linear.md new file mode 100644 index 0000000000..43bf8c3dfe --- /dev/null +++ b/devlog/_plan/260912_cache_lane/036_prefix_linear.md @@ -0,0 +1,13 @@ +# Linear canonical suffix parsing + +Previous D: affinity integrated by parent; prefix serial base81f0c78d7a2bf56e759511e89f450c7d49e0a42e. New public reviews on4347 discussion_r3995155701/P1 and3995155709/P2 require code repair, not only adaptation. C3 bounded parser performance/correctness. Same no-local-suites/build/typecheck/install/no-merge scope; hosted CI is runtime verifier. + +First rebase own prefix commits after5042a376 onto pinned81f0c78d. Union three mapped structure conflicts preserving base affinity/helper paragraphs and prefix opt-in contract; original runtime delta unchanged before repair. Preserve Warexpor/Cursor credit. + +MODIFY src/claude/inbound-cache-stabilize.ts: replace repeated whole-rest regex scans and slices with one forward fence-range scan plus backward line cursor. Each backward iteration strips trailing horizontal/line whitespace, isolates one complete single line via lastIndexOf newline, accepts only exact total_tokens with digits + one ASCII space + tokens left or either full exact TaskCreate paragraph, and checks monotonically decreasing fence-range cursor. On recognized line move end cursor before separator newlines, retain only latest total/nudge. Slice original instructions once at final end; no match returns original bytes. Fence opener/closer behavior retained. No whole shrinking-string rescan or per-footer copy. No multiline/tab/formfeed inside canonical tag. + +MODIFY tests/claude-integration/claude-inbound-cache-stabilize.test.ts: add controls for newline/tab/formfeed between digits and tokens left, exact single-space positive, CRLF outer separators, many consecutive footer sequence (20k) retains exact prefix/latest footer. No tight wall-clock benchmark; normal hosted test timeout plus source complexity review validates bounded work. Preserve existing fence/default-off/HTTP/header/config tests. + +Update structure/data-planes/inbound-compat.md to state single-line canonical notices and linear scan. Exact old runtime-delta comparison binds inherited source review, new parser receives independent A and implementation source audit. GitHub hosted finaltip CI after --no-verify lease push. Parent owns merge and review-thread resolution. Safe public final handoff will be exported to tracked devlog at verification completion; scratch raw evidence retained locally only. + +Design dispositions LINEAR01-04 accepted. Keep committed `end` unchanged until notice acceptance. A speculative `lineEnd` skips only complete LF/CRLF sequences; candidate horizontal padding trims only ASCII space/tab by indices. Do not cross whitespace-only lines, consume lone CR or trim unmatched prefix. After matching, commit end before immediately preceding LF/CRLF runs; failed candidate returns prior committed prefix unchanged. Compare exact nudge strings and anchored digits + literal single space. Fence index decreases only. Add preserved two-space prefix, malformed-before-valid, whitespace-only separator, loneCR and many closed fences+footers controls. diff --git a/src/claude/inbound-cache-stabilize.ts b/src/claude/inbound-cache-stabilize.ts index bb75fc184f..e085dd7c4c 100644 --- a/src/claude/inbound-cache-stabilize.ts +++ b/src/claude/inbound-cache-stabilize.ts @@ -18,8 +18,7 @@ * above it and flip `instructions` after a previously stable peel. */ -const TRAILING_TOTAL_RE = - /(?:^|(?:\r?\n)+)[ \t]*(\d+\s+tokens left<\/total_tokens>)[ \t]*(?:\r?\n)*$/; +const TOTAL_NOTICE_RE = /^[0-9]+ tokens left<\/total_tokens>$/; /** Legacy Claude Code TaskCreate reminder (pre-"tracking progress"). */ const TASKCREATE_NUDGE_LEGACY = @@ -32,14 +31,6 @@ const TASKCREATE_NUDGE_LEGACY = const TASKCREATE_NUDGE_CC_2_1_263 = "The task tools haven't been used recently. If you're working on tasks that would benefit from tracking progress, consider using TaskCreate to add new tasks and TaskUpdate to update task status (set to in_progress when starting, completed when done). Also consider cleaning up the task list if it has become stale. Only use these if relevant to the current work. This is just a gentle reminder - ignore if not applicable."; -const TRAILING_NUDGE_RE = new RegExp( - `(?:^|(?:\\r?\\n)+)[ \\t]*(${escapeRegExp(TASKCREATE_NUDGE_LEGACY)}|${escapeRegExp(TASKCREATE_NUDGE_CC_2_1_263)})[ \\t]*(?:\\r?\\n)*$`, -); - -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - interface FenceRange { start: number; end: number; @@ -88,62 +79,52 @@ function fencedRanges(source: string): FenceRange[] { return ranges; } -function isInsideFence(ranges: readonly FenceRange[], index: number): boolean { - return ranges.some(range => index >= range.start && index < range.end); -} - -function peelOne( - rest: string, - ranges: readonly FenceRange[], -): { rest: string; total?: string; nudge?: string } | null { - const total = rest.match(TRAILING_TOTAL_RE); - if (total?.[1]) { - const matchStart = rest.length - total[0].length; - const tagAt = matchStart + total[0].indexOf(total[1]); - if (!isInsideFence(ranges, tagAt)) { - return { rest: rest.slice(0, rest.length - total[0].length), total: total[1] }; - } - } - const nudge = rest.match(TRAILING_NUDGE_RE); - if (nudge?.[1]) { - const matchStart = rest.length - nudge[0].length; - const tagAt = matchStart + nudge[0].indexOf(nudge[1]); - if (!isInsideFence(ranges, tagAt)) { - return { rest: rest.slice(0, rest.length - nudge[0].length), nudge: nudge[1] }; - } - } - return null; -} - +/** A backwards cursor consumes each line and fence range at most once. */ export function stabilizeClaudeInstructionsForPromptCache( instructions: string, ): { instructions: string; dynamicNotice: string | null } { - if (!instructions) { - return { instructions: "", dynamicNotice: null }; - } - const ranges = fencedRanges(instructions); - let rest = instructions; + let fenceIndex = ranges.length - 1; + let end = instructions.length; let latestTotal: string | null = null; let latestNudge: string | null = null; let peeled = false; + for (;;) { - const next = peelOne(rest, ranges); - if (!next) break; + // This is speculative: a failed candidate must not trim the retained prefix. + let lineEnd = end; + while (lineEnd > 0 && instructions[lineEnd - 1] === "\n") { + lineEnd--; + if (lineEnd > 0 && instructions[lineEnd - 1] === "\r") lineEnd--; + } + if (lineEnd === 0) break; + const lineStart = instructions.lastIndexOf("\n", lineEnd - 1) + 1; + let contentStart = lineStart; + let contentEnd = lineEnd; + while (contentStart < contentEnd && (instructions[contentStart] === " " || instructions[contentStart] === "\t")) contentStart++; + while (contentEnd > contentStart && (instructions[contentEnd - 1] === " " || instructions[contentEnd - 1] === "\t")) contentEnd--; + const notice = instructions.slice(contentStart, contentEnd); + const total = TOTAL_NOTICE_RE.test(notice); + const nudge = notice === TASKCREATE_NUDGE_LEGACY || notice === TASKCREATE_NUDGE_CC_2_1_263; + if (!total && !nudge) break; + + while (fenceIndex >= 0 && ranges[fenceIndex]!.start > contentStart) fenceIndex--; + if (fenceIndex >= 0 && contentStart < ranges[fenceIndex]!.end) break; + if (total && latestTotal === null) latestTotal = notice; + if (nudge && latestNudge === null) latestNudge = notice; peeled = true; - rest = next.rest; - if (next.total && latestTotal === null) latestTotal = next.total; - if (next.nudge && latestNudge === null) latestNudge = next.nudge; - } - if (!peeled) { - return { instructions, dynamicNotice: null }; + // Commit only the recognized notice and its immediately preceding LF/CRLF separators. + end = lineStart; + while (end > 0 && instructions[end - 1] === "\n") { + end--; + if (end > 0 && instructions[end - 1] === "\r") end--; + } } + if (!peeled) return { instructions, dynamicNotice: null }; const noticeParts: string[] = []; if (latestTotal) noticeParts.push(latestTotal); if (latestNudge) noticeParts.push(latestNudge); - const dynamicNotice = noticeParts.length > 0 ? noticeParts.join("\n\n") : null; - - return { instructions: rest, dynamicNotice }; + return { instructions: instructions.slice(0, end), dynamicNotice: noticeParts.join("\n\n") }; } diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index c4bd51b46b..2d17c11875 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -118,9 +118,13 @@ final native affinity, and shared-system keys do not provide either conversation `src/claude/inbound.ts` reads only literal `claudeCode.stabilizePromptCache: true` from its existing configuration argument. The default is off for every translated Messages caller. -`src/claude/inbound-cache-stabilize.ts` relocates only exact trailing unfenced harness notices +`src/claude/inbound-cache-stabilize.ts` relocates only exact single-line trailing unfenced harness notices into a trailing user input message; unmatched and fenced text is preserved, including an open fence through EOF. Native passthrough never enters this translator. Without opt-in the original system-parts cache-key derivation remains unchanged; with opt-in the metadata-less key uses stabilized instructions. Metadata-derived keys retain their existing derivation. This configuration changes prompt roles, not conversation identity, and cannot guarantee upstream cache reuse. + +Instruction notice extraction scans fence ranges once and walks original lines backwards with +a decreasing cursor. It accepts exactly one ASCII space inside the token notice, preserves +unmatched prefix bytes, and does not repeatedly scan or copy shrinking prompt prefixes. diff --git a/tests/claude-integration/claude-inbound-cache-stabilize.test.ts b/tests/claude-integration/claude-inbound-cache-stabilize.test.ts index 73363578ca..b11cbe3aaa 100644 --- a/tests/claude-integration/claude-inbound-cache-stabilize.test.ts +++ b/tests/claude-integration/claude-inbound-cache-stabilize.test.ts @@ -231,6 +231,47 @@ describe("stabilizeClaudeInstructionsForPromptCache", () => { }); }); +describe("linear canonical notice extraction", () => { + for (const separator of ["\n", "\r\n", "\t", "\f", " "]) { + test(`noncanonical inner separator ${JSON.stringify(separator)} is preserved`, () => { + const system = `System.\n\n123${separator}tokens left`; + expect(stabilizeClaudeInstructionsForPromptCache(system)).toEqual({ instructions: system, dynamicNotice: null }); + const body = translateHarness(system).body; + expect(body.instructions).toBe(system); + expect(body.input).toHaveLength(1); + }); + } + + test("retains prefix trailing spaces after a successful peel and failed next candidate", () => { + expect(stabilizeClaudeInstructionsForPromptCache(`System. \n\n${footer(1)}`)).toEqual({ + instructions: "System. ", dynamicNotice: footer(1), + }); + }); + test("CRLF separators and horizontal padding retain the canonical notice", () => { + expect(stabilizeClaudeInstructionsForPromptCache(`System.\r\n\r\n \t${footer(1)}\t \r\n`)).toEqual({ + instructions: "System.", dynamicNotice: footer(1), + }); + }); + test("malformed notice before a valid suffix remains byte-for-byte", () => { + const prefix = `System. \n123\ntokens left `; + expect(stabilizeClaudeInstructionsForPromptCache(`${prefix}\n${footer(2)}`)).toEqual({ + instructions: prefix, dynamicNotice: footer(2), + }); + }); + test("a whitespace-only trailing line and lone CR are not canonical separators", () => { + for (const system of [`System.\n${footer(1)}\n \n`, `System.\n${footer(1)}\r`]) { + expect(stabilizeClaudeInstructionsForPromptCache(system)).toEqual({ instructions: system, dynamicNotice: null }); + } + }); + test("twenty thousand notices after many fences keep only the latest without repeated prefix scans", () => { + const prefix = "System.\n" + ("```xml\nexample\n```\n").repeat(2_000) + "Stable. "; + const notices = Array.from({ length: 20_000 }, (_, index) => footer(index)); + expect(stabilizeClaudeInstructionsForPromptCache(`${prefix}\n\n${notices.join("\n")}`)).toEqual({ + instructions: prefix, dynamicNotice: footer(19_999), + }); + }); +}); + describe("anthropicToResponsesTranslation cache-stabilize wire-in", () => { test("ordinary caller with the exact unfenced suffix keeps instructions and input unchanged", () => { const latest = footer(15_000_000); From 4f6cd1ad3f0215f9cbfd5be53a55b5b6f7cd90f0 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 14:29:03 +0900 Subject: [PATCH 4/4] docs: preserve cache lane handoff and final evidence export --- .../260912_cache_lane/037_prefix_slot.md | 9 +++++++ devlog/_plan/260912_cache_lane/050_verify.md | 4 ++- devlog/_plan/260912_cache_lane/060_handoff.md | 26 +++++++++++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 devlog/_plan/260912_cache_lane/037_prefix_slot.md create mode 100644 devlog/_plan/260912_cache_lane/060_handoff.md diff --git a/devlog/_plan/260912_cache_lane/037_prefix_slot.md b/devlog/_plan/260912_cache_lane/037_prefix_slot.md new file mode 100644 index 0000000000..0d97df1bfc --- /dev/null +++ b/devlog/_plan/260912_cache_lane/037_prefix_slot.md @@ -0,0 +1,9 @@ +# Final prefix slot and durable evidence + +Parent pinned dev81f6cd5915ca59f784a584d8cd739adff55c9bd0 after Cline4371, holding other structure merges. Previous D: linear canonical parser repair e1d262acee freshly source-audited PASS0, no local product execution. Class C2 adaptation/docs, no local suites/build/typecheck/install/no merge. + +Rebase own three commits after81f0c78d onto81f6cd5915, preserving exact repair/source/test bytes and all parent-base source/docs. Conflict union only in mapped docs; unexpected runtime conflicts need independent review. Compare oldbase81f0..e1d262 against newbase81f6..newhead; source/test patches identical. Lease push remains pinned to remote e1c92f10c3, which was intentionally kept during repair. + +MODIFY050_verify.md: .tmp is scratch, not sole durable evidence. NEW060_handoff.md safe tracked index includes original dispositions, branches/PRs, known source-audit and CI references, remaining3433 live acceptance, local suites NOT RUN, cycle list and parent-only integration. Private paths/raw evidence stay in scratch. Export current exact SHA/CI/source-review data to4347 PR body on publication, then update only PR body with terminal results; this preserves final source head without a self-referential new commit. Keep parent integration annotations when updating PR body. Final verification must inspect the exported body before closing goal. + +Independent conflict audit binds prior fresh linear-parser PASS to final head; no old pre-P1/P2 PASS stands in for repair. Parent decides review-thread resolution and merge. diff --git a/devlog/_plan/260912_cache_lane/050_verify.md b/devlog/_plan/260912_cache_lane/050_verify.md index 8b174b37b5..f778a37fef 100644 --- a/devlog/_plan/260912_cache_lane/050_verify.md +++ b/devlog/_plan/260912_cache_lane/050_verify.md @@ -1,7 +1,9 @@ # Hosted verification and delivery -Prerequisites: independent implementation PRs. NEW durable .tmp/cache-handoff/050_handoff.md records actual worktree, branches/PR URLs/full head SHA, source dispositions/credits, cycle receipts, remaining acceptance and reviews. Capture gh pr view/checks and gh run view JSON at each final independent tip; ordinary manual children only for real correction dependencies. No native membership mutation. +Prerequisites: independent implementation PRs. Scratch .tmp/cache-handoff/050_handoff.md records actual worktree, branches/PR URLs/full head SHA, source dispositions/credits, cycle receipts, remaining acceptance and reviews. Capture gh pr view/checks and gh run view JSON at each final independent tip; ordinary manual children only for real correction dependencies. No native membership mutation. No product source changes planned here. If CI exposes a scoped defect, append a numbered repair plan and full PABCD cycle before implementation, then verify new exact head. Hosted workflow definition determines jobs actually executed; skipped/cancelled runs are never passes. No automatic workflow cancellation or protection edit. Local tests/build/typecheck/install remain NOT RUN. Only source/diff checks may be wrapped in cxc receipt and must retain their true label. C: final head matches hosted run headSha; successful required jobs and skipped jobs recorded individually. D: finish handoff with source review gaps and Hermes field residual, no merge/issue closure. Parent decides integration. + +Durable delivery: 060_handoff.md is the tracked safe index. Before any scratch cleanup, export exact final PR/head/CI and source-review evidence into the #4347 PR body, preserving parent annotations, and read it back. Terminal CI updates change that body only, so the verified source head remains stable. Private paths and raw security analysis never enter the public index or PR. Scratch is not the sole retained handoff. diff --git a/devlog/_plan/260912_cache_lane/060_handoff.md b/devlog/_plan/260912_cache_lane/060_handoff.md new file mode 100644 index 0000000000..0d49e2bfa5 --- /dev/null +++ b/devlog/_plan/260912_cache_lane/060_handoff.md @@ -0,0 +1,26 @@ +# Cache lane handoff index + +The cache lane contains independent dev PRs, with no native stack or artificial dependency chain. Main implementation used managed worktree slot `7e43`; the coordination task owns integration and original-PR closures. This index preserves source dispositions. Exact final-head CI and source-review snapshots are exported to the [prefix PR description](https://github.com/lidge-jun/opencodex/pull/4347) and read back at delivery; private execution paths and raw scratch evidence are excluded. + +| Source | Delivered PR / branch | Outcome and remaining evidence | +| --- | --- | --- | +| #4118 | [#4338](https://github.com/lidge-jun/opencodex/pull/4338), `codex/260912-60plus-cache-claim` | Claim deferral adopted. Parent integrated as `75d3e5c78f9ac7fc7125ee27294a962456bf32bb` and closed the source PR. Exact carried head `d27db6dd56c481572728bc99043e2c528f11e1bc`: [hosted CI 34673563105](https://github.com/lidge-jun/opencodex/actions/runs/34673563105) SUCCESS, 19 jobs successful / 2 skipped. | +| #4050 | [#4340](https://github.com/lidge-jun/opencodex/pull/4340), `codex/260912-60plus-cache-affinity` | Go affinity adopted and reverse Go-to-ChatGPT native identity repaired. Parent integrated as `81f0c78d7a2bf56e759511e89f450c7d49e0a42e`. Final source head `d354924f0af38a48f5768fca0cd09c5145bdb4bd`, fresh repair/conflict audit PASS, blocker 0. [Hosted CI 34674962749](https://github.com/lidge-jun/opencodex/actions/runs/34674962749) pending at this checkpoint. | +| #4052 | [#4347](https://github.com/lidge-jun/opencodex/pull/4347), `codex/260912-60plus-cache-prefix` | Reimplemented behind literal-true operator configuration; ordinary callers preserve roles and keys. Fresh public P1/P2 findings superseded the first helper approval. The repaired parser uses decreasing line/fence cursors and exact single-space grammar; fresh algorithm audit PASS at `e1d262acee27d55388cccdb96430669e944fbd90`. Final base adaptation and hosted evidence are recorded in the PR body. | +| #3433 | [#4365](https://github.com/lidge-jun/opencodex/pull/4365), `codex/260912-60plus-cache-hermes` | Transport contract tests only, no runtime synthesis. Exact source head `b254efc8385ce2a9dc34b9a5ac7d2a449605d75d`, independent source audit PASS, blocker 0. [Hosted CI 34674763850](https://github.com/lidge-jun/opencodex/actions/runs/34674763850) pending at this checkpoint. Live issue acceptance below remains open. | + +## Hermes acceptance still open + +The [latest controlled field-presence observation](https://github.com/lidge-jun/opencodex/issues/3433#issuecomment-5551855276) omitted measured identity fields. The [maintainer follow-up](https://github.com/lidge-jun/opencodex/issues/3433#issuecomment-5556427205) requests a real client-assigned identity stable within a conversation and distinct for a fresh conversation, then comparison at the outbound boundary. Synthetic A/A/B fixtures verify the OCX Direct transport contract when executed; they do not prove actual Hermes emission, Pool cohort stability or improved cache hits. #3433 is not solved by the Claude PRs. #3719 thinking replay is separate. + +## Review and attribution + +Carry commits preserve luvs01 credit for #4118; David Wang plus original GPT-6 Astra/Claude Fable trailers for #4050; Warexpor and Cursor Agent for #4052. No code from other authors is relabeled as sole authorship. + +The first affinity source review missed reverse native routing; the repair was independently reviewed before parent integration. The first prefix helper review missed quadratic scanning and broad inner whitespace; it was superseded by a fresh algorithm review after correction. A green original-PR run or plan approval never substitutes for final implementation review. Outstanding GitHub objections and final-tip status are re-read before the coordinating maintainer's decision. + +## Execution record + +Each work phase used its own persisted P-A-B-C-D cycle: roadmap, claim, affinity, prefix, native-affinity repair, prefix adaptation, affinity adaptation, Hermes contract, serial affinity slot, linear prefix repair, final prefix slot, then hosted-evidence verification. Source and conflict reviews used inherited-model read-only subagents; no native architect role was claimed. + +Local tests of every size, build, typecheck and install were **NOT RUN** by explicit instruction. Local receipts contain text/applicability checks only. Product execution belongs to the linked GitHub-hosted runs; skipped Windows full shards and macOS control are not passing executions. Pushes used `--no-verify`, with exact old-head leases for owned branch rebases. This task did not merge, release, publish packages, restart services or change user runtime settings.