Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions assets/magic-context.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@
"description": "Enable automatic npm self-update checks for the OpenCode plugin. Security: USER-only in config loader, so hostile project configs cannot suppress updates.",
"type": "boolean"
},
"anthropic_transport_providers": {
"description": "ProviderIDs whose Claude-named models are served through OpenCode's \"@ai-sdk/anthropic\" adapter (e.g. [\"github-copilot\"] for Copilot Claude), i.e. the wire drops empty text/reasoning parts before the API exactly like the canonical Anthropic provider. Extends Magic Context's reasoning-strip machinery to those routes. Security: USER-only in the config loader; a project config cannot widen the gate. List a provider only if every Claude model under it uses the @ai-sdk/anthropic adapter.",
"type": "array",
"items": {
"type": "string",
"minLength": 1
}
},
"language": {
"description": "Output language for Magic Context's generated content and guidance, as a 2-letter ISO 639-1 code (e.g. \"tr\", \"es\", \"de\", \"ja\", \"pt\"). When set, the historian, dreamer, sidekick, and the agent-guidance block instruct the model to write its PROSE in this language while keeping all structural tokens (XML tags, the five memory category names, code identifiers, file paths) in English. USER-LEVEL ONLY (ignored in project config for security). Unset = today's behavior (model mirrors the conversation; English scaffolding). Changing it triggers one cache re-materialization; existing compartments/memories keep their original language until naturally rewritten.",
"type": "string"
Expand Down
1 change: 1 addition & 0 deletions packages/docs/src/content/docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ Behavior tuning most installs never need to touch.

| Key | Type | Default | Description |
|---|---|---|---|
| `anthropic_transport_providers` | string[] | — | ProviderIDs whose Claude-named models are served through OpenCode's "@ai-sdk/anthropic" adapter (e.g. ["github-copilot"] for Copilot Claude), i.e. the wire drops empty text/reasoning parts before the API exactly like the canonical Anthropic provider. Extends Magic Context's reasoning-strip machinery to those routes. Security: USER-only in the config loader; a project config cannot widen the gate. List a provider only if every Claude model under it uses the @ai-sdk/anthropic adapter. |
| `smart_notes.retina_handoff` | boolean | `false` | When true, dreamer skips smart notes whose surface conditions compiled to retina provider configs at authoring time. Default false keeps both paths active until the retina consumer is deployed. |
| `models.window_overlay_path` | string | — | |
| `toast_duration_ms` | number (0–60000) | `5000` | TUI toast lifetime in milliseconds for Magic Context notifications. Set to 0 to disable Magic Context toasts entirely (min: 0, max: 60000, default: 5000) |
Expand Down
9 changes: 9 additions & 0 deletions packages/plugin/src/config/project-security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ describe("stripUnsafeProjectConfigFields", () => {
expect(warnings.some((w) => w.includes("auto_update"))).toBe(true);
});

it("strips anthropic_transport_providers from project config (transport gating is user-tier)", () => {
const raw: Record<string, unknown> = {
anthropic_transport_providers: ["github-copilot"],
};
const warnings = stripUnsafeProjectConfigFields(raw);
expect("anthropic_transport_providers" in raw).toBe(false);
expect(warnings.some((w) => w.includes("anthropic_transport_providers"))).toBe(true);
});

it("strips fail_closed_blocking from project config (user-tier only)", () => {
const raw: Record<string, unknown> = {
fail_closed_blocking: false,
Expand Down
9 changes: 9 additions & 0 deletions packages/plugin/src/config/project-security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,15 @@ export function stripUnsafeProjectConfigFields(projectRaw: Record<string, unknow
);
}

// Widening the empty-sentinel gate is a wire-behavior decision; a cloned
// repo must not declare that its models filter empty parts.
if ("anthropic_transport_providers" in projectRaw) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The stripUnsafeProjectConfigFields docstring enumerates every stripped project field, but does not mention the newly stripped anthropic_transport_providers. Add a bullet so the documented security contract stays in sync with the added strip.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/plugin/src/config/project-security.ts, line 322:

<comment>The stripUnsafeProjectConfigFields docstring enumerates every stripped project field, but does not mention the newly stripped `anthropic_transport_providers`. Add a bullet so the documented security contract stays in sync with the added strip.</comment>

<file context>
@@ -317,6 +317,15 @@ export function stripUnsafeProjectConfigFields(projectRaw: Record<string, unknow
 
+    // Widening the empty-sentinel gate is a wire-behavior decision; a cloned
+    // repo must not declare that its models filter empty parts.
+    if ("anthropic_transport_providers" in projectRaw) {
+        delete projectRaw.anthropic_transport_providers;
+        warnings.push(
</file context>

delete projectRaw.anthropic_transport_providers;
warnings.push(
"Ignoring anthropic_transport_providers from project config (security: transport gating is a user-level setting).",
);
}

if ("fail_closed_blocking" in projectRaw) {
delete projectRaw.fail_closed_blocking;
warnings.push(
Expand Down
10 changes: 10 additions & 0 deletions packages/plugin/src/config/schema/magic-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,16 @@ describe("MagicContextConfigSchema", () => {
expect(MagicContextConfigSchema.parse({ auto_update: true }).auto_update).toBe(true);
});

it("accepts an optional anthropic_transport_providers allow-list and normalizes it", () => {
const parsed = MagicContextConfigSchema.parse({
anthropic_transport_providers: [" GitHub-Copilot "],
});
expect(parsed.anthropic_transport_providers).toEqual(["github-copilot"]);
expect(
MagicContextConfigSchema.parse({}).anthropic_transport_providers,
).toBeUndefined();
});

it("accepts an explicitly configured Pi subagent extension allowlist", () => {
expect(
MagicContextConfigSchema.parse({
Expand Down
9 changes: 9 additions & 0 deletions packages/plugin/src/config/schema/magic-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -751,6 +751,9 @@ export interface MagicContextConfig {
/** Auto-update the cached OpenCode plugin wrapper when a newer npm version is available.
* USER config only; project configs cannot disable it. Default: true. */
auto_update?: boolean;
/** ProviderIDs whose Claude models ride OpenCode's @ai-sdk/anthropic transport.
* USER config only; project configs cannot set it. See sentinel.modelAcceptsEmptyContent. */
anthropic_transport_providers?: string[];
/** Output language for generated Magic Context prose. USER config only. */
language?: string;
/** Active user-owned model profile after user/project resolution. */
Expand Down Expand Up @@ -957,6 +960,12 @@ export const MagicContextConfigSchema = z
.describe(
"Enable automatic npm self-update checks for the OpenCode plugin. Security: USER-only in config loader, so hostile project configs cannot suppress updates.",
),
anthropic_transport_providers: z
.array(z.string().trim().toLowerCase().min(1))
.optional()
.describe(
'ProviderIDs whose Claude-named models are served through OpenCode\'s "@ai-sdk/anthropic" adapter (e.g. ["github-copilot"] for Copilot Claude), i.e. the wire drops empty text/reasoning parts before the API exactly like the canonical Anthropic provider. Extends Magic Context\'s reasoning-strip machinery to those routes. Security: USER-only in the config loader; a project config cannot widen the gate. List a provider only if every Claude model under it uses the @ai-sdk/anthropic adapter.',
),
language: z
.string()
.trim()
Expand Down
55 changes: 55 additions & 0 deletions packages/plugin/src/hooks/magic-context/sentinel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/// <reference types="bun-types" />

import { afterEach, describe, expect, it } from "bun:test";

import { modelAcceptsEmptyContent, setAnthropicTransportProviders } from "./sentinel";

describe("modelAcceptsEmptyContent", () => {
afterEach(() => {
setAnthropicTransportProviders([]);
});

it("accepts the canonical anthropic provider regardless of model name", () => {
expect(modelAcceptsEmptyContent("anthropic")).toBe(true);
expect(modelAcceptsEmptyContent("anthropic", "claude-opus-4-7")).toBe(true);
expect(modelAcceptsEmptyContent("anthropic", "some-other-model")).toBe(true);
});

it("rejects unconfigured providers even for Claude-named models", () => {
expect(modelAcceptsEmptyContent("github-copilot")).toBe(false);
expect(modelAcceptsEmptyContent("github-copilot", "claude-sonnet-4-6")).toBe(false);
expect(modelAcceptsEmptyContent(undefined, "claude-sonnet-4-6")).toBe(false);
expect(modelAcceptsEmptyContent("openrouter")).toBe(false);
});

it("accepts Claude-named models under a configured Anthropic-transport provider", () => {
setAnthropicTransportProviders(["github-copilot"]);
expect(modelAcceptsEmptyContent("github-copilot", "claude-sonnet-4-6")).toBe(true);
expect(modelAcceptsEmptyContent("github-copilot", "CLAUDE-Opus-5")).toBe(true);
expect(modelAcceptsEmptyContent("GITHUB-COPILOT", "claude-sonnet-4-6")).toBe(true);
});

it("keeps non-Claude models under a configured provider fail-closed", () => {
// Mixed-adapter providers route non-Claude models through adapters that
// do NOT filter empty parts; the gate must stay closed for them.
setAnthropicTransportProviders(["github-copilot"]);
expect(modelAcceptsEmptyContent("github-copilot", "gpt-5.5")).toBe(false);
expect(modelAcceptsEmptyContent("github-copilot")).toBe(false);
expect(modelAcceptsEmptyContent("github-copilot", "")).toBe(false);
});

it("normalizes and validates configured provider entries", () => {
setAnthropicTransportProviders([" GitHub-Copilot ", "", " "]);
expect(modelAcceptsEmptyContent("github-copilot", "claude-x")).toBe(true);
expect(modelAcceptsEmptyContent("", "claude-x")).toBe(false);
});

it("resets to fail-closed when the allow-list is cleared", () => {
setAnthropicTransportProviders(["github-copilot"]);
setAnthropicTransportProviders([]);
expect(modelAcceptsEmptyContent("github-copilot", "claude-x")).toBe(false);
setAnthropicTransportProviders(["github-copilot"]);
setAnthropicTransportProviders(undefined);
expect(modelAcceptsEmptyContent("github-copilot", "claude-x")).toBe(false);
});
});
43 changes: 41 additions & 2 deletions packages/plugin/src/hooks/magic-context/sentinel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,48 @@ export const WHOLE_MESSAGE_PLACEHOLDER_TEXT = "[dropped]";
*
* Unknown or non-canonical providers therefore must keep native parts (or use
* non-empty whole-message placeholders) rather than producing empty sentinels.
*
* One user-asserted exception exists: providerIDs listed in the user-tier
* `anthropic_transport_providers` setting serve Claude-named models through
* OpenCode's `@ai-sdk/anthropic` adapter as well, so the wire filter drops
* empty sentinels for them too (see isClaudeModelUnderConfiguredAnthropicTransport).
*/
export function modelAcceptsEmptyContent(providerID?: string, modelName?: string): boolean {
if (providerID === "anthropic") return true;
return isClaudeModelUnderConfiguredAnthropicTransport(providerID, modelName);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: For an allow-listed Claude transport, the compartment trigger now believes reasoning cannot be cleared because it omits modelName. This disables projected reasoning reclamation and can force an unnecessary historian run at the force band even when the post-drop usage would be below target; pass the resolved model name into the trigger capability or compute an explicit capability at the caller.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/plugin/src/hooks/magic-context/sentinel.ts, line 42:

<comment>For an allow-listed Claude transport, the compartment trigger now believes reasoning cannot be cleared because it omits `modelName`. This disables projected reasoning reclamation and can force an unnecessary historian run at the force band even when the post-drop usage would be below target; pass the resolved model name into the trigger capability or compute an explicit capability at the caller.</comment>

<file context>
@@ -31,9 +31,48 @@ export const WHOLE_MESSAGE_PLACEHOLDER_TEXT = "[dropped]";
+ */
+export function modelAcceptsEmptyContent(providerID?: string, modelName?: string): boolean {
+    if (providerID === "anthropic") return true;
+    return isClaudeModelUnderConfiguredAnthropicTransport(providerID, modelName);
+}
+
</file context>

}

let anthropicTransportProviders: ReadonlySet<string> = new Set();

/**
* Populate the Anthropic-transport allow-list from the user-tier setting
* `anthropic_transport_providers` (see config entry point). Only providers
* whose Claude models ride OpenCode's `@ai-sdk/anthropic` adapter belong here:
* that adapter filters empty text/reasoning parts before the wire. A provider
* that mixes adapters (Claude models on @ai-sdk/anthropic, other models on an
* openai-compatible adapter) is safe to list because the gate additionally
* requires a Claude-named model. Fail-closed: unlisted providers, missing
* model names, and non-Claude model names keep native parts.
*/
export function modelAcceptsEmptyContent(providerID?: string): boolean {
return providerID === "anthropic";
export function setAnthropicTransportProviders(providerIDs?: Iterable<string>): void {
const normalized = new Set<string>();
for (const id of providerIDs ?? []) {
if (typeof id === "string" && id.trim().length > 0) {
normalized.add(id.trim().toLowerCase());
}
}
anthropicTransportProviders = normalized;
}

function isClaudeModelUnderConfiguredAnthropicTransport(
providerID?: string,
modelName?: string,
): boolean {
if (!providerID || !modelName) return false;
return (
anthropicTransportProviders.has(providerID.toLowerCase()) &&
modelName.toLowerCase().includes("claude")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The fail-closed gate for a listed provider uses a bare includes("claude") substring check on the model name. That opens the empty-sentinel gate for any model whose ID merely contains the substring "claude" even when it is not actually a Claude model served through @ai-sdk/anthropic — e.g. on a mixed-adapter provider a model ID like "x-claude-compat" would be stripped to empty parts that a non-Anthropic adapter forwards as real content (the 400 "reasoning_content is missing" path the gate is meant to avoid). Every real Claude model ID is a claude-*/claude/* prefix, so anchoring the match to the start keeps the same intended coverage while staying fail-closed.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/plugin/src/hooks/magic-context/sentinel.ts, line 74:

<comment>The fail-closed gate for a listed provider uses a bare `includes("claude")` substring check on the model name. That opens the empty-sentinel gate for any model whose ID merely contains the substring "claude" even when it is not actually a Claude model served through `@ai-sdk/anthropic` — e.g. on a mixed-adapter provider a model ID like "x-claude-compat" would be stripped to empty parts that a non-Anthropic adapter forwards as real content (the 400 "reasoning_content is missing" path the gate is meant to avoid). Every real Claude model ID is a `claude-*`/`claude/*` prefix, so anchoring the match to the start keeps the same intended coverage while staying fail-closed.</comment>

<file context>
@@ -31,9 +31,48 @@ export const WHOLE_MESSAGE_PLACEHOLDER_TEXT = "[dropped]";
+    if (!providerID || !modelName) return false;
+    return (
+        anthropicTransportProviders.has(providerID.toLowerCase()) &&
+        modelName.toLowerCase().includes("claude")
+    );
 }
</file context>

);
}

/**
Expand Down
78 changes: 77 additions & 1 deletion packages/plugin/src/hooks/magic-context/strip-content.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/// <reference types="bun-types" />

import { beforeEach, describe, expect, it, mock } from "bun:test";
import { setAnthropicTransportProviders } from "./sentinel";
import {
clearOldReasoning,
findLatestAssistantReasoningMutationExemptMessage,
Expand Down Expand Up @@ -1184,7 +1185,7 @@ describe("strip-content", () => {
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 for github-copilot without a configured transport allow-list", () => {
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" }]);
Expand All @@ -1194,6 +1195,81 @@ describe("strip-content", () => {
expect(stripped).toBe(0);
});

it("#then strips github-copilot Claude models when the provider is in anthropic_transport_providers", () => {
setAnthropicTransportProviders(["github-copilot"]);
try {
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",
{
modelName: "claude-sonnet-4-6",
},
);

expect(stripped).toBe(1);
// The first assistant keeps its reasoning (index-0 rule); the
// second is replaced with an empty-text sentinel.
expect(a1.parts[0]).toEqual({ type: "reasoning", text: "first" });
expect(a2.parts[0]).toEqual({ type: "text", text: "" });
} finally {
setAnthropicTransportProviders([]);
}
});

it("#then keeps non-Claude models under a configured provider untouched", () => {
setAnthropicTransportProviders(["github-copilot"]);
try {
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",
{
modelName: "gpt-5.5",
},
);

expect(stripped).toBe(0);
expect(a1.parts[0]).toEqual({ type: "reasoning", text: "first" });
expect(a2.parts[0]).toEqual({ type: "reasoning", text: "second" });
} finally {
setAnthropicTransportProviders([]);
}
});

it("#then findMergedReasoningStripCandidateIds follows the same gate", () => {
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" }]);

expect(
findMergedReasoningStripCandidateIds([u, a1, a2], "github-copilot", {
modelName: "claude-sonnet-4-6",
}),
).toEqual([]);

setAnthropicTransportProviders(["github-copilot"]);
try {
expect(
findMergedReasoningStripCandidateIds([u, a1, a2], "github-copilot", {
modelName: "claude-sonnet-4-6",
}),
).toEqual(["m-a2"]);
} finally {
setAnthropicTransportProviders([]);
}
});

it("#then runs normally for providerID === 'anthropic'", () => {
const u = message("m-u", "user", [{ type: "text", text: "hi" }]);
const a1 = message("m-a1", "assistant", [
Expand Down
32 changes: 21 additions & 11 deletions packages/plugin/src/hooks/magic-context/strip-content.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { isRecord } from "../../shared/record-type-guard";
import { isSentinel, makeSentinel, makeWholeMessageSentinel } from "./sentinel";
import {
isSentinel,
makeSentinel,
makeWholeMessageSentinel,
modelAcceptsEmptyContent,
} from "./sentinel";
import type { MessageLike, ThinkingLikePart } from "./tag-messages";

const DROPPED_PLACEHOLDER_PATTERN = /^\[dropped §\d+§\]$/;
Expand Down Expand Up @@ -797,9 +802,9 @@ export function applyFrozenTrailingBlankDecisions(
export function findMergedReasoningStripCandidateIds(
messages: MessageLike[],
providerID?: string,
options?: { mutationExemptMessage?: MessageLike },
options?: { mutationExemptMessage?: MessageLike; modelName?: string },
): string[] {
if (providerID !== "anthropic") return [];
if (!modelAcceptsEmptyContent(providerID, options?.modelName)) return [];

const ids = new Set<string>();
for (const entry of planMergedAssistantReasoningStrip(
Expand Down Expand Up @@ -861,8 +866,10 @@ export function stripReasoningFromAssistantIds(
messages: MessageLike[],
providerID: string | undefined,
messageIds: ReadonlySet<string>,
modelName?: string,
): number {
if (providerID !== "anthropic" || messageIds.size === 0) return 0;
if (messageIds.size === 0) return 0;
if (!modelAcceptsEmptyContent(providerID, modelName)) return 0;
let stripped = 0;
for (const message of messages) {
const id = message.info.id;
Expand All @@ -883,15 +890,18 @@ export function stripReasoningFromMergedAssistants(
options?: {
mutationExemptMessage?: MessageLike;
frozenMessageIds?: ReadonlySet<string>;
modelName?: string;
},
): number {
// Anthropic-only workaround for @ai-sdk/anthropic's groupIntoBlocks
// index-0-thinking rule. openai-compatible providers like Kimi/
// Moonshot enforce the opposite invariant (every tool-call assistant
// 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;
// Workaround for @ai-sdk/anthropic's groupIntoBlocks index-0-thinking
// rule. Besides the canonical provider, user-configured Anthropic-transport
// providers running Claude models (see sentinel.ts) hit the same signed-
// block contract. OpenAI-compatible non-Claude models like Kimi/Moonshot
// enforce the opposite invariant (every tool-call assistant must have
// non-empty `reasoning_content`), so they stay excluded — the strip would
// trigger 400 "reasoning_content is missing" there. See the call site in
// transform.ts for the full rationale.
if (!modelAcceptsEmptyContent(providerID, options?.modelName)) return 0;

let stripped = 0;
for (const entry of planMergedAssistantReasoningStrip(
Expand Down
Loading