From 609105cf9148ca18decfc81e8d1859e30af3aff9 Mon Sep 17 00:00:00 2001
From: t
Date: Mon, 7 Sep 2026 18:22:37 +0900
Subject: [PATCH 1/4] docs: plan lane D manual release chain [skip ci]
---
devlog/_plan/260907_lane_d/000_plan.md | 32 +++++++++++++++++++
devlog/_plan/260907_lane_d/010_thinking.md | 24 ++++++++++++++
devlog/_plan/260907_lane_d/020_receipt.md | 12 +++++++
.../_plan/260907_lane_d/030_account_prices.md | 13 ++++++++
.../_plan/260907_lane_d/040_price_editor.md | 23 +++++++++++++
.../_plan/260907_lane_d/050_usage_ranges.md | 30 +++++++++++++++++
6 files changed, 134 insertions(+)
create mode 100644 devlog/_plan/260907_lane_d/000_plan.md
create mode 100644 devlog/_plan/260907_lane_d/010_thinking.md
create mode 100644 devlog/_plan/260907_lane_d/020_receipt.md
create mode 100644 devlog/_plan/260907_lane_d/030_account_prices.md
create mode 100644 devlog/_plan/260907_lane_d/040_price_editor.md
create mode 100644 devlog/_plan/260907_lane_d/050_usage_ranges.md
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/010_thinking.md b/devlog/_plan/260907_lane_d/010_thinking.md
new file mode 100644
index 0000000000..e321ac03ce
--- /dev/null
+++ b/devlog/_plan/260907_lane_d/010_thinking.md
@@ -0,0 +1,24 @@
+# 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.
+Release retained thinkingBuf after text frame enqueue, before signature frame enqueue;
+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.
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..7b710df91e
--- /dev/null
+++ b/devlog/_plan/260907_lane_d/020_receipt.md
@@ -0,0 +1,12 @@
+# 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.
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.
From b462cc54f223a34a10003873870dcc0aa92353b9 Mon Sep 17 00:00:00 2001
From: t
Date: Mon, 7 Sep 2026 18:23:28 +0900
Subject: [PATCH 2/4] docs: record lane D roadmap audit decisions [skip ci]
---
devlog/_plan/260907_lane_d/001_roadmap_audit.md | 16 ++++++++++++++++
1 file changed, 16 insertions(+)
create mode 100644 devlog/_plan/260907_lane_d/001_roadmap_audit.md
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.
From c35b5a6e504a4513ad59957f5c81b7db45f8bec6 Mon Sep 17 00:00:00 2001
From: t
Date: Mon, 7 Sep 2026 18:29:46 +0900
Subject: [PATCH 3/4] fix(claude): preserve legacy thinking block order [skip
ci]
Defer item-bound thinking emission until its envelope can place redacted blocks first. Preserve budget enforcement and live text/tool output. Refs #3719; replay/cache acceptance remains open.
Co-authored-by: lidge-jun <243035832+lidge-jun@users.noreply.github.com>
---
devlog/_plan/260907_lane_d/010_thinking.md | 4 +-
src/claude/outbound.ts | 59 ++--
.../claude-outbound.test.ts | 306 +++++++++++++++++-
3 files changed, 343 insertions(+), 26 deletions(-)
diff --git a/devlog/_plan/260907_lane_d/010_thinking.md b/devlog/_plan/260907_lane_d/010_thinking.md
index e321ac03ce..f2bb7e18bf 100644
--- a/devlog/_plan/260907_lane_d/010_thinking.md
+++ b/devlog/_plan/260907_lane_d/010_thinking.md
@@ -17,8 +17,10 @@ Astra Dirac found two blockers: unmatched-item reordering and closure memory ove
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.
-Release retained thinkingBuf after text frame enqueue, before signature frame enqueue;
+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/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();
+ }
+ });
+});
From c5fb7e3332432c6546bffa9990bce5b971653139 Mon Sep 17 00:00:00 2001
From: t
Date: Mon, 7 Sep 2026 18:30:46 +0900
Subject: [PATCH 4/4] fix(models): guard edits during unknown receipt recovery
[skip ci]
Co-authored-by: lidge-jun <243035832+lidge-jun@users.noreply.github.com>
---
devlog/_plan/260907_lane_d/020_receipt.md | 5 +++++
gui/src/components/ModelDisplayNameDialog.tsx | 20 +++++++++++++------
gui/src/pages/Models.tsx | 1 +
gui/tests/models-display-name-editor.test.tsx | 12 +++++++++--
4 files changed, 30 insertions(+), 8 deletions(-)
diff --git a/devlog/_plan/260907_lane_d/020_receipt.md b/devlog/_plan/260907_lane_d/020_receipt.md
index 7b710df91e..97568c39a8 100644
--- a/devlog/_plan/260907_lane_d/020_receipt.md
+++ b/devlog/_plan/260907_lane_d/020_receipt.md
@@ -10,3 +10,8 @@ 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/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({
-