diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ebe669940..f675d8333 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -134,7 +134,7 @@ Background maintenance (V2: per-task cron scheduling). A process-wide 15-min tim Both types of drops deduplicate with age-based reclaims and freeze their drop mode (`full`, `truncated`, or `edit_marker`) in the `tags.drop_mode` column for deterministic replay on later passes. Truncated-mode tool shells replace all original argument keys with an inert marker `{ dropped: "[dropped §N§]" }` across OpenCode, Pi, and Rust; tool dispatch verifies arguments via `assertExecutableToolInput` (`src/hooks/magic-context/dropped-input-guard.ts` / `packages/pi-plugin/src/dropped-input-guard-pi.ts`), throwing an actionable recovery error (`ctx_expand` original arguments) if a dropped placeholder is submitted. Two-pass age reclaim advances `tool_reclaim_watermark` only on an actual application opportunity, freezing during plain execute residency. Open tool arcs (pending/running tool calls without completed outputs or errored status) are excluded from reclaim, drop, truncate, or `edit_marker` selection via `partHasCompletedResult` (`src/hooks/magic-context/tool-drop-target.ts`), reclaiming closed arcs (completed string outputs or errored tool parts with `status === "error"`) while clamping deep clones of message parts so OpenCode's live in-memory execution objects stay byte-identical. - **Tiered emergency drop and reclaim episodes (derived force band: ≥85% at the default threshold, up to 92% at raised thresholds)**: target-headroom eviction down to `fixedFloor + 0.30 × (ceiling − fixedFloor)`, tools oldest-first across tiers (T3 misc → T2 edit/search → T1 navigation), newest-20% recency reserve on T1/T2. `floorTags` (full active set, for floor accounting) vs `tags` (droppable candidates). Reclaim uses a continuous pressure-episode contract across OpenCode and Pi: `last_emergency_input_sample` acts as an episode latch admitting one non-empty emergency batch per force-pressure episode; zero-removal evaluations leave it armed. Fresh usage readings within the episode do not release the latch; dropping below the force band rearms it for future pressure, and independent provider-visible mutations rearm it so candidates ride already-priced busts. Heuristic cleanup, caveman compression, duplicate cleanup, and reasoning clearing adhere to the same episode discipline, admitting one originating application per continuous primary execute episode. Below 95%, the newest-20 dropped tool calls keep a `[dropped §N§]` skeleton (the `tool_use` survives, output replaced); older drops are fully removed. At provider-proven or estimated ≥95% pressure, TypeScript emergency selection yields both `protected_tags` and the T1/T2 recency reserve, fully removing selected completed arcs while retaining open arcs and the newest K=3 `ctx_reduce` exemplars; reasoning-adjacent tool arcs retain a paired skeleton (`requiresToolArcSkeleton` in `heuristic-cleanup.ts` and `packages/pi-plugin/src/heuristic-cleanup-pi.ts`) to prevent Anthropic from merging signed assistant turns. The target and non-empty-batch episode discipline do not change. At force pressure, a head cap landing inside the first completed arc admits the whole atomic component if it fits before the protected tail, rather than fencing back to an empty head. - **Compaction markers**: inject an OpenCode-compatible compaction boundary so `filterCompacted` stops at the historian's last compartment, shrinking the transform-input array. The marker move is **deferred** from historian publish into the next materializing pass (one bust covers both the `` rebuild and the boundary advance); CAS-guarded, restart-safe. -- **Content stripping** (`strip-content.ts`, `caveman.ts`, `sentinel.ts`): stateless strip functions + deterministic in-place sentinel replacement + persisted watermarks. Provider-aware: empty-content sentinels only stay empty for providers that accept them (`modelAcceptsEmptyContent`); others get a `[dropped]` placeholder (e.g. Copilot/Bedrock break tool adjacency on empty parts — #135). Merged-assistant reasoning neutralization tracks applied message IDs via `merged_reasoning_stripped_ids` in `session_meta` (with CoreState units twin in Rust), freezing the applied set on cache-busting passes and replaying on defer passes while keeping whitespace-only text parts sentinel-invisible, with frozen sentinel IDs surviving temporary transform absence across compaction marker advances until `message.deleted`. For Anthropic Fable 5.1 thinking-prefix binding mismatches (classified in `overflow-detection.ts`), the recovery arm strips bound reasoning from the targeted assistant (`stripReasoningFromAssistantIds` in `strip-content.ts`), recording the frozen ID (`binding_mismatch:`) in `merged_reasoning_stripped_ids` for replay stability before clearing the armed target upon live turn completion. Trailing assistant blank decisions (`TrailingBlankDecision`) freeze while a message is newest (on every pass, capturing the last-live shape), becoming immutable once historical (`findTrailingBlankDecisionCandidates` / `applyFrozenTrailingBlankDecisions`), normalizing late-visible empty text blocks on non-newest assistant messages across race directions and cloning message part arrays before length-changing splices to avoid mutating live execution object graphs. Trailing blank classifications snapshot source shapes before sentinel insertion so `keep` decisions never manufacture absent suffixes; poisoned keeps demote to `strip` via CAS on cache-busting passes. The transform wrapper (`src/plugin/messages-transform.ts`) preserves user-terminated prompt tails (`preserveUserTerminatedTail`) when OpenCode concurrently appends a pending assistant shell mid-transform, re-anchoring the user message at the wire tail so the shell cannot turn into an illegal assistant prefill. +- **Content stripping** (`strip-content.ts`, `caveman.ts`, `sentinel.ts`): stateless strip functions + deterministic in-place sentinel replacement + persisted watermarks. Adapter-aware: empty-content sentinels only stay empty for models whose wire accepts them (`modelAcceptsEmptyContent`, resolved once per pass from `api.npm === "@ai-sdk/anthropic"` via `anthropic-wire.ts` so a custom provider fronting Claude is covered); others get a `[dropped]` placeholder (e.g. Copilot/Bedrock break tool adjacency on empty parts — #135). Every consumer takes that one resolved boolean, so no two phases of a pass can disagree. An unresolved model or registry carries the session's last materialized answer forward rather than narrowing, and any real change is priced through the m[0] upgrade identity so it folds exactly once; Rust mode stays canonical-only (`docs/AUDIT-KNOWN-ISSUES.md` A55 has the full state table). Merged-assistant reasoning neutralization tracks applied message IDs via `merged_reasoning_stripped_ids` in `session_meta` (with CoreState units twin in Rust), freezing the applied set on cache-busting passes and replaying on defer passes while keeping whitespace-only text parts sentinel-invisible, with frozen sentinel IDs surviving temporary transform absence across compaction marker advances until `message.deleted`. For Anthropic Fable 5.1 thinking-prefix binding mismatches (classified in `overflow-detection.ts`), the recovery arm strips bound reasoning from the targeted assistant (`stripReasoningFromAssistantIds` in `strip-content.ts`), recording the frozen ID (`binding_mismatch:`) in `merged_reasoning_stripped_ids` for replay stability before clearing the armed target upon live turn completion. Trailing assistant blank decisions (`TrailingBlankDecision`) freeze while a message is newest (on every pass, capturing the last-live shape), becoming immutable once historical (`findTrailingBlankDecisionCandidates` / `applyFrozenTrailingBlankDecisions`), normalizing late-visible empty text blocks on non-newest assistant messages across race directions and cloning message part arrays before length-changing splices to avoid mutating live execution object graphs. Trailing blank classifications snapshot source shapes before sentinel insertion so `keep` decisions never manufacture absent suffixes; poisoned keeps demote to `strip` via CAS on cache-busting passes. The transform wrapper (`src/plugin/messages-transform.ts`) preserves user-terminated prompt tails (`preserveUserTerminatedTail`) when OpenCode concurrently appends a pending assistant shell mid-transform, re-anchoring the user message at the wire tail so the shell cannot turn into an illegal assistant prefill. - **Message / git-commit indexes**: Maintain an FTS5 raw-message index outside the search hot path (via async reconciliation + live `message.updated` events) and a HEAD-only non-merge git-commit corpus populated by the dream timer. Out-of-band orphan session sweeps (`src/features/magic-context/message-index.ts`) discover candidate sessions by unioning across all harness-scoped `SESSION_SCOPED_TABLES` against OpenCode's authoritative session table. Session deletions record pending cleanups in `pending_session_cleanup` (distinguishing Rust module deletions via `:rust` harness tags), retried on dream timer ticks (`retryPendingSessionCleanups` / `retryPendingRustSessionCleanupsForProject`); `deleteSessionScopedRows` protects session coordinates until module acknowledgement. For directories that are not git repositories or are empty (no commits yet), the sweep coordinator future-dates the last sweep time to park them on a 24-hour re-probe cooldown to avoid error log flooding. - **Commit-detection utility**: A unified detection helper (`src/shared/commit-detection.ts`) parses git commit hashes (7-12 hex chars) paired with commit-action verbs (commit, cherry-pick, merge, rebase) to detect commit boundaries consistently across the historian trigger, OpenCode note-nudge, and Pi note-nudge. - **System-prompt injection** (`system-prompt-hash.ts`): injects only the Magic Context guidance text + a frozen `Today's date:` line (per-session sticky, updated only on cache-busting passes). Guidance is concatenated into `output.system[0]` with a blank line separator rather than appended as a second array entry so wire serializers emit a single `system` role message compatible with strict chat templates (Qwen/vLLM/LiteLLM). Guidance is gated by `ctx_reduce` availability (resolved from the tools map of the session's first user message). While the verdict is provisional (before the first user message has been processed/persisted), the guidance block still renders using a fail-open default, but the computed system prompt hash is not written to the database to prevent cache-busting flips when the first user message freezes the verdict. Prompt surfaces resolve presets ("full" vs "light", using `src/shared/prompt-surface.ts` and `src/tools/light-descriptions.ts` for light guidance and tool descriptions under the ratified 1825-token budget ceiling). Adjunct blocks (`` / ``) are NOT here — they moved into m[0]/m[1] so the system prompt stays maximally cache-stable. Skipped entirely for OpenCode's internal `title`/`summary`/`compaction` agents and for hidden child sessions (detected by the `magic-context-` title prefix). diff --git a/STRUCTURE.md b/STRUCTURE.md index a4a71b463..01623bee5 100644 --- a/STRUCTURE.md +++ b/STRUCTURE.md @@ -149,6 +149,7 @@ Unless specified otherwise, TypeScript paths are relative to `packages/plugin/` - `src/hooks/magic-context/supersession-reclaim.ts`: Select superseded spent control-plane tool outputs (oldest todowrite, ctx_reduce, zero-value meta calls) and older edit/write calls for the same file under the `smart_drops` configuration flag, derivation anchored in a newest-20 owner floor from persisted tag chronology. - `src/hooks/magic-context/tool-drop-target.ts`: Candidate selection for tool output reduction, protecting open tool arcs via `partHasCompletedResult` while replacing dropped invocation arguments with inert markers and reclaiming completed or errored arcs. - `src/hooks/magic-context/dropped-input-guard.ts`: Intercept tool execution before dispatch if arguments contain dropped placeholder strings, replacing dropped inputs with inert non-executable markers (`{ dropped: "[dropped §N§]" }`) and instructing the model to recover original arguments via `ctx_expand`. +- `src/hooks/magic-context/anthropic-wire.ts`: Resolve, once per process from `client.provider.list()`, which `(providerID, modelID)` pairs OpenCode serializes with the `@ai-sdk/anthropic` adapter, so empty-part sentinels are gated on the wire format rather than the provider name. - `src/hooks/magic-context/sentinel.ts`: Decide provider predicates (such as `modelAcceptsEmptyContent` and `variantChangeBustsProviderCache`, with cache preservation for Anthropic Fable 5.1 and OpenAI GPT-6 Astra) controlling strip/flush behavior. - `src/hooks/magic-context/tail-hygiene-walk.ts`: Single-walk tail hygiene measurement instrument (`{U, T}` baseline), tracking active vs protected mass, computing baseline deltas, and calculating low-overhead structural size proxy signatures of served messages. - `src/hooks/magic-context/protected-tail-boundary.ts`: Calculate the eligible head and protected tail boundary, enforce completed tool-arc fencing, admit oversize leading completed components under the head cap, and emit self-describing boundary diagnostics. diff --git a/docs/AUDIT-KNOWN-ISSUES.md b/docs/AUDIT-KNOWN-ISSUES.md index 6daa074bd..01e1e5c93 100644 --- a/docs/AUDIT-KNOWN-ISSUES.md +++ b/docs/AUDIT-KNOWN-ISSUES.md @@ -906,3 +906,51 @@ it. Results carry `status=pending` so the caller can see the condition has not fired. The same reasoning covers restricted children that carry `ctx_search` (retrospective): notes are the same sensitivity class as project memories, which those children already search. + +## A55: the Anthropic-wire empty-sentinel capability is TS-mode only (Rust parity deferred) + +`modelAcceptsEmptyContent` resolves the empty-sentinel capability from the +adapter OpenCode actually uses (`api.npm === "@ai-sdk/anthropic"`), so a custom +provider fronting Claude — a Bedrock or Vertex gateway under any provider id — +now gets the merged-reasoning strip that its wire requires. See +`hooks/magic-context/anthropic-wire.ts`. + +The `mc-module` Rust transform decides the same fact from the provider id, in one +shared predicate — `request_accepts_empty_content` +(`crates/mc-module/src/transform.rs:13753`) — plus about eight inline +`provider_id != Some("anthropic")` comparisons in the neighbouring trailing-blank, +user-terminated-tail, and reasoning-cutoff lanes. Rust mode +(`transform_mode: "rust"`) therefore stays canonical-Anthropic only, and +`rust-mode-transform.ts` passes `modelAcceptsEmptyContent(providerID)` without a +model id on purpose: widening the host lane alone would let the two lanes +disagree about the served bytes, which is worse than the narrow gate. + +The parity work is smaller than the comparison count suggests. The module already +folds this capability into its pass digest (`transform.rs:12229`), so it would +handle a change correctly once the resolved boolean rides `TransformRequest` +instead of being re-derived from `provider_id`. + +Pi is unaffected. Pi resolves its own capability from `ctx.model.provider` and +never calls the OpenCode strip functions, so there is nothing to widen there +until Pi exposes a comparable adapter fact. + +`resolveEmptySentinelCapability` resolves five states per pass. "Folds?" is whether +a change of this component alone can materialize m[0]: + +| Pass state | Capability | Recorded as widened | Folds? | +|---|---|---|---| +| Registry resolved, canonical `anthropic` | on | no | never — component absent both sides | +| Registry resolved, custom provider on `@ai-sdk/anthropic` | on | yes | once, when it first differs | +| Registry resolved, any other adapter | off | no | never | +| Registry unresolved, live model matches the cached m[0] model | carried forward | carried forward | never — nothing changed | +| Registry unresolved, live model differs or is unobservable | off | no | never — `model_change` owns that fold | + +Two operational consequences. A gateway added to `opencode.json` mid-session needs +an OpenCode restart, because the adapter snapshot is read once per process. And a +failed lookup does not narrow a session that already served widened bytes: it keeps +the carried answer until a retry succeeds, because narrowing would stop replaying +persisted merged-reasoning strips and reinstate the 400. + +Closing the gap means carrying one resolved boolean in the transform request and +replacing the module's provider comparisons with it, plus Rust-side coverage. Do +not re-flag the TS/Rust asymmetry as a bug until that work is scheduled. diff --git a/packages/plugin/src/config/latch-permanence-guard.test.ts b/packages/plugin/src/config/latch-permanence-guard.test.ts index c68e36afe..90d2c13b5 100644 --- a/packages/plugin/src/config/latch-permanence-guard.test.ts +++ b/packages/plugin/src/config/latch-permanence-guard.test.ts @@ -166,6 +166,10 @@ const KNOWN_SLOTS: Record = { classification: "PUBLICATION", reason: "Publishes last-known-good model metadata, not a failure verdict; refresh writes a later successful value.", }, + "packages/plugin/src/hooks/magic-context/anthropic-wire.ts:loadPromise": { + classification: "VERDICT", + reason: "Saved: only a successful load is memoized. A failure clears the slot and retries after a 60-second cooldown. resolveEmptySentinelCapability() carries the session's prior answer forward while the registry is unresolved, so it does not narrow.", + }, }; function sourceFiles(directory: string): string[] { diff --git a/packages/plugin/src/hooks/magic-context/anthropic-wire.test.ts b/packages/plugin/src/hooks/magic-context/anthropic-wire.test.ts new file mode 100644 index 000000000..a2f212773 --- /dev/null +++ b/packages/plugin/src/hooks/magic-context/anthropic-wire.test.ts @@ -0,0 +1,405 @@ +/// + +import { afterEach, describe, expect, it } from "bun:test"; +import type { PluginContext } from "../../plugin/types"; +import { + ensureAnthropicWireModelsLoaded, + isAnthropicWireModel, + resetAnthropicWireModelsForTest, + resolveEmptySentinelCapability, +} from "./anthropic-wire"; +import { modelAcceptsEmptyContent } from "./sentinel"; +import { + findMergedReasoningStripDecisions, + stripReasoningFromMergedAssistants, +} from "./strip-content"; +import type { MessageLike } from "./tag-messages"; + +type ProviderListModel = { id?: string; api?: unknown }; + +function message(id: string, role: string, parts: unknown[]): MessageLike { + return { info: { id, role, sessionID: "ses-1" }, parts }; +} + +function fakeClient( + all: unknown, + onCall?: () => void, +): { client: PluginContext["client"]; calls: () => number } { + let calls = 0; + const client = { + provider: { + list: async () => { + calls += 1; + onCall?.(); + return { data: all === undefined ? undefined : { all } }; + }, + }, + }; + return { client: client as unknown as PluginContext["client"], calls: () => calls }; +} + +function provider(id: string, models: Record) { + return { id, models }; +} + +const anthropicWire = { npm: "@ai-sdk/anthropic" }; + +afterEach(() => { + resetAnthropicWireModelsForTest(); +}); + +describe("anthropic-wire", () => { + describe("#given a custom provider serving Claude on the Anthropic adapter", () => { + it("#then reports the model as Anthropic wire under its own provider id", async () => { + const { client } = fakeClient([ + provider("my_gateway", { + "bedrock/claude-opus-5": { id: "bedrock/claude-opus-5", api: anthropicWire }, + }), + ]); + + await ensureAnthropicWireModelsLoaded(client); + + expect(isAnthropicWireModel("my_gateway", "bedrock/claude-opus-5")).toBe(true); + expect(modelAcceptsEmptyContent("my_gateway", "bedrock/claude-opus-5")).toBe(true); + }); + }); + + describe("#given one provider that mixes adapters across its models", () => { + it("#then answers per model rather than per provider", async () => { + const { client } = fakeClient([ + provider("proxy", { + "claude-sonnet-5": { id: "claude-sonnet-5", api: anthropicWire }, + "gpt-5.5": { id: "gpt-5.5", api: { npm: "@ai-sdk/openai-compatible" } }, + }), + ]); + + await ensureAnthropicWireModelsLoaded(client); + + expect(isAnthropicWireModel("proxy", "claude-sonnet-5")).toBe(true); + expect(isAnthropicWireModel("proxy", "gpt-5.5")).toBe(false); + }); + }); + + describe("#given adapters that run their own transforms", () => { + it("#then excludes Bedrock Converse and Vertex Anthropic", async () => { + const { client } = fakeClient([ + provider("bedrock", { + "claude-opus-5": { + id: "claude-opus-5", + api: { npm: "@ai-sdk/amazon-bedrock" }, + }, + }), + provider("google-vertex-anthropic", { + "claude-opus-5": { + id: "claude-opus-5", + api: { npm: "@ai-sdk/google-vertex/anthropic" }, + }, + }), + ]); + + await ensureAnthropicWireModelsLoaded(client); + + expect(isAnthropicWireModel("bedrock", "claude-opus-5")).toBe(false); + expect(isAnthropicWireModel("google-vertex-anthropic", "claude-opus-5")).toBe(false); + }); + }); + + describe("#given a model keyed differently from its id", () => { + it("#then matches on either identifier", async () => { + const { client } = fakeClient([ + provider("gateway", { "record-key": { id: "model-id", api: anthropicWire } }), + ]); + + await ensureAnthropicWireModelsLoaded(client); + + expect(isAnthropicWireModel("gateway", "record-key")).toBe(true); + expect(isAnthropicWireModel("gateway", "model-id")).toBe(true); + }); + }); + + describe("#given a lookup that cannot be resolved", () => { + it("#then fails closed before the registry loads", () => { + expect(isAnthropicWireModel("my_gateway", "claude-opus-5")).toBe(false); + expect(modelAcceptsEmptyContent("my_gateway", "claude-opus-5")).toBe(false); + }); + + it("#then fails closed without a model id", async () => { + const { client } = fakeClient([ + provider("my_gateway", { + "claude-opus-5": { id: "claude-opus-5", api: anthropicWire }, + }), + ]); + await ensureAnthropicWireModelsLoaded(client); + + expect(isAnthropicWireModel("my_gateway", undefined)).toBe(false); + expect(modelAcceptsEmptyContent("my_gateway")).toBe(false); + }); + + it("#then fails closed on a malformed response", async () => { + const { client } = fakeClient(undefined); + await ensureAnthropicWireModelsLoaded(client); + + expect(isAnthropicWireModel("my_gateway", "claude-opus-5")).toBe(false); + }); + + it("#then fails closed without retrying every pass when the lookup throws", async () => { + let calls = 0; + const client = { + provider: { + list: async () => { + calls += 1; + throw new Error("no server"); + }, + }, + } as unknown as PluginContext["client"]; + + await ensureAnthropicWireModelsLoaded(client); + + expect(isAnthropicWireModel("my_gateway", "claude-opus-5")).toBe(false); + await ensureAnthropicWireModelsLoaded(client); + expect(calls).toBe(1); + }); + }); + + describe("#given canonical Anthropic", () => { + it("#then keeps its answer without any registry entry", () => { + expect(modelAcceptsEmptyContent("anthropic")).toBe(true); + expect(modelAcceptsEmptyContent("anthropic", "claude-opus-5")).toBe(true); + }); + }); + + describe("#given a widened provider across an execute pass then a defer pass", () => { + // The regression this guards: the capability must resolve identically on + // every pass of a session. If an execute pass strips merged reasoning and a + // later defer pass answers differently, the defer pass restores the native + // reasoning bytes and collapses the cached prefix. + const buildFixture = () => { + const newest = message("m-newest", "assistant", [ + { type: "reasoning", text: "newest stays exempt" }, + ]); + return { + newest, + messages: [ + message("m-u", "user", [{ type: "text", text: "continue" }]), + message("m-a1", "assistant", [ + { type: "reasoning", text: "plan" }, + { type: "text", text: "first" }, + ]), + message("m-a2", "assistant", [ + { type: "reasoning", text: "interleaved" }, + { type: "text", text: "second" }, + ]), + newest, + ], + }; + }; + + it("#then strips on the execute pass and replays byte-identically on defer", async () => { + const { client } = fakeClient([ + provider("my_gateway", { + "claude-opus-5": { id: "claude-opus-5", api: anthropicWire }, + }), + ]); + await ensureAnthropicWireModelsLoaded(client); + const capability = modelAcceptsEmptyContent("my_gateway", "claude-opus-5"); + expect(capability).toBe(true); + + const executePass = buildFixture(); + const frozen = new Set( + findMergedReasoningStripDecisions(executePass.messages, capability, new Set(), { + mutationExemptMessage: executePass.newest, + }), + ); + expect(frozen.size).toBeGreaterThan(0); + expect( + stripReasoningFromMergedAssistants(executePass.messages, capability, { + frozenMessageIds: frozen, + mutationExemptMessage: executePass.newest, + }), + ).toBe(1); + const executeBytes = JSON.stringify(executePass.messages); + + // Defer pass: OpenCode rebuilds the array from its own DB, so replay runs + // against fresh objects and must detect nothing new. + const deferPass = buildFixture(); + expect( + findMergedReasoningStripDecisions(deferPass.messages, capability, frozen, { + mutationExemptMessage: deferPass.newest, + }), + ).toEqual([]); + stripReasoningFromMergedAssistants(deferPass.messages, capability, { + frozenMessageIds: frozen, + mutationExemptMessage: deferPass.newest, + }); + + expect(JSON.stringify(deferPass.messages)).toBe(executeBytes); + expect(deferPass.newest.parts[0]).toEqual({ + type: "reasoning", + text: "newest stays exempt", + }); + }); + + it("#then leaves an unregistered provider's reasoning native on both passes", () => { + const capability = modelAcceptsEmptyContent("unlisted_gateway", "claude-opus-5"); + expect(capability).toBe(false); + + const executePass = buildFixture(); + const native = JSON.stringify(executePass.messages); + expect( + findMergedReasoningStripDecisions(executePass.messages, capability, new Set()), + ).toEqual([]); + expect( + stripReasoningFromMergedAssistants(executePass.messages, capability, { + frozenMessageIds: new Set(), + }), + ).toBe(0); + expect(JSON.stringify(executePass.messages)).toBe(native); + }); + }); + + describe("#given resolveEmptySentinelCapability", () => { + const GATEWAY = { providerID: "my_gateway", modelID: "claude-opus-5" }; + const GATEWAY_KEY = "my_gateway/claude-opus-5"; + + async function loadGateway() { + const { client } = fakeClient([ + provider("my_gateway", { + "claude-opus-5": { id: "claude-opus-5", api: anthropicWire }, + }), + ]); + await ensureAnthropicWireModelsLoaded(client); + } + + it("#then reports widened for a registry-resolved custom provider", async () => { + await loadGateway(); + expect( + resolveEmptySentinelCapability({ + ...GATEWAY, + modelKey: GATEWAY_KEY, + cachedModelKey: null, + cachedWidenedByCustomProvider: false, + }), + ).toEqual({ acceptsEmptySentinels: true, widenedByCustomProvider: true }); + }); + + it("#then keeps canonical Anthropic out of the widened flag", async () => { + await loadGateway(); + expect( + resolveEmptySentinelCapability({ + providerID: "anthropic", + modelID: "claude-opus-5", + modelKey: "anthropic/claude-opus-5", + cachedModelKey: null, + cachedWidenedByCustomProvider: false, + }), + ).toEqual({ acceptsEmptySentinels: true, widenedByCustomProvider: false }); + }); + + it("#then reports neither for a resolved non-Anthropic model", async () => { + await loadGateway(); + expect( + resolveEmptySentinelCapability({ + providerID: "openai", + modelID: "gpt-6", + modelKey: "openai/gpt-6", + cachedModelKey: null, + cachedWidenedByCustomProvider: false, + }), + ).toEqual({ acceptsEmptySentinels: false, widenedByCustomProvider: false }); + }); + + it("#then carries a widened session forward while the registry is unresolved", () => { + // The dangerous direction: narrowing here would stop replaying strips this + // session already persisted and put signed thinking back on the wire. + expect( + resolveEmptySentinelCapability({ + ...GATEWAY, + modelKey: GATEWAY_KEY, + cachedModelKey: GATEWAY_KEY, + cachedWidenedByCustomProvider: true, + }), + ).toEqual({ acceptsEmptySentinels: true, widenedByCustomProvider: true }); + }); + + it("#then refuses to carry a widened answer onto a different model", () => { + // Gateway → Kimi with an unresolved registry. Carrying the session's + // answer here would put empty parts on a wire that forwards them as + // real content (issue #135). + expect( + resolveEmptySentinelCapability({ + providerID: "moonshot", + modelID: "kimi-k2", + modelKey: "moonshot/kimi-k2", + cachedModelKey: GATEWAY_KEY, + cachedWidenedByCustomProvider: true, + }), + ).toEqual({ acceptsEmptySentinels: false, widenedByCustomProvider: false }); + }); + + it("#then compares aliased model keys in their canonical form", () => { + expect( + resolveEmptySentinelCapability({ + providerID: "openai-codex", + modelID: "claude-opus-5", + modelKey: "openai-codex/claude-opus-5", + cachedModelKey: "openai/claude-opus-5", + cachedWidenedByCustomProvider: true, + }), + ).toEqual({ acceptsEmptySentinels: true, widenedByCustomProvider: true }); + }); + + it("#then claims nothing when no model is observable", () => { + expect( + resolveEmptySentinelCapability({ + modelKey: "", + cachedModelKey: GATEWAY_KEY, + cachedWidenedByCustomProvider: true, + }), + ).toEqual({ acceptsEmptySentinels: false, widenedByCustomProvider: false }); + }); + + it("#then still serves canonical Anthropic with no registry and no cache", () => { + expect( + resolveEmptySentinelCapability({ + providerID: "anthropic", + modelKey: "", + cachedModelKey: null, + cachedWidenedByCustomProvider: false, + }), + ).toEqual({ acceptsEmptySentinels: true, widenedByCustomProvider: false }); + }); + }); + + describe("#given repeated and concurrent load requests", () => { + it("#then reads the provider list exactly once per process", async () => { + const { client, calls } = fakeClient([ + provider("gateway", { + "claude-opus-5": { id: "claude-opus-5", api: anthropicWire }, + }), + ]); + + await Promise.all([ + ensureAnthropicWireModelsLoaded(client), + ensureAnthropicWireModelsLoaded(client), + ]); + await ensureAnthropicWireModelsLoaded(client); + + expect(calls()).toBe(1); + expect(isAnthropicWireModel("gateway", "claude-opus-5")).toBe(true); + }); + + it("#then does not memoize the no-client case", async () => { + await ensureAnthropicWireModelsLoaded(undefined); + const { client, calls } = fakeClient([ + provider("gateway", { + "claude-opus-5": { id: "claude-opus-5", api: anthropicWire }, + }), + ]); + + await ensureAnthropicWireModelsLoaded(client); + + expect(calls()).toBe(1); + expect(isAnthropicWireModel("gateway", "claude-opus-5")).toBe(true); + }); + }); +}); diff --git a/packages/plugin/src/hooks/magic-context/anthropic-wire.ts b/packages/plugin/src/hooks/magic-context/anthropic-wire.ts new file mode 100644 index 000000000..921275f41 --- /dev/null +++ b/packages/plugin/src/hooks/magic-context/anthropic-wire.ts @@ -0,0 +1,216 @@ +import type { PluginContext } from "../../plugin/types"; +import { piModelRefToCanonical } from "../../shared/harness-provider-map"; +import { log } from "../../shared/logger"; +import { isRecord } from "../../shared/record-type-guard"; + +/** + * Which `(providerID, modelID)` pairs ride OpenCode's Anthropic transport. + * + * OpenCode filters empty text/reasoning parts off the wire in exactly ONE + * branch, and that branch is gated on the resolved adapter — + * `model.api.npm === "@ai-sdk/anthropic"` — not on the provider id. Magic + * Context's empty-part sentinels are only valid inside that branch, so the + * capability has to come from the same fact OpenCode itself uses. + * + * The provider id is the wrong key twice over. A custom provider in + * `opencode.json` can declare `npm: "@ai-sdk/anthropic"` under any id (a + * Bedrock or Vertex gateway fronted by an Anthropic-format API), and one + * provider can serve different models through different adapters. So the + * answer is read per model from `client.provider.list()`, and never asserted by + * configuration: neither a repo nor a user can declare a wire format that + * OpenCode does not actually use. + * + * Only `@ai-sdk/anthropic` qualifies. `@ai-sdk/amazon-bedrock` (Converse) and + * `@ai-sdk/google-vertex/anthropic` run their own transforms and stay excluded, + * as documented on `modelAcceptsEmptyContent` in `sentinel.ts`. + */ +const ANTHROPIC_WIRE_NPM = "@ai-sdk/anthropic"; + +/** Bound the lookup so a hung local request cannot stall the transform. */ +const LOOKUP_DEADLINE_MS = 5_000; + +/** + * Retry window after a failed lookup. Long enough that a broken local API is not + * re-probed on every transform pass, short enough that a session recovers without + * a restart. Mirrors the 60s TTL the Synapse discovery probe already uses + * (`plugin/embedding-routing.ts`), so both retry latches read the same way. + */ +const RETRY_COOLDOWN_MS = 60_000; + +/** + * Nested rather than a `providerID/modelID` string: model ids routinely contain + * slashes (`@bedrock-region/vendor.model`, `anthropic/claude-*`), so a joined key + * makes distinct pairs collide. + */ +let wireModelsByProvider: ReadonlyMap> = new Map(); +let loadPromise: Promise | undefined; +let registryLoaded = false; +let lastFailureAt = 0; + +/** + * True when this exact model is serialized in Anthropic's message format, + * whatever its provider is called in `opencode.json`. + * + * Fails closed: an unloaded registry, a missing model id, and a provider that + * `provider.list()` never reported all answer `false`, which leaves native + * parts in place — the behavior before this capability existed. + */ +export function isAnthropicWireModel(providerID?: string, modelID?: string): boolean { + if (!providerID || !modelID) return false; + return wireModelsByProvider.get(providerID)?.has(modelID) === true; +} + +/** + * Read the adapter of every configured model once per process. + * + * A SUCCESSFUL load is memoized on the promise, so concurrent transform passes + * await one shared load and every later pass reads the same answer. That answer + * has to be stable: `modelAcceptsEmptyContent` decides provider-visible bytes, + * so a flip mid-session rewrites an already-served prefix. + * + * A FAILED load is not memoized: answering `false` is the more dangerous + * direction (see `resolveEmptySentinelCapability`), so it retries after a + * cooldown while that resolver holds the session's answer steady in the meantime. + */ +export function ensureAnthropicWireModelsLoaded(client?: PluginContext["client"]): Promise { + if (loadPromise) return loadPromise; + if (!client) return Promise.resolve(); + if (lastFailureAt > 0 && Date.now() - lastFailureAt < RETRY_COOLDOWN_MS) { + return Promise.resolve(); + } + const attempt = loadAnthropicWireModels(client).then((loaded) => { + if (loaded) return; + lastFailureAt = Date.now(); + // This callback runs after the synchronous assignment below, so no + // concurrent awaiter can observe a cleared memo and fire a second lookup; + // clearing here only re-opens the slot for a later attempt. + loadPromise = undefined; + }); + loadPromise = attempt; + return attempt; +} + +async function loadAnthropicWireModels(client: PluginContext["client"]): Promise { + try { + // Read structurally: the plugin's SDK client types do not declare `api` + // on a model, so a typed access would not compile. + const response = await Promise.race([ + client.provider.list(), + new Promise((resolve) => + setTimeout(() => resolve(undefined), LOOKUP_DEADLINE_MS), + ), + ]); + const providers: unknown = isRecord(response?.data) ? response.data.all : undefined; + if (!Array.isArray(providers)) { + log( + `anthropic wire model lookup returned no provider list (${response === undefined ? "timed out" : "unexpected shape"}); retrying later`, + ); + return false; + } + const next = new Map>(); + let modelCount = 0; + for (const provider of providers) { + if (!isRecord(provider)) continue; + const providerID = provider.id; + if (typeof providerID !== "string" || providerID.length === 0) continue; + const models = provider.models; + if (!isRecord(models)) continue; + for (const [recordKey, model] of Object.entries(models)) { + modelCount += 1; + const api = isRecord(model) ? model.api : undefined; + if (!isRecord(api) || api.npm !== ANTHROPIC_WIRE_NPM) continue; + const ids = next.get(providerID) ?? new Set(); + // The record key and `model.id` are the same string in every + // OpenCode release observed, and the harness reports one of them + // as its model id. Registering both stops a divergence from + // silently turning the capability off. + ids.add(recordKey); + const modelID = isRecord(model) ? model.id : undefined; + if (typeof modelID === "string") ids.add(modelID); + next.set(providerID, ids); + } + } + wireModelsByProvider = next; + registryLoaded = true; + // "0 of N" on a machine that has Claude configured is the signal that this + // lookup no longer reads the adapter it expects. + let wireModels = 0; + for (const ids of next.values()) wireModels += ids.size; + log(`anthropic wire models resolved: ${wireModels} of ${modelCount} configured`); + return true; + } catch (error) { + // Narrow before logging. A `provider.list()` rejection can echo the request + // context, and that context carries provider `options`, `env`, `key` and + // `headers` — including a gateway URL with inline credentials. + log( + `anthropic wire model lookup failed; retrying later: ${ + error instanceof Error ? error.message : typeof error + }`, + ); + return false; + } +} + +export interface EmptySentinelCapability { + /** Whether empty-part sentinels are valid on this pass's wire. */ + acceptsEmptySentinels: boolean; + /** + * True only when a non-canonical provider was widened by the registry. This is + * the case whose served bytes CHANGE relative to a session that predates the + * registry, so it is the case the m[0] upgrade identity has to record. + */ + widenedByCustomProvider: boolean; +} + +/** + * Resolve the pass's empty-sentinel capability, including the unresolved case. + * + * One function so no phase re-derives the rule: `modelAcceptsEmptyContent` owns + * "is this the Anthropic wire", and this owns "what do we do when we cannot tell". + * + * Unresolved is NOT the same as "not widened". A model or registry we cannot read + * this pass must not narrow a session that already served widened bytes: narrowing + * stops replaying merged-reasoning strips that are already persisted, and native + * signed thinking then goes back on the wire — the 400 this capability prevents. + * So an unresolved pass carries forward what the session last materialized under. + * + * That carry-forward is keyed to the MODEL, not just the session. Otherwise a + * session that switched from a widened gateway to an unrelated provider would keep + * the widened answer while the registry is unresolved, and empty parts would reach + * a wire that forwards them as real content (issue #135). + */ +export function resolveEmptySentinelCapability(args: { + providerID?: string; + modelID?: string; + /** + * Model key of the live request, or empty when it is not observable. Compared + * against `cachedModelKey` after canonicalization, because the cached side is + * stored canonicalized (`inject-compartments.ts` normalizes both sides of its + * own model comparison the same way). Skipping that here would make the + * comparison silently never match for the aliased provider ids. + */ + modelKey?: string; + /** Model key the cached m[0] baseline was materialized under. */ + cachedModelKey?: string | null; + /** Whether that cached baseline was widened by the registry. */ + cachedWidenedByCustomProvider: boolean; +}): EmptySentinelCapability { + const canonical = args.providerID === "anthropic"; + if (args.modelID && registryLoaded) { + const widened = !canonical && isAnthropicWireModel(args.providerID, args.modelID); + return { acceptsEmptySentinels: canonical || widened, widenedByCustomProvider: widened }; + } + const liveKey = piModelRefToCanonical(args.modelKey ?? ""); + const sameModel = + liveKey.length > 0 && liveKey === piModelRefToCanonical(args.cachedModelKey ?? ""); + const widened = sameModel && args.cachedWidenedByCustomProvider; + return { acceptsEmptySentinels: canonical || widened, widenedByCustomProvider: widened }; +} + +/** Test seam: restore the unloaded state. */ +export function resetAnthropicWireModelsForTest(): void { + wireModelsByProvider = new Map(); + registryLoaded = false; + loadPromise = undefined; + lastFailureAt = 0; +} diff --git a/packages/plugin/src/hooks/magic-context/compartment-render-epoch.ts b/packages/plugin/src/hooks/magic-context/compartment-render-epoch.ts index 48494bb11..ebd0f5d02 100644 --- a/packages/plugin/src/hooks/magic-context/compartment-render-epoch.ts +++ b/packages/plugin/src/hooks/magic-context/compartment-render-epoch.ts @@ -3,12 +3,24 @@ export const COMPARTMENT_RENDER_EPOCH = "cre2"; const EPOCH_COMPONENT_PREFIX = "|compartment-render:"; const MURAL_COMPONENT_PREFIX = "|mural-enabled:"; const BUDGET_COMPONENT_PREFIX = "|render-budgets:"; +/** + * Present only when the empty-sentinel capability was widened by the adapter + * registry (a non-canonical provider serving Claude on `@ai-sdk/anthropic`). + * Absence therefore means "not registry-widened", which is the correct reading + * for both a canonical Anthropic session and a legacy row written before this + * component existed. That asymmetry is deliberate: unlike mural/budget, a + * missing component here is a KNOWN prior state, not an unknown one, so a + * widened session folds once instead of adopting silently. + */ +const ANTHROPIC_WIRE_COMPONENT_PREFIX = "|anthropic-wire:"; export interface CachedM0UpgradeIdentity { upgradeState: string | null; compartmentRenderEpoch: string | null; muralEnabled: boolean | null; renderBudgetIdentity: string | null; + /** True only when the adapter registry widened the empty-sentinel gate. */ + anthropicWireWidened: boolean; } /** @@ -20,6 +32,7 @@ export function encodeCachedM0UpgradeIdentity( compartmentRenderEpoch: string | null = COMPARTMENT_RENDER_EPOCH, muralEnabled: boolean | null = null, renderBudgetIdentity: string | null = null, + anthropicWireWidened = false, ): string | null { let encoded = upgradeState ?? ""; if (compartmentRenderEpoch !== null) { @@ -31,6 +44,9 @@ export function encodeCachedM0UpgradeIdentity( if (renderBudgetIdentity !== null) { encoded += `${BUDGET_COMPONENT_PREFIX}${renderBudgetIdentity}`; } + if (anthropicWireWidened) { + encoded += `${ANTHROPIC_WIRE_COMPONENT_PREFIX}1`; + } return encoded.length > 0 ? encoded : null; } @@ -50,12 +66,14 @@ export function decodeCachedM0UpgradeIdentity(value: string | null): CachedM0Upg compartmentRenderEpoch: null, muralEnabled: null, renderBudgetIdentity: null, + anthropicWireWidened: false, }; } const componentIndexes = [ value.indexOf(EPOCH_COMPONENT_PREFIX), value.indexOf(MURAL_COMPONENT_PREFIX), value.indexOf(BUDGET_COMPONENT_PREFIX), + value.indexOf(ANTHROPIC_WIRE_COMPONENT_PREFIX), ].filter((index) => index >= 0); const identityEnd = componentIndexes.length > 0 ? Math.min(...componentIndexes) : value.length; const upgradeState = value.slice(0, identityEnd); @@ -65,5 +83,6 @@ export function decodeCachedM0UpgradeIdentity(value: string | null): CachedM0Upg compartmentRenderEpoch: component(value, EPOCH_COMPONENT_PREFIX), muralEnabled: muralComponent === "1" ? true : muralComponent === "0" ? false : null, renderBudgetIdentity: component(value, BUDGET_COMPONENT_PREFIX), + anthropicWireWidened: component(value, ANTHROPIC_WIRE_COMPONENT_PREFIX) === "1", }; } diff --git a/packages/plugin/src/hooks/magic-context/compartment-trigger.ts b/packages/plugin/src/hooks/magic-context/compartment-trigger.ts index aa8be21cc..4f398ea08 100644 --- a/packages/plugin/src/hooks/magic-context/compartment-trigger.ts +++ b/packages/plugin/src/hooks/magic-context/compartment-trigger.ts @@ -603,10 +603,12 @@ export function checkCompartmentTrigger( return { shouldFire: false }; } - // Never project reclaimed reasoning unless this harness can actually clear - // it from the provider wire. The OpenCode fallback shares the sentinel - // predicate with the postprocess clearing path; Pi explicitly supplies its - // own provider-independent capability. + // Never project reclaimed reasoning unless this harness can actually clear it + // from the provider wire. Both harnesses now supply `canClearReasoning` + // explicitly — OpenCode passes the capability its transform already resolved, + // Pi passes its own. The predicate below is a provider-id-only fallback for a + // caller that supplies neither; it under-reports for a custom provider serving + // Claude, so a new caller should pass the capability rather than rely on it. const canClearReasoning = reasoningProjection?.canClearReasoning ?? modelAcceptsEmptyContent(reasoningProjection?.providerID); diff --git a/packages/plugin/src/hooks/magic-context/inject-compartments-anthropic-wire.test.ts b/packages/plugin/src/hooks/magic-context/inject-compartments-anthropic-wire.test.ts new file mode 100644 index 000000000..362e6cb78 --- /dev/null +++ b/packages/plugin/src/hooks/magic-context/inject-compartments-anthropic-wire.test.ts @@ -0,0 +1,182 @@ +/// + +import { describe, expect, it } from "bun:test"; +import { runMigrations } from "../../features/magic-context/migrations"; +import { initializeDatabase } from "../../features/magic-context/storage-db"; +import { getOrCreateSessionMeta } from "../../features/magic-context/storage-meta-session"; +import { Database } from "../../shared/sqlite"; +import { closeQuietly } from "../../shared/sqlite-helpers"; +import { decodeCachedM0UpgradeIdentity } from "./compartment-render-epoch"; +import { injectM0M1, type M0M1State } from "./inject-compartments"; + +const SESSION_ID = "ses_anthropic_wire_inject"; +const PROJECT_ID = "git:anthropic-wire-project"; + +function makeDb(): Database { + const db = new Database(":memory:"); + initializeDatabase(db); + runMigrations(db); + getOrCreateSessionMeta(db, SESSION_ID); + return db; +} + +const MODEL_KEY = "my_gateway/claude-opus-5"; + +function hardSignals(modelKey: string) { + return { + systemHash: "system-hash-1", + toolSetHash: "tool-hash-1", + modelKey, + cacheExpired: false, + lastResponseTime: 0, + }; +} + +function pass( + db: Database, + state: M0M1State, + options: { + anthropicWireWidened?: boolean; + isCacheBustingPass?: boolean; + /** Empty string stands for "the live model is not observable this pass". */ + modelKey?: string; + }, +) { + return injectM0M1({ + db, + sessionId: SESSION_ID, + messages: [], + state, + projectPath: PROJECT_ID, + isCacheBustingPass: options.isCacheBustingPass ?? false, + anthropicWireWidened: options.anthropicWireWidened, + hardSignals: hardSignals(options.modelKey ?? MODEL_KEY), + memoryInjectionBudgetTokens: 8_000, + historyBudgetTokens: 60_000, + }); +} + +/** + * The empty-sentinel capability changes provider-visible tail bytes, so a change + * has to fold m[0] exactly once rather than rewrite the tail underneath a cached + * prefix on a defer pass. These tests pin "exactly once" in both directions and + * pin the cases that must never fold. + */ +describe("m[0] upgrade identity: anthropic-wire capability", () => { + it("folds once when a session's model is newly resolved as Anthropic wire", () => { + const db = makeDb(); + try { + const state = getOrCreateSessionMeta(db, SESSION_ID) as unknown as M0M1State; + // A session materialized before this capability existed carries no + // component, which reads as "not widened". + pass(db, state, { anthropicWireWidened: false, isCacheBustingPass: true }); + expect( + decodeCachedM0UpgradeIdentity(state.cachedM0UpgradeState).anthropicWireWidened, + ).toBe(false); + + const widened = pass(db, state, { anthropicWireWidened: true }); + expect(widened.decision).toEqual({ value: true, reason: "render_config" }); + expect(widened.m0RematerializedThisPass).toBe(true); + expect( + decodeCachedM0UpgradeIdentity(state.cachedM0UpgradeState).anthropicWireWidened, + ).toBe(true); + + // Exactly once: the next pass replays the folded baseline. + const settled = pass(db, state, { anthropicWireWidened: true }); + expect(settled.m0RematerializedThisPass).toBe(false); + expect(settled.m0Bytes).toEqual(widened.m0Bytes); + } finally { + closeQuietly(db); + } + }); + + it("folds once in the narrowing direction too", () => { + const db = makeDb(); + try { + const state = getOrCreateSessionMeta(db, SESSION_ID) as unknown as M0M1State; + pass(db, state, { anthropicWireWidened: true, isCacheBustingPass: true }); + + const narrowed = pass(db, state, { anthropicWireWidened: false }); + expect(narrowed.decision).toEqual({ value: true, reason: "render_config" }); + + const settled = pass(db, state, { anthropicWireWidened: false }); + expect(settled.m0RematerializedThisPass).toBe(false); + } finally { + closeQuietly(db); + } + }); + + it("never folds for a session that is not registry-widened", () => { + const db = makeDb(); + try { + const state = getOrCreateSessionMeta(db, SESSION_ID) as unknown as M0M1State; + // Canonical Anthropic and every non-Anthropic provider report `false`: + // the component is absent on both sides, so this reason can never fire. + pass(db, state, { anthropicWireWidened: false, isCacheBustingPass: true }); + expect( + decodeCachedM0UpgradeIdentity(state.cachedM0UpgradeState).anthropicWireWidened, + ).toBe(false); + + for (let index = 0; index < 3; index += 1) { + const later = pass(db, state, { anthropicWireWidened: false }); + expect(later.m0RematerializedThisPass).toBe(false); + } + } finally { + closeQuietly(db); + } + }); + + it("never folds while the live model is not observable", () => { + const db = makeDb(); + try { + const state = getOrCreateSessionMeta(db, SESSION_ID) as unknown as M0M1State; + // A pass that cannot see its model must not fold: reading "unknown" as + // "not widened" would fold once on the next pass that does resolve it, + // on a session whose capability never changed. + pass(db, state, { + anthropicWireWidened: false, + isCacheBustingPass: true, + modelKey: "", + }); + const unknown = pass(db, state, { anthropicWireWidened: true, modelKey: "" }); + expect(unknown.decision.reason).not.toBe("render_config"); + } finally { + closeQuietly(db); + } + }); + + it("leaves a changed model to the model-change trigger instead of double-attributing", () => { + const db = makeDb(); + try { + const state = getOrCreateSessionMeta(db, SESSION_ID) as unknown as M0M1State; + pass(db, state, { anthropicWireWidened: true, isCacheBustingPass: true }); + + // Switching to a model that is not registry-widened flips the capability + // AND the model key. One fold, attributed to the model change. + const switched = pass(db, state, { + anthropicWireWidened: false, + modelKey: "openai/gpt-6", + }); + expect(switched.decision).toEqual({ + value: true, + reason: "model_change", + m0ModelKeyPrev: MODEL_KEY, + m0ModelKeyNew: "openai/gpt-6", + }); + } finally { + closeQuietly(db); + } + }); + + it("treats an omitted capability as not widened rather than as a change", () => { + const db = makeDb(); + try { + const state = getOrCreateSessionMeta(db, SESSION_ID) as unknown as M0M1State; + pass(db, state, { isCacheBustingPass: true }); + const later = pass(db, state, {}); + expect(later.m0RematerializedThisPass).toBe(false); + } finally { + closeQuietly(db); + } + }); +}); diff --git a/packages/plugin/src/hooks/magic-context/inject-compartments.ts b/packages/plugin/src/hooks/magic-context/inject-compartments.ts index 387491b44..390fcf452 100644 --- a/packages/plugin/src/hooks/magic-context/inject-compartments.ts +++ b/packages/plugin/src/hooks/magic-context/inject-compartments.ts @@ -760,6 +760,14 @@ export interface M0SnapshotMarkers { muralHash?: string | null; muralEnabled: boolean | null; renderBudgetIdentity: string | null; + /** + * True only when the adapter registry widened the empty-sentinel gate for + * this pass. It changes provider-visible tail bytes (structural-noise + * sentinels, whole-message placeholder text), so it belongs in the m[0] + * upgrade identity: the flip then folds exactly once instead of + * first-applying on a defer pass that promised byte-identical replay. + */ + anthropicWireWidened: boolean; } /** @@ -841,6 +849,9 @@ export interface M0M1RenderOptions { * and the fold's model accepts images, materializeM0 resolves + renders the * deterministic mural on demand and folds its image into the m[0] baseline. */ muralEnabled?: boolean; + /** True only when the adapter registry widened the empty-sentinel gate; it + * becomes part of the m[0] upgrade identity so the flip folds once. */ + anthropicWireWidened?: boolean; isCacheBustingPass?: boolean; /** Force/emergency may serve a fresh recovery prefix even if persistence loses contention. */ allowFreshContentionFallback?: boolean; @@ -1172,6 +1183,7 @@ interface M0SnapshotMarkerReadArgs { /** False suppresses the profile and mural surfaces alongside project memory. */ memoryEnabled?: boolean; muralEnabled?: boolean; + anthropicWireWidened?: boolean; memoryInjectionBudgetTokens?: number; historyBudgetTokens?: number; hardSignals?: M0HardSignals; @@ -1419,6 +1431,7 @@ function readCurrentM0SnapshotMarkersUncached(args: M0SnapshotMarkerReadArgs): { args.memoryInjectionBudgetTokens, args.historyBudgetTokens, ), + anthropicWireWidened: args.anthropicWireWidened === true, }, }; } @@ -1436,6 +1449,7 @@ function refreshVolatileMarkerInputs( modelKey: hard.modelKey, projectIdentity: args.projectPath ?? null, muralEnabled: args.memoryEnabled !== false && args.muralEnabled === true, + anthropicWireWidened: args.anthropicWireWidened === true, renderBudgetIdentity: renderBudgetIdentity( args.memoryInjectionBudgetTokens, args.historyBudgetTokens, @@ -1523,6 +1537,7 @@ function snapshotMarkersFromCachedM0(state: M0M1State): M0SnapshotMarkers | null muralHash: state.cachedM0MuralHash ?? null, muralEnabled: cachedUpgradeIdentity.muralEnabled, renderBudgetIdentity: cachedUpgradeIdentity.renderBudgetIdentity, + anthropicWireWidened: cachedUpgradeIdentity.anthropicWireWidened, }; } @@ -1563,6 +1578,7 @@ export function mustMaterialize(args: { /** False suppresses the profile and mural surfaces alongside project memory. */ memoryEnabled?: boolean; muralEnabled?: boolean; + anthropicWireWidened?: boolean; memoryInjectionBudgetTokens?: number; historyBudgetTokens?: number; }): MaterializeDecision { @@ -1601,6 +1617,29 @@ export function mustMaterialize(args: { ) { return { value: true, reason: "render_config" }; } + // The empty-sentinel capability is a provider-visible byte fact, so a change has + // to fold rather than rewrite the tail underneath a cached prefix. The fold also + // opens the mutation gates on that same pass, so the merged-reasoning strip + // first-applies into the bust it just paid for (ARCHITECTURE invariant 1). + // + // Scoped to a KNOWN, UNCHANGED model, for two separate reasons: + // - An empty live model key means "unknown this pass", and an unknown signal + // must never fold (same rule the model-change gate below states). Reading + // unknown as "not widened" would fold once on the next pass that resolves + // the model, on a session whose capability never actually changed. + // - A model that DID change already folds through `model_change` below, which + // rewrites this component as part of the same materialization. Comparing here + // too would only mis-attribute that fold. + // Absence reads as `false` on both sides (see ANTHROPIC_WIRE_COMPONENT_PREFIX), + // so canonical Anthropic and non-Anthropic sessions never fold for this reason. + const liveModelKeyForWire = piModelRefToCanonical(hard.modelKey); + if ( + liveModelKeyForWire !== "" && + liveModelKeyForWire === piModelRefToCanonical(args.state.cachedM0ModelKey ?? "") && + cachedUpgradeIdentity.anthropicWireWidened !== current.anthropicWireWidened + ) { + return { value: true, reason: "render_config" }; + } // ── HARD: provider-side cache eviction (the cache was already dead) ── // Folding m[1] into m[0] here is "free" — the prefix is being re-cached @@ -2121,6 +2160,7 @@ function applyMarkersToState( markers.compartmentRenderEpoch, markers.muralEnabled, markers.renderBudgetIdentity, + markers.anthropicWireWidened, ); // Runtime markers must be mirrored into flat state because the next // mustMaterialize pass reads cachedM0SystemHash/ToolSetHash/ModelKey directly @@ -2206,6 +2246,7 @@ export function materializeM0(options: M0M1RenderOptions): MaterializeM0Result { injectDocs: options.injectDocs, memoryEnabled: options.memoryEnabled, muralEnabled: options.muralEnabled, + anthropicWireWidened: options.anthropicWireWidened, memoryInjectionBudgetTokens: options.memoryInjectionBudgetTokens, historyBudgetTokens: options.historyBudgetTokens, hardSignals: options.hardSignals, @@ -2376,6 +2417,7 @@ export function materializeM0(options: M0M1RenderOptions): MaterializeM0Result { projectIdentity: projectPath ?? null, muralEnabled: snapshotMarkers.muralEnabled, renderBudgetIdentity: snapshotMarkers.renderBudgetIdentity, + anthropicWireWidened: snapshotMarkers.anthropicWireWidened, }; // NOTE: maxMemoryId is deliberately EXCLUDED from this stale-check. // Additive memory writes (write/promote) do not invalidate the rendered @@ -2437,6 +2479,7 @@ export function materializeM0(options: M0M1RenderOptions): MaterializeM0Result { snapshotMarkers.compartmentRenderEpoch, snapshotMarkers.muralEnabled, snapshotMarkers.renderBudgetIdentity, + snapshotMarkers.anthropicWireWidened, ), systemHash: snapshotMarkers.systemHash, toolSetHash: snapshotMarkers.toolSetHash, @@ -2846,6 +2889,7 @@ function markersFromCachedRow(row: CachedM0M1Row): M0SnapshotMarkers | null { muralHash: row.cached_m0_mural_hash ?? null, muralEnabled: cachedUpgradeIdentity.muralEnabled, renderBudgetIdentity: cachedUpgradeIdentity.renderBudgetIdentity, + anthropicWireWidened: cachedUpgradeIdentity.anthropicWireWidened, }; } @@ -2900,6 +2944,7 @@ function applyCachedRowToState(state: M0M1State, row: CachedM0M1Row): void { markers.compartmentRenderEpoch, markers.muralEnabled, markers.renderBudgetIdentity, + markers.anthropicWireWidened, ); state.cachedM0SystemHash = markers.systemHash; state.cachedM0ToolSetHash = markers.toolSetHash; @@ -3056,6 +3101,7 @@ function renderFreshM0NonPersisted(options: M0M1RenderOptions): { injectDocs: options.injectDocs, memoryEnabled: options.memoryEnabled, muralEnabled: options.muralEnabled, + anthropicWireWidened: options.anthropicWireWidened, memoryInjectionBudgetTokens: options.memoryInjectionBudgetTokens, historyBudgetTokens: options.historyBudgetTokens, hardSignals: options.hardSignals, @@ -3283,6 +3329,7 @@ export function injectM0M1(options: M0M1RenderOptions): InjectM0M1Result { injectDocs: options.injectDocs, memoryEnabled: options.memoryEnabled, muralEnabled: options.muralEnabled, + anthropicWireWidened: options.anthropicWireWidened, memoryInjectionBudgetTokens: options.memoryInjectionBudgetTokens, historyBudgetTokens: options.historyBudgetTokens, }); diff --git a/packages/plugin/src/hooks/magic-context/rust-mode-transform.ts b/packages/plugin/src/hooks/magic-context/rust-mode-transform.ts index 8942da053..99b02b239 100644 --- a/packages/plugin/src/hooks/magic-context/rust-mode-transform.ts +++ b/packages/plugin/src/hooks/magic-context/rust-mode-transform.ts @@ -119,6 +119,7 @@ import { RECOVERY_NO_HEAD_LIMIT } from "./protected-tail-boundary"; import { RawFallbackContextLimitError } from "./raw-fallback-context-limit"; import { findLastAssistantModelFromOpenCodeDb } from "./read-session-db"; import type { RawMessageOrdinalAnchor } from "./read-session-raw"; +import { modelAcceptsEmptyContent } from "./sentinel"; import { snapshotTrailingBlankSourceDecisions } from "./strip-content"; import { computeSyntheticCallId, normalizeTodoStateJson } from "./todo-view"; import type { TransformDeps } from "./transform"; @@ -1707,7 +1708,9 @@ export function createRustModeTransform( db: deps.db, sessionId, messages: replay.messages as MessageLike[], - resolvedProviderID: replayModel?.providerID, + // Rust mode stays canonical-Anthropic only; the module keeps its own + // `provider_id == "anthropic"` gates and both lanes must agree. + acceptsEmptySentinels: modelAcceptsEmptyContent(replayModel?.providerID), }); const trustedReplayLimit = replayModel ? resolveTrustedContextLimit(replayModel.providerID, replayModel.modelID, { @@ -3147,7 +3150,11 @@ export function createRustModeTransform( materializedBoundary, fullFeatureMode: !sessionMeta.isSubagent, compactionOff: deps.compactionOff, - resolvedProviderID: model?.providerID, + // Rust mode stays canonical-Anthropic only. The module keeps its + // own `provider_id == "anthropic"` gates, so widening the host side + // alone would let the two lanes disagree on the served bytes. See + // `anthropic-wire.ts` for the TS-mode capability. + acceptsEmptySentinels: modelAcceptsEmptyContent(model?.providerID), thinkingBindingRecoveryEnabledForModel: isFable51ThinkingBindingModel( model?.providerID, model?.modelID, diff --git a/packages/plugin/src/hooks/magic-context/sentinel.test.ts b/packages/plugin/src/hooks/magic-context/sentinel.test.ts index 19142f523..eab5d04ab 100644 --- a/packages/plugin/src/hooks/magic-context/sentinel.test.ts +++ b/packages/plugin/src/hooks/magic-context/sentinel.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from "bun:test"; -import { variantChangeBustsProviderCache } from "./sentinel"; +import { + replaySentinelByMessageIds, + variantChangeBustsProviderCache, + WHOLE_MESSAGE_PLACEHOLDER_TEXT, +} from "./sentinel"; describe("variantChangeBustsProviderCache", () => { test.each([ @@ -20,3 +24,33 @@ describe("variantChangeBustsProviderCache", () => { expect(variantChangeBustsProviderCache(providerID, modelID)).toBe(expected); }); }); + +describe("replaySentinelByMessageIds", () => { + test("normalizes an old empty whole-message sentinel for a model that rejects empty content", () => { + const messages = [ + { + info: { id: "assistant-1" }, + parts: [{ type: "text", text: "" }], + }, + ]; + + replaySentinelByMessageIds(messages, new Set(["assistant-1"]), false); + + expect(messages[0]?.parts).toEqual([ + { type: "text", text: WHOLE_MESSAGE_PLACEHOLDER_TEXT }, + ]); + }); + + test("normalizes an old placeholder for an Anthropic-wire model", () => { + const messages = [ + { + info: { id: "assistant-1" }, + parts: [{ type: "text", text: WHOLE_MESSAGE_PLACEHOLDER_TEXT }], + }, + ]; + + replaySentinelByMessageIds(messages, new Set(["assistant-1"]), true); + + expect(messages[0]?.parts).toEqual([{ type: "text", text: "" }]); + }); +}); diff --git a/packages/plugin/src/hooks/magic-context/sentinel.ts b/packages/plugin/src/hooks/magic-context/sentinel.ts index 02afd0a28..023b991e8 100644 --- a/packages/plugin/src/hooks/magic-context/sentinel.ts +++ b/packages/plugin/src/hooks/magic-context/sentinel.ts @@ -1,6 +1,7 @@ import { isFable51ThinkingBindingModel } from "../../features/magic-context/overflow-detection"; import { canonicalModelIdentity } from "../../shared/harness-provider-map"; import { isRecord } from "../../shared/record-type-guard"; +import { isAnthropicWireModel } from "./anthropic-wire"; /** * Whole-message sentinel placeholder for providers that must not receive empty @@ -20,22 +21,29 @@ import { isRecord } from "../../shared/record-type-guard"; export const WHOLE_MESSAGE_PLACEHOLDER_TEXT = "[dropped]"; /** - * Decide whether empty-text sentinels are safe for the provider's wire path. + * Decide whether empty-text sentinels are safe for the model's wire path. * - * The gate is deliberately canonical-Anthropic only. OpenCode filters empty - * text/reasoning parts only in the `@ai-sdk/anthropic` branch before sending - * to the provider; github-copilot and other non-Anthropic adapters forward - * `{type:"text", text:""}` parts as real content blocks. Bedrock also filters - * empty text later, but native `step-start` boundaries and empty sentinels are - * not byte-equivalent before that filter runs. Google Vertex Anthropic maps to - * an Anthropic SDK key but does not enter OpenCode's `@ai-sdk/anthropic` - * empty-part filter. + * The gate is the `@ai-sdk/anthropic` adapter, not the provider name. OpenCode + * filters empty text/reasoning parts only in that branch before sending to the + * provider; other adapters forward `{type:"text", text:""}` parts as real + * content blocks. Bedrock Converse also filters empty text later, but native + * `step-start` boundaries and empty sentinels are not byte-equivalent before + * that filter runs. Google Vertex Anthropic maps to an Anthropic SDK key but + * does not enter OpenCode's `@ai-sdk/anthropic` empty-part filter. * - * Unknown or non-canonical providers therefore must keep native parts (or use - * non-empty whole-message placeholders) rather than producing empty sentinels. + * Canonical `anthropic` answers true without a lookup so the registry is never + * on the critical path for the common case. Any other provider must be reported + * by `provider.list()` as serving this model on `@ai-sdk/anthropic` — see + * `anthropic-wire.ts`. Unknown models keep native parts (or use non-empty + * whole-message placeholders) rather than producing empty sentinels. + * + * This is the only place the WIRE RULE lives. `resolveEmptySentinelCapability` + * in `anthropic-wire.ts` wraps it with the unresolved-model policy and is what + * the transform calls; every consumer downstream takes the resolved boolean, so + * no two phases of one pass can disagree and oscillate the cached prefix. */ -export function modelAcceptsEmptyContent(providerID?: string): boolean { - return providerID === "anthropic"; +export function modelAcceptsEmptyContent(providerID?: string, modelID?: string): boolean { + return providerID === "anthropic" || isAnthropicWireModel(providerID, modelID); } /** @@ -129,19 +137,19 @@ export function makeSentinel(originalPart: unknown): { /** * Create a sentinel for replacing a WHOLE assistant message's parts list. * - * Picks `""` when the live provider is the canonical Anthropic provider - * (whose AI-SDK normalization filters empty content from the wire), - * `[dropped]` otherwise. See `modelAcceptsEmptyContent` for the rule. + * Picks `""` when the live model rides the Anthropic wire (whose AI-SDK + * normalization filters empty content off the wire), `[dropped]` otherwise. + * See `modelAcceptsEmptyContent` for the rule that resolves the flag. * * The chosen placeholder text is kept in `WHOLE_MESSAGE_PLACEHOLDER_TEXT` * so `isSentinel` recognizes both shapes (idempotency on replay). */ export function makeWholeMessageSentinel( - providerID?: string, + acceptsEmptySentinels?: boolean, ): { type: "text"; text: string } & Record { return { type: "text", - text: modelAcceptsEmptyContent(providerID) ? "" : WHOLE_MESSAGE_PLACEHOLDER_TEXT, + text: acceptsEmptySentinels ? "" : WHOLE_MESSAGE_PLACEHOLDER_TEXT, }; } @@ -163,14 +171,14 @@ export function isSentinel(part: unknown): boolean { /** * Replay persisted whole-message decisions onto a fresh host projection. - * Canonical Anthropic keeps empty sentinels because its adapter filters them. - * For non-empty-sentinel providers, hidden seam rows are removed instead: they + * Anthropic-wire models keep empty sentinels because the adapter filters them. + * For non-empty-sentinel models, hidden seam rows are removed instead: they * were absent on the fold pass, so removal is the only byte-identical replay. */ export function replaySentinelByMessageIds( messages: Array<{ info: { id?: string }; parts: unknown[] }>, ids: Set, - providerID?: string, + acceptsEmptySentinels?: boolean, hiddenSeamIds: ReadonlySet = new Set(), ): { replayed: number; missingIds: string[] } { if (ids.size === 0) return { replayed: 0, missingIds: [] }; @@ -181,14 +189,23 @@ export function replaySentinelByMessageIds( const id = msg.info.id; if (!id || !ids.has(id)) continue; seen.add(id); - if (!modelAcceptsEmptyContent(providerID) && hiddenSeamIds.has(id)) { + if (!acceptsEmptySentinels && hiddenSeamIds.has(id)) { messages.splice(index, 1); replayed += 1; continue; } - if (msg.parts.length === 1 && isSentinel(msg.parts[0])) continue; + const sentinel = msg.parts.length === 1 ? msg.parts[0] : undefined; + const expectedText = acceptsEmptySentinels ? "" : WHOLE_MESSAGE_PLACEHOLDER_TEXT; + if ( + sentinel !== undefined && + isSentinel(sentinel) && + isRecord(sentinel) && + sentinel.text === expectedText + ) { + continue; + } msg.parts.length = 0; - msg.parts.push(makeWholeMessageSentinel(providerID)); + msg.parts.push(makeWholeMessageSentinel(acceptsEmptySentinels)); replayed += 1; } const missingIds: string[] = []; diff --git a/packages/plugin/src/hooks/magic-context/strip-content.test.ts b/packages/plugin/src/hooks/magic-context/strip-content.test.ts index a154d1d6d..82fdc00b4 100644 --- a/packages/plugin/src/hooks/magic-context/strip-content.test.ts +++ b/packages/plugin/src/hooks/magic-context/strip-content.test.ts @@ -571,7 +571,7 @@ describe("strip-content", () => { { type: "text", text: "[dropped §8§]" }, ]); - const result = stripDroppedPlaceholderMessages([assistant], "anthropic"); + const result = stripDroppedPlaceholderMessages([assistant], true); expect(result.stripped).toBe(1); expect(assistant.parts).toEqual([SENTINEL]); @@ -584,7 +584,7 @@ describe("strip-content", () => { { type: "text", text: "[dropped §8§]" }, ]); - const result = stripDroppedPlaceholderMessages([assistant], "opencode-go"); + const result = stripDroppedPlaceholderMessages([assistant], false); expect(result.stripped).toBe(1); expect(assistant.parts).toEqual([WHOLE_MESSAGE_SENTINEL]); @@ -708,13 +708,9 @@ describe("strip-content", () => { // Not exempt: a1 is no longer the newest assistant — the exact // transition that previously stripped it. - const stripped = stripReasoningFromMergedAssistants( - [u, a1, u2, newest], - "anthropic", - { - mutationExemptMessage: newest, - }, - ); + const stripped = stripReasoningFromMergedAssistants([u, a1, u2, newest], true, { + mutationExemptMessage: newest, + }); expect(stripped).toBe(0); expect(a1.parts[1]).toMatchObject({ type: "reasoning", text: "thinking body" }); @@ -728,7 +724,7 @@ describe("strip-content", () => { ]); const a2 = message("m-a2", "assistant", [{ type: "text", text: "second in run" }]); - const stripped = stripReasoningFromMergedAssistants([u, a1, a2], "anthropic"); + const stripped = stripReasoningFromMergedAssistants([u, a1, a2], true); expect(stripped).toBe(1); expect(a1.parts[1]).toMatchObject({ type: "text", text: "" }); @@ -743,7 +739,7 @@ describe("strip-content", () => { { type: "text", text: "response" }, ]); - const stripped = stripReasoningFromMergedAssistants([u, a1], "anthropic"); + const stripped = stripReasoningFromMergedAssistants([u, a1], true); expect(stripped).toBe(0); expect(a1.parts).toEqual([ @@ -765,7 +761,7 @@ describe("strip-content", () => { { type: "text", text: "second response" }, ]); - const stripped = stripReasoningFromMergedAssistants([u, a1, a2], "anthropic"); + const stripped = stripReasoningFromMergedAssistants([u, a1, a2], true); expect(stripped).toBe(1); expect(a1.parts).toEqual([ @@ -796,8 +792,8 @@ describe("strip-content", () => { ]); const messages = [first, cached, newest]; - expect(findMergedReasoningStripCandidateIds(messages, "anthropic")).toEqual([]); - expect(stripReasoningFromMergedAssistants(messages, "anthropic")).toBe(0); + expect(findMergedReasoningStripCandidateIds(messages, true)).toEqual([]); + expect(stripReasoningFromMergedAssistants(messages, true)).toBe(0); expect(cached.parts[0]).toEqual({ type: "reasoning", text: "cached reasoning", @@ -842,7 +838,7 @@ describe("strip-content", () => { const unprotectedLatestBefore = JSON.stringify( unprotected.latest.parts.slice(0, 2), ); - stripReasoningFromMergedAssistants(unprotected.messages, "anthropic"); + stripReasoningFromMergedAssistants(unprotected.messages, true); expect(JSON.stringify(unprotected.latest.parts.slice(0, 2))).not.toBe( unprotectedLatestBefore, ); @@ -851,7 +847,7 @@ describe("strip-content", () => { const latestBefore = JSON.stringify(protectedFixture.latest.parts.slice(0, 2)); const stripped = stripReasoningFromMergedAssistants( protectedFixture.messages, - "anthropic", + true, { mutationExemptMessage: protectedFixture.latest }, ); @@ -889,7 +885,7 @@ describe("strip-content", () => { const first = buildFixture(); expect( - stripReasoningFromMergedAssistants(first.messages, "anthropic", { + stripReasoningFromMergedAssistants(first.messages, true, { frozenMessageIds: new Set(["m-frozen"]), mutationExemptMessage: first.newest, }), @@ -902,7 +898,7 @@ describe("strip-content", () => { const rebuilt = buildFixture(); expect( - stripReasoningFromMergedAssistants(rebuilt.messages, "anthropic", { + stripReasoningFromMergedAssistants(rebuilt.messages, true, { frozenMessageIds: new Set(["m-frozen"]), mutationExemptMessage: rebuilt.newest, }), @@ -931,7 +927,7 @@ describe("strip-content", () => { { type: "text", text: "finished" }, ]); - const stripped = stripReasoningFromMergedAssistants([u, a1, a2, a3], "anthropic"); + const stripped = stripReasoningFromMergedAssistants([u, a1, a2, a3], true); expect(stripped).toBe(2); expect(a1.parts[0]).toEqual({ type: "reasoning", text: "plan" }); @@ -953,7 +949,7 @@ describe("strip-content", () => { { type: "text", text: "reply 2" }, ]); - const stripped = stripReasoningFromMergedAssistants([u1, a1, u2, a2], "anthropic"); + const stripped = stripReasoningFromMergedAssistants([u1, a1, u2, a2], true); expect(stripped).toBe(0); expect(a1.parts[0]).toEqual({ type: "reasoning", text: "r1" }); @@ -974,7 +970,7 @@ describe("strip-content", () => { { type: "text", text: "done" }, ]); - const stripped = stripReasoningFromMergedAssistants([u, a1, t, a2], "anthropic"); + const stripped = stripReasoningFromMergedAssistants([u, a1, t, a2], true); expect(stripped).toBe(0); expect(a1.parts[0]).toEqual({ type: "reasoning", text: "r1" }); @@ -989,7 +985,7 @@ describe("strip-content", () => { { type: "text", text: "just text, no reasoning" }, ]); - const stripped = stripReasoningFromMergedAssistants([u, a1], "anthropic"); + const stripped = stripReasoningFromMergedAssistants([u, a1], true); expect(stripped).toBe(0); expect(a1.parts).toHaveLength(1); @@ -1005,7 +1001,7 @@ describe("strip-content", () => { { type: "text", text: "final" }, ]); - const stripped = stripReasoningFromMergedAssistants([u, a1], "anthropic"); + const stripped = stripReasoningFromMergedAssistants([u, a1], true); expect(stripped).toBe(1); expect(a1.parts[0]).toEqual({ type: "text", text: "preamble" }); @@ -1023,7 +1019,7 @@ describe("strip-content", () => { { type: "text", text: "output" }, ]); - const stripped = stripReasoningFromMergedAssistants([u, a1], "anthropic"); + const stripped = stripReasoningFromMergedAssistants([u, a1], true); expect(stripped).toBe(0); expect(a1.parts[1]).toEqual({ type: "reasoning", text: "reasoning here" }); @@ -1041,7 +1037,7 @@ describe("strip-content", () => { { type: "reasoning", text: "r3" }, ]); - const stripped = stripReasoningFromMergedAssistants([u, a1], "anthropic"); + const stripped = stripReasoningFromMergedAssistants([u, a1], true); expect(stripped).toBe(2); expect(a1.parts).toHaveLength(5); @@ -1065,7 +1061,7 @@ describe("strip-content", () => { { type: "text", text: "t2" }, ]); - const stripped = stripReasoningFromMergedAssistants([u, a1, a2], "anthropic"); + const stripped = stripReasoningFromMergedAssistants([u, a1, a2], true); expect(stripped).toBe(2); expect(a1.parts[0]).toEqual({ type: "text", text: "preamble" }); @@ -1087,7 +1083,7 @@ describe("strip-content", () => { { type: "text", text: "reply 2" }, ]); - const stripped = stripReasoningFromMergedAssistants([u, a1, a2], "anthropic"); + const stripped = stripReasoningFromMergedAssistants([u, a1, a2], true); expect(stripped).toBe(1); expect(a1.parts[0]).toEqual({ type: "thinking", thinking: "thought 1" }); @@ -1107,7 +1103,7 @@ describe("strip-content", () => { { type: "redacted_thinking", data: "opaque" }, ]); - const stripped = stripReasoningFromMergedAssistants([u, a1, a2], "anthropic"); + const stripped = stripReasoningFromMergedAssistants([u, a1, a2], true); expect(stripped).toBe(2); expect(a1.parts[0]).toEqual({ type: "reasoning", text: "r" }); @@ -1128,7 +1124,7 @@ describe("strip-content", () => { { type: "text", text: "final" }, ]); - const stripped = stripReasoningFromMergedAssistants([u, a1, a2], "anthropic"); + const stripped = stripReasoningFromMergedAssistants([u, a1, a2], true); expect(stripped).toBe(2); expect(a1.parts.map((p) => (p as { type: string }).type)).toEqual(["text", "text"]); @@ -1139,16 +1135,16 @@ describe("strip-content", () => { }); }); - describe("#given providerID gate (anthropic-only workaround)", () => { + describe("#given the empty-sentinel capability gate", () => { // Verifies the Kimi/Moonshot fix: stripReasoningFromMergedAssistants // is an Anthropic-AI-SDK-specific workaround for groupIntoBlocks. // For openai-compatible providers like Kimi, stripping reasoning // from non-first merged assistants triggers // "thinking is enabled but reasoning_content is missing in // assistant tool call message at index N". The function MUST be a - // no-op for non-anthropic providers. + // no-op unless the caller resolved the Anthropic-wire capability. - it("#then is a no-op when providerID is undefined", () => { + it("#then is a no-op when the capability is undefined", () => { const u = message("m-u", "user", [{ type: "text", text: "hi" }]); const a1 = message("m-a1", "assistant", [ { type: "reasoning", text: "first reasoning" }, @@ -1167,7 +1163,7 @@ describe("strip-content", () => { expect(a2.parts[0]).toEqual({ type: "reasoning", text: "second reasoning" }); }); - it("#then is a no-op for opencode-go (Kimi/Moonshot)", () => { + it("#then is a no-op when the capability is false (Kimi/Moonshot)", () => { const u = message("m-u", "user", [{ type: "text", text: "hi" }]); const a1 = message("m-a1", "assistant", [ { type: "reasoning", text: "first reasoning" }, @@ -1178,24 +1174,24 @@ describe("strip-content", () => { { type: "tool", tool: "bash", id: "bash:2" }, ]); - const stripped = stripReasoningFromMergedAssistants([u, a1, a2], "opencode-go"); + const stripped = stripReasoningFromMergedAssistants([u, a1, a2], false); expect(stripped).toBe(0); expect(a1.parts[0]).toEqual({ type: "reasoning", text: "first reasoning" }); expect(a2.parts[0]).toEqual({ type: "reasoning", text: "second reasoning" }); }); - it("#then is a no-op for github-copilot", () => { + it("#then is a no-op when the capability is false for a proxy provider", () => { const u = message("m-u", "user", [{ type: "text", text: "hi" }]); const a1 = message("m-a1", "assistant", [{ type: "reasoning", text: "first" }]); const a2 = message("m-a2", "assistant", [{ type: "reasoning", text: "second" }]); - const stripped = stripReasoningFromMergedAssistants([u, a1, a2], "github-copilot"); + const stripped = stripReasoningFromMergedAssistants([u, a1, a2], false); expect(stripped).toBe(0); }); - it("#then runs normally for providerID === 'anthropic'", () => { + it("#then runs normally when the capability is true", () => { const u = message("m-u", "user", [{ type: "text", text: "hi" }]); const a1 = message("m-a1", "assistant", [ { type: "reasoning", text: "first reasoning" }, @@ -1204,7 +1200,7 @@ describe("strip-content", () => { { type: "reasoning", text: "second reasoning" }, ]); - const stripped = stripReasoningFromMergedAssistants([u, a1, a2], "anthropic"); + const stripped = stripReasoningFromMergedAssistants([u, a1, a2], true); expect(stripped).toBe(1); // First kept, second sentineled @@ -1287,7 +1283,7 @@ describe("strip-content", () => { } const guardedInlineCount = replayStrippedInlineThinking(guarded, tagMap(guarded), 99); - const guardedDropped = stripDroppedPlaceholderMessages(guarded, "anthropic"); + const guardedDropped = stripDroppedPlaceholderMessages(guarded, true); expect(guardedInlineCount).toBe(oldInlineCount); expect(guardedDropped.stripped).toBe(oldDroppedCount); @@ -1305,7 +1301,7 @@ describe("frozen merged reasoning parts", () => { ]), ]; const legacy = build(); - stripReasoningFromMergedAssistants(legacy, "anthropic", { + stripReasoningFromMergedAssistants(legacy, true, { frozenMessageIds: new Set(["legacy"]), }); expect(legacy[0].parts).toEqual([ @@ -1314,7 +1310,7 @@ describe("frozen merged reasoning parts", () => { SENTINEL, ]); const fresh = build(); - stripReasoningFromMergedAssistants(fresh, "anthropic", { + stripReasoningFromMergedAssistants(fresh, true, { frozenMessageIds: new Set(["legacy"]), }); expect(JSON.stringify(fresh)).toBe(JSON.stringify(legacy)); @@ -1328,8 +1324,8 @@ describe("frozen merged reasoning parts", () => { { id: "second", type: "reasoning", text: "stripped second" }, ]); const first = build(); - const frozen = new Set(findMergedReasoningStripDecisions([first], "anthropic", new Set())); - stripReasoningFromMergedAssistants([first], "anthropic", { frozenMessageIds: frozen }); + const frozen = new Set(findMergedReasoningStripDecisions([first], true, new Set())); + stripReasoningFromMergedAssistants([first], true, { frozenMessageIds: frozen }); expect(first.parts[0]).toMatchObject({ type: "reasoning", text: "kept first" }); expect(first.parts[2]).toEqual(SENTINEL); const fresh = build(); @@ -1338,8 +1334,8 @@ describe("frozen merged reasoning parts", () => { message("preceding", "assistant", [{ type: "text", text: "prior" }]), fresh, ]; - expect(findMergedReasoningStripDecisions(changedRun, "anthropic", frozen)).toEqual([]); - stripReasoningFromMergedAssistants(changedRun, "anthropic", { frozenMessageIds: frozen }); + expect(findMergedReasoningStripDecisions(changedRun, true, frozen)).toEqual([]); + stripReasoningFromMergedAssistants(changedRun, true, { frozenMessageIds: frozen }); expect(fresh.parts[1]).toMatchObject({ type: "reasoning", text: "kept first" }); expect(fresh.parts[3]).toEqual(SENTINEL); }); @@ -1354,22 +1350,22 @@ describe("frozen merged reasoning parts", () => { const frozen = new Set( findMergedReasoningStripDecisions( [message("preceding", "assistant", [{ type: "text", text: "prior" }]), first], - "anthropic", + true, new Set(), ), ); - stripReasoningFromMergedAssistants([first], "anthropic", { frozenMessageIds: frozen }); + stripReasoningFromMergedAssistants([first], true, { frozenMessageIds: frozen }); expect(first.parts[0]).toEqual(SENTINEL); const exempt = build(); expect( - stripReasoningFromMergedAssistants([exempt], "anthropic", { + stripReasoningFromMergedAssistants([exempt], true, { frozenMessageIds: frozen, mutationExemptMessage: exempt, }), ).toBe(0); expect(exempt.parts[0]).toMatchObject({ type: "reasoning" }); expect( - stripReasoningFromMergedAssistants([exempt], "openai", { frozenMessageIds: frozen }), + stripReasoningFromMergedAssistants([exempt], false, { frozenMessageIds: frozen }), ).toBe(0); }); }); diff --git a/packages/plugin/src/hooks/magic-context/strip-content.ts b/packages/plugin/src/hooks/magic-context/strip-content.ts index ae58300e8..1a0ffd514 100644 --- a/packages/plugin/src/hooks/magic-context/strip-content.ts +++ b/packages/plugin/src/hooks/magic-context/strip-content.ts @@ -51,7 +51,7 @@ function isSystemInjectedText(text: string): boolean { export function stripSystemInjectedMessages( messages: MessageLike[], protectedTailStart: number, - providerID?: string, + acceptsEmptySentinels?: boolean, ): { stripped: number; sentineledIds: string[] } { let stripped = 0; const sentineledIds: string[] = []; @@ -105,7 +105,7 @@ export function stripSystemInjectedMessages( if (hasContentPart && allContentIsSystemInjection) { msg.parts.length = 0; - msg.parts.push(makeWholeMessageSentinel(providerID)); + msg.parts.push(makeWholeMessageSentinel(acceptsEmptySentinels)); stripped++; if (typeof msg.info.id === "string") sentineledIds.push(msg.info.id); } @@ -164,7 +164,7 @@ const METADATA_PART_TYPES = new Set([ */ export function stripDroppedPlaceholderMessages( messages: MessageLike[], - providerID?: string, + acceptsEmptySentinels?: boolean, ): { stripped: number; sentineledIds: string[]; @@ -245,7 +245,7 @@ export function stripDroppedPlaceholderMessages( if (hasContentPart && !hasNonDroppedContent) { msg.parts.length = 0; - msg.parts.push(makeWholeMessageSentinel(providerID)); + msg.parts.push(makeWholeMessageSentinel(acceptsEmptySentinels)); stripped++; if (typeof msg.info.id === "string") sentineledIds.push(msg.info.id); } @@ -371,9 +371,9 @@ const CLEARED_REASONING_TYPES = new Set(["thinking", "reasoning"]); * sentinels so message.parts length stays constant between passes. * * See strip-structural-noise.ts for the cache-safety rationale. Caller contract: - * run only when `modelAcceptsEmptyContent(providerID)` is true. OpenCode's - * canonical Anthropic adapter filters empty text sentinels before the wire; - * other adapters can forward them as real content blocks. + * run only when the pass's resolved `acceptsEmptySentinels` is true. OpenCode's + * `@ai-sdk/anthropic` adapter filters empty text sentinels before the wire; other + * adapters can forward them as real content blocks. */ export function stripClearedReasoning(messages: MessageLike[]): number { let stripped = 0; @@ -808,10 +808,10 @@ export function applyFrozenTrailingBlankDecisions( */ export function findMergedReasoningStripCandidateIds( messages: MessageLike[], - providerID?: string, + acceptsEmptySentinels?: boolean, options?: { mutationExemptMessage?: MessageLike }, ): string[] { - if (providerID !== "anthropic") return []; + if (!acceptsEmptySentinels) return []; const ids = new Set(); for (const entry of planMergedAssistantReasoningStrip( @@ -871,10 +871,10 @@ export function findMergedReasoningStripCandidateIds( */ export function stripReasoningFromAssistantIds( messages: MessageLike[], - providerID: string | undefined, + acceptsEmptySentinels: boolean | undefined, messageIds: ReadonlySet, ): number { - if (providerID !== "anthropic" || messageIds.size === 0) return 0; + if (!acceptsEmptySentinels || messageIds.size === 0) return 0; let stripped = 0; for (const message of messages) { const id = message.info.id; @@ -895,11 +895,11 @@ export function stripReasoningFromAssistantIds( */ export function findMergedReasoningStripDecisions( messages: MessageLike[], - providerID: string | undefined, + acceptsEmptySentinels: boolean | undefined, frozenIds: ReadonlySet, options?: { mutationExemptMessage?: MessageLike }, ): string[] { - if (providerID !== "anthropic") return []; + if (!acceptsEmptySentinels) return []; const frozenParts = readFrozenMergedReasoningParts(frozenIds); const decisions: string[] = []; for (const entry of planMergedAssistantReasoningStrip( @@ -920,7 +920,7 @@ export function findMergedReasoningStripDecisions( export function stripReasoningFromMergedAssistants( messages: MessageLike[], - providerID?: string, + acceptsEmptySentinels?: boolean, options?: { mutationExemptMessage?: MessageLike; frozenMessageIds?: ReadonlySet; @@ -932,7 +932,7 @@ export function stripReasoningFromMergedAssistants( // must have non-empty `reasoning_content`), so the strip would // trigger 400 "reasoning_content is missing" there. See call site // in transform.ts for the full rationale. - if (providerID !== "anthropic") return 0; + if (!acceptsEmptySentinels) return 0; let stripped = 0; const frozenParts = readFrozenMergedReasoningParts(options?.frozenMessageIds ?? new Set()); @@ -999,9 +999,8 @@ export interface StripProcessedImagesResult { * incl. defer) re-strips only already-frozen ids, byte-identical regardless of * how the live array grew. * - * Caller contract: run only when `modelAcceptsEmptyContent(providerID)` is - * true, because non-Anthropic adapters can forward the empty text replacement - * to the wire. + * Caller contract: run only when the pass's resolved `acceptsEmptySentinels` is + * true, because other adapters can forward the empty text replacement to the wire. */ export function stripProcessedImages( messages: MessageLike[], diff --git a/packages/plugin/src/hooks/magic-context/strip-structural-noise.ts b/packages/plugin/src/hooks/magic-context/strip-structural-noise.ts index 47486e8f3..2d3d54073 100644 --- a/packages/plugin/src/hooks/magic-context/strip-structural-noise.ts +++ b/packages/plugin/src/hooks/magic-context/strip-structural-noise.ts @@ -26,7 +26,7 @@ function isStructuralNoisePart(part: unknown): boolean { * prefixes stay byte-stable while OpenCode filters the empty text parts before * the wire. * - * Caller contract: run only when `modelAcceptsEmptyContent(providerID)` is true. + * Caller contract: run only when the pass's resolved `acceptsEmptySentinels` is true. * Non-Anthropic adapters can forward empty text parts as real wire content. * * Idempotent: sentinels are themselves recognized on subsequent passes and diff --git a/packages/plugin/src/hooks/magic-context/tag-messages-whitespace.test.ts b/packages/plugin/src/hooks/magic-context/tag-messages-whitespace.test.ts index e56f855c9..e2522c0e6 100644 --- a/packages/plugin/src/hooks/magic-context/tag-messages-whitespace.test.ts +++ b/packages/plugin/src/hooks/magic-context/tag-messages-whitespace.test.ts @@ -260,10 +260,10 @@ describe("whitespace-only assistant tag transition", () => { ]); }); - it("keeps leading whitespace before signed thinking byte-identical for both provider shapes", () => { - for (const providerID of ["anthropic", "github-copilot"]) { + it("keeps leading whitespace before signed thinking byte-identical for both capabilities", () => { + for (const acceptsEmptySentinels of [true, false] as const) { const db = openTestDb(); - const sessionId = `ses-leading-whitespace-${providerID}`; + const sessionId = `ses-leading-whitespace-${acceptsEmptySentinels}`; const message = assistant( "assistant-leading", [ @@ -275,20 +275,20 @@ describe("whitespace-only assistant tag transition", () => { const before = JSON.stringify(message.parts); tagMessages(sessionId, [message], createTagger(), db); - stripDroppedPlaceholderMessages([message], providerID); + stripDroppedPlaceholderMessages([message], acceptsEmptySentinels); expect(JSON.stringify(message.parts)).toBe(before); expect(getTagsBySession(db, sessionId)).toEqual([]); } }); - it("preserves provider-specific wholly blank assistant canonicalization", () => { - for (const [providerID, expected] of [ - ["anthropic", ""], - ["github-copilot", "[dropped]"], + it("preserves capability-specific wholly blank assistant canonicalization", () => { + for (const [acceptsEmptySentinels, expected] of [ + [true, ""], + [false, "[dropped]"], ] as const) { const db = openTestDb(); - const sessionId = `ses-wholly-blank-${providerID}`; + const sessionId = `ses-wholly-blank-${acceptsEmptySentinels}`; const message = assistant( "assistant-wholly-blank", [{ type: "text", text: " \t" }], @@ -296,7 +296,7 @@ describe("whitespace-only assistant tag transition", () => { ); tagMessages(sessionId, [message], createTagger(), db); - stripDroppedPlaceholderMessages([message], providerID); + stripDroppedPlaceholderMessages([message], acceptsEmptySentinels); expect(message.parts).toEqual([{ type: "text", text: expected }]); expect(getTagsBySession(db, sessionId)).toEqual([]); diff --git a/packages/plugin/src/hooks/magic-context/transform-postprocess-phase.test.ts b/packages/plugin/src/hooks/magic-context/transform-postprocess-phase.test.ts index c41853097..f4bd5ae9e 100644 --- a/packages/plugin/src/hooks/magic-context/transform-postprocess-phase.test.ts +++ b/packages/plugin/src/hooks/magic-context/transform-postprocess-phase.test.ts @@ -301,7 +301,7 @@ describe("postprocess replay snapshot", () => { await runPostTransformPhase( basePostTransformArgs(db, sessionId, messages, { db: spiedDb, - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, }), ); return messages; @@ -569,7 +569,7 @@ describe("stripped placeholder replay across temporary marker windows", () => { await runPostTransformPhase( basePostTransformArgs(db, sessionId, missingAssistantPass, { schedulerDecision: missingPassDecision, - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, }), ); const foldWire = serializeAnthropicVisibleRoleGroups(missingAssistantPass); @@ -592,7 +592,7 @@ describe("stripped placeholder replay across temporary marker windows", () => { await runPostTransformPhase( basePostTransformArgs(db, sessionId, replayPass, { schedulerDecision: replayPassDecision, - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, }), ); @@ -620,7 +620,7 @@ describe("stripped placeholder replay across temporary marker windows", () => { basePostTransformArgs(db, sessionId, compactionOffMessages, { compactionOff: true, schedulerDecision: "execute", - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, }), ); expect(compactionOffMessages[0]?.parts).toEqual([ @@ -633,7 +633,7 @@ describe("stripped placeholder replay across temporary marker windows", () => { basePostTransformArgs(db, sessionId, resumedMessages, { compactionOff: false, schedulerDecision: "defer", - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, }), ); expect(resumedMessages[0]?.parts).toEqual([{ type: "text", text: "" }]); @@ -662,7 +662,7 @@ describe("stripped placeholder replay across temporary marker windows", () => { schedulerDecision: "execute", pendingMaterializationSessions: new Set([sessionId]), schedulerDeferReason: null, - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, hiddenMessagesAtCompactionSeam: hiddenAssistants, }), ); @@ -691,7 +691,7 @@ describe("stripped placeholder replay across temporary marker windows", () => { await runPostTransformPhase( basePostTransformArgs(db, sessionId, replayMessages, { schedulerDecision: "defer", - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, }), ); @@ -2200,7 +2200,7 @@ describe("issue #386 sustained execute-pressure batching", () => { clearReasoningAge: 30, smartDrops: true, cavemanTextCompression: { enabled: true, minChars: 300 }, - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, currentTurnId: turnId, lastHeuristicsTurnId, }; @@ -2277,7 +2277,7 @@ describe("issue #386 sustained execute-pressure batching", () => { clearReasoningAge: 30, smartDrops: true, cavemanTextCompression: { enabled: true, minChars: 300 }, - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, currentTurnId: turnId, lastHeuristicsTurnId, tags: getActiveTagsBySession(db, sessionId), @@ -3039,7 +3039,7 @@ describe("postprocess empty-sentinel provider gate", () => { schedulerDecision: "execute", contextUsage: { percentage: 60, inputTokens: 6000 }, currentTurnId: "turn-cleared", - resolvedProviderID: "github-copilot", + acceptsEmptySentinels: false, }), ); @@ -3066,7 +3066,7 @@ describe("postprocess empty-sentinel provider gate", () => { schedulerDecision: "execute", contextUsage: { percentage: 60, inputTokens: 6000 }, currentTurnId: "turn-clear-write", - resolvedProviderID: "github-copilot", + acceptsEmptySentinels: false, clearReasoningAge: 1, reasoningByMessage: new Map([[oldMsg, [oldThinking]]]) as never, messageTagNumbers: new Map([ @@ -3102,7 +3102,7 @@ describe("postprocess empty-sentinel provider gate", () => { pendingMaterializationSessions: new Set([sessionId]), contextUsage: { percentage: 60, inputTokens: 6000 }, currentTurnId: "turn-clear-write-anthropic", - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, clearReasoningAge: 1, reasoningByMessage: new Map([[oldMsg, [oldThinking]]]) as never, messageTagNumbers: new Map([ @@ -3143,7 +3143,7 @@ describe("postprocess empty-sentinel provider gate", () => { basePostTransformArgs(db, sessionId, messages, { watermark: 1, messageTagNumbers: new Map([[userMessage, 1]]), - resolvedProviderID: "github-copilot", + acceptsEmptySentinels: false, }), ); @@ -3179,7 +3179,7 @@ describe("postprocess empty-sentinel provider gate", () => { basePostTransformArgs(db, sessionId, messages, { watermark: 1, messageTagNumbers: new Map([[userMessage, 1]]), - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, schedulerDecision: "execute", pendingMaterializationSessions: new Set([sessionId]), contextUsage: { percentage: 60, inputTokens: 6000 }, @@ -3219,7 +3219,7 @@ describe("postprocess empty-sentinel provider gate", () => { schedulerDecision: "defer", watermark: 0, messageTagNumbers: new Map([[userMessage, 1]]), - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, }), ); @@ -3249,7 +3249,7 @@ describe("postprocess empty-sentinel provider gate", () => { await runPostTransformPhase( basePostTransformArgs(db, sessionId, messages, { schedulerDecision: "defer", - resolvedProviderID: "github-copilot", + acceptsEmptySentinels: false, }), ); @@ -3327,7 +3327,7 @@ describe("final message representation", () => { schedulerDecision: "execute", contextUsage: { percentage: 60, inputTokens: 6000 }, currentTurnId: "turn-late-clear", - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, tags: getActiveTagsBySession(db, sessionId), targets: foldTargets, batch: foldBatch, @@ -3385,7 +3385,7 @@ describe("final message representation", () => { await runPostTransformPhase( basePostTransformArgs(db, sessionId, deferMessages, { schedulerDecision: "defer", - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, tags: getActiveTagsBySession(db, sessionId), targets: deferTargets, batch: deferBatch, @@ -3461,7 +3461,7 @@ describe("final message representation", () => { schedulerDecision: "execute", contextUsage: { percentage: 60, inputTokens: 6000 }, currentTurnId: "turn-preserve-reasoning", - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, tags: getActiveTagsBySession(db, sessionId), targets: foldTargets, batch: foldBatch, @@ -3494,7 +3494,7 @@ describe("final message representation", () => { await runPostTransformPhase( basePostTransformArgs(db, sessionId, deferMessages, { schedulerDecision: "defer", - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, tags: getActiveTagsBySession(db, sessionId), targets: deferTargets, batch: deferBatch, @@ -3567,7 +3567,7 @@ describe("final message representation", () => { schedulerDecision: "execute", contextUsage: { percentage: 60, inputTokens: 6000 }, currentTurnId: "turn-final-adjacency", - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, tags: getActiveTagsBySession(db, sessionId), targets: foldTargets, batch: foldBatch, @@ -3596,7 +3596,7 @@ describe("final message representation", () => { await runPostTransformPhase( basePostTransformArgs(db, sessionId, deferMessages, { schedulerDecision: "defer", - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, tags: getActiveTagsBySession(db, sessionId), targets: deferTargets, batch: deferBatch, @@ -3611,7 +3611,7 @@ describe("final message representation", () => { expect(foldWire).not.toContain("signature-invalid-after-merge"); const beforeSecondFinalization = JSON.stringify(foldMessages); - expect(finalizeMessageRepresentation(foldMessages, "anthropic")).toEqual({ + expect(finalizeMessageRepresentation(foldMessages, true)).toEqual({ clearedParts: 0, mergedReasoningParts: 0, }); @@ -3635,7 +3635,7 @@ describe("final message representation", () => { }, ] as unknown as MessageLike[]); const nonAnthropicBefore = JSON.stringify(nonAnthropicMessages); - expect(finalizeMessageRepresentation(nonAnthropicMessages, "github-copilot")).toEqual({ + expect(finalizeMessageRepresentation(nonAnthropicMessages, false)).toEqual({ clearedParts: 0, mergedReasoningParts: 0, }); @@ -3674,7 +3674,7 @@ describe("final message representation", () => { const recovery = await runPostTransformPhase( basePostTransformArgs(db, sessionId, recoveryMessages, { schedulerDecision: "defer", - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, thinkingBindingRecoveryEnabledForModel: true, }), ); @@ -3706,7 +3706,7 @@ describe("final message representation", () => { const replay = await runPostTransformPhase( basePostTransformArgs(db, sessionId, replayMessages, { schedulerDecision: "defer", - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, thinkingBindingRecoveryEnabledForModel: true, }), ); @@ -3744,7 +3744,7 @@ describe("final message representation", () => { sessionId, messages, fullFeatureMode: true, - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, thinkingBindingRecoveryEnabledForModel: true, tagger: createTagger(), ctxReduceAvailability: { callable: true, frozen: true }, @@ -3818,7 +3818,7 @@ describe("final message representation", () => { sessionId, messages, fullFeatureMode: true, - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, trailingBlankSourceDecisions: new Map([ ["assistant-stripped", "strip"], ["assistant-kept", "keep:2"], @@ -3853,7 +3853,7 @@ describe("final message representation", () => { const result = await runPostTransformPhase( basePostTransformArgs(db, sessionId, messages, { schedulerDecision: "defer", - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, }), ); @@ -3903,7 +3903,7 @@ describe("final message representation", () => { await runPostTransformPhase( basePostTransformArgs(db, sessionId, acceptedPass, { schedulerDecision: "defer", - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, }), ); expect(JSON.stringify(acceptedTarget.parts)).toBe(acceptedBytes); @@ -3915,7 +3915,7 @@ describe("final message representation", () => { await runPostTransformPhase( basePostTransformArgs(db, sessionId, transitionedDefer, { schedulerDecision: "defer", - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, }), ); expect(JSON.stringify(deferTarget.parts)).toBe(acceptedBytes); @@ -3928,7 +3928,7 @@ describe("final message representation", () => { basePostTransformArgs(db, sessionId, bustMessages, { schedulerDecision: "execute", pendingMaterializationSessions: new Set([sessionId]), - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, }), ); const bustTarget = findMessage(bustMessages, "assistant-transitioned"); @@ -3947,7 +3947,7 @@ describe("final message representation", () => { await runPostTransformPhase( basePostTransformArgs(db, sessionId, replayMessages, { schedulerDecision: "defer", - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, }), ); expect(serializeAnthropicWirePrefix(replayMessages)).toBe( @@ -3995,7 +3995,7 @@ describe("final message representation", () => { basePostTransformArgs(db, sessionId, messages, { schedulerDecision: "execute", pendingMaterializationSessions: new Set([sessionId]), - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, trailingBlankSourceDecisions, }), ); @@ -4052,7 +4052,7 @@ describe("final message representation", () => { await runPostTransformPhase( basePostTransformArgs(db, sessionId, preBustDefer.messages, { schedulerDecision: "defer", - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, trailingBlankSourceDecisions: preBustDefer.trailingBlankSourceDecisions, }), ); @@ -4069,7 +4069,7 @@ describe("final message representation", () => { basePostTransformArgs(db, sessionId, bust.messages, { schedulerDecision: "execute", pendingMaterializationSessions: new Set([sessionId]), - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, trailingBlankSourceDecisions: bust.trailingBlankSourceDecisions, }), ); @@ -4101,7 +4101,7 @@ describe("final message representation", () => { await runPostTransformPhase( basePostTransformArgs(db, sessionId, replay.messages, { schedulerDecision: "defer", - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, trailingBlankSourceDecisions: replay.trailingBlankSourceDecisions, }), ); @@ -4143,7 +4143,7 @@ describe("final message representation", () => { basePostTransformArgs(db, sessionId, markerAbsent.messages, { schedulerDecision: "execute", pendingMaterializationSessions: new Set([sessionId]), - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, trailingBlankSourceDecisions: markerAbsent.trailingBlankSourceDecisions, }), ); @@ -4153,7 +4153,7 @@ describe("final message representation", () => { await runPostTransformPhase( basePostTransformArgs(db, sessionId, defer.messages, { schedulerDecision: "defer", - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, trailingBlankSourceDecisions: defer.trailingBlankSourceDecisions, }), ); @@ -4169,7 +4169,7 @@ describe("final message representation", () => { basePostTransformArgs(db, sessionId, visibleBust.messages, { schedulerDecision: "execute", pendingMaterializationSessions: new Set([sessionId]), - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, trailingBlankSourceDecisions: visibleBust.trailingBlankSourceDecisions, }), ); @@ -4219,7 +4219,7 @@ describe("final message representation", () => { await runPostTransformPhase( basePostTransformArgs(db, sessionId, bust.messages, { schedulerDecision: "execute", - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, trailingBlankSourceDecisions: bust.trailingBlankSourceDecisions, }), ); @@ -4230,7 +4230,7 @@ describe("final message representation", () => { await runPostTransformPhase( basePostTransformArgs(db, sessionId, replay.messages, { schedulerDecision: "defer", - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, trailingBlankSourceDecisions: replay.trailingBlankSourceDecisions, }), ); @@ -4264,7 +4264,7 @@ describe("final message representation", () => { basePostTransformArgs(db, sessionId, messages, { compactionOff: true, schedulerDecision: "execute", - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, }), ); @@ -4293,7 +4293,7 @@ describe("final message representation", () => { } as unknown as MessageLike; const messages = [older, newest]; - finalizeMessageRepresentation(messages, "anthropic", { + finalizeMessageRepresentation(messages, true, { reasoningMutationExemptMessage: newest, trailingBlankDecisions: new Map([ ["assistant-older-whitespace", "strip"], @@ -4338,7 +4338,7 @@ describe("final message representation", () => { } as unknown as MessageLike; const messages = [lone, adjacent, answered, newest]; - finalizeMessageRepresentation(messages, "anthropic", { + finalizeMessageRepresentation(messages, true, { reasoningMutationExemptMessage: newest, trailingBlankDecisions: new Map([ ["assistant-lone-reasoning", "strip"], @@ -4406,7 +4406,7 @@ describe("final message representation", () => { await runPostTransformPhase( basePostTransformArgs(db, scenario.sessionId, firstMessages, { schedulerDecision: "execute", - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, }), ); const firstBytes = JSON.stringify(firstMessages[0].parts); @@ -4419,7 +4419,7 @@ describe("final message representation", () => { await runPostTransformPhase( basePostTransformArgs(db, scenario.sessionId, replayMessages, { schedulerDecision: "defer", - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, }), ); expect(JSON.stringify(replayMessages[0].parts)).toBe(firstBytes); @@ -4449,7 +4449,7 @@ describe("final message representation", () => { await runPostTransformPhase( basePostTransformArgs(db, sessionId, deferMessages, { schedulerDecision: "defer", - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, }), ); const deferBytes = JSON.stringify(findMessage(deferMessages, "assistant-late").parts); @@ -4464,7 +4464,7 @@ describe("final message representation", () => { basePostTransformArgs(db, sessionId, bustMessages, { schedulerDecision: "execute", pendingMaterializationSessions: new Set([sessionId]), - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, }), ); expect(getTrailingBlankDecisions(db, sessionId).get("assistant-late")).toBe("keep"); @@ -4479,7 +4479,7 @@ describe("final message representation", () => { await runPostTransformPhase( basePostTransformArgs(db, sessionId, replayMessages, { schedulerDecision: "defer", - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, }), ); expect(JSON.stringify(findMessage(replayMessages, "assistant-late").parts)).toBe(bustBytes); @@ -4552,7 +4552,7 @@ describe("final message representation", () => { const first = await runPostTransformPhase( basePostTransformArgs(db, sessionId, firstMessages, { schedulerDecision: "defer", - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, }), ); const firstBytes = JSON.stringify(firstMessages[0].parts); @@ -4575,7 +4575,7 @@ describe("final message representation", () => { const replay = await runPostTransformPhase( basePostTransformArgs(db, sessionId, replayMessages, { schedulerDecision: "defer", - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, }), ); @@ -4606,7 +4606,7 @@ describe("final message representation", () => { await runPostTransformPhase( basePostTransformArgs(db, sessionId, firstMessages, { schedulerDecision: "defer", - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, }), ); const firstBytes = JSON.stringify(firstMessages[0].parts); @@ -4619,7 +4619,7 @@ describe("final message representation", () => { const replay = await runPostTransformPhase( basePostTransformArgs(db, sessionId, replayMessages, { schedulerDecision: "defer", - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, }), ); expect(replay.bustedThisPass).toBe(false); @@ -4652,7 +4652,7 @@ describe("final message representation", () => { await runPostTransformPhase( basePostTransformArgs(db, sessionId, first, { schedulerDecision: "defer", - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, }), ); expect(getTrailingBlankDecisions(db, sessionId).get("assistant-target")).toBe("keep"); @@ -4661,7 +4661,7 @@ describe("final message representation", () => { await runPostTransformPhase( basePostTransformArgs(db, sessionId, recounted, { schedulerDecision: "defer", - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, }), ); expect(getTrailingBlankDecisions(db, sessionId).get("assistant-target")).toBe("keep:3"); @@ -4671,7 +4671,7 @@ describe("final message representation", () => { await runPostTransformPhase( basePostTransformArgs(db, sessionId, demoted, { schedulerDecision: "defer", - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, }), ); expect(getTrailingBlankDecisions(db, sessionId).get("assistant-target")).toBe("strip"); @@ -4680,7 +4680,7 @@ describe("final message representation", () => { await runPostTransformPhase( basePostTransformArgs(db, sessionId, replay, { schedulerDecision: "defer", - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, }), ); expect(replay[0].parts).toEqual([{ type: "text", text: "answer" }]); @@ -4713,7 +4713,7 @@ describe("final message representation", () => { await runPostTransformPhase( basePostTransformArgs(db, sessionId, messages, { schedulerDecision: "defer", - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, }), ); @@ -4766,7 +4766,7 @@ describe("final message representation", () => { await runPostTransformPhase( basePostTransformArgs(db, sessionId, messages, { schedulerDecision: "defer", - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, trailingBlankSourceDecisions: sourceDecisions, }), ); @@ -4837,7 +4837,7 @@ describe("final message representation", () => { await runPostTransformPhase( basePostTransformArgs(db, sessionId, messages, { schedulerDecision: "defer", - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, }), ); @@ -4904,7 +4904,7 @@ describe("final message representation", () => { const result = await runPostTransformPhase( basePostTransformArgs(db, sessionId, messages, { schedulerDecision: "defer", - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, }), ); expect(result.bustedThisPass).toBe(false); @@ -4976,7 +4976,7 @@ describe("final message representation", () => { await runPostTransformPhase( basePostTransformArgs(db, sessionId, messages, { schedulerDecision: "defer", - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, }), ); @@ -5030,8 +5030,8 @@ describe("final message representation", () => { const targeted = cloneMessages(fixture); const targetedLateMutation = targeted.find((message) => message.info.id === "merged-b")!; - const oldResult = finalizeMessageRepresentation(fullWalk, "anthropic"); - const targetedResult = finalizeMessageRepresentation(targeted, "anthropic", { + const oldResult = finalizeMessageRepresentation(fullWalk, true); + const targetedResult = finalizeMessageRepresentation(targeted, true, { prependedMessageCount: 2, reasoningMutatedMessages: [targetedLateMutation], }); @@ -5411,7 +5411,7 @@ describe("marker-drain reasoning representation", () => { basePostTransformArgs(db, sessionId, applying, { ...first, tagger, - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, deferredMaterializationSessions: new Set([sessionId]), hiddenMessagesAtCompactionSeam: [applyingRaw[1]], deferredHistoryWasPendingAtPassStart: true, @@ -5458,7 +5458,7 @@ describe("marker-drain reasoning representation", () => { basePostTransformArgs(db, sessionId, deferred, { ...second, tagger: freshTagger, - resolvedProviderID: "anthropic", + acceptsEmptySentinels: true, }), ); expect(JSON.stringify(deferred.find((m) => m.info.id === "error-assistant")!.parts)).toBe( diff --git a/packages/plugin/src/hooks/magic-context/transform-postprocess-phase.ts b/packages/plugin/src/hooks/magic-context/transform-postprocess-phase.ts index 157ab2cbc..93a449264 100644 --- a/packages/plugin/src/hooks/magic-context/transform-postprocess-phase.ts +++ b/packages/plugin/src/hooks/magic-context/transform-postprocess-phase.ts @@ -96,7 +96,7 @@ import { markNoteNudgeDelivered, peekNoteNudgeText } from "./note-nudger"; import { hasVisibleNoteReadCall } from "./note-visibility"; import type { PassOutcome } from "./pass-outcome"; import { estimateTokens } from "./read-session-formatting"; -import { modelAcceptsEmptyContent, replaySentinelByMessageIds } from "./sentinel"; +import { replaySentinelByMessageIds } from "./sentinel"; import { applyFrozenTrailingBlankDecisions, assistantHasReasoningPart, @@ -396,16 +396,16 @@ export function replayRustModeBindingMismatchStrips(args: { db: ContextDatabase; sessionId: string; messages: MessageLike[]; - resolvedProviderID?: string; + acceptsEmptySentinels?: boolean; }): void { - if (!modelAcceptsEmptyContent(args.resolvedProviderID)) return; + if (!args.acceptsEmptySentinels) return; const recoveryMessageIds = new Set(); for (const id of getMergedReasoningStrippedIds(args.db, args.sessionId)) { if (!id.startsWith(THINKING_BINDING_RECOVERY_FROZEN_PREFIX)) continue; const messageId = id.slice(THINKING_BINDING_RECOVERY_FROZEN_PREFIX.length); if (messageId.length > 0) recoveryMessageIds.add(messageId); } - stripReasoningFromAssistantIds(args.messages, args.resolvedProviderID, recoveryMessageIds); + stripReasoningFromAssistantIds(args.messages, args.acceptsEmptySentinels, recoveryMessageIds); } /** @@ -513,7 +513,7 @@ export function runRustModePostprocess(args: { materializedBoundary?: RustMaterializedCompactionBoundary; fullFeatureMode: boolean; compactionOff?: boolean; - resolvedProviderID?: string; + acceptsEmptySentinels?: boolean; thinkingBindingRecoveryEnabledForModel?: boolean; trailingBlankSourceDecisions?: TrailingBlankSourceDecisions; trailingBlankNewestAssistantId?: string; @@ -566,7 +566,7 @@ export function runRustModePostprocess(args: { } } const trailingBlankDecisions = new Map(); - if (modelAcceptsEmptyContent(args.resolvedProviderID)) { + if (args.acceptsEmptySentinels) { try { for (const [id, decision] of getTrailingBlankDecisions(args.db, args.sessionId)) { trailingBlankDecisions.set(id, decision); @@ -635,7 +635,7 @@ export function runRustModePostprocess(args: { const recoveryMessageIds = new Set(); let thinkingBindingRecovery: { flagTarget: string; messageId: string } | null = null; - if (modelAcceptsEmptyContent(args.resolvedProviderID)) { + if (args.acceptsEmptySentinels) { try { for (const id of getMergedReasoningStrippedIds(args.db, args.sessionId)) { if (!id.startsWith(THINKING_BINDING_RECOVERY_FROZEN_PREFIX)) continue; @@ -670,7 +670,11 @@ export function runRustModePostprocess(args: { } catch (error) { sessionLog(args.sessionId, "rust thinking binding recovery failed:", error); } - stripReasoningFromAssistantIds(args.messages, args.resolvedProviderID, recoveryMessageIds); + stripReasoningFromAssistantIds( + args.messages, + args.acceptsEmptySentinels, + recoveryMessageIds, + ); } const marker = getPersistedCompactionMarkerState(args.db, args.sessionId); return { @@ -926,11 +930,13 @@ interface RunPostTransformPhaseArgs { */ smartDrops?: boolean; /** - * Provider resolved once by the main transform for this pass. Used for every - * empty-sentinel gate and whole-message placeholder choice so postprocess - * cannot diverge from the main transform on cold DB-recovered passes. + * Empty-sentinel capability, resolved once by the main transform for this pass + * (`resolveEmptySentinelCapability`). Every empty-sentinel gate and + * whole-message placeholder choice reads THIS, never a provider id, so + * postprocess cannot diverge from the main transform on cold DB-recovered + * passes — a divergence would rewrite a prefix the main pass already served. */ - resolvedProviderID?: string; + acceptsEmptySentinels?: boolean; /** True only when the live request is canonical Anthropic Fable 5.1. */ thinkingBindingRecoveryEnabledForModel?: boolean; /** Raw harness observations captured before any Magic Context insertion or sentinelization. */ @@ -950,6 +956,12 @@ interface RunPostTransformPhaseArgs { /** mural.enabled — drives the on-demand deterministic mural * render inside the HARD fold. */ muralEnabled?: boolean; + /** + * True only when the adapter registry widened the empty-sentinel gate. + * Carried into the m[0] upgrade identity so the flip folds once instead of + * rewriting the tail underneath an already-served prefix. + */ + anthropicWireWidened?: boolean; }; } @@ -1056,7 +1068,7 @@ export function evaluateEmergencyFailClosed(input: { export function finalizeMessageRepresentation( messages: MessageLike[], - resolvedProviderID?: string, + acceptsEmptySentinels?: boolean, options?: { prependedMessageCount?: number; reasoningMutatedMessages?: Iterable; @@ -1069,7 +1081,7 @@ export function finalizeMessageRepresentation( }, ): { clearedParts: number; mergedReasoningParts: number } { let clearedParts = 0; - if (modelAcceptsEmptyContent(resolvedProviderID)) { + if (acceptsEmptySentinels) { const prependedMessageCount = Math.min( messages.length, Math.max(0, options?.prependedMessageCount ?? 0), @@ -1092,18 +1104,18 @@ export function finalizeMessageRepresentation( ? 0 : stripReasoningFromAssistantIds( messages, - resolvedProviderID, + acceptsEmptySentinels, options?.thinkingBindingRecoveryMessageIds ?? new Set(), ); const mergedReasoningParts = bindingRecoveryParts + (options?.skipMergedReasoningStrip ? 0 - : stripReasoningFromMergedAssistants(messages, resolvedProviderID, { + : stripReasoningFromMergedAssistants(messages, acceptsEmptySentinels, { mutationExemptMessage: options?.reasoningMutationExemptMessage, frozenMessageIds: options?.mergedReasoningStrippedIds, })); - if (!options?.skipTrailingWhitespaceStrip && modelAcceptsEmptyContent(resolvedProviderID)) { + if (!options?.skipTrailingWhitespaceStrip && acceptsEmptySentinels) { applyFrozenTrailingBlankDecisions(messages, options?.trailingBlankDecisions ?? new Map()); } return { clearedParts, mergedReasoningParts }; @@ -1205,6 +1217,7 @@ export async function runPostTransformPhase( injectDocs: args.m0M1.injectDocs, memoryEnabled: args.m0M1.memoryEnabled, muralEnabled: args.m0M1.muralEnabled, + anthropicWireWidened: args.m0M1.anthropicWireWidened, memoryInjectionBudgetTokens: args.m0M1.memoryInjectionBudgetTokens, historyBudgetTokens: args.m0M1.historyBudgetTokens, hardSignals: args.m0M1.hardSignals, @@ -1257,6 +1270,7 @@ export async function runPostTransformPhase( allowFreshContentionFallback: forceMaterialization || emergencyDropEligible, hardSignals: args.m0M1.hardSignals, muralEnabled: args.m0M1.muralEnabled, + anthropicWireWidened: args.m0M1.anthropicWireWidened, compactionOff, }); preparedPrefix = foldResult; @@ -1402,7 +1416,7 @@ export async function runPostTransformPhase( sessionLog(args.sessionId, "ctx_reduce permission read failed (ignored):", error); } } - const canUseEmptySentinels = modelAcceptsEmptyContent(args.resolvedProviderID); + const canUseEmptySentinels = args.acceptsEmptySentinels; if (shouldRunHeuristics) { const subagentRerun = !args.fullFeatureMode && @@ -1953,6 +1967,7 @@ export async function runPostTransformPhase( allowFreshContentionFallback: forceMaterialization || emergencyDropEligible, hardSignals: args.m0M1.hardSignals, muralEnabled: args.m0M1.muralEnabled, + anthropicWireWidened: args.m0M1.anthropicWireWidened, // Compaction-off materializes through the zero-compartment // path: memory/docs/user-profile render, but historical // compartment rows never reach . @@ -2080,7 +2095,7 @@ export async function runPostTransformPhase( const { replayed } = replaySentinelByMessageIds( args.messages, persistedIds, - args.resolvedProviderID, + args.acceptsEmptySentinels, hiddenSeamIds, ); if (replayed > 0) { @@ -2102,7 +2117,7 @@ export async function runPostTransformPhase( if (isCacheBustingPass) { const droppedResult = stripDroppedPlaceholderMessages( args.messages, - args.resolvedProviderID, + args.acceptsEmptySentinels, ); const protectedTailStart = Math.max( 0, @@ -2111,17 +2126,17 @@ export async function runPostTransformPhase( const systemInjectedResult = stripSystemInjectedMessages( args.messages, protectedTailStart, - args.resolvedProviderID, + args.acceptsEmptySentinels, ); const hiddenMessages = args.hiddenMessagesAtCompactionSeam ?? []; const hiddenDroppedResult = stripDroppedPlaceholderMessages( hiddenMessages, - args.resolvedProviderID, + args.acceptsEmptySentinels, ); const hiddenSystemInjectedResult = stripSystemInjectedMessages( hiddenMessages, hiddenMessages.length, - args.resolvedProviderID, + args.acceptsEmptySentinels, ); const newlyNeutralized = @@ -2532,7 +2547,7 @@ export async function runPostTransformPhase( if (isCacheBustingPass) { const candidates = findMergedReasoningStripDecisions( args.messages, - args.resolvedProviderID, + args.acceptsEmptySentinels, mergedReasoningStrippedIds, { mutationExemptMessage: reasoningMutationExemptMessage }, ); @@ -2686,7 +2701,7 @@ export async function runPostTransformPhase( const tFinalRepresentation = performance.now(); const finalRepresentation = finalizeMessageRepresentation( args.messages, - args.resolvedProviderID, + args.acceptsEmptySentinels, { prependedMessageCount, reasoningMutatedMessages, diff --git a/packages/plugin/src/hooks/magic-context/transform.ts b/packages/plugin/src/hooks/magic-context/transform.ts index ae26fa568..681052366 100644 --- a/packages/plugin/src/hooks/magic-context/transform.ts +++ b/packages/plugin/src/hooks/magic-context/transform.ts @@ -59,9 +59,11 @@ import type { ModelInput } from "../../shared/model-resolution"; import { getSdkContextLimit } from "../../shared/models-dev-cache"; import type { PromptSurfaceConfig } from "../../shared/prompt-surface"; import type { PromptSurfaceRuntime } from "../../shared/prompt-surface-runtime"; +import { ensureAnthropicWireModelsLoaded, resolveEmptySentinelCapability } from "./anthropic-wire"; import { canConsumeDeferredOnThisPass } from "./cache-busting-signals"; import { replayCavemanCompression } from "./caveman-cleanup"; import { commitCompactionModeRecord, reconcileCompactionMode } from "./compaction-off-transition"; +import { decodeCachedM0UpgradeIdentity } from "./compartment-render-epoch"; import { getActiveCompartmentRun, startCompartmentAgent } from "./compartment-runner"; import { buildTriggerInMemoryTail, checkCompartmentTrigger } from "./compartment-trigger"; import { @@ -110,7 +112,6 @@ import { findLastAssistantModelFromOpenCodeDb } from "./read-session-db"; import { extractInMemoryMessageViews } from "./read-session-raw"; import { createRustModeTransform, type RustModeModuleClient } from "./rust-mode-transform"; import { sendStatusNotification } from "./send-session-notification"; -import { modelAcceptsEmptyContent } from "./sentinel"; import { replayClearedReasoning, replayStrippedInlineThinking, @@ -1304,12 +1305,32 @@ export function createTransform(deps: TransformDeps) { deps.liveModelBySession?.set(sessionId, recovered); } } - // Single pass-local provider resolution for every empty-sentinel producer. - // A cold pass may recover the model from OpenCode's DB above; hot passes hit - // the live map. Reusing this value keeps cold/hot output identical and keeps - // postprocess from making a divergent provider decision later in the pass. + // Single pass-local provider resolution. A cold pass may recover the model + // from OpenCode's DB above; hot passes hit the live map. Reusing this value + // keeps cold/hot output identical. Empty-sentinel producers no longer read + // the provider id at all — they take the resolved capability below. const resolvedProviderID = modelForBudget?.providerID; - const canUseEmptySentinels = modelAcceptsEmptyContent(resolvedProviderID); + // Read the resolved adapter of every configured model once per process, + // before the first gate reads it. Awaiting here (memoized after the first + // pass) keeps the answer identical for every phase of every pass, so the + // capability can never flip mid-session and rewrite a cached prefix. + await ensureAnthropicWireModelsLoaded(deps.client); + // One resolution for the whole pass, including the unresolved case. See + // `resolveEmptySentinelCapability` for why unresolved carries the session's + // last materialized answer forward instead of narrowing to `false`, and why + // that carry-forward is keyed to the model rather than the session. + const { acceptsEmptySentinels: canUseEmptySentinels, widenedByCustomProvider } = + resolveEmptySentinelCapability({ + providerID: resolvedProviderID, + modelID: modelForBudget?.modelID, + modelKey: modelForBudget + ? `${modelForBudget.providerID}/${modelForBudget.modelID}` + : "", + cachedModelKey: sessionMeta.cachedM0ModelKey, + cachedWidenedByCustomProvider: decodeCachedM0UpgradeIdentity( + sessionMeta.cachedM0UpgradeState, + ).anthropicWireWidened, + }); const resolvedContextLimit = modelForBudget ? resolveTrustedContextLimit(modelForBudget.providerID, modelForBudget.modelID, { db, @@ -1733,7 +1754,7 @@ export function createTransform(deps: TransformDeps) { boundaryContextLimit, inMemoryTail, taggerFloor, - { providerID: resolvedProviderID }, + { canClearReasoning: canUseEmptySentinels }, ); if (triggerResult.shouldFire) { sessionLog( @@ -2197,6 +2218,7 @@ export function createTransform(deps: TransformDeps) { injectDocs: deps.injectDocs, memoryEnabled: deps.memoryConfig?.enabled, muralEnabled: deps.muralEnabled, + anthropicWireWidened: widenedByCustomProvider, memoryInjectionBudgetTokens: deps.memoryConfig?.injectionBudgetTokens, historyBudgetTokens, hardSignals: m0HardSignals, @@ -2311,10 +2333,10 @@ export function createTransform(deps: TransformDeps) { // the primary agent that spawned them. cavemanTextCompression: !reducedMode ? deps.cavemanTextCompression : undefined, smartDrops: deps.smartDrops === true, - // Pass the single resolved provider through to postprocess so every + // Pass the single resolved capability through to postprocess so every // empty-sentinel gate and whole-message placeholder choice agrees for // this transform pass, including cold DB-recovered passes. - resolvedProviderID, + acceptsEmptySentinels: canUseEmptySentinels, thinkingBindingRecoveryEnabledForModel: isFable51ThinkingBindingModel( modelForBudget?.providerID, modelForBudget?.modelID, @@ -2339,6 +2361,7 @@ export function createTransform(deps: TransformDeps) { temporalAwareness: deps.experimentalTemporalAwareness, hardSignals: m0HardSignals, muralEnabled: deps.muralEnabled, + anthropicWireWidened: widenedByCustomProvider, }, }); passOutcome.markFinalized(); diff --git a/packages/plugin/src/plugin/messages-transform.test.ts b/packages/plugin/src/plugin/messages-transform.test.ts index 3d3a27258..add68a83e 100644 --- a/packages/plugin/src/plugin/messages-transform.test.ts +++ b/packages/plugin/src/plugin/messages-transform.test.ts @@ -374,7 +374,7 @@ describe("createMessagesTransformHandler — issue #327 wire tail", () => { }, parts: [], }); - finalizeMessageRepresentation(out.messages as any, "anthropic", { + finalizeMessageRepresentation(out.messages as any, true, { trailingBlankDecisions: new Map([["assistant-pending", "keep"]]), }); },