From 9cde6e735294f4605e2a8655dda4aaac195beb04 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Mon, 7 Sep 2026 07:38:27 +0900 Subject: [PATCH 1/6] test(responses): cover established task delivery and compaction Add one synthetic complete send_message_to_thread envelope after a real tool pair. Cover ordinary responses, stored-ID continuation, v2 compaction_trigger and v1 compact; assert upstream content, order and pairing plus compact output contracts. Coverage motivated by issue #3807 reports from @DaveW001 and @stephen-drew, using the narrowed envelope contract documented in #3735. These are synthetic fixtures, not captured reporter requests; no original source patch is copied. Validation: git diff --check passed. Tests, typecheck and build NOT RUN by explicit instruction. Production code and missing-call-id guards are unchanged. --- .../responses-compaction-routing.test.ts | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/tests/responses/responses-compaction-routing.test.ts b/tests/responses/responses-compaction-routing.test.ts index 8e92f28715..8faca32bef 100644 --- a/tests/responses/responses-compaction-routing.test.ts +++ b/tests/responses/responses-compaction-routing.test.ts @@ -1776,6 +1776,130 @@ describe("external task-input envelopes (#3735)", () => { } }); +describe("established-history external task input (#3807)", () => { + // Synthetic complete envelope from the #3735 contract; #3807's history rendering + // is not a captured outbound request. Keep the real tool pair distinct from delivery. + const deliveryText = " Follow up on the earlier tool result.\n"; + const acknowledged = "Delivery acknowledged."; + const continuationText = "Continue the established task."; + const summary = "Earlier tool returned 7; follow-up delivery is pending."; + const history = () => [ + { type: "message", role: "user", content: "Read the earlier value." }, + { type: "function_call", call_id: "call_history", name: "read_value", arguments: "{}" }, + { type: "function_call_output", call_id: "call_history", output: "earlier value: 7" }, + { type: "message", role: "assistant", content: "Earlier result recorded." }, + { + type: "function_call_output", id: "fco_external_followup", + name: "send_message_to_thread", namespace: "codex_app", output: deliveryText, + }, + ]; + const requestBody = () => ({ + model: "gw/model", stream: false, store: false, input: history(), + tools: [{ type: "function", name: "read_value", parameters: { type: "object", properties: {} } }], + }); + const wireHistory = [ + { role: "user", content: "Read the earlier value." }, + { role: "assistant", tool_calls: [{ id: "call_history", type: "function", function: { name: "read_value", arguments: "{}" } }] }, + { role: "tool", tool_call_id: "call_history", content: "earlier value: 7" }, + { role: "assistant", content: "Earlier result recorded." }, + { role: "user", content: deliveryText }, + ]; + + function captureChat(text: string): Array> { + const captured: Array> = []; + globalThis.fetch = (async (_url: unknown, init?: RequestInit) => { + captured.push(JSON.parse(String(init?.body))); + return jsonResponse({ + choices: [{ index: 0, message: { role: "assistant", content: text }, finish_reason: "stop" }], + usage: { prompt_tokens: 10, completion_tokens: 5 }, + }); + }) as typeof fetch; + return captured; + } + + function expectHistory(sent: Record, tail: Array> = []) { + const messages = sent.messages as Array>; + expect(messages).toHaveLength(wireHistory.length + tail.length); + expect(messages).toMatchObject([...wireHistory, ...tail]); + // Exactly one original pair: delivery must not acquire a synthesized tool identity. + expect(messages.flatMap(message => message.tool_calls ?? [])).toEqual(wireHistory[1]!.tool_calls); + expect(messages.filter(message => message.role === "tool")).toEqual([wireHistory[2]]); + expect(JSON.stringify(sent)).not.toContain("[tool output for unknown call]"); + } + + test("ordinary response preserves inter-task delivery after an established tool pair", async () => { + const captured = captureChat(acknowledged); + const res = await handleResponses(compactionRequest(requestBody()), + keyProviderConfig({ adapter: "openai-chat" }), { model: "", provider: "" }); + expect(res.status).toBe(200); + const json = await res.json() as { status?: string }; + expect(json.status).toBe("completed"); + expect(captured).toHaveLength(1); + expectHistory(captured[0]!); + }); + + test("stored-ID continuation replays the established tool pair and inter-task delivery in order", async () => { + const captured = captureChat(acknowledged); + const config = keyProviderConfig({ adapter: "openai-chat" }); + const first = await handleResponses(compactionRequest({ ...requestBody(), store: true }), + config, { model: "", provider: "" }); + expect(first.status).toBe(200); + const saved = await first.json() as { id: string; status?: string }; + expect(saved.status).toBe("completed"); + expect(typeof saved.id).toBe("string"); + expect(saved.id.length).toBeGreaterThan(0); + expect(captured).toHaveLength(1); + expectHistory(captured[0]!); + + // Send only the new user turn: the handler must retrieve the previous raw history. + const res = await handleResponses(compactionRequest({ + ...requestBody(), previous_response_id: saved.id, + input: [{ type: "message", role: "user", content: continuationText }], + }), config, { model: "", provider: "" }); + expect(res.status).toBe(200); + const json = await res.json() as { status?: string }; + expect(json.status).toBe("completed"); + expect(captured).toHaveLength(2); + expectHistory(captured[1]!, [ + { role: "assistant", content: acknowledged }, + { role: "user", content: continuationText }, + ]); + }); + + for (const version of ["v2 trigger", "v1 compact"] as const) { + test(`${version} preserves established-history delivery and pairing before summarization`, async () => { + const captured = captureChat(summary); + const config = keyProviderConfig({ adapter: "openai-chat" }); + const res = version === "v2 trigger" + ? await handleResponses(compactionRequest({ + ...requestBody(), input: [...history(), { type: "compaction_trigger" }], + }), config, { model: "", provider: "" }) + : await handleResponsesCompact(new Request("http://localhost/v1/responses/compact", { + method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(requestBody()), + }), config, { model: "", provider: "" }); + expect(res.status).toBe(200); + const json = await res.json() as { output: Array> }; + expect(captured).toHaveLength(1); + expectHistory(captured[0]!, [ + { role: "user", content: expect.stringContaining("CONTEXT CHECKPOINT COMPACTION") }, + ]); + expect(captured[0]!.tools).toBeUndefined(); + expect(JSON.stringify(captured)).not.toContain("compaction_trigger"); + if (version === "v2 trigger") { + expect(json.output.filter(item => item.type === "compaction")).toEqual([{ + type: "compaction", id: expect.stringMatching(/^cmp_/), + encrypted_content: `ocx1:${Buffer.from(summary, "utf8").toString("base64")}`, + }]); + } else { + expect(json.output).toEqual([ + { type: "message", role: "user", content: [{ type: "input_text", text: "Read the earlier value." }] }, + { type: "message", role: "user", content: [{ type: "input_text", text: expect.stringContaining(`\n${summary}`) }] }, + ]); + } + }); + } +}); + describe("unpaired tool result boundary (#3259)", () => { function unpairedBody(item: Record): Record { return { From 12b174ebc90e980970024445825f6b49a374154c Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Mon, 7 Sep 2026 07:42:22 +0900 Subject: [PATCH 2/6] fix(claude): preserve signed and opaque replay block boundaries Preserve empty signed and redacted-only replay items, keep signature updates within their source thinking block, and emit opaque blocks in order. Retain hidden-summary policy and document deferred Claude hidden-text replay and live/cache claims. Add exact-array synthetic round-trip coverage; local tests and typecheck intentionally not run, pending parent combined remote CI. Co-authored-by: SB Yoon <44089734+yansigit@users.noreply.github.com> Co-authored-by: Yumi --- .../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 + src/adapters/anthropic.ts | 11 +- src/bridge.ts | 34 +++- src/claude/outbound.ts | 11 +- src/responses/parser.ts | 19 +- .../anthropic-thinking-signature.test.ts | 188 +++++++++++++++++- .../claude-outbound.test.ts | 17 ++ tests/responses/reasoning-envelope.test.ts | 4 +- 15 files changed, 277 insertions(+), 23 deletions(-) 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 ffc3ad6e88..7f1c0a54aa 100644 --- a/docs-site/src/content/docs/fr/guides/claude-code.md +++ b/docs-site/src/content/docs/fr/guides/claude-code.md @@ -506,6 +506,8 @@ Le proxy traduit chaque requête Anthropic Messages API au format Codex Response | `max_tokens` | `max_output_tokens` | | `stop_sequences` | `stop` | +Sur l’adaptateur Anthropic prévu, les blocs signés non masqués (y compris thinking vide) et les blocs redacted opaques sont préservés. `hideThinkingSummary` reste inchangé : le texte signé masqué localement n’est pas exposé aux clients Claude ; sa relecture sans perte via cette frontière reste non établie. Les anciennes enveloppes combinées ne permettent pas de rétablir l’ordre après émission du texte en streaming. `claudeCode.compatibility: "enforce"` refuse toujours la relecture thinking. Cela ne prouve ni l’acceptation réelle par Anthropic ni une amélioration du cache ; [#3719](https://github.com/lidge-jun/opencodex/issues/3719) reste ouvert. + **Cas d'erreur (400) :** JSON mal formé ; `model` absent ou vide ; `messages` absent ou vide ; rôle non pris en charge ; `tool_result` sans `tool_use_id` ; `tool_use` sans identifiant ni nom ; `tool_choice` nommé sans nom. diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index b237955adb..f1c1bfbfe1 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -519,6 +519,8 @@ The proxy translates every Anthropic Messages API request into the Codex Respons | `max_tokens` | `max_output_tokens` | | `stop_sequences` | `stop` | +Replay preserves non-hidden signed blocks (including empty thinking) and opaque redacted blocks on the intended Anthropic adapter. `hideThinkingSummary` remains unchanged: locally hidden signed text is not exposed to Claude clients, and lossless replay through that hidden Claude boundary is not established. Older combined reasoning envelopes cannot recover original block order once streaming text has been emitted. `claudeCode.compatibility: "enforce"` still rejects thinking replay. This does not establish live Anthropic acceptance or cache-hit improvements; [#3719](https://github.com/lidge-jun/opencodex/issues/3719) remains open. + **Error cases (400):** malformed JSON; missing/empty `model`; missing/empty `messages`; unsupported role; `tool_result` without `tool_use_id`; `tool_use` without id/name; named `tool_choice` without name. 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 43b8bcee24..2445e7fa3a 100644 --- a/docs-site/src/content/docs/ja/guides/claude-code.md +++ b/docs-site/src/content/docs/ja/guides/claude-code.md @@ -374,6 +374,8 @@ Claude Code の `/effort` 設定はアダプターでも維持されます。 | `max_tokens` | `max_output_tokens` | | `stop_sequences` | `stop` | +意図した Anthropic アダプターでは、非表示でない署名付きブロック(空の thinking を含む)と不透明な redacted ブロックを保持します。`hideThinkingSummary` は変更しません。ローカルで隠した署名付きテキストは Claude クライアントに公開せず、この非表示境界での無損失再生は未確認です。旧形式の結合エンベロープは、テキスト送信後に元のブロック順を復元できません。`claudeCode.compatibility: "enforce"` は引き続き thinking 再生を拒否します。実際の Anthropic 受理やキャッシュ改善の証明ではなく、[#3719](https://github.com/lidge-jun/opencodex/issues/3719) は未解決です。 + **エラー条件(400):** 不正な JSON、欠落または空の `model`、欠落または空の `messages`、未サポートの role、`tool_use_id` のない `tool_result`、id/name のない `tool_use`、name のない名前指定 `tool_choice` です。 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 0964f2ff49..4e3535d821 100644 --- a/docs-site/src/content/docs/ko/guides/claude-code.md +++ b/docs-site/src/content/docs/ko/guides/claude-code.md @@ -412,6 +412,8 @@ Claude Code의 `/effort` 설정은 어댑터에서도 유지돼요. | `max_tokens` | `max_output_tokens` | | `stop_sequences` | `stop` | +의도한 Anthropic 어댑터에서는 숨기지 않은 서명 블록(빈 thinking 포함)과 불투명 redacted 블록을 보존해요. `hideThinkingSummary` 정책은 유지돼요. 로컬에서 숨긴 서명 텍스트를 Claude 클라이언트에 노출하지 않으며, 이 숨김 경계를 통한 무손실 재생은 아직 보장하지 않아요. 이전 결합 봉투는 스트리밍 텍스트가 이미 전송됐다면 원래 블록 순서를 복원할 수 없어요. `claudeCode.compatibility: "enforce"`는 여전히 thinking 재생을 거절해요. 실제 Anthropic 수락이나 캐시 적중 개선을 증명한 것은 아니며 [#3719](https://github.com/lidge-jun/opencodex/issues/3719)는 열어 둬요. + **오류 조건(400):** 잘못된 JSON, 누락되거나 빈 `model`, 누락되거나 빈 `messages`, 지원하지 않는 role, `tool_use_id` 없는 `tool_result`, id/name 없는 `tool_use`, name 없는 이름 지정 `tool_choice`예요. 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 1769642bd7..847e79964a 100644 --- a/docs-site/src/content/docs/ru/guides/claude-code.md +++ b/docs-site/src/content/docs/ru/guides/claude-code.md @@ -399,6 +399,8 @@ Claude Code — это лишь учётные данные для доступ | `max_tokens` | `max_output_tokens` | | `stop_sequences` | `stop` | +На выбранном адаптере Anthropic сохраняются нескрытые подписанные блоки (включая пустой thinking) и непрозрачные блоки redacted. Политика `hideThinkingSummary` не меняется: локально скрытый подписанный текст не раскрывается клиентам Claude, а воспроизведение без потерь через эту границу пока не подтверждено. Старые объединённые конверты не восстанавливают порядок после отправки потокового текста. `claudeCode.compatibility: "enforce"` по-прежнему отклоняет thinking replay. Приём реальным Anthropic и улучшение кеша не доказаны; [#3719](https://github.com/lidge-jun/opencodex/issues/3719) остаётся открытым. + **Случаи ошибок (400):** некорректный JSON; отсутствующий или пустой `model`; отсутствующий или пустой `messages`; неподдерживаемая роль; `tool_result` без `tool_use_id`; `tool_use` без id/name; именованный `tool_choice` без имени. 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 29d96506ac..f1c7fca438 100644 --- a/docs-site/src/content/docs/tr/guides/claude-code.md +++ b/docs-site/src/content/docs/tr/guides/claude-code.md @@ -588,6 +588,8 @@ dönüştürür: | `max_tokens` | `max_output_tokens` | | `stop_sequences` | `stop` | +Hedeflenen Anthropic adaptöründe gizlenmemiş imzalı bloklar (boş thinking dahil) ve opak redacted blokları korunur. `hideThinkingSummary` değişmez: yerel olarak gizlenen imzalı metin Claude istemcilerine gösterilmez; bu sınır üzerinden kayıpsız yeniden oynatma doğrulanmamıştır. Eski birleşik zarflarda metin akışla gönderildikten sonra özgün blok sırası geri getirilemez. `claudeCode.compatibility: "enforce"` thinking yeniden oynatmasını hâlâ reddeder. Bu, gerçek Anthropic kabulünü veya önbellek iyileşmesini kanıtlamaz; [#3719](https://github.com/lidge-jun/opencodex/issues/3719) açık kalır. + **Hata durumları (400):** hatalı biçimlendirilmiş JSON; eksik/boş `model`; eksik/boş `messages`; desteklenmeyen rol; `tool_use_id` içermeyen `tool_result`; kimlik/ad içermeyen `tool_use`; ad içermeyen adlandırılmış `tool_choice`. 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 3e2824d3e9..2b8c0bfa98 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 @@ -350,6 +350,8 @@ Claude Code 的 `/effort` 设置会完整保留并传递给适配器: | `max_tokens` | `max_output_tokens` | | `stop_sequences` | `stop` | +在预期的 Anthropic 适配器上,保留未隐藏的签名块(包括空 thinking)和不透明的 redacted 块。`hideThinkingSummary` 策略不变:不会向 Claude 客户端公开本地隐藏的签名文本,尚未证明经过此隐藏边界的无损重放。旧版组合信封在流式文本发出后无法恢复原始块顺序。`claudeCode.compatibility: "enforce"` 仍拒绝 thinking 重放。这不证明真实 Anthropic 接受请求或缓存命中改善;[#3719](https://github.com/lidge-jun/opencodex/issues/3719) 仍未关闭。 + **错误情况(400):**JSON 格式错误;缺少/空的 `model`;缺少/空的 `messages`;不支持的 role;`tool_result` 缺少 `tool_use_id`;`tool_use` 缺少 id/name;指定名称的 `tool_choice` 缺少 name。 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 5a6fbf3a86..db1c779145 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 @@ -426,6 +426,8 @@ Claude Code 的 `/effort` 設定會完整保留並傳遞給適配器: | `max_tokens` | `max_output_tokens` | | `stop_sequences` | `stop` | +在預期的 Anthropic 適配器上,保留未隱藏的簽名區塊(包括空 thinking)和不透明的 redacted 區塊。`hideThinkingSummary` 政策不變:不會向 Claude 用戶端公開本地隱藏的簽名文字,尚未證明經過此隱藏邊界的無損重播。舊版組合信封在串流文字發出後無法恢復原始區塊順序。`claudeCode.compatibility: "enforce"` 仍拒絕 thinking 重播。這不證明真實 Anthropic 接受請求或快取命中改善;[#3719](https://github.com/lidge-jun/opencodex/issues/3719) 仍未關閉。 + **錯誤情況(400):**JSON 格式錯誤;缺少/空的 `model`;缺少/空的 `messages`;不支援的 role;`tool_result` 缺少 `tool_use_id`;`tool_use` 缺少 id/name;指定名稱的 `tool_choice` 缺少 name。 diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 6eea4764a1..a9a8279198 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -1118,9 +1118,14 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti break; } case "content_block_start": { - const block = data.content_block as { type: string; id?: string; name?: string; data?: string } | undefined; + const block = data.content_block as { type: string; id?: string; name?: string; data?: string; thinking?: string } | undefined; if (!block) break; currentBlockType = block.type; + if (block.type === "thinking") { + // Preserve even a display:omitted block boundary. The bridge can then + // distinguish consecutive empty signed blocks from signature updates. + yield { type: "thinking_delta", thinking: typeof block.thinking === "string" ? block.thinking : "" }; + } if (block.type === "tool_use") { currentToolCallId = usableToolUseId(block.id); currentToolCallName = toolNames.fromWire(block.name ?? ""); @@ -1151,8 +1156,8 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti // later text blocks independent. yield { type: "thinking_delta", thinking: delta.reasoning }; } else if (delta.type === "signature_delta" && typeof delta.signature === "string" && (currentBlockType === "thinking" || currentBlockType === "reasoning")) { - // Arrives once, just before the thinking block's content_block_stop; block-scoped - // so a stray signature on a non-thinking block can never be captured. + // Anthropic SDKs replace the signature with this value. Forward updates + // within the block; the bridge closes on the next semantic boundary. yield { type: "thinking_signature", signature: delta.signature }; } else if (delta.type === "input_json_delta" && typeof delta.partial_json === "string" && currentBlockType === "tool_use") { // Forwarded immediately: the bridge maps each delta to a client-visible diff --git a/src/bridge.ts b/src/bridge.ts index 645dfff8e7..ff044a5e52 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -945,6 +945,13 @@ export function bridgeToResponsesSSE( } if (event.type !== "done" && event.type !== "incomplete" && event.type !== "error") continue; } + // Anthropic signature_delta supplies the latest signature, not an append-only + // fragment (anthropic-sdk-typescript MessageStream). Keep consecutive updates + // together; the next semantic event belongs to the following block. + if (pendingSignature !== undefined && event.type !== "thinking_signature" && event.type !== "heartbeat") { + if (currentReasoning) closeCurrentReasoning(); + else flushHiddenReasoningEnvelope(); + } switch (event.type) { case "assistant_boundary": { // A guarded continuation starts a fresh assistant output item while keeping the @@ -1054,15 +1061,21 @@ export function bridgeToResponsesSSE( case "thinking_signature": { pendingSignatureBytes = replaceRetainedString(pendingSignatureBytes, event.signature, "reasoning"); pendingSignature = event.signature; - // Signature arrives at the end of the thinking block. With a visible reasoning item - // open, closeCurrentReasoning attaches the envelope; hidden/suppressed blocks flush - // an envelope-only reasoning item now. - if (!currentReasoning) flushHiddenReasoningEnvelope(); + // Delay closing until the next semantic event so a signature update cannot + // create another block or become attached to the following thinking text. break; } case "redacted_thinking": { + if (currentMsg) closeCurrentMessage("commentary"); + if (currentReasoning) closeCurrentReasoning(); + if (currentRawReasoning) closeCurrentRawReasoning(); + flushHiddenRawReasoning(); + if (currentToolCall) closeCurrentToolCall(); budget?.chargeRetained(bytesOf(event.data), { kind: "reasoning" }); pendingRedacted.push(event.data); + // A redacted block is complete at content_block_start. Emit it here, + // not with a later thinking block or after a tool call at turn end. + flushHiddenReasoningEnvelope(); break; } case "kiro_redacted_reasoning": { @@ -1816,6 +1829,9 @@ function buildResponseJSONWithBudget( if (budget) releaseTranslatedEvent(e, budget); continue; } + if (batchSignature !== undefined && e.type !== "thinking_signature" && e.type !== "heartbeat") { + flushSummaryReasoning(); + } switch (e.type) { case "assistant_boundary": flushText("commentary"); @@ -1860,19 +1876,23 @@ function buildResponseJSONWithBudget( } break; case "thinking_signature": - // End of the current thinking block — flush it WITH the signature envelope so the - // block/signature pairing survives multi-block turns. + // Like streaming, retain the latest signature update until the next semantic + // event. Flushing every update would manufacture signature-only siblings. batchSignatureBytes = replaceBatchRetainedString(batchSignatureBytes, e.signature, "reasoning"); batchSignature = e.signature; - flushSummaryReasoning(); break; case "redacted_thinking": + flushText("commentary"); + flushSummaryReasoning(); + flushRawReasoning(); + flushToolCall(); { const dataBytes = bytesOf(e.data); budget?.chargeRetained(dataBytes, { kind: "reasoning" }); batchRedactedBytes += dataBytes; } batchRedacted.push(e.data); + flushSummaryReasoning(); break; case "kiro_redacted_reasoning": // Stash only — pushed after the trailing flushes. One blob per turn, so last wins. diff --git a/src/claude/outbound.ts b/src/claude/outbound.ts index 8c0db5b7b8..4dcdaa0eb7 100644 --- a/src/claude/outbound.ts +++ b/src/claude/outbound.ts @@ -559,7 +559,10 @@ export function responsesSseToAnthropicSse( if (env?.sig) open.reasoningSig = env.sig; closeOpenBlock(); } - if (red.length > 0) ensureStarted(); + if (red.length > 0) { + ensureStarted(); + closeOpenBlock(); + } for (const data of red) { const idx = blockIndex++; emit("content_block_start", { type: "content_block_start", index: idx, content_block: { type: "redacted_thinking", data } }); @@ -806,10 +809,14 @@ export function responsesJsonToAnthropicMessage(json: unknown, model: string): R } const encrypted = typeof raw.encrypted_content === "string" ? raw.encrypted_content : ""; const env = encrypted ? decodeReasoningEnvelope(encrypted) : null; + // Legacy combined envelopes place redacted blocks before the signed block, + // matching the Anthropic adapter. New bridge output uses separate items. + for (const data of env?.red ?? []) content.push({ type: "redacted_thinking", data }); + // env.txt may be locally hidden text. Do not expose it here or manufacture + // a new signed continuity carrier; hidden-summary replay remains limited. if (parts.length > 0 || env?.sig) { content.push({ type: "thinking", thinking: parts.join("\n\n"), signature: env?.sig ?? encodeReasoningEnvelope({ txt: parts.join("\n\n") }) }); } - for (const data of env?.red ?? []) content.push({ type: "redacted_thinking", data }); break; } case "function_call": { diff --git a/src/responses/parser.ts b/src/responses/parser.ts index a81a693a4a..396f2170b2 100644 --- a/src/responses/parser.ts +++ b/src/responses/parser.ts @@ -126,6 +126,12 @@ export function parseRequest( } return holder; }; + const preservePendingReplay = () => { + const replay = pendingReasoning.filter(entry => entry.envelopeSigned || entry.part.redacted?.length); + if (replay.length > 0) { + ensureAssistantPlaceholder(messages, data.model, now).content.push(...replay.map(entry => entry.part)); + } + }; // Tool specs surfaced by a prior tool_search (deferred tools, e.g. subagents). Codex does not // re-list these in `tools`, but chat models can only call listed tools — so we re-inject them. const loadedToolSpecs: unknown[] = []; @@ -148,6 +154,12 @@ export function parseRequest( const effectiveType = (item as { type?: string }).type ?? ("role" in item ? "message" : undefined); const itemRole = (item as { role?: string }).role; const externalTaskInput = effectiveType === "function_call_output" ? externalTaskInputContent(item) : undefined; + // A signed/opaque assistant-only turn still owns its replay blocks, even + // without a following assistant text or tool call to drain the pending list. + if (effectiveType === "agent_message" || externalTaskInput !== undefined + || (effectiveType === "message" && ["user", "developer", "system"].includes(itemRole ?? ""))) { + preservePendingReplay(); + } // Raw protocol items do not map one-to-one onto context messages. Capture the boundary while // both representations are available so later metadata can stay before conversation in both. if ( @@ -269,7 +281,7 @@ export function parseRequest( const envelope = typeof reasoning.encrypted_content === "string" ? decodeReasoningEnvelope(reasoning.encrypted_content) : null; - const thinkingText = envelope?.txt || text; + const thinkingText = envelope?.txt ?? text; // Kiro reasoning round-trip: a krc-only item carries nothing renderable — it is provider // state for the assistant turn that ALREADY closed, because Kiro emits its @@ -285,7 +297,7 @@ export function parseRequest( // Native/non-ocxr1 encrypted-only reasoning is opaque here. Do not create a detached // assistant turn or invent replayable plaintext/signatures from the encrypted payload. - if (thinkingText.length > 0) { + if (thinkingText.length > 0 || envelope?.sig || envelope?.red?.length) { const part: OcxThinkingContent = { type: "thinking", thinking: thinkingText, @@ -296,7 +308,7 @@ export function parseRequest( const envelopeSigned = typeof envelope?.sig === "string"; const previous = pendingReasoning[pendingReasoning.length - 1]; - if (!envelopeSigned && previous && !previous.envelopeSigned) { + if (!envelopeSigned && !part.redacted && previous && !previous.envelopeSigned && !previous.part.redacted) { previous.part = { ...part, thinking: `${previous.part.thinking}\n${part.thinking}`, @@ -466,6 +478,7 @@ export function parseRequest( } } } + preservePendingReplay(); if (data.previous_response_id && continuationConversationMessageIndex === undefined) { continuationConversationMessageIndex = messages.length; } diff --git a/tests/adapters/anthropic/anthropic-thinking-signature.test.ts b/tests/adapters/anthropic/anthropic-thinking-signature.test.ts index 68c972a742..db8f489c4f 100644 --- a/tests/adapters/anthropic/anthropic-thinking-signature.test.ts +++ b/tests/adapters/anthropic/anthropic-thinking-signature.test.ts @@ -4,7 +4,12 @@ import { createAnthropicAdapter as createAnthropicAdapterProduction } from "../. import { parseRequest } from "../../../src/responses/parser"; import { encodeReasoningEnvelope, decodeReasoningEnvelope, OCX_REASONING_PREFIX } from "../../../src/responses/reasoning-envelope"; import type { AdapterEvent, OcxProviderConfig, OcxThinkingContent } from "../../../src/types"; -import { withTestTranslatorBudget } from "../../helpers/translator-budget"; +import { createTestTranslatorBudget, withTestTranslatorBudget } from "../../helpers/translator-budget"; + +import { anthropicToResponsesBody } from "../../../src/claude/inbound"; +import { collectAnthropicMessage, responsesSseToAnthropicSse, responsesJsonToAnthropicMessage } from "../../../src/claude/outbound"; +import { createGoogleAdapter } from "../../../src/adapters/google"; +import { sanitizeReasoningInputContent } from "../../../src/adapters/openai-responses"; const createAnthropicAdapter = (...args: Parameters) => withTestTranslatorBudget(createAnthropicAdapterProduction(...args)); @@ -127,11 +132,11 @@ describe("bridge ocxr1 envelope emission", () => { ...baseEvents, ], "claude-x"); const output = response.output as Record[]; - const reasoning = output.find(i => i.type === "reasoning"); - expect(reasoning).toBeDefined(); - const env = decodeReasoningEnvelope(reasoning!.encrypted_content as string); - expect(env?.sig).toBe("RealSig1234567890=="); - expect(env?.red).toEqual(["RED1"]); + const reasoning = output.filter(i => i.type === "reasoning"); + expect(reasoning.map(item => decodeReasoningEnvelope(item.encrypted_content as string))).toEqual([ + { red: ["RED1"] }, + { sig: "RealSig1234567890==" }, + ]); }); test("redacted-only turn still emits an envelope reasoning item (SSE)", async () => { @@ -326,3 +331,174 @@ describe("passthrough scrub of ocxr1 envelopes", () => { expect(req.body ?? "").toContain('"rs_1"'); // reasoning item itself survives }); }); + + +describe("Claude / Responses / intended Anthropic replay fidelity", () => { + // Synthetic fixtures prove transport fidelity only, never upstream signature validity. + const first = { type: "thinking", thinking: "first\nexact", signature: "FirstSyntheticSignature123456==" }; + const second = { type: "thinking", thinking: "second", signature: "SecondSyntheticSignature123456==" }; + const empty = { type: "thinking", thinking: "", signature: "EmptySyntheticSignature123456==" }; + const before = { type: "redacted_thinking", data: "opaque-before" }; + const middle = { type: "redacted_thinking", data: "opaque-middle" }; + const after = { type: "redacted_thinking", data: "opaque-after" }; + const tool = { type: "tool_use", id: "toolu_replay", name: "lookup", input: { q: "x" } }; + const cases = [ + { name: "consecutive signed blocks", blocks: [first, second, tool] }, + { name: "opaque blocks in source order", blocks: [before, first, middle, second, after, tool] }, + { name: "empty signed block", blocks: [empty, tool] }, + { name: "consecutive empty signed blocks", blocks: [empty, { ...empty, signature: "OtherEmptySyntheticSignature123456==" }, tool] }, + { name: "redacted-only tool turn", blocks: [before, after, tool] }, + ]; + + for (const fixture of cases) { + for (const streaming of [true, false]) { + test(`${fixture.name}: ${streaming ? "SSE" : "JSON"} full chain preserves exact blocks`, async () => { + const adapter = createAnthropicAdapter(provider, "none"); + let events: AdapterEvent[]; + if (streaming) { + const frames = [frame("message_start", { message: { usage: { input_tokens: 1, output_tokens: 0 } } })]; + fixture.blocks.forEach((block, index) => { + frames.push(frame("content_block_start", { index, content_block: block.type === "thinking" + ? { type: "thinking", thinking: "", signature: "" } + : block.type === "tool_use" ? { ...tool, input: {} } : block })); + if ("thinking" in block) { + // Omitted thinking has no thinking_delta on the actual wire. + if (block.thinking) frames.push(frame("content_block_delta", { index, delta: { type: "thinking_delta", thinking: block.thinking } })); + frames.push(frame("content_block_delta", { index, delta: { type: "signature_delta", signature: block.signature } })); + } else if (block.type === "tool_use") { + frames.push(frame("content_block_delta", { index, delta: { type: "input_json_delta", partial_json: JSON.stringify(tool.input) } })); + } + frames.push(frame("content_block_stop", { index })); + }); + frames.push(frame("message_delta", { delta: { stop_reason: "tool_use" }, usage: { output_tokens: 1 } }), frame("message_stop", {})); + events = await collect(adapter.parseStream(sseResponse(frames))); + } else { + events = await adapter.parseResponse!(new Response(JSON.stringify({ + id: "msg_fixture", type: "message", role: "assistant", model: "claude-x", + content: fixture.blocks, stop_reason: "tool_use", usage: { input_tokens: 1, output_tokens: 1 }, + }))); + } + let message: Record; + if (streaming) { + async function* upstream() { yield* events; } + const budget = createTestTranslatorBudget(); + message = await collectAnthropicMessage(responsesSseToAnthropicSse( + bridgeToResponsesSSE(upstream(), "claude-x"), "claude-x", { translatorBudget: budget }, + ), "claude-x", budget); + } else { + message = responsesJsonToAnthropicMessage(buildResponseJSON(events, "claude-x"), "claude-x"); + } + expect(message.content).toEqual(fixture.blocks); + const parsed = parseRequest(anthropicToResponsesBody({ + model: "anthropic/claude-x", messages: [ + { role: "user", content: "question" }, + { role: "assistant", content: message.content }, + { role: "user", content: [{ type: "tool_result", tool_use_id: tool.id, content: "result" }] }, + ], + })); + const request = await adapter.buildRequest(parsed); + const replay = JSON.parse(request.body as string) as { messages: Array<{ role: string; content: unknown }> }; + expect(replay.messages).toEqual([ + { role: "user", content: [{ type: "text", text: "question" }] }, + { role: "assistant", content: fixture.blocks }, + { role: "user", content: [{ type: "tool_result", tool_use_id: tool.id, content: "result" }] }, + ]); + }); + } + } + + test("signature updates replace rather than concatenate, across heartbeats", async () => { + // Both official SDKs assign signature_delta.signature instead of appending it: + // anthropic-sdk-typescript/src/lib/MessageStream.ts and + // anthropic-sdk-python/src/anthropic/lib/streaming/_messages.py. + const adapter = createAnthropicAdapter(provider); + const events = await collect(adapter.parseStream(sseResponse([ + frame("content_block_start", { index: 0, content_block: { type: "thinking", thinking: "", signature: "" } }), + frame("content_block_delta", { index: 0, delta: { type: "thinking_delta", thinking: "first" } }), + frame("content_block_delta", { index: 0, delta: { type: "signature_delta", signature: "old" } }), + ": heartbeat\n\n", + frame("content_block_delta", { index: 0, delta: { type: "signature_delta", signature: "FirstSyntheticSignature123456==" } }), + frame("content_block_stop", { index: 0 }), + frame("content_block_start", { index: 1, content_block: { type: "thinking", thinking: "", signature: "" } }), + frame("content_block_delta", { index: 1, delta: { type: "thinking_delta", thinking: "second" } }), + frame("content_block_delta", { index: 1, delta: { type: "signature_delta", signature: "SecondSyntheticSignature123456==" } }), + frame("content_block_stop", { index: 1 }), + frame("message_stop", {}), + ]))); + async function* upstream() { yield* events; } + const streamed = sseItems(await drainSse(bridgeToResponsesSSE(upstream(), "claude-x"))); + const buffered = buildResponseJSON(events, "claude-x").output as Record[]; + for (const items of [streamed, buffered]) { + expect(items.map(item => ({ summary: item.summary, envelope: decodeReasoningEnvelope(item.encrypted_content as string) }))).toEqual([ + { summary: [{ type: "summary_text", text: "first" }], envelope: { sig: "FirstSyntheticSignature123456==" } }, + { summary: [{ type: "summary_text", text: "second" }], envelope: { sig: "SecondSyntheticSignature123456==" } }, + ]); + } + }); + + test("signed/opaque-only assistant turns survive a user boundary and end of input", () => { + for (const continuation of [[], [{ role: "user", content: "next" }]]) { + const parsed = parseRequest(anthropicToResponsesBody({ model: "anthropic/claude-x", messages: [ + { role: "assistant", content: [empty, before, after] }, ...continuation, + ] })); + const assistant = parsed.context.messages.find(message => message.role === "assistant"); + expect(assistant?.content).toEqual([ + expect.objectContaining({ type: "thinking", thinking: "", signature: empty.signature }), + expect.objectContaining({ type: "thinking", thinking: "", redacted: [before.data] }), + expect.objectContaining({ type: "thinking", thinking: "", redacted: [after.data] }), + ]); + } + }); + + test("locally hidden signed text remains exact on Responses replay without being exposed to Claude", async () => { + const events: AdapterEvent[] = [ + { type: "thinking_delta", thinking: "hidden exact\ntext" }, + { type: "thinking_signature", signature: first.signature }, + { type: "text_delta", text: "answer" }, + { type: "done", usage: { inputTokens: 1, outputTokens: 1 } }, + ]; + async function* upstream() { yield* events; } + const items = sseItems(await drainSse(bridgeToResponsesSSE(upstream(), "claude-x", undefined, undefined, undefined, undefined, 2000, { hideThinkingSummary: true }))); + const response = buildResponseJSON(events, "claude-x", { hideThinkingSummary: true }); + for (const output of [items, response.output as Record[]]) { + const reasoning = output.find(item => item.type === "reasoning")!; + expect(reasoning.summary).toEqual([]); + expect(decodeReasoningEnvelope(reasoning.encrypted_content as string)).toEqual({ sig: first.signature, txt: "hidden exact\ntext" }); + const request = await createAnthropicAdapter(provider, "none").buildRequest(parseRequest({ model: "anthropic/claude-x", input: output })); + const replay = JSON.parse(request.body as string) as { messages: Array<{ content: unknown }> }; + expect(replay.messages[0].content).toEqual([ + { type: "thinking", thinking: "hidden exact\ntext", signature: first.signature }, + { type: "text", text: "answer" }, + ]); + // Deliberate existing limitation: no new signed carrier and no hidden-text disclosure. + expect(JSON.stringify(responsesJsonToAnthropicMessage({ output }, "claude-x"))).not.toContain("hidden exact"); + } + expect(() => anthropicToResponsesBody({ model: "m", messages: [{ role: "assistant", content: [ + { type: "thinking", thinking: "", signature: encodeReasoningEnvelope({ sig: first.signature, txt: "hidden exact" }) }, + ] }] })).toThrow(/continuity/); + }); + + test("explicitly empty signed envelope text does not fall back to a different summary", () => { + const parsed = parseRequest({ model: "m", input: [ + { type: "reasoning", summary: [{ type: "summary_text", text: "different summary" }], encrypted_content: encodeReasoningEnvelope({ sig: empty.signature, txt: "" }) }, + ] }); + expect(parsed.context.messages[0]?.content).toEqual([ + { type: "thinking", thinking: "", signature: empty.signature }, + ]); + }); + + test("opaque Anthropic payloads do not become Google signatures or native Responses encryption", async () => { + const body = anthropicToResponsesBody({ model: "google/gemini-test", messages: [ + { role: "assistant", content: [empty, before, tool] }, + { role: "user", content: [{ type: "tool_result", tool_use_id: tool.id, content: "result" }] }, + ] }); + const google = withTestTranslatorBudget(createGoogleAdapter({ adapter: "google", baseUrl: "https://generativelanguage.googleapis.com", apiKey: "synthetic" })); + const request = await google.buildRequest(parseRequest(body)); + for (const output of [request.body as string, JSON.stringify(sanitizeReasoningInputContent(body))]) { + expect(output).not.toContain(empty.signature); + expect(output).not.toContain(before.data); + expect(output).not.toContain("ocxr1:"); + } + expect(parseRequest({ model: "m", input: [{ type: "reasoning", summary: [], encrypted_content: "native-opaque" }] }).context.messages).toEqual([]); + }); +}); diff --git a/tests/claude-integration/claude-outbound.test.ts b/tests/claude-integration/claude-outbound.test.ts index e78cc529d3..33faa2e839 100644 --- a/tests/claude-integration/claude-outbound.test.ts +++ b/tests/claude-integration/claude-outbound.test.ts @@ -1179,6 +1179,23 @@ describe("sanitizeWebSearchInput (#381)", () => { expect(events[2].data.content_block).toEqual({ type: "redacted_thinking", data: "opaque" }); }); + test("redacted reasoning closes an open text block before opening its opaque block", async () => { + const events = await collectEvents(responsesSseToAnthropicSse(streamFrom([ + sse("response.output_text.delta", { delta: "text" }), + sse("response.output_item.done", { + item: { type: "reasoning", id: "rs_red", encrypted_content: encodeReasoningEnvelope({ red: ["opaque"] }) }, + }), + sse("response.completed", { response: { status: "completed", usage: {} } }), + ].join("")), "m")); + expect(events.filter(event => event.name === "content_block_start" || event.name === "content_block_stop") + .map(event => ({ name: event.name, index: event.data.index }))).toEqual([ + { name: "content_block_start", index: 0 }, + { name: "content_block_stop", index: 0 }, + { name: "content_block_start", index: 1 }, + { name: "content_block_stop", index: 1 }, + ]); + }); + test("signature-only reasoning emits an empty thinking block with the genuine signature", async () => { const events = await collectEvents(responsesSseToAnthropicSse(streamFrom([ sse("response.output_item.done", { diff --git a/tests/responses/reasoning-envelope.test.ts b/tests/responses/reasoning-envelope.test.ts index 75c51994f2..2469b8e46b 100644 --- a/tests/responses/reasoning-envelope.test.ts +++ b/tests/responses/reasoning-envelope.test.ts @@ -34,8 +34,8 @@ describe("reasoning and tool/result envelopes", () => { const message = responsesJsonToAnthropicMessage({ output: [{ type: "reasoning", summary: [{ type: "summary_text", text: "visible" }], encrypted_content: encoded }], }, "m") as any; - expect(message.content[0]).toMatchObject({ type: "thinking", signature: "sig" }); - expect(message.content.slice(1)).toEqual([ + expect(message.content[2]).toMatchObject({ type: "thinking", signature: "sig" }); + expect(message.content.slice(0, 2)).toEqual([ { type: "redacted_thinking", data: "red-a" }, { type: "redacted_thinking", data: "red-b" }, ]); From 8f8790e31cdaf6d9c0083324aa01d6f778410e98 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Mon, 7 Sep 2026 07:43:23 +0900 Subject: [PATCH 3/6] docs(devlog): record axis three protocol delivery plan --- .../_plan/260907_axis3_protocol/000_plan.md | 13 ++++++ .../260907_axis3_protocol/001_roadmap_lock.md | 3 ++ .../010_prepare_and_verify.md | 42 +++++++++++++++++++ .../260907_axis3_protocol/011_candidate.md | 9 ++++ .../260907_axis3_protocol/020_delivery.md | 7 ++++ 5 files changed, 74 insertions(+) create mode 100644 devlog/_plan/260907_axis3_protocol/000_plan.md create mode 100644 devlog/_plan/260907_axis3_protocol/001_roadmap_lock.md create mode 100644 devlog/_plan/260907_axis3_protocol/010_prepare_and_verify.md create mode 100644 devlog/_plan/260907_axis3_protocol/011_candidate.md create mode 100644 devlog/_plan/260907_axis3_protocol/020_delivery.md diff --git a/devlog/_plan/260907_axis3_protocol/000_plan.md b/devlog/_plan/260907_axis3_protocol/000_plan.md new file mode 100644 index 0000000000..36cfe95772 --- /dev/null +++ b/devlog/_plan/260907_axis3_protocol/000_plan.md @@ -0,0 +1,13 @@ +# Axis 3 protocol fidelity roadmap + +Mode: satisfy-spec HOTL, requested by the maintainer on 2026-09-07. Deliver source-grounded dispositions for #3815, #3816, #3807, #3719 and land accepted fixes with original authors credited in commits. No local suites or typecheck; verification is remote exact final-head CI, with lower-layer CI only on final failure. Ordinary manual PR chain only; admin merge authorized. No explicit token or wall-time limit was requested; agents use bounded tasks and waits. Do not invoke private provider accounts or spend inference credits. Tools: local Git/files, GitHub gh, Astra high leaf agents. Writes confined to task worktrees and this axis's GitHub branches/PRs. Preserve unrelated dirty work. + +Scope: ordered Claude thinking/redacted/tool-result envelope fidelity; Grok strict-client control frame projection; valid task-seed diagnosis. Exclude new auth/routing/default policies, fabricated provider signatures or tool pairing IDs (new Responses reasoning item IDs are permitted transport identities), cache savings claims, unrelated axes, deployment/release. Unknown field/runtime reports receive explicit deferred dispositions per user direction. + +Work phases: wp0 roadmap audit and lock; wp1 prepare two independently reviewable source layers and any justified contract regressions, then remote final combined verification; wp2 publish/merge ordinary PRs bottom-up and record final ancestry/dispositions. The two source fixes are independent; the manual chain is the user's requested integration/CI grouping, not a runtime dependency. + +Success: roadmap verified, accepted changes reviewed and remotely validated, commits credit SB Yoon (yansigit) and Yumi for #3815 and Danh Thanh (dt418) for #3816, landed SHA proven ancestor of refreshed dev; uncertain #3807/#3719 runtime or cache claims remain open. Stop only after accepted delivery and explicit dispositions. Escalate only an unavoidable owner-policy choice; defer that portion and continue the rest. + +Acceptance: (1) thinking then text/tool then result retains order and genuine signatures; opaque blocks remain bounded and malformed/nested signatures fail closed. (2) Grok user agent receives ordinary Responses data without codex.rate_limits/codex.response.metadata, while proxy inspection and normal clients retain metadata. (3) valid external task seeds preserve text/order; absent metadata invalid tool outputs still reject. (4) no credential, admission, cache-retention default, provider/routing policy mutation. (5) final CI must really run relevant tests/typecheck, not skip/cancel or fabricate success. No local suite was run. Final failure permits lower-layer CI for localization; unrelated failures may defer delivery, never count as success. + +Sources: PRs https://github.com/lidge-jun/opencodex/pull/3815 and /pull/3816; issues /issues/3807 and /issues/3719. Current dev 137d6a727. Evidence snapshots under .tmp/axis3. Public notes contain no unreleased vulnerability detail; any new security investigation stays in scratch. diff --git a/devlog/_plan/260907_axis3_protocol/001_roadmap_lock.md b/devlog/_plan/260907_axis3_protocol/001_roadmap_lock.md new file mode 100644 index 0000000000..115f8c3dfa --- /dev/null +++ b/devlog/_plan/260907_axis3_protocol/001_roadmap_lock.md @@ -0,0 +1,3 @@ +# Roadmap lock + +Independent Astra high reviewer Pauli passed the amended wp0 roadmap. Transport reasoning IDs are permitted; fabricated tool pairing IDs remain prohibited. Claude fallback retention must be bounded or removed and checked remotely. Grok parser must follow SSE last-field/reset semantics. No runtime was changed in wp0. Next: wp1 carries source layers, adds justified regression coverage and verifies the final combined head remotely. diff --git a/devlog/_plan/260907_axis3_protocol/010_prepare_and_verify.md b/devlog/_plan/260907_axis3_protocol/010_prepare_and_verify.md new file mode 100644 index 0000000000..c278f094bf --- /dev/null +++ b/devlog/_plan/260907_axis3_protocol/010_prepare_and_verify.md @@ -0,0 +1,42 @@ +# Prepare and verify combined protocol candidate + +Reverify base/source heads before build. Carry exact source deltas from the scratch diff snapshots, fold independently confirmed review fixes only. Each commit contains verified contributor trailers. Do not include upstream planning notes or unrelated changes. + +Layer 1 MODIFY: +scripts/test-layout/layout.json +src/claude/inbound.ts +src/claude/outbound.ts +src/responses/reasoning-envelope.ts +tests/claude-integration/claude-code-thought-signature-scope.test.ts +tests/claude-integration/claude-inbound.test.ts +tests/claude-integration/claude-outbound.test.ts +tests/claude-integration/claude-source-envelope.test.ts +tests/fixtures/test-layout-expected.json +tests/responses/reasoning-envelope.test.ts + +Preserve genuine signatures; encode bounded unsigned/redacted fallback; keep structured tool results. Layer 2 NEW src/server/grok-responses-control-frame.ts and MODIFY: +src/server/grok-responses-control-frame.ts +src/server/responses/core.ts +tests/responses/responses-snapshot-repair-server.test.ts + +Separate strict-client filtering from internal inspection. On a Grok metadata frame, forward no incompatible client frame; on ordinary delta, preserve unchanged; ordinary clients remain unchanged. No shared account/routing changes. + +Potential follow-up tests belong only in existing responses/Claude test files after diagnosis, with independent expected values. If no valid unhandled #3807 input is established, leave production guards unchanged. #3719 cache-hit and true Anthropic signed replay cannot be certified by codec fixtures. + +SoT: update docs-site/src/content/docs/guides/claude-code.md and existing translated counterparts only if #3815 makes their drop-policy statements stale. Read docs-site/AGENTS.md first. No global retention change. + +Verification: user prohibits local suites/typecheck (NOT RUN). Inspect source and diff-check locally. Push task branches with --no-verify. Dispatch existing Cross-platform CI workflow on final combined head, lane all. Confirm workflow head SHA, jobs, conclusion, test/typecheck execution from logs. Final CI failure permits lower-layer CI. Keep workflow/protection configuration unchanged; suppress only task-owned redundant automatic runs when needed for requested top-first scheduling, reporting cancelled runs honestly. No real accounts are used. + +## Audit amendments + +New rs_ reasoning IDs are normal transport identity, not fabricated tool call pairing. Do not synthesize tool-call IDs to bypass #3807 validation. + +Before acceptance, remove unbounded thinkingBuf retention introduced by #3815 or charge it to the existing TranslatorBudget retained bytes with normal fail-closed overflow. Use the established budget and error event; no silent truncation or new policy default. Cover multi-part text exactness, empty continuity fallback, and overflow with a small injected existing budget in remote regression tests. Decoder/consumer traces must prove any compact continuity marker still replays the original summary. + +#3816 must use SSE last-event-field-wins semantics, including colonless/empty resets and removal of only one optional leading space. Test event-only, data-only, repeated event fields in both orders, and preservation of ordinary completion data. Keep downstream Grok WebSocket support deferred because the existing surface marker is absent there; do not claim this HTTP/SSE patch solves it. + +## WP1 source refresh and scoped hardening + +Previous D: roadmap locked; execute reviewed source preparation. PR #3815 advanced to 76e07d181c48dca8c80167878381e1edb5642395 during investigation, including budget fixes and translated guide changes; carry fresh source, not old snapshots. Add a third dependent hardening layer only for source-proven preservation faults. MODIFY src/responses/parser.ts: retain recognized redacted-only and empty signed envelopes even when text is empty, preserving real boundary grouping. MODIFY src/bridge.ts: preserve signed block boundaries and redacted block positions identically in streaming/buffered output; signature fragments must be assembled at owning adapter boundary. MODIFY src/claude/outbound.ts only for exact block order/text restoration where current contract permits; do not invent a new signed continuity carrier or change hide-thinking policy. If hidden signed replay needs a new policy/carrier, explicitly defer that part rather than widening scope. Existing budget/guard contracts remain. + +Tests: existing tests/responses/anthropic-thinking-signature.test.ts or matching current domain file and Claude envelope tests get exact block-array roundtrip oracles; no fixture claims a live genuine signature. tests/responses/responses-compaction-routing.test.ts gets an established-history complete send_message_to_thread envelope across normal response, stored-ID continuation, v2 compaction_trigger and v1 compact endpoint, preserving real pairing and task content. If current fixture support makes a case impractical, record exact gap; no runtime seed repair. diff --git a/devlog/_plan/260907_axis3_protocol/011_candidate.md b/devlog/_plan/260907_axis3_protocol/011_candidate.md new file mode 100644 index 0000000000..7edc9aa1ce --- /dev/null +++ b/devlog/_plan/260907_axis3_protocol/011_candidate.md @@ -0,0 +1,9 @@ +# Combined candidate + +Source baseline: dev 137d6a727. Foundation carries #3815 through 76e07d181 with SB Yoon/Yumi commit trailers. Grok carries #3816 d5e0a9a2 and corrects SSE event overwrite/reset semantics, with Danh Thanh trailers. Added established-history external-task HTTP/continuation/compact fixtures without changing the missing-ID guard. Replay hardening preserves signed/opaque-only inputs and block ordering; signature updates replace previous values according to the SDK accumulator contract, and block closure waits for the next semantic event. + +Independent source reviews: Pauli scoped foundation PASS (18/18 files); Faraday Grok/seed PASS. Final Claude combined source audit and remote CI pending. Local suites/typecheck/build not run under user instruction. No live accounts invoked. + +Deferred: #3807 lacks raw failing current-version input; #3719 still needs live intended-Anthropic acceptance and controlled cache comparisons. Locally hidden text through Claude and legacy combined-envelope streaming order recovery are not claimed supported. Existing compatibility enforcement, hidden presentation, credential/admission and retention policies remain. + +Ordinary PR chain is an integration grouping requested by owner, with final combined CI first. Lower-layer runs only if it fails. Admin merge is authorized after accepted evidence. No GitHub native stack or fabricated check status. diff --git a/devlog/_plan/260907_axis3_protocol/020_delivery.md b/devlog/_plan/260907_axis3_protocol/020_delivery.md new file mode 100644 index 0000000000..050cc76d54 --- /dev/null +++ b/devlog/_plan/260907_axis3_protocol/020_delivery.md @@ -0,0 +1,7 @@ +# Publish and deliver verified manual chain + +Prerequisite: wp1 accepted-source review and successful final-head remote validation, or source-grounded defer outcome. Publish ordinary PRs targeting dev then the parent branch, using every repository template section. Bodies name source PRs, own layer-only diff, exact final combined CI evidence and explicit lower-layer CI deferral per owner instruction. Do not attest local CI. Preserve original contributor trailers in commits; admin merge with merge commits preserves their identity. + +Read live native-stack membership and head/base identity before merge. Never register a native stack. Parent merges to dev first; retain its branch, retarget child to dev, verify current head and ancestry. If integration tree changes materially, refresh final combined CI before landing. Use --admin and --match-head-commit exact guard. Do not merge into the parent branch by mistake. Refresh origin/dev and prove each merge SHA ancestor. Close superseded source PRs only after equivalent fix is actually landed, with credit and replacement link. Keep #3807 and #3719 open if real reproduction/cache acceptance remains unmet. No release or deployment. + +Record final PR URLs, source-to-delivery mapping, commit authors/trailers, CI run and exact SHA, review verdicts, remaining limitations and preserved dirty-work evidence. No fabricated status checks. Completion: every candidate has an honest disposition, accepted work is landed, unresolved diagnostics explicitly deferred under user direction. From c721b94494a9c6f12e059aec9f9c77f0a0ce0380 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Mon, 7 Sep 2026 07:48:43 +0900 Subject: [PATCH 4/6] fix(claude): report terminal closure buffer overflow once Distinguish termination entry from terminal delivery so closure-time overflow can release thinking and emit the bounded error without retrying closure. Prioritize collected errors over unfinished block serialization. Add eight real-budget closure-only overflow cases for EOF, failure, completion and incomplete terminals, including shared-budget collection. Tests and typecheck intentionally not run; parent owns final combined remote CI. Co-authored-by: SB Yoon <44089734+yansigit@users.noreply.github.com> Co-authored-by: Yumi --- src/claude/outbound.ts | 16 +++- .../claude-outbound.test.ts | 84 +++++++++++++++++++ 2 files changed, 97 insertions(+), 3 deletions(-) diff --git a/src/claude/outbound.ts b/src/claude/outbound.ts index 4dcdaa0eb7..ac06afac2d 100644 --- a/src/claude/outbound.ts +++ b/src/claude/outbound.ts @@ -234,6 +234,9 @@ export function responsesSseToAnthropicSse( let bufferBytes = 0; let started = false; let terminated = false; + // Starting termination can still throw while closing a block or emitting its + // terminal frame. Only a delivered terminal forbids the bounded overflow error. + let terminalDelivered = false; let cancelled = false; let blockIndex = 0; let open: OpenBlock | null = null; @@ -338,6 +341,7 @@ export function responsesSseToAnthropicSse( usage: anthropicUsage(usage, webSearchRequests), }); emit("message_stop", { type: "message_stop" }); + terminalDelivered = true; }; // upstreamDerived: transient upstream statuses become overloaded_error so the // Anthropic-SDK client retries with backoff; proxy-internal exceptions stay @@ -346,12 +350,15 @@ export function responsesSseToAnthropicSse( // resets reach the reader catch (no failed-tail relay) and stay api_error — // same as today, deliberate residual. const fail = (status: number, message: string, upstreamDerived = false, code?: string) => { - if (terminated) return; + // finish/fail sets terminated before closeOpenBlock. A closure-time + // allocation failure must still emit one error, without retrying closure. + if (terminated && (code !== "translation_buffer_limit" || terminalDelivered)) return; terminated = true; if (code === "translation_buffer_limit") { releaseThinkingBuffer(open); if (open?.callId) translatorBudget.closeCall(open.callId); open = null; + terminalDelivered = true; // No normal close frames are valid after overflow. Emit exactly one bounded // typed terminal without consulting the exhausted budget. controller.enqueue(encoder.encode(sseFrame("error", anthropicErrorBody( @@ -368,10 +375,12 @@ export function responsesSseToAnthropicSse( // Do not manufacture message_start before the terminal error. Earlier transport-only // pings remain valid and do not turn the failure into a partial message. emit("error", anthropicErrorBody(status, message, type, code)); + terminalDelivered = true; return; } closeOpenBlock(); emit("error", anthropicErrorBody(status, message, type, code)); + terminalDelivered = true; }; const handleFrame = (eventName: string, data: Rec) => { @@ -981,9 +990,10 @@ export async function collectAnthropicMessage( } finally { reader.releaseLock(); } - closeBlock(); - + // Error is authoritative. In particular, do not allocate another copy of an + // unfinished thinking block after the translator reported closure overflow. if (error) return error; + closeBlock(); return { id: `msg_${uuid()}`, type: "message", diff --git a/tests/claude-integration/claude-outbound.test.ts b/tests/claude-integration/claude-outbound.test.ts index 33faa2e839..78fcf0879c 100644 --- a/tests/claude-integration/claude-outbound.test.ts +++ b/tests/claude-integration/claude-outbound.test.ts @@ -342,6 +342,90 @@ describe("claude outbound SSE", () => { expect(reasoningReleased).toBe(reasoningCommitted); }); + for (const terminal of ["eof", "failed", "completed", "incomplete"] as const) { + for (const buffered of [false, true]) { + test(`closure-only reasoning overflow: ${terminal}, ${buffered ? "collector" : "stream"}`, async () => { + // All small deltas fit, including replacement reservations. Closing needs + // the retained 32 KiB text PLUS its base64 signature frame. For the + // collector allow its additional retained text in the same real budget. + const budget = createTestTranslatorBudget({ maxTurnBytes: (buffered ? 102 : 70) * 1024 }); + let reasoningBytes = 0; + let maxReasoningBytes = 0; + let reasoningBytesAtOverflow = -1; + const trackedBudget: TranslatorBudget = { + openCall: id => budget.openCall(id), + closeCall: id => budget.closeCall(id), + reserveTransient(bytes, scope) { + let reservation: ReturnType; + try { reservation = budget.reserveTransient(bytes, scope); } + catch (error) { reasoningBytesAtOverflow = reasoningBytes; throw error; } + return { + commitRetained() { + reservation.commitRetained(); + if (scope.kind === "reasoning") { + reasoningBytes += bytes; + maxReasoningBytes = Math.max(maxReasoningBytes, reasoningBytes); + } + }, + release: () => reservation.release(), + }; + }, + chargeRetained: (bytes, scope) => budget.chargeRetained(bytes, scope), + releaseRetained(bytes, scope) { + if (scope.kind === "reasoning") reasoningBytes -= bytes; + budget.releaseRetained(bytes, scope); + }, + observeAcceptedRequestCopy: bytes => budget.observeAcceptedRequestCopy(bytes), + observeExternallyCapped: (kind, bytes) => budget.observeExternallyCapped(kind, bytes), + snapshot: () => budget.snapshot(), + dispose: () => budget.dispose(), + }; + const text = "x".repeat(32 * 1024); + const frames = Array.from({ length: 128 }, () => sse("response.reasoning_text.delta", { + item_id: "rs_closure", content_index: 0, delta: text.slice(0, 256), + })); + if (terminal !== "eof") { + frames.push(sse(`response.${terminal}`, { response: terminal === "failed" + ? { error: { message: "upstream failure", status: 502 } } + : terminal === "incomplete" + ? { status: "incomplete", incomplete_details: { reason: "max_output_tokens" }, usage: {} } + : { status: "completed", usage: {} } })); + // Neither a repeated completion nor a later failure may add a terminal. + frames.push(sse("response.completed", { response: { status: "completed", usage: {} } })); + frames.push(sse("response.failed", { response: { error: { message: "late failure" } } })); + } + const stream = responsesSseToAnthropicSse(streamFromChunks(frames), "m", { + translatorBudget: trackedBudget, pingIntervalMs: 0, + }); + if (buffered) { + const message = await collectAnthropicMessage(stream, "m", trackedBudget); + expect(message).toMatchObject({ type: "error", error: { + type: "request_too_large", code: "translation_buffer_limit", + } }); + expect(message).not.toHaveProperty("content"); + expect(message).not.toHaveProperty("stop_reason"); + } else { + const events = await collectEvents(stream); + const deltas = events.filter(event => event.data.delta?.type === "thinking_delta"); + expect(deltas.map(event => event.data.delta.thinking).join("")).toBe(text); + expect(events.filter(event => event.name === "error")).toHaveLength(1); + expect(events.at(-1)).toMatchObject({ name: "error", data: { type: "error", error: { + type: "request_too_large", code: "translation_buffer_limit", + } } }); + expect(JSON.stringify(events.at(-1)).length).toBeLessThan(1024); + expect(events.some(event => event.name === "message_stop" || event.name === "message_delta" || event.name === "content_block_stop")).toBe(false); + expect(events.some(event => event.data.delta?.type === "signature_delta")).toBe(false); + } + // These prove failure happened after all text was retained, not while + // ingesting a delta, and the error path released the thinking reservation. + expect(reasoningBytesAtOverflow).toBe(text.length); + expect(maxReasoningBytes).toBeGreaterThanOrEqual(text.length); + expect(reasoningBytes).toBe(0); + expect(budget.snapshot().overflows).toBe(1); + }); + } + } + test("same-part deltas and index-free reasoning frames never get a separator", async () => { const samePart = [ sse("response.created", { response: { id: "resp_1", status: "in_progress" } }), From 76e667fbb6e06b58d84a92e45481b9f0db27a298 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Mon, 7 Sep 2026 07:59:20 +0900 Subject: [PATCH 5/6] test(responses): account for ordinary tool catalog guidance Combined Linux CI at c721b94494a9c6f12e059aec9f9c77f0a0ce0380 reported six messages where the seed fixtures expected five. Non-OpenAI chat translation prepends system tool-catalog guidance while compaction removes context.tools first. Explicitly require one system prefix advertising read_value on ordinary and stored-ID turns, and none on compact turns. Keep exact total length, ordered history content, original tool pairing and compact output assertions. Follow-up to synthetic #3807 coverage motivated by @DaveW001 and @stephen-drew; no original source patch copied. Source-only review and git diff --check passed. Local tests, typecheck and build NOT RUN by instruction. --- .../responses-compaction-routing.test.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/tests/responses/responses-compaction-routing.test.ts b/tests/responses/responses-compaction-routing.test.ts index 8faca32bef..a787024d95 100644 --- a/tests/responses/responses-compaction-routing.test.ts +++ b/tests/responses/responses-compaction-routing.test.ts @@ -1817,10 +1817,20 @@ describe("established-history external task input (#3807)", () => { return captured; } - function expectHistory(sent: Record, tail: Array> = []) { + function expectHistory( + sent: Record, + tail: Array> = [], + withToolCatalog = true, + ) { const messages = sent.messages as Array>; - expect(messages).toHaveLength(wireHistory.length + tail.length); - expect(messages).toMatchObject([...wireHistory, ...tail]); + // Ordinary non-OpenAI chat turns prepend catalog guidance; compaction removes + // context.tools before translation. Require that exact prefix, not arbitrary extras. + const prefix = withToolCatalog ? [{ + role: "system", + content: expect.stringContaining("Valid tool names for this turn are exactly `read_value`."), + }] : []; + expect(messages).toHaveLength(prefix.length + wireHistory.length + tail.length); + expect(messages).toMatchObject([...prefix, ...wireHistory, ...tail]); // Exactly one original pair: delivery must not acquire a synthesized tool identity. expect(messages.flatMap(message => message.tool_calls ?? [])).toEqual(wireHistory[1]!.tool_calls); expect(messages.filter(message => message.role === "tool")).toEqual([wireHistory[2]]); @@ -1882,7 +1892,7 @@ describe("established-history external task input (#3807)", () => { expect(captured).toHaveLength(1); expectHistory(captured[0]!, [ { role: "user", content: expect.stringContaining("CONTEXT CHECKPOINT COMPACTION") }, - ]); + ], false); expect(captured[0]!.tools).toBeUndefined(); expect(JSON.stringify(captured)).not.toContain("compaction_trigger"); if (version === "v2 trigger") { From 9b5b670db3e24ae5522c5d61e74c071c71257a26 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Mon, 7 Sep 2026 08:00:29 +0900 Subject: [PATCH 6/6] test(claude): correct replay and closure overflow oracles Match the canonical user string observed at parent combined head c721b9449 while preserving exact assistant block arrays. Capture closure-overflow output before collecting under the same unreset budget, separating concurrent ingestion pressure from closure-only failure. Assert all text, one bounded error, no success terminal, exact 32768-byte overflow boundary and no second overflow. No local tests or typecheck run. Co-authored-by: SB Yoon <44089734+yansigit@users.noreply.github.com> Co-authored-by: Yumi --- .../anthropic-thinking-signature.test.ts | 2 +- .../claude-outbound.test.ts | 41 +++++++++++-------- 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/tests/adapters/anthropic/anthropic-thinking-signature.test.ts b/tests/adapters/anthropic/anthropic-thinking-signature.test.ts index db8f489c4f..86ca82b412 100644 --- a/tests/adapters/anthropic/anthropic-thinking-signature.test.ts +++ b/tests/adapters/anthropic/anthropic-thinking-signature.test.ts @@ -399,7 +399,7 @@ describe("Claude / Responses / intended Anthropic replay fidelity", () => { const request = await adapter.buildRequest(parsed); const replay = JSON.parse(request.body as string) as { messages: Array<{ role: string; content: unknown }> }; expect(replay.messages).toEqual([ - { role: "user", content: [{ type: "text", text: "question" }] }, + { role: "user", content: "question" }, { role: "assistant", content: fixture.blocks }, { role: "user", content: [{ type: "tool_result", tool_use_id: tool.id, content: "result" }] }, ]); diff --git a/tests/claude-integration/claude-outbound.test.ts b/tests/claude-integration/claude-outbound.test.ts index 78fcf0879c..67380bb44a 100644 --- a/tests/claude-integration/claude-outbound.test.ts +++ b/tests/claude-integration/claude-outbound.test.ts @@ -346,9 +346,11 @@ describe("claude outbound SSE", () => { for (const buffered of [false, true]) { test(`closure-only reasoning overflow: ${terminal}, ${buffered ? "collector" : "stream"}`, async () => { // All small deltas fit, including replacement reservations. Closing needs - // the retained 32 KiB text PLUS its base64 signature frame. For the - // collector allow its additional retained text in the same real budget. - const budget = createTestTranslatorBudget({ maxTurnBytes: (buffered ? 102 : 70) * 1024 }); + // the retained 32 KiB text PLUS its base64 signature frame. Capture the + // generated stream before collection: concurrent collector retention can + // exceed a shared budget during ingestion instead of exercising closure. + // Collection below reuses this SAME budget, without resetting it. + const budget = createTestTranslatorBudget({ maxTurnBytes: 70 * 1024 }); let reasoningBytes = 0; let maxReasoningBytes = 0; let reasoningBytesAtOverflow = -1; @@ -397,24 +399,31 @@ describe("claude outbound SSE", () => { const stream = responsesSseToAnthropicSse(streamFromChunks(frames), "m", { translatorBudget: trackedBudget, pingIntervalMs: 0, }); - if (buffered) { - const message = await collectAnthropicMessage(stream, "m", trackedBudget); + const captured = buffered ? await new Response(stream).text() : undefined; + const capturedFrames = captured?.split("\n\n").filter(Boolean).map(frame => `${frame}\n\n`); + const events = await collectEvents(capturedFrames ? streamFromChunks(capturedFrames) : stream); + const deltas = events.filter(event => event.data.delta?.type === "thinking_delta"); + expect(deltas.map(event => event.data.delta.thinking).join("")).toBe(text); + expect(events.filter(event => event.name === "error")).toHaveLength(1); + expect(events.at(-1)).toMatchObject({ name: "error", data: { type: "error", error: { + type: "request_too_large", code: "translation_buffer_limit", + } } }); + expect(JSON.stringify(events.at(-1)).length).toBeLessThan(1024); + expect(events.some(event => event.name === "message_stop" || event.name === "message_delta" || event.name === "content_block_stop")).toBe(false); + expect(events.some(event => event.data.delta?.type === "signature_delta")).toBe(false); + if (capturedFrames) { + expect(capturedFrames.join("")).toBe(captured); + expect(reasoningBytesAtOverflow).toBe(text.length); + expect(reasoningBytes).toBe(0); + expect(budget.snapshot().overflows).toBe(1); + // Feed the actual generated frames, without inventing an error event or + // collecting one huge chunk that introduces a different buffer limit. + const message = await collectAnthropicMessage(streamFromChunks(capturedFrames), "m", trackedBudget); expect(message).toMatchObject({ type: "error", error: { type: "request_too_large", code: "translation_buffer_limit", } }); expect(message).not.toHaveProperty("content"); expect(message).not.toHaveProperty("stop_reason"); - } else { - const events = await collectEvents(stream); - const deltas = events.filter(event => event.data.delta?.type === "thinking_delta"); - expect(deltas.map(event => event.data.delta.thinking).join("")).toBe(text); - expect(events.filter(event => event.name === "error")).toHaveLength(1); - expect(events.at(-1)).toMatchObject({ name: "error", data: { type: "error", error: { - type: "request_too_large", code: "translation_buffer_limit", - } } }); - expect(JSON.stringify(events.at(-1)).length).toBeLessThan(1024); - expect(events.some(event => event.name === "message_stop" || event.name === "message_delta" || event.name === "content_block_stop")).toBe(false); - expect(events.some(event => event.data.delta?.type === "signature_delta")).toBe(false); } // These prove failure happened after all text was retained, not while // ingesting a delta, and the error path released the thinking reservation.