diff --git a/devlog/_plan/260907_lane_d/000_plan.md b/devlog/_plan/260907_lane_d/000_plan.md new file mode 100644 index 0000000000..97f6761463 --- /dev/null +++ b/devlog/_plan/260907_lane_d/000_plan.md @@ -0,0 +1,32 @@ +# Lane D release-train roadmap + +Satisfy-spec HOTL for delegated recommendations #16 → #17 → #15 → #21 → #25. +Goal: independently audited manual dependent PRs ready for main-session integration. +Scope: Claude outbound, display-name dialog, usage costs/overlays/summary, usage GUI, +plus directly required CLI/API/tests/docs. i18n files are append-only shared per main's +2026-09-07 correction. No other lane-owned files; no merge/release/main/preview. +No local test/typecheck/build/install. Remote ci.yml lane=all at final top SHA is +sole product verifier. Local source and diff checks are not execution evidence. +No user token or wall-clock bound supplied. Use existing repo/GitHub authorization. +Stop: top-head green with reviewer verdicts and layer PR/SHA evidence; otherwise +record exact DEFER/BLOCKED reasons without claiming implementation passes. +Memory/evidence: this unit plus .tmp/lane-d for review drafts. Unpublished security +material stays in scratch. Reclaim failed delegated work after two distinct agents; +other-lane file collision requires main coordination. + +## Dependency and publication map + +| Phase | Item | Outcome | Branch | +|---|---|---|---| +| 0 | Roadmap | Lock all diff plans before code | first layer docs | +| 1 | #3719 slice | Legacy redacted-before-signed SSE/JSON parity | codex/260907-d1-thinking | +| 2 | receipt guard | Prevent new intent while recovery is pending | codex/260907-d2-receipt | +| 3 | #3817 | Exact account identity resolves provider overlays | codex/260907-d3-account-prices | +| 4 | #3667 | Price editor + CLI + authoritative explicit zero | codex/260907-d4-price-editor | +| 5 | #3379 slice / #2956 | Inclusive custom usage bounds + GUI | codex/260907-d5-usage-ranges | +| 6 | readiness | Fresh top CI, screenshots and implementation audits | top branch | + +All lower subjects include [skip ci]; every push uses --no-verify. Native stack null. +Only phase 6 dispatches ci.yml lane=all; failures get Astra-high exact-log diagnosis, +fixes on their owning layer and rebase --update-refs cascade. Main alone merges. +#3719 and #3379 stay open. #2956 credit uses verified GitHub author identity. diff --git a/devlog/_plan/260907_lane_d/001_roadmap_audit.md b/devlog/_plan/260907_lane_d/001_roadmap_audit.md new file mode 100644 index 0000000000..c14a08d2da --- /dev/null +++ b/devlog/_plan/260907_lane_d/001_roadmap_audit.md @@ -0,0 +1,16 @@ +# Roadmap audit resolution + +Astra Herschel (01a07b2b-5148-73c0-a067-a13485ab32c9) returned +GO-WITH-FIXES with four bounded roadmap corrections. All are incorporated in +040_price_editor.md and 050_usage_ranges.md: register management routes; persist +manual-price display state; filter individual ledger entries before daily aggregation; +preserve apiKeyId and scan consistency; define milliseconds and explicit window bounds. + +Astra Dirac identified two thinking design blockers, recorded in 010 for re-audit: +item ownership and simultaneous reasoning/frame retention. Astra Ohm limits the account +mapping to evidenced Codex identities and requires consistent tier-namespace resolution. +The first implementation phase must finish those fold-backs before code changes. + +Only documentation has changed. Source references were inspected; product tests, +typecheck, builds and installs are NOT RUN by delegation instruction. Product acceptance +remains open until top-head Cross-platform CI executes lane=all. diff --git a/devlog/_plan/260907_lane_d/010_thinking.md b/devlog/_plan/260907_lane_d/010_thinking.md new file mode 100644 index 0000000000..f2bb7e18bf --- /dev/null +++ b/devlog/_plan/260907_lane_d/010_thinking.md @@ -0,0 +1,26 @@ +# 010 Thinking ordering +MODIFY src/claude/outbound.ts ensureBlock/closeOpenBlock and reasoning done. +Before: thinking start/deltas are emitted immediately; done closes thinking then red. +After: retain already-budgeted thinking text, defer its start/index/delta until close; +reasoning done emits red blocks before flushing pending signed thinking. Preserve text +and tool order, hidden env.txt non-disclosure, genuine signature and budget release. +MODIFY tests/claude-integration/claude-outbound.test.ts: compare collected SSE against +literal expected content and JSON for combined envelopes with preceding deltas, +multiple summary parts/red blocks, text prefix, signed-only, red-only. Check sequential +non-overlapping block indices and cancellation/overflow existing assertions. +Independent Astra audit must resolve streaming latency and allocation implications. + +Verification: NOT RUN locally by user instruction; focused tests execute in final top-head Cross-platform CI. + +## A audit fold-back +Astra Dirac found two blockers: unmatched-item reordering and closure memory overlap. +Track bounded reasoningItemKey separately from part identity; flush on changed explicit +item identity, and close unrelated pending thinking before another item's red blocks. +Only same identity (including both omitted) reorders red before pending thinking. +Retain thinkingBuf through signature emission as before; +queued frame budget stays authoritative, never weakened. Add near-limit valid control, +shared-budget collector control, overflow/cancel regressions. Deferred thinking is an +accepted visible-latency tradeoff; text/tool frames remain live with incremental-reader +coverage. Late done after a different emitted block cannot reorder earlier content. + +Re-audit Dirac: VERDICT PASS, blockers=0. Accept tight artificial budget capacity reduction; retain original overflow assertions and production limits. diff --git a/devlog/_plan/260907_lane_d/020_receipt.md b/devlog/_plan/260907_lane_d/020_receipt.md new file mode 100644 index 0000000000..97568c39a8 --- /dev/null +++ b/devlog/_plan/260907_lane_d/020_receipt.md @@ -0,0 +1,17 @@ +# 020 Display-name receipt recovery +MODIFY gui/src/components/ModelDisplayNameDialog.tsx. +Before: input/reset enabled whenever saving=false; input onEdit clears recovery. +After: new mutationOutcomeUnknown prop from Models.tsx recovery.confirmed===false +disables draft editing and reset, submit retains +read/retry action. Handler guards prevent synthetic events bypassing disabled controls. +Close/cancel stays available. This is bounded UI recovery, not server request ordering. +MODIFY gui/tests/models-display-name-editor.test.tsx: unknown receipt cannot replace intent; retry recovers; confirmed saved:true +and ordinary validation error remain +editable. Screenshot changed disabled input/reset with retry available. + +Verification: NOT RUN locally by user instruction; focused tests execute in final top-head Cross-platform CI. + +Implementation: unknown outcome guards input/reset handlers and submit, and focuses Retry +when saving fails without a receipt. Saved:true remains editable. Transport/body failure +matrix attempts a replacement intent and asserts no second PUT before read-only retry. +Astra Herschel plan verdict PASS. Screenshots and product execution await top CI artifact. diff --git a/devlog/_plan/260907_lane_d/030_account_prices.md b/devlog/_plan/260907_lane_d/030_account_prices.md new file mode 100644 index 0000000000..4806657cdb --- /dev/null +++ b/devlog/_plan/260907_lane_d/030_account_prices.md @@ -0,0 +1,13 @@ +# 030 Account price identity +MODIFY src/usage/user-cost-overlays.ts registry refresh and signature/version. +Before: configured provider set and overlay rows only. +After: exact account identifiers/log labels from config mapped to established provider +identity. Include mapping in signature for memo and aggregate cache invalidation. +MODIFY src/usage/cost.ts resolveMatchedPrice: exact configured namespace and exact +user overlay precede account identity; unresolved suffix is never guessed/stripped. +MODIFY tests/usage/usage-cost.test.ts or existing provider-overlay tests: custom account +id, qualified id, stable log label, configured collision, unrelated hyphenated provider, +account rename/removal invalidation. Account aliases never become identity authority. +Audit determines precise supported historical labels from actual producer evidence. + +Verification: NOT RUN locally by user instruction; focused tests execute in final top-head Cross-platform CI. diff --git a/devlog/_plan/260907_lane_d/040_price_editor.md b/devlog/_plan/260907_lane_d/040_price_editor.md new file mode 100644 index 0000000000..4628710d0d --- /dev/null +++ b/devlog/_plan/260907_lane_d/040_price_editor.md @@ -0,0 +1,23 @@ +# 040 Manual price editor +MODIFY src/usage/cost.ts userOverlayMatch: valid operator all-zero row returns user +price, while generated catalog zeros keep unknown/fallback semantics. +MODIFY src/server/management/model-routes.ts: exact-provider model-costs GET/PUT, +validate four finite nonnegative bounded rates or null reset, preserve siblings, +rollback on persist failure, no routing/catalog mutation required for price-only edits. +MODIFY src/cli/models-runtime.ts, models-runtime-subcommands.ts and capabilities.ts: +models set-price provider/model --input N --output N [--cache-read N --cache-write N] +or --auto. GET for show and PUT for set/reset through existing management client. +ADD gui/src/components/ModelPriceDialog.tsx; MODIFY Models.tsx and models-shared.ts +only as needed: edit action, load exact saved override, inputs 4 rates USD/1M, +save/reset and manual indicator. Reuse dialog/fetch/i18n patterns. All locale keys +append-only pricing.override.*. Add endpoint, CLI, estimator and GUI regressions; +register new test files in both append-only layout manifests. Public docs and generated +CLI surface map mirror actual capability entries; source-generation commands NOT RUN +locally so map is updated by its source contract without claiming verification. + +Verification: NOT RUN locally by user instruction; focused tests execute in final top-head Cross-platform CI. + +A fold-back: add GET/PUT entries in src/server/management/route-registry.ts. +Reuse providerModelCostsConfigError. GET returns sanitized per-provider modelCosts map; +Models owns a typed map loaded with catalog or dedicated GET, so manual badges survive +reload. CLI omitted cache-read/cache-write rates default to zero, explicitly documented. diff --git a/devlog/_plan/260907_lane_d/050_usage_ranges.md b/devlog/_plan/260907_lane_d/050_usage_ranges.md new file mode 100644 index 0000000000..35b6758a76 --- /dev/null +++ b/devlog/_plan/260907_lane_d/050_usage_ranges.md @@ -0,0 +1,30 @@ +# 050 Custom usage windows +REIMPLEMENT range slice from PR #2956 with Manson2438 credit; do not carry offline reports. +ADD src/usage/time-range.ts strict timestamp parser and inclusive since/until bounds; +MODIFY summary.ts accumulator interface to support bounded windows without poisoning +preset daily aggregates. Use stream ledger filtering for partial days if compact daily +partitions cannot answer exact boundaries. Reject malformed/reversed bounds at API/CLI. +MODIFY src/server/management/logs-usage-routes.ts custom-window path before preset cache, +stream/filter into isolated accumulator preserving surface/provider/model and truncation +metadata. Do not persist normalized ledger rows. Include bounds in response. +MODIFY CLI observe/capabilities usage flags and GUI Usage.tsx custom datetime inputs, +independent draft/applied bounds, cache key includes bounds, grid anchored to effective +window, clear returns to preset. All locale keys append-only usage.range.*. +Tests: inclusive boundaries, partial same-day, reversed/invalid, empty ledger, existing +provider/model/surface filters, preset cache after custom query; GUI apply/clear/errors. +Public API/CLI docs describe epoch/ISO contract and local datetime conversion. + +Verification: NOT RUN locally by user instruction; focused tests execute in final top-head Cross-platform CI. + +A fold-back: immutable window option on createUsageSummaryAccumulator; add() checks +inclusive bounds AFTER recording whole-scan snapshot timestamps but BEFORE partitioning. +clone preserves window. summarize uses window endpoint for grid, actual now for generatedAt; +retain 366-day grid cap. Custom queries use isolated row-unique accumulator via existing +getFilteredUsageAggregate with window in key. Reuse overlay/timezone revision restart +and scanner identity controls. Preserve apiKeyId and current filter echo alongside all +other filters. USAGE_RANGES remains preset-only; response range stays selected preset +with customWindow:true, since/until explicit bounds (bounds override preset). API accepts +integer epoch milliseconds or full ISO-8601 with timezone only; require both bounds; +reject negative/unsafe/date-invalid/reversed, never normalize overflow dates. +MODIFY src/cli/usage-report.ts heading prints since/until for customWindow responses. +GUI datetime values become epoch ms locally; end selected minute includes 59.999s. diff --git a/gui/src/components/ModelDisplayNameDialog.tsx b/gui/src/components/ModelDisplayNameDialog.tsx index 2a57ff8279..09854e104d 100644 --- a/gui/src/components/ModelDisplayNameDialog.tsx +++ b/gui/src/components/ModelDisplayNameDialog.tsx @@ -10,6 +10,7 @@ interface ModelDisplayNameDialogProps { saving: boolean; requestError: string | null; currentNamePending?: boolean; + mutationOutcomeUnknown?: boolean; onRetry?: () => void; onEdit?: () => void; onSave: (displayName: string) => void; @@ -28,6 +29,7 @@ export default function ModelDisplayNameDialog({ saving, requestError, currentNamePending = false, + mutationOutcomeUnknown = false, onRetry, onEdit, onSave, @@ -37,6 +39,7 @@ export default function ModelDisplayNameDialog({ const t = useT(); const dialogRef = useRef(null); const inputRef = useRef(null); + const submitRef = useRef(null); const wasSavingRef = useRef(saving); const titleId = useId(); const helpId = useId(); @@ -55,8 +58,11 @@ export default function ModelDisplayNameDialog({ useEffect(() => { const saveFailed = wasSavingRef.current && !saving && Boolean(requestError); wasSavingRef.current = saving; - if (saveFailed) inputRef.current?.focus(); - }, [requestError, saving]); + if (saveFailed) { + if (mutationOutcomeUnknown) submitRef.current?.focus(); + else inputRef.current?.focus(); + } + }, [requestError, saving, mutationOutcomeUnknown]); // Parent replaces this snapshot only after a confirmed mutation, not typing or polling. // Adjust before committing children, preserving the mounted dialog and its focus refs. @@ -102,6 +108,7 @@ export default function ModelDisplayNameDialog({ event.preventDefault(); if (saving) return; if (onRetry) { onRetry(); return; } + if (mutationOutcomeUnknown) return; const nextValidationKey = modelDisplayNameValidationKey(draft); setValidationKey(nextValidationKey); if (!nextValidationKey) onSave(draft.trim()); @@ -137,8 +144,9 @@ export default function ModelDisplayNameDialog({ placeholder={t("models.displayNamePlaceholder")} aria-describedby={`${helpId}${visibleError ? ` ${errorId}` : ""}`} aria-invalid={validationError ? true : undefined} - disabled={saving} + disabled={saving || mutationOutcomeUnknown} onChange={event => { + if (saving || mutationOutcomeUnknown) return; onEdit?.(); setDraft(event.target.value); setValidationKey(null); @@ -157,15 +165,15 @@ export default function ModelDisplayNameDialog({ - diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index c342866d7e..fc8db5626e 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -2643,6 +2643,7 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; saving={displayNameSaving} requestError={displayNameRequestError} currentNamePending={displayNameCurrentPending} + mutationOutcomeUnknown={displayNameRecovery?.confirmed === false} onRetry={displayNameRecovery ? () => void saveDisplayName(displayNameRecovery.value) : undefined} onEdit={() => setDisplayNameRecovery(null)} onSave={value => void saveDisplayName(value)} diff --git a/gui/tests/models-display-name-editor.test.tsx b/gui/tests/models-display-name-editor.test.tsx index b0656391ed..9d67f986e0 100644 --- a/gui/tests/models-display-name-editor.test.tsx +++ b/gui/tests/models-display-name-editor.test.tsx @@ -448,8 +448,16 @@ describe("Models dashboard discovered display name integration", () => { expect(currentNameText()).toContain("Current name unavailable until refresh"); expect(currentNameText()).not.toContain("Your name"); expect(container.textContent).toContain("The change may have been saved"); + expect(dialogInput().disabled).toBe(true); + expect(dialogButton("Reset name").disabled).toBe(true); expect(dialogButton("Retry").disabled).toBe(false); expect(dialogButton("Cancel").disabled).toBe(false); + await act(async () => { + setInputValue(dialogInput(), "Replacement intent"); + dialogButton("Reset name").dispatchEvent(new testWindow.MouseEvent("click", { bubbles: true })); + }); + expect(dialogButton("Retry").disabled).toBe(false); + expect(mutationBodies).toHaveLength(1); await act(async () => container.querySelector("dialog form")!.dispatchEvent( new testWindow.Event("submit", { bubbles: true, cancelable: true }), )); @@ -522,12 +530,12 @@ describe("Models dashboard discovered display name integration", () => { if (stage === "reload") expect(seenSignals[1]).toBe(seenSignals[0]); await act(async () => deadline.abort(new DOMException("Timed out", "TimeoutError"))); await flush(); - expect(dialogInput().disabled).toBe(false); + expect(dialogInput().disabled).toBe(stage === "mutation"); expect(dialogButton("Cancel").disabled).toBe(false); expect(dialogInput().value).toBe("Possibly saved"); expect(container.textContent).toContain(stage === "mutation" ? "The change may have been saved" : "The change was saved"); - expect(testWindow.document.activeElement).toBe(dialogInput()); + expect(testWindow.document.activeElement).toBe(stage === "mutation" ? dialogButton("Retry") : dialogInput()); stall = false; if (descriptor) Object.defineProperty(AbortSignal, "timeout", descriptor); await act(async () => dialogButton("Retry").click()); diff --git a/src/claude/outbound.ts b/src/claude/outbound.ts index 1975d5b390..d4e7758ee0 100644 --- a/src/claude/outbound.ts +++ b/src/claude/outbound.ts @@ -215,6 +215,8 @@ interface OpenBlock { callId?: string; /** Last fixed-size reasoning identity (item + summary/content index) seen by this block. */ reasoningPartKey?: string; + /** Fixed-size item identity; missing IDs only match other missing IDs. */ + reasoningItemKey?: string; thinkingBuf?: string; thinkingBufBytes?: number; reasoningSig?: string; @@ -308,6 +310,20 @@ export function responsesSseToAnthropicSse( open.webSearchArgsEmitted = true; } if (open.kind === "thinking") { + // Delay the index and all thinking frames until closure so a matching + // done envelope can put its redacted blocks first. The existing buffer + // remains charged through signature emission, including queued frames. + open.index = blockIndex++; + emit("content_block_start", { + type: "content_block_start", index: open.index, + content_block: { type: "thinking", thinking: "", signature: "" }, + }); + if (open.thinkingBuf) { + emit("content_block_delta", { + type: "content_block_delta", index: open.index, + delta: { type: "thinking_delta", thinking: open.thinkingBuf }, + }); + } const signature = open.reasoningSig ?? encodeReasoningEnvelope({ txt: open.thinkingBuf ?? "" }, translatorBudget); emit("content_block_delta", { type: "content_block_delta", index: open.index, @@ -323,12 +339,13 @@ export function responsesSseToAnthropicSse( ensureStarted(); if (open && open.kind === kind) return; closeOpenBlock(); + if (kind === "thinking") { + open = { kind, index: -1, thinkingBuf: "", thinkingBufBytes: 0 }; + return; + } const index = blockIndex++; - const contentBlock: Rec = kind === "text" - ? { type: "text", text: "" } - : { type: "thinking", thinking: "", signature: "" }; - emit("content_block_start", { type: "content_block_start", index, content_block: contentBlock }); - open = { kind, index, thinkingBuf: "", thinkingBufBytes: 0 }; + emit("content_block_start", { type: "content_block_start", index, content_block: { type: "text", text: "" } }); + open = { kind, index }; }; const finish = (stopReason: string, usage: unknown) => { if (terminated) return; @@ -405,11 +422,13 @@ export function responsesSseToAnthropicSse( case "response.reasoning_summary_text.delta": case "response.reasoning_text.delta": { if (typeof data.delta !== "string" || data.delta.length === 0) break; + const itemKey = boundedReasoningIdentity(data.item_id); + if (open?.kind === "thinking" && open.reasoningItemKey !== itemKey) closeOpenBlock(); ensureBlock("thinking"); const active = open; if (!active || active.kind !== "thinking") break; // The JSON path joins reasoning summary/content parts with "\n\n" - // (responsesJsonToAnthropicMessage); mirror that at part and item boundaries + // (responsesJsonToAnthropicMessage); mirror that at part boundaries // so multi-part summaries do not glue into one run-on paragraph. Frames // without part indices produce a constant key and never get a separator. const slot = eventName === "response.reasoning_summary_text.delta" @@ -418,7 +437,7 @@ export function responsesSseToAnthropicSse( // Upstream string metadata can be arbitrarily large. Hash strings into fixed-size // components while retaining item and part equality, rather than dropping item_id and // accidentally joining distinct malformed reasoning items. - const partKey = `${boundedReasoningIdentity(data.item_id)}:${slot}`; + const partKey = `${itemKey}:${slot}`; const needsPartSeparator = active.reasoningPartKey !== undefined && active.reasoningPartKey !== partKey; const appended = `${needsPartSeparator ? "\n\n" : ""}${data.delta}`; @@ -436,17 +455,8 @@ export function responsesSseToAnthropicSse( reservation.release(); throw error; } - if (needsPartSeparator) { - emit("content_block_delta", { - type: "content_block_delta", index: active.index, - delta: { type: "thinking_delta", thinking: "\n\n" }, - }); - } + active.reasoningItemKey = itemKey; active.reasoningPartKey = partKey; - emit("content_block_delta", { - type: "content_block_delta", index: active.index, - delta: { type: "thinking_delta", thinking: data.delta }, - }); break; } case "response.output_item.added": { @@ -563,20 +573,27 @@ export function responsesSseToAnthropicSse( const encrypted = typeof item.encrypted_content === "string" ? item.encrypted_content : ""; const env = encrypted ? decodeReasoningEnvelope(encrypted, translatorBudget) : null; const red = env?.red ?? []; - if (env?.sig && open?.kind !== "thinking") ensureBlock("thinking"); - if (open?.kind === "thinking") { - if (env?.sig) open.reasoningSig = env.sig; + const itemKey = boundedReasoningIdentity(item.id); + // A late/unrelated done cannot reorder or sign another item's text. + if (open?.kind === "thinking" && open.reasoningItemKey !== itemKey) { closeOpenBlock(); } if (red.length > 0) { ensureStarted(); - closeOpenBlock(); + if (open?.kind !== "thinking") 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 } }); emit("content_block_stop", { type: "content_block_stop", index: idx }); } + if (env?.sig && open?.kind !== "thinking") { + ensureBlock("thinking"); + } + if (open?.kind === "thinking") { + if (env?.sig) open.reasoningSig = env.sig; + closeOpenBlock(); + } } break; } diff --git a/tests/claude-integration/claude-outbound.test.ts b/tests/claude-integration/claude-outbound.test.ts index 67380bb44a..72f7a22bdf 100644 --- a/tests/claude-integration/claude-outbound.test.ts +++ b/tests/claude-integration/claude-outbound.test.ts @@ -483,8 +483,8 @@ describe("claude outbound SSE", () => { responsesSseToAnthropicSse(streamFromChunks([upstream]), "m"), "m", ) as Record; - expect(msg.content.find((b: Record) => b.type === "thinking").thinking) - .toBe("AB\n\nC\n\nD"); + expect(msg.content.filter((b: Record) => b.type === "thinking") + .map((b: Record) => b.thinking)).toEqual(["AB", "C\n\nD"]); }); test("malformed array reasoning identities retain distinct boundaries", async () => { @@ -505,8 +505,8 @@ describe("claude outbound SSE", () => { responsesSseToAnthropicSse(streamFromChunks([upstream]), "m"), "m", ) as Record; - expect(msg.content.find((b: Record) => b.type === "thinking").thinking) - .toBe("A\n\nB"); + expect(msg.content.filter((b: Record) => b.type === "thinking") + .map((b: Record) => b.thinking)).toEqual(["A", "B"]); }); test("data-only Responses frames infer event names from payload types", async () => { @@ -1304,3 +1304,301 @@ describe("sanitizeWebSearchInput (#381)", () => { expect(events[3].data.delta).toEqual({ type: "signature_delta", signature: "sig-only" }); }); }); + +describe("deferred Claude thinking order", () => { + const fixtures = [ + { + name: "combined envelope with preceding multipart deltas", + envelope: { sig: "signed-visible", red: ["opaque-1", "opaque-2"], txt: "hidden-only" }, + deltas: [ + sse("response.reasoning_summary_text.delta", { item_id: "rs", summary_index: 0, delta: "Fir" }), + sse("response.reasoning_summary_text.delta", { item_id: "rs", summary_index: 0, delta: "st" }), + sse("response.reasoning_summary_text.delta", { item_id: "rs", summary_index: 1, delta: "Second" }), + sse("response.reasoning_text.delta", { item_id: "rs", content_index: 0, delta: "Third" }), + ], + summary: [{ text: "First" }, { text: "Second" }], + content: [{ text: "Third" }], + expected: [ + { type: "text", text: "prefix" }, + { type: "redacted_thinking", data: "opaque-1" }, + { type: "redacted_thinking", data: "opaque-2" }, + { type: "thinking", thinking: "First\n\nSecond\n\nThird", signature: "signed-visible" }, + ], + }, + { + name: "combined envelope without deltas keeps signed thinking empty", + envelope: { sig: "signed-empty", red: ["opaque-1", "opaque-2"], txt: "hidden-only" }, + deltas: [], summary: [], content: [], + expected: [ + { type: "text", text: "prefix" }, + { type: "redacted_thinking", data: "opaque-1" }, + { type: "redacted_thinking", data: "opaque-2" }, + { type: "thinking", thinking: "", signature: "signed-empty" }, + ], + }, + { + name: "signed-only envelope", + envelope: { sig: "signed-only", txt: "hidden-only" }, + deltas: [], summary: [], content: [], + expected: [ + { type: "text", text: "prefix" }, + { type: "thinking", thinking: "", signature: "signed-only" }, + ], + }, + { + name: "red-only envelope", + envelope: { red: ["opaque-1", "opaque-2"], txt: "hidden-only" }, + deltas: [], summary: [], content: [], + expected: [ + { type: "text", text: "prefix" }, + { type: "redacted_thinking", data: "opaque-1" }, + { type: "redacted_thinking", data: "opaque-2" }, + ], + }, + ]; + + for (const fixture of fixtures) { + test(`${fixture.name}: JSON and collected SSE match literal content`, async () => { + const item = { + type: "reasoning", id: "rs", summary: fixture.summary, content: fixture.content, + encrypted_content: encodeReasoningEnvelope(fixture.envelope), + }; + const frames = [ + sse("response.output_text.delta", { delta: "prefix" }), + ...fixture.deltas, + sse("response.output_item.done", { item }), + sse("response.completed", { response: { status: "completed" } }), + ]; + const json = responsesJsonToAnthropicMessage({ status: "completed", output: [ + { type: "message", content: [{ type: "output_text", text: "prefix" }] }, item, + ] }, "m"); + const message = await collectAnthropicMessage( + responsesSseToAnthropicSse(streamFromChunks(frames), "m", { pingIntervalMs: 0 }), "m", + ); + expect(json.content).toEqual(fixture.expected); + expect(message.content).toEqual(fixture.expected); + expect(JSON.stringify(message)).not.toContain("hidden-only"); + expect(message.stop_reason).toBe("end_turn"); + + const events = await collectEvents(responsesSseToAnthropicSse(streamFromChunks(frames), "m", { pingIntervalMs: 0 })); + let active: number | null = null; + let next = 0; + for (const event of events) { + if (event.name === "content_block_start") { + expect(active).toBeNull(); + expect(event.data.index).toBe(next); + active = next++; + } else if (event.name === "content_block_delta" || event.name === "content_block_stop") { + expect(active).not.toBeNull(); + expect(event.data.index).toBe(active); + if (event.name === "content_block_stop") active = null; + } + } + expect(active).toBeNull(); + expect(next).toBe(fixture.expected.length); + expect(events.at(-1)?.name).toBe("message_stop"); + }); + } + + for (const [deltaId, doneId, matching] of [ + ["a", "a", true], ["a", "b", false], + [undefined, undefined, true], ["a", undefined, false], [undefined, "b", false], + ] as const) { + test(`done item boundary ${String(deltaId)} -> ${String(doneId)}`, async () => { + const message = await collectAnthropicMessage(responsesSseToAnthropicSse(streamFromChunks([ + sse("response.reasoning_text.delta", { item_id: deltaId, delta: "A" }), + sse("response.output_item.done", { item: { + type: "reasoning", id: doneId, + encrypted_content: encodeReasoningEnvelope({ sig: "done-signature", red: ["done-red"] }), + } }), + sse("response.completed", { response: { status: "completed" } }), + ]), "m", { pingIntervalMs: 0 }), "m"); + expect(message.content).toEqual(matching ? [ + { type: "redacted_thinking", data: "done-red" }, + { type: "thinking", thinking: "A", signature: "done-signature" }, + ] : [ + { type: "thinking", thinking: "A", signature: "ocxr1:eyJ0eHQiOiJBIn0=" }, + { type: "redacted_thinking", data: "done-red" }, + { type: "thinking", thinking: "", signature: "done-signature" }, + ]); + }); + } + + for (const [firstId, secondId] of [["a", "b"], ["a", undefined], [undefined, "b"]] as const) { + test(`delta item boundary ${String(firstId)} -> ${String(secondId)} flushes first`, async () => { + const message = await collectAnthropicMessage(responsesSseToAnthropicSse(streamFromChunks([ + sse("response.reasoning_text.delta", { item_id: firstId, delta: "A" }), + sse("response.reasoning_text.delta", { item_id: secondId, delta: "B" }), + sse("response.output_item.done", { item: { + type: "reasoning", id: secondId, + encrypted_content: encodeReasoningEnvelope({ sig: "second-signature", red: ["second-red"] }), + } }), + sse("response.completed", { response: { status: "completed" } }), + ]), "m", { pingIntervalMs: 0 }), "m"); + expect(message.content).toEqual([ + { type: "thinking", thinking: "A", signature: "ocxr1:eyJ0eHQiOiJBIn0=" }, + { type: "redacted_thinking", data: "second-red" }, + { type: "thinking", thinking: "B", signature: "second-signature" }, + ]); + }); + } + + test("separate red and signed items preserve their stream order", async () => { + const items = [ + { type: "reasoning", id: "red", encrypted_content: encodeReasoningEnvelope({ red: ["first-red"] }) }, + { type: "reasoning", id: "signed", summary: [{ text: "A" }], encrypted_content: encodeReasoningEnvelope({ sig: "sig-A" }) }, + { type: "reasoning", id: "red-last", encrypted_content: encodeReasoningEnvelope({ red: ["last-red"] }) }, + ]; + const message = await collectAnthropicMessage(responsesSseToAnthropicSse(streamFromChunks([ + sse("response.output_item.done", { item: items[0] }), + sse("response.reasoning_text.delta", { item_id: "signed", delta: "A" }), + sse("response.output_item.done", { item: items[1] }), + sse("response.output_item.done", { item: items[2] }), + sse("response.completed", { response: { status: "completed" } }), + ]), "m", { pingIntervalMs: 0 }), "m"); + const expected = [ + { type: "redacted_thinking", data: "first-red" }, + { type: "thinking", thinking: "A", signature: "sig-A" }, + { type: "redacted_thinking", data: "last-red" }, + ]; + expect(message.content).toEqual(expected); + expect(responsesJsonToAnthropicMessage({ output: items }, "m").content).toEqual(expected); + }); + + for (const genuineSignature of [false, true]) { + for (const buffered of [false, true]) { + test(`near-limit valid thinking: ${genuineSignature ? "genuine" : "fallback"}, ${buffered ? "shared collector" : "stream"}`, async () => { + // The live collector also retains the emitted content/signature, unlike + // the stream-only near-limit control. Both use one budget throughout. + // Shared encoding admission needs ~254 KiB for the 20 KiB fallback + // including source and queued text; genuine signatures bypass encoding. + const maxTurnBytes = (genuineSignature ? (buffered ? 128 : 70) : (buffered ? 320 : 280)) * 1024; + const budget = createTestTranslatorBudget({ maxTurnBytes }); + const text = "x".repeat((genuineSignature ? 32 : 20) * 1024); + const frames = Array.from({ length: text.length / 256 }, () => sse("response.reasoning_text.delta", { + item_id: "rs_control", content_index: 0, delta: text.slice(0, 256), + })); + frames.push(sse("response.output_item.done", { item: { + type: "reasoning", id: "rs_control", + ...(genuineSignature ? { encrypted_content: encodeReasoningEnvelope({ sig: "control-signature", red: ["control-red"] }) } : {}), + } })); + frames.push(sse("response.completed", { response: { status: "completed" } })); + const stream = responsesSseToAnthropicSse(streamFromChunks(frames), "m", { + translatorBudget: budget, pingIntervalMs: 0, + }); + if (buffered) { + // Collect live with the exact translator budget; no capture/reset/new budget. + const message = await collectAnthropicMessage(stream, "m", budget); + expect(message.type).toBe("message"); + const content = message.content as Record[]; + expect(content.map(block => block.type)).toEqual(genuineSignature + ? ["redacted_thinking", "thinking"] : ["thinking"]); + const thinking = content.at(-1)!; + expect(thinking.thinking).toBe(text); + if (genuineSignature) expect(thinking.signature).toBe("control-signature"); + else expect(decodeReasoningEnvelope(thinking.signature as string)?.txt).toBe(text); + expect(message.stop_reason).toBe("end_turn"); + } else { + const events = await collectEvents(stream); + expect(events.filter(event => event.data.delta?.type === "thinking_delta") + .map(event => event.data.delta.thinking).join("")).toBe(text); + const signature = events.find(event => event.data.delta?.type === "signature_delta")?.data.delta.signature; + if (genuineSignature) expect(signature).toBe("control-signature"); + else expect(decodeReasoningEnvelope(signature)?.txt).toBe(text); + expect(events.at(-1)?.name).toBe("message_stop"); + expect(events.some(event => event.name === "error")).toBe(false); + } + expect(budget.snapshot().overflows).toBe(0); + expect(budget.snapshot().highWaterBytes).toBeGreaterThan(60 * 1024); + expect(budget.snapshot().highWaterBytes).toBeLessThanOrEqual(maxTurnBytes); + }); + } + } + + test("cancelling deferred thinking releases its buffer and cancels upstream", async () => { + const budget = createTestTranslatorBudget(); + const text = "pending".repeat(1024); + let signalConsumed!: () => void; + const consumed = new Promise(resolve => { signalConsumed = resolve; }); + let sent = false; + let cancelReason: unknown; + const upstream = new ReadableStream({ + pull(controller) { + if (sent) { + // A second read proves the first delta has passed through handleFrame. + signalConsumed(); + return; + } + sent = true; + controller.enqueue(new TextEncoder().encode(sse("response.reasoning_text.delta", { + item_id: "pending", delta: text, + }))); + }, + cancel(reason) { cancelReason = reason; }, + }, { highWaterMark: 0 }); + const stream = responsesSseToAnthropicSse(upstream, "m", { translatorBudget: budget, pingIntervalMs: 0 }); + await consumed; + expect(budget.snapshot().currentBytes).toBeGreaterThanOrEqual(text.length); + await stream.cancel("client cancelled"); + expect(cancelReason).toBe("client cancelled"); + expect(budget.snapshot().currentBytes).toBe(0); + expect(budget.snapshot().overflows).toBe(0); + }); + + test("thinking waits for closure while text and tool arguments remain incremental; late done stays late", async () => { + let controller!: ReadableStreamDefaultController; + const upstream = new ReadableStream({ start(value) { controller = value; } }); + const reader = responsesSseToAnthropicSse(upstream, "m", { pingIntervalMs: 0 }).getReader(); + const send = (name: string, data: Record) => controller.enqueue(new TextEncoder().encode(sse(name, data))); + const next = async () => { + const { done, value } = await reader.read(); + expect(done).toBe(false); + return JSON.parse(new TextDecoder().decode(value).split("\ndata: ")[1]!.trim()) as Record; + }; + try { + send("response.reasoning_text.delta", { item_id: "early", delta: "A" }); + expect(await next()).toMatchObject({ type: "message_start" }); + expect(await next()).toEqual({ type: "ping" }); + // An explicit transport checkpoint proves no thinking start/index/text escaped. + send("response.heartbeat", {}); + expect(await next()).toEqual({ type: "ping" }); + + send("response.output_text.delta", { delta: "live-1" }); + expect(await next()).toMatchObject({ type: "content_block_start", index: 0, content_block: { type: "thinking" } }); + expect(await next()).toEqual({ type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "A" } }); + expect(await next()).toEqual({ type: "content_block_delta", index: 0, delta: { type: "signature_delta", signature: "ocxr1:eyJ0eHQiOiJBIn0=" } }); + expect(await next()).toEqual({ type: "content_block_stop", index: 0 }); + expect(await next()).toMatchObject({ type: "content_block_start", index: 1, content_block: { type: "text" } }); + expect(await next()).toEqual({ type: "content_block_delta", index: 1, delta: { type: "text_delta", text: "live-1" } }); + send("response.output_text.delta", { delta: "live-2" }); + expect(await next()).toEqual({ type: "content_block_delta", index: 1, delta: { type: "text_delta", text: "live-2" } }); + + send("response.output_item.added", { item: { type: "function_call", id: "fc", call_id: "call", name: "Read" } }); + expect(await next()).toEqual({ type: "content_block_stop", index: 1 }); + expect(await next()).toMatchObject({ type: "content_block_start", index: 2, content_block: { type: "tool_use", name: "Read" } }); + for (const fragment of ['{"path":', '"/x"}']) { + send("response.function_call_arguments.delta", { item_id: "fc", delta: fragment }); + expect(await next()).toEqual({ type: "content_block_delta", index: 2, delta: { type: "input_json_delta", partial_json: fragment } }); + } + send("response.output_item.done", { item: { type: "function_call", id: "fc" } }); + expect(await next()).toEqual({ type: "content_block_stop", index: 2 }); + + send("response.output_item.done", { item: { + type: "reasoning", id: "early", encrypted_content: encodeReasoningEnvelope({ sig: "late-sig", red: ["late-red"] }), + } }); + expect(await next()).toEqual({ type: "content_block_start", index: 3, content_block: { type: "redacted_thinking", data: "late-red" } }); + expect(await next()).toEqual({ type: "content_block_stop", index: 3 }); + expect(await next()).toEqual({ type: "content_block_start", index: 4, content_block: { type: "thinking", thinking: "", signature: "" } }); + expect(await next()).toEqual({ type: "content_block_delta", index: 4, delta: { type: "signature_delta", signature: "late-sig" } }); + expect(await next()).toEqual({ type: "content_block_stop", index: 4 }); + send("response.completed", { response: { status: "completed" } }); + controller.close(); + expect(await next()).toMatchObject({ type: "message_delta", delta: { stop_reason: "tool_use" } }); + expect(await next()).toEqual({ type: "message_stop" }); + expect((await reader.read()).done).toBe(true); + } finally { + await reader.cancel(); + reader.releaseLock(); + } + }); +});