@cortexkit/opencode-magic-context 0.17.2 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/features/magic-context/dreamer/runner.d.ts +15 -0
- package/dist/features/magic-context/dreamer/runner.d.ts.map +1 -1
- package/dist/features/magic-context/migrations.d.ts.map +1 -1
- package/dist/features/magic-context/sidekick/agent.d.ts.map +1 -1
- package/dist/features/magic-context/storage-db.d.ts.map +1 -1
- package/dist/features/magic-context/storage-meta-persisted.d.ts +14 -0
- package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
- package/dist/features/magic-context/storage-meta-shared.d.ts +1 -0
- package/dist/features/magic-context/storage-meta-shared.d.ts.map +1 -1
- package/dist/features/magic-context/storage-meta.d.ts +1 -1
- package/dist/features/magic-context/storage-meta.d.ts.map +1 -1
- package/dist/features/magic-context/storage.d.ts +1 -1
- package/dist/features/magic-context/storage.d.ts.map +1 -1
- package/dist/features/magic-context/types.d.ts +1 -0
- package/dist/features/magic-context/types.d.ts.map +1 -1
- package/dist/features/magic-context/user-memory/review-user-memories.d.ts +2 -0
- package/dist/features/magic-context/user-memory/review-user-memories.d.ts.map +1 -1
- package/dist/hooks/magic-context/command-handler.d.ts +2 -0
- package/dist/hooks/magic-context/command-handler.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-compressor.d.ts +1 -0
- package/dist/hooks/magic-context/compartment-runner-compressor.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-historian.d.ts +7 -0
- package/dist/hooks/magic-context/compartment-runner-historian.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-incremental.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-partial-recomp.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-recomp.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-types.d.ts +2 -0
- package/dist/hooks/magic-context/compartment-runner-types.d.ts.map +1 -1
- package/dist/hooks/magic-context/hook-handlers.d.ts.map +1 -1
- package/dist/hooks/magic-context/hook.d.ts.map +1 -1
- package/dist/hooks/magic-context/system-prompt-hash.d.ts.map +1 -1
- package/dist/hooks/magic-context/todo-view.d.ts +102 -0
- package/dist/hooks/magic-context/todo-view.d.ts.map +1 -0
- package/dist/hooks/magic-context/transform-compartment-phase.d.ts +1 -0
- package/dist/hooks/magic-context/transform-compartment-phase.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform-message-helpers.d.ts +22 -0
- package/dist/hooks/magic-context/transform-message-helpers.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform.d.ts +2 -0
- package/dist/hooks/magic-context/transform.d.ts.map +1 -1
- package/dist/index.js +626 -178
- package/dist/plugin/dream-timer.d.ts.map +1 -1
- package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
- package/dist/plugin/rpc-handlers.d.ts +2 -1
- package/dist/plugin/rpc-handlers.d.ts.map +1 -1
- package/dist/shared/index.d.ts +1 -0
- package/dist/shared/index.d.ts.map +1 -1
- package/dist/shared/model-suggestion-retry.d.ts +37 -0
- package/dist/shared/model-suggestion-retry.d.ts.map +1 -1
- package/dist/shared/models-dev-cache.d.ts.map +1 -1
- package/dist/shared/resolve-fallbacks.d.ts +32 -0
- package/dist/shared/resolve-fallbacks.d.ts.map +1 -0
- package/dist/shared/tag-transcript.d.ts.map +1 -1
- package/dist/tools/ctx-memory/tools.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/shared/index.ts +1 -0
- package/src/shared/model-suggestion-retry.test.ts +251 -0
- package/src/shared/model-suggestion-retry.ts +194 -6
- package/src/shared/models-dev-cache.ts +7 -7
- package/src/shared/resolve-fallbacks.test.ts +136 -0
- package/src/shared/resolve-fallbacks.ts +76 -0
- package/src/shared/tag-transcript.ts +3 -2
- package/src/tui/index.tsx +114 -18
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { createOpencodeClient } from "@opencode-ai/sdk";
|
|
2
2
|
|
|
3
|
+
import { detectOverflow } from "../features/magic-context/overflow-detection";
|
|
3
4
|
import { log } from "./logger";
|
|
5
|
+
import { parseProviderModel } from "./resolve-fallbacks";
|
|
4
6
|
|
|
5
7
|
type Client = ReturnType<typeof createOpencodeClient>;
|
|
6
8
|
|
|
@@ -20,6 +22,29 @@ export interface PromptRetryOptions {
|
|
|
20
22
|
timeoutMs?: number;
|
|
21
23
|
/** External abort signal — cancels the in-flight LLM prompt immediately when aborted */
|
|
22
24
|
signal?: AbortSignal;
|
|
25
|
+
/**
|
|
26
|
+
* Ordered list of "provider/modelID" alternates to try if the primary call
|
|
27
|
+
* (and its single-suggestion retry) fails. Empty / undefined = no fallback
|
|
28
|
+
* iteration (legacy behavior).
|
|
29
|
+
*
|
|
30
|
+
* Fallback policy:
|
|
31
|
+
* - Each fallback gets the FULL `timeoutMs` budget (per-attempt, not total).
|
|
32
|
+
* - Suggestion-retry runs inside each attempt (so "did you mean X?" errors
|
|
33
|
+
* still self-heal at the primary AND at each fallback).
|
|
34
|
+
* - Iteration stops immediately on abort/timeout/context-overflow errors —
|
|
35
|
+
* fallbacks won't help and the caller's emergency-recovery path needs
|
|
36
|
+
* to handle these.
|
|
37
|
+
* - On all-failed, the LAST error is thrown (matches legacy behavior when
|
|
38
|
+
* `fallbackModels` is empty).
|
|
39
|
+
*/
|
|
40
|
+
fallbackModels?: readonly string[];
|
|
41
|
+
/**
|
|
42
|
+
* Identifier for structured logging (e.g. "dreamer:consolidate",
|
|
43
|
+
* "historian", "compressor", "sidekick"). Helps correlate fallback
|
|
44
|
+
* attempts to a specific call site in `magic-context.log`. Defaults to
|
|
45
|
+
* "subagent" if not provided.
|
|
46
|
+
*/
|
|
47
|
+
callContext?: string;
|
|
23
48
|
}
|
|
24
49
|
|
|
25
50
|
export interface ModelSuggestionInfo {
|
|
@@ -95,6 +120,15 @@ async function promptWithTimeout(
|
|
|
95
120
|
timeoutMs: number,
|
|
96
121
|
signal?: AbortSignal,
|
|
97
122
|
): Promise<void> {
|
|
123
|
+
// Bail immediately if the caller's signal is already aborted (e.g.
|
|
124
|
+
// lease loss before this attempt was scheduled). Per spec
|
|
125
|
+
// `addEventListener('abort', ...)` on an already-aborted signal fires
|
|
126
|
+
// synchronously in modern Node/Bun, but an explicit guard is clearer
|
|
127
|
+
// and avoids one wasted upstream `client.session.prompt` round-trip
|
|
128
|
+
// before `isNonRetryable` catches the cancellation at the chain loop.
|
|
129
|
+
if (signal?.aborted) {
|
|
130
|
+
throw new Error("prompt aborted by external signal");
|
|
131
|
+
}
|
|
98
132
|
const controller = new AbortController();
|
|
99
133
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
100
134
|
|
|
@@ -121,22 +155,76 @@ async function promptWithTimeout(
|
|
|
121
155
|
}
|
|
122
156
|
}
|
|
123
157
|
|
|
124
|
-
|
|
158
|
+
/**
|
|
159
|
+
* Returns true if the error indicates a NON-RETRYABLE condition where iterating
|
|
160
|
+
* to a fallback model would be pointless or harmful:
|
|
161
|
+
*
|
|
162
|
+
* - External abort (user cancellation, lease loss, etc.) — caller wants to
|
|
163
|
+
* stop, not retry.
|
|
164
|
+
* - Context overflow — same prompt will overflow on any reasonably-sized
|
|
165
|
+
* model. Caller has its own emergency-recovery path for this.
|
|
166
|
+
* - Timeout — same wall-clock budget on the same prompt is unlikely to
|
|
167
|
+
* succeed on another model. Caller decides whether to retry at a higher
|
|
168
|
+
* level (e.g. historian's MAX_HISTORIAN_RETRIES loop).
|
|
169
|
+
*
|
|
170
|
+
* Everything else (auth errors, ProviderModelNotFoundError without suggestion,
|
|
171
|
+
* rate limits, transient network failures, etc.) is considered retryable on a
|
|
172
|
+
* different model.
|
|
173
|
+
*/
|
|
174
|
+
function isNonRetryable(error: unknown, externalSignal?: AbortSignal): boolean {
|
|
175
|
+
if (externalSignal?.aborted) return true;
|
|
176
|
+
|
|
177
|
+
if (error instanceof Error) {
|
|
178
|
+
if (error.name === "AbortError") return true;
|
|
179
|
+
// promptWithTimeout wraps both abort cases in plain `Error` with a
|
|
180
|
+
// recognizable message.
|
|
181
|
+
if (error.message === "prompt aborted by external signal") return true;
|
|
182
|
+
if (/^prompt timed out after \d+ms$/.test(error.message)) return true;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (detectOverflow(error).isOverflow) return true;
|
|
186
|
+
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function shortErr(error: unknown): string {
|
|
191
|
+
if (error instanceof Error) {
|
|
192
|
+
return error.name && error.name !== "Error"
|
|
193
|
+
? `${error.name}: ${error.message}`
|
|
194
|
+
: error.message;
|
|
195
|
+
}
|
|
196
|
+
return extractMessage(error);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Try a single prompt attempt against the supplied body, with the existing
|
|
201
|
+
* single-suggestion retry layered inside (so "did you mean X?" still self-heals
|
|
202
|
+
* per attempt). Throws on failure; returns on success.
|
|
203
|
+
*/
|
|
204
|
+
async function attemptOnce(
|
|
125
205
|
client: Client,
|
|
126
206
|
args: PromptArgs,
|
|
127
|
-
|
|
207
|
+
timeoutMs: number,
|
|
208
|
+
signal: AbortSignal | undefined,
|
|
209
|
+
callContext: string,
|
|
210
|
+
label: string,
|
|
128
211
|
): Promise<void> {
|
|
129
|
-
const timeoutMs = options.timeoutMs ?? 300_000;
|
|
130
|
-
|
|
131
212
|
try {
|
|
132
|
-
await promptWithTimeout(client, args, timeoutMs,
|
|
213
|
+
await promptWithTimeout(client, args, timeoutMs, signal);
|
|
214
|
+
return;
|
|
133
215
|
} catch (error) {
|
|
216
|
+
// If non-retryable (abort, overflow, timeout), bubble up immediately.
|
|
217
|
+
// Don't even try suggestion retry — caller needs the original error.
|
|
218
|
+
if (isNonRetryable(error, signal)) throw error;
|
|
219
|
+
|
|
134
220
|
const suggestion = parseModelSuggestion(error);
|
|
135
221
|
if (!suggestion || !args.body.model) {
|
|
222
|
+
// No suggestion available — caller's fallback loop will decide
|
|
223
|
+
// whether to try the next chain entry.
|
|
136
224
|
throw error;
|
|
137
225
|
}
|
|
138
226
|
|
|
139
|
-
log(
|
|
227
|
+
log(`[${callContext}] ${label}: model not found, retrying with suggestion`, {
|
|
140
228
|
original: `${suggestion.providerID}/${suggestion.modelID}`,
|
|
141
229
|
suggested: suggestion.suggestion,
|
|
142
230
|
});
|
|
@@ -154,7 +242,107 @@ export async function promptSyncWithModelSuggestionRetry(
|
|
|
154
242
|
},
|
|
155
243
|
},
|
|
156
244
|
timeoutMs,
|
|
245
|
+
signal,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Run an OpenCode subagent prompt with model fallback support.
|
|
252
|
+
*
|
|
253
|
+
* Attempts the configured primary model first (whatever `args.body.model` or
|
|
254
|
+
* the registered agent default resolves to), then iterates through
|
|
255
|
+
* `options.fallbackModels` if provided. Each attempt internally retries once on
|
|
256
|
+
* the SDK's "model not found, did you mean X?" suggestion. Aborts, timeouts,
|
|
257
|
+
* and context-overflow errors short-circuit the fallback loop because retrying
|
|
258
|
+
* the same prompt against another model won't help.
|
|
259
|
+
*
|
|
260
|
+
* Behavior with `fallbackModels` empty/undefined is identical to the pre-v0.18
|
|
261
|
+
* single-suggestion retry — fully backward-compatible for callers that haven't
|
|
262
|
+
* been updated to thread a chain.
|
|
263
|
+
*/
|
|
264
|
+
export async function promptSyncWithModelSuggestionRetry(
|
|
265
|
+
client: Client,
|
|
266
|
+
args: PromptArgs,
|
|
267
|
+
options: PromptRetryOptions = {},
|
|
268
|
+
): Promise<void> {
|
|
269
|
+
const timeoutMs = options.timeoutMs ?? 300_000;
|
|
270
|
+
const callContext = options.callContext ?? "subagent";
|
|
271
|
+
const fallbacks = options.fallbackModels ?? [];
|
|
272
|
+
|
|
273
|
+
// Attempt 0 = whatever the agent or explicit body.model resolves to.
|
|
274
|
+
// Subsequent attempts override body.model with each fallback in order.
|
|
275
|
+
const explicitPrimaryLabel =
|
|
276
|
+
args.body.model?.providerID && args.body.model.modelID
|
|
277
|
+
? `${args.body.model.providerID}/${args.body.model.modelID}`
|
|
278
|
+
: "primary";
|
|
279
|
+
|
|
280
|
+
let lastError: unknown = null;
|
|
281
|
+
|
|
282
|
+
try {
|
|
283
|
+
await attemptOnce(
|
|
284
|
+
client,
|
|
285
|
+
args,
|
|
286
|
+
timeoutMs,
|
|
157
287
|
options.signal,
|
|
288
|
+
callContext,
|
|
289
|
+
explicitPrimaryLabel,
|
|
290
|
+
);
|
|
291
|
+
return;
|
|
292
|
+
} catch (error) {
|
|
293
|
+
lastError = error;
|
|
294
|
+
if (isNonRetryable(error, options.signal)) throw error;
|
|
295
|
+
|
|
296
|
+
if (fallbacks.length === 0) {
|
|
297
|
+
// No fallbacks configured — behave exactly like legacy: propagate
|
|
298
|
+
// the original error (which may already have had its suggestion
|
|
299
|
+
// retry attempted inside `attemptOnce`).
|
|
300
|
+
throw error;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
log(
|
|
304
|
+
`[${callContext}] primary (${explicitPrimaryLabel}) failed: ${shortErr(error)}; trying ${fallbacks.length} fallback(s)`,
|
|
158
305
|
);
|
|
159
306
|
}
|
|
307
|
+
|
|
308
|
+
// Iterate fallbacks.
|
|
309
|
+
for (let i = 0; i < fallbacks.length; i += 1) {
|
|
310
|
+
const parsed = parseProviderModel(fallbacks[i]);
|
|
311
|
+
if (!parsed) {
|
|
312
|
+
log(`[${callContext}] skipping invalid fallback spec: ${fallbacks[i]}`);
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const label = `${parsed.providerID}/${parsed.modelID}`;
|
|
317
|
+
const attemptArgs: PromptArgs = {
|
|
318
|
+
...args,
|
|
319
|
+
body: { ...args.body, model: parsed },
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
try {
|
|
323
|
+
await attemptOnce(client, attemptArgs, timeoutMs, options.signal, callContext, label);
|
|
324
|
+
log(
|
|
325
|
+
`[${callContext}] fallback succeeded with ${label} (attempt ${i + 2}/${fallbacks.length + 1})`,
|
|
326
|
+
);
|
|
327
|
+
return;
|
|
328
|
+
} catch (error) {
|
|
329
|
+
lastError = error;
|
|
330
|
+
if (isNonRetryable(error, options.signal)) throw error;
|
|
331
|
+
|
|
332
|
+
const remaining = fallbacks.length - i - 1;
|
|
333
|
+
if (remaining > 0) {
|
|
334
|
+
log(
|
|
335
|
+
`[${callContext}] ${label} failed: ${shortErr(error)}; ${remaining} fallback(s) left`,
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// All exhausted. Log the full chain and throw the last error so the
|
|
342
|
+
// caller's report (e.g. /ctx-dream tasks_json) still surfaces a real
|
|
343
|
+
// diagnostic.
|
|
344
|
+
log(
|
|
345
|
+
`[${callContext}] all models exhausted; tried: ${[explicitPrimaryLabel, ...fallbacks].join(", ")}; last error: ${shortErr(lastError)}`,
|
|
346
|
+
);
|
|
347
|
+
throw lastError ?? new Error("All fallback models failed");
|
|
160
348
|
}
|
|
@@ -24,6 +24,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
24
24
|
import { homedir, platform } from "node:os";
|
|
25
25
|
import { join } from "node:path";
|
|
26
26
|
import { getCacheDir } from "./data-path";
|
|
27
|
+
import { parseJsonc } from "./jsonc-parser";
|
|
27
28
|
import { sessionLog } from "./logger";
|
|
28
29
|
|
|
29
30
|
interface OpencodeClientLike {
|
|
@@ -195,19 +196,18 @@ function loadModelsDevMetadataFromFile(): Map<string, CachedModelMetadata> {
|
|
|
195
196
|
try {
|
|
196
197
|
const configPath = getOpencodeConfigPath();
|
|
197
198
|
if (configPath && existsSync(configPath)) {
|
|
198
|
-
|
|
199
|
-
//
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
const config = JSON.parse(raw) as {
|
|
199
|
+
// Use the shared JSONC parser — handles `//` comments AND trailing commas.
|
|
200
|
+
// The previous custom regex stripped comments only; OpenCode's `opencode.jsonc`
|
|
201
|
+
// frequently contains trailing commas (valid JSONC, invalid JSON), which broke
|
|
202
|
+
// custom provider model-limit resolution silently. See issue #14 follow-up.
|
|
203
|
+
const config = parseJsonc<{
|
|
204
204
|
provider?: Record<
|
|
205
205
|
string,
|
|
206
206
|
{
|
|
207
207
|
models?: Record<string, { limit?: { context?: number; input?: number } }>;
|
|
208
208
|
}
|
|
209
209
|
>;
|
|
210
|
-
};
|
|
210
|
+
}>(readFileSync(configPath, "utf-8"));
|
|
211
211
|
|
|
212
212
|
if (config.provider && typeof config.provider === "object") {
|
|
213
213
|
for (const [providerId, provider] of Object.entries(config.provider)) {
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { DREAMER_AGENT } from "../agents/dreamer";
|
|
4
|
+
import { HISTORIAN_AGENT } from "../agents/historian";
|
|
5
|
+
import { SIDEKICK_AGENT } from "../agents/sidekick";
|
|
6
|
+
import { parseProviderModel, resolveFallbackChain } from "./resolve-fallbacks";
|
|
7
|
+
|
|
8
|
+
describe("resolveFallbackChain", () => {
|
|
9
|
+
test("returns builtin chain when user provides nothing", () => {
|
|
10
|
+
const chain = resolveFallbackChain(DREAMER_AGENT, undefined);
|
|
11
|
+
// Builtin DREAMER_FALLBACK_CHAIN expands to multiple provider/model entries.
|
|
12
|
+
expect(chain.length).toBeGreaterThan(2);
|
|
13
|
+
// Every entry must be in "provider/model" form.
|
|
14
|
+
for (const entry of chain) {
|
|
15
|
+
expect(entry.indexOf("/")).toBeGreaterThan(0);
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("returns builtin chain for empty string", () => {
|
|
20
|
+
const chain = resolveFallbackChain(DREAMER_AGENT, "");
|
|
21
|
+
expect(chain.length).toBeGreaterThan(0);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("returns builtin chain for empty array", () => {
|
|
25
|
+
const chain = resolveFallbackChain(DREAMER_AGENT, []);
|
|
26
|
+
expect(chain.length).toBeGreaterThan(0);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("user-only when user provides valid fallback_models string", () => {
|
|
30
|
+
const chain = resolveFallbackChain(DREAMER_AGENT, "anthropic/claude-sonnet-4-6");
|
|
31
|
+
expect(chain).toEqual(["anthropic/claude-sonnet-4-6"]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("user-only when user provides valid fallback_models array", () => {
|
|
35
|
+
const chain = resolveFallbackChain(DREAMER_AGENT, [
|
|
36
|
+
"anthropic/claude-sonnet-4-6",
|
|
37
|
+
"google/gemini-3-flash",
|
|
38
|
+
]);
|
|
39
|
+
expect(chain).toEqual(["anthropic/claude-sonnet-4-6", "google/gemini-3-flash"]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("dedupes user-provided list", () => {
|
|
43
|
+
const chain = resolveFallbackChain(DREAMER_AGENT, [
|
|
44
|
+
"anthropic/claude-sonnet-4-6",
|
|
45
|
+
"anthropic/claude-sonnet-4-6",
|
|
46
|
+
"google/gemini-3-flash",
|
|
47
|
+
]);
|
|
48
|
+
expect(chain).toEqual(["anthropic/claude-sonnet-4-6", "google/gemini-3-flash"]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("strips invalid 'provider/model' entries", () => {
|
|
52
|
+
const chain = resolveFallbackChain(DREAMER_AGENT, [
|
|
53
|
+
"anthropic/claude-sonnet-4-6",
|
|
54
|
+
"no-slash-here",
|
|
55
|
+
"/leading-slash",
|
|
56
|
+
"trailing-slash/",
|
|
57
|
+
"",
|
|
58
|
+
" ",
|
|
59
|
+
]);
|
|
60
|
+
expect(chain).toEqual(["anthropic/claude-sonnet-4-6"]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("trims whitespace in user entries", () => {
|
|
64
|
+
const chain = resolveFallbackChain(DREAMER_AGENT, [
|
|
65
|
+
" anthropic/claude-sonnet-4-6 ",
|
|
66
|
+
"\tgoogle/gemini-3-flash\n",
|
|
67
|
+
]);
|
|
68
|
+
expect(chain).toEqual(["anthropic/claude-sonnet-4-6", "google/gemini-3-flash"]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("returns empty array for unknown agent with no user fallbacks", () => {
|
|
72
|
+
const chain = resolveFallbackChain("unknown-agent", undefined);
|
|
73
|
+
expect(chain).toEqual([]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("returns user fallbacks for unknown agent when provided", () => {
|
|
77
|
+
const chain = resolveFallbackChain("unknown-agent", ["foo/bar"]);
|
|
78
|
+
expect(chain).toEqual(["foo/bar"]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("HISTORIAN_AGENT has builtin chain", () => {
|
|
82
|
+
const chain = resolveFallbackChain(HISTORIAN_AGENT, undefined);
|
|
83
|
+
expect(chain.length).toBeGreaterThan(0);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("SIDEKICK_AGENT has builtin chain", () => {
|
|
87
|
+
const chain = resolveFallbackChain(SIDEKICK_AGENT, undefined);
|
|
88
|
+
expect(chain.length).toBeGreaterThan(0);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("user-only policy: builtin not appended even if user set short list", () => {
|
|
92
|
+
const chain = resolveFallbackChain(DREAMER_AGENT, ["anthropic/claude-sonnet-4-6"]);
|
|
93
|
+
expect(chain).toEqual(["anthropic/claude-sonnet-4-6"]);
|
|
94
|
+
// Confirm length is exactly 1, not user+builtin
|
|
95
|
+
expect(chain.length).toBe(1);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("parseProviderModel", () => {
|
|
100
|
+
test("parses standard provider/model", () => {
|
|
101
|
+
expect(parseProviderModel("anthropic/claude-sonnet-4-6")).toEqual({
|
|
102
|
+
providerID: "anthropic",
|
|
103
|
+
modelID: "claude-sonnet-4-6",
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("handles model id with slashes (only splits on first slash)", () => {
|
|
108
|
+
expect(parseProviderModel("lemonade/GLM-4.7-Flash-GGUF/main")).toEqual({
|
|
109
|
+
providerID: "lemonade",
|
|
110
|
+
modelID: "GLM-4.7-Flash-GGUF/main",
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("trims whitespace", () => {
|
|
115
|
+
expect(parseProviderModel(" anthropic/claude-sonnet-4-6 ")).toEqual({
|
|
116
|
+
providerID: "anthropic",
|
|
117
|
+
modelID: "claude-sonnet-4-6",
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("returns null for no slash", () => {
|
|
122
|
+
expect(parseProviderModel("anthropic")).toBeNull();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("returns null for leading slash", () => {
|
|
126
|
+
expect(parseProviderModel("/claude-sonnet-4-6")).toBeNull();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("returns null for trailing slash", () => {
|
|
130
|
+
expect(parseProviderModel("anthropic/")).toBeNull();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("returns null for empty string", () => {
|
|
134
|
+
expect(parseProviderModel("")).toBeNull();
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { getAgentFallbackModels } from "./model-requirements";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolve the final fallback model list to attempt for an OpenCode subagent
|
|
5
|
+
* call.
|
|
6
|
+
*
|
|
7
|
+
* Policy (decided 2026-05-10):
|
|
8
|
+
* - If user configured explicit `fallback_models` in their magic-context.jsonc
|
|
9
|
+
* for this agent: use ONLY those. Respects user intent, no surprise
|
|
10
|
+
* providers.
|
|
11
|
+
* - If user did NOT configure any: fall back to the plugin's builtin
|
|
12
|
+
* provider-agnostic chain (`AGENT_MODEL_REQUIREMENTS`).
|
|
13
|
+
*
|
|
14
|
+
* The returned list does NOT include the primary model — it's the ordered
|
|
15
|
+
* list of *alternates* to try after the primary fails. Each entry is
|
|
16
|
+
* "provider/modelID" form.
|
|
17
|
+
*
|
|
18
|
+
* Duplicates and empty strings are filtered. Entries that don't match the
|
|
19
|
+
* "provider/modelID" shape (must contain a "/" with non-empty parts) are
|
|
20
|
+
* also dropped — defensive guard against malformed user config.
|
|
21
|
+
*/
|
|
22
|
+
export function resolveFallbackChain(
|
|
23
|
+
agentName: string,
|
|
24
|
+
userFallbacks: readonly string[] | string | undefined,
|
|
25
|
+
): string[] {
|
|
26
|
+
const userList = normalizeUserFallbacks(userFallbacks);
|
|
27
|
+
|
|
28
|
+
if (userList.length > 0) {
|
|
29
|
+
return dedupe(userList.filter(isValidModelSpec));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const builtin = getAgentFallbackModels(agentName);
|
|
33
|
+
if (!builtin || builtin.length === 0) return [];
|
|
34
|
+
return dedupe(builtin.filter(isValidModelSpec));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizeUserFallbacks(userFallbacks: readonly string[] | string | undefined): string[] {
|
|
38
|
+
if (!userFallbacks) return [];
|
|
39
|
+
if (typeof userFallbacks === "string") {
|
|
40
|
+
const trimmed = userFallbacks.trim();
|
|
41
|
+
return trimmed ? [trimmed] : [];
|
|
42
|
+
}
|
|
43
|
+
return userFallbacks.map((s) => s.trim()).filter((s) => s.length > 0);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isValidModelSpec(spec: string): boolean {
|
|
47
|
+
const slash = spec.indexOf("/");
|
|
48
|
+
return slash > 0 && slash < spec.length - 1;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function dedupe(list: string[]): string[] {
|
|
52
|
+
const seen = new Set<string>();
|
|
53
|
+
const out: string[] = [];
|
|
54
|
+
for (const item of list) {
|
|
55
|
+
if (seen.has(item)) continue;
|
|
56
|
+
seen.add(item);
|
|
57
|
+
out.push(item);
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Parse a "provider/modelID" string into the OpenCode `model` object shape.
|
|
64
|
+
* Returns null on invalid input.
|
|
65
|
+
*
|
|
66
|
+
* Note: only splits on the FIRST "/" — modelID can legitimately contain slashes
|
|
67
|
+
* (e.g. `lemonade/GLM-4.7-Flash-GGUF/main`).
|
|
68
|
+
*/
|
|
69
|
+
export function parseProviderModel(spec: string): { providerID: string; modelID: string } | null {
|
|
70
|
+
const slash = spec.indexOf("/");
|
|
71
|
+
if (slash < 1 || slash >= spec.length - 1) return null;
|
|
72
|
+
return {
|
|
73
|
+
providerID: spec.slice(0, slash).trim(),
|
|
74
|
+
modelID: spec.slice(slash + 1).trim(),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -159,9 +159,10 @@ export function tagTranscript(
|
|
|
159
159
|
const messageId = message.info.id;
|
|
160
160
|
|
|
161
161
|
let textOrdinal = 0;
|
|
162
|
+
const parts = message.parts;
|
|
162
163
|
|
|
163
|
-
for (let partIndex = 0; partIndex <
|
|
164
|
-
const part =
|
|
164
|
+
for (let partIndex = 0; partIndex < parts.length; partIndex += 1) {
|
|
165
|
+
const part = parts[partIndex];
|
|
165
166
|
if (part === undefined) continue;
|
|
166
167
|
|
|
167
168
|
if (part.kind === "text") {
|
package/src/tui/index.tsx
CHANGED
|
@@ -453,6 +453,111 @@ function showStatusDialog(api: TuiPluginApi) {
|
|
|
453
453
|
})
|
|
454
454
|
}
|
|
455
455
|
|
|
456
|
+
/**
|
|
457
|
+
* Register Magic Context command palette entries, preferring the v1.14.42+
|
|
458
|
+
* `keymap.registerLayer` API and falling back to the legacy
|
|
459
|
+
* `api.command.register` for older hosts.
|
|
460
|
+
*
|
|
461
|
+
* The `keymap.registerLayer` shape uses `name`/`title`/`run`/`namespace`
|
|
462
|
+
* (see `@opencode-ai/plugin/tui` types) and is what the host's own legacy
|
|
463
|
+
* command-shim translates into. Calling it directly skips the deprecation
|
|
464
|
+
* warning and works without depending on the (now-deprecated) `api.command`
|
|
465
|
+
* namespace existing at all.
|
|
466
|
+
*
|
|
467
|
+
* Version coverage:
|
|
468
|
+
* 1.14.0–1.14.41 — `api.command.register` only
|
|
469
|
+
* 1.14.42–1.14.43 — both surfaces broken (api.command removed, keymap landed
|
|
470
|
+
* but with bugs); plugins crash on init either way
|
|
471
|
+
* 1.14.44+ — `api.keymap.registerLayer` canonical, `api.command` shim
|
|
472
|
+
*/
|
|
473
|
+
function registerCommandPaletteEntries(api: TuiPluginApi): void {
|
|
474
|
+
type ApiAny = {
|
|
475
|
+
keymap?: {
|
|
476
|
+
registerLayer?: (layer: {
|
|
477
|
+
commands: Array<Record<string, unknown>>
|
|
478
|
+
bindings: Array<Record<string, unknown>>
|
|
479
|
+
}) => unknown
|
|
480
|
+
}
|
|
481
|
+
command?: {
|
|
482
|
+
register?: (cb: () => Array<Record<string, unknown>>) => unknown
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
const apiAny = api as unknown as ApiAny
|
|
486
|
+
|
|
487
|
+
if (typeof apiAny.keymap?.registerLayer === "function") {
|
|
488
|
+
// Audit Finding #2 hardening: even when registerLayer exists as a
|
|
489
|
+
// function, the underlying keymap implementation in OpenCode TUI
|
|
490
|
+
// 1.14.42-1.14.43 can throw at call time. Without the try-catch the
|
|
491
|
+
// `return` below would propagate the throw and the legacy
|
|
492
|
+
// `command.register` fallback path (~20 lines down) would be
|
|
493
|
+
// unreachable. The cost is one debug log on the rare broken-TUI
|
|
494
|
+
// build; the benefit is that older command.register-only TUIs
|
|
495
|
+
// running alongside a partially-broken keymap surface still get
|
|
496
|
+
// their command palette entries.
|
|
497
|
+
try {
|
|
498
|
+
apiAny.keymap.registerLayer({
|
|
499
|
+
commands: [
|
|
500
|
+
{
|
|
501
|
+
namespace: "palette",
|
|
502
|
+
name: "magic-context.status",
|
|
503
|
+
title: "Magic Context: Status",
|
|
504
|
+
category: "Magic Context",
|
|
505
|
+
run() {
|
|
506
|
+
showStatusDialog(api)
|
|
507
|
+
},
|
|
508
|
+
},
|
|
509
|
+
{
|
|
510
|
+
namespace: "palette",
|
|
511
|
+
name: "magic-context.recomp",
|
|
512
|
+
title: "Magic Context: Recomp",
|
|
513
|
+
category: "Magic Context",
|
|
514
|
+
run() {
|
|
515
|
+
showRecompDialog(api)
|
|
516
|
+
},
|
|
517
|
+
},
|
|
518
|
+
],
|
|
519
|
+
bindings: [],
|
|
520
|
+
})
|
|
521
|
+
return
|
|
522
|
+
} catch (err) {
|
|
523
|
+
console.debug(
|
|
524
|
+
"[magic-context-tui] keymap.registerLayer threw; falling back to command.register",
|
|
525
|
+
err,
|
|
526
|
+
)
|
|
527
|
+
// Fall through to legacy registration.
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (typeof apiAny.command?.register === "function") {
|
|
532
|
+
apiAny.command.register(() => [
|
|
533
|
+
{
|
|
534
|
+
title: "Magic Context: Status",
|
|
535
|
+
value: "magic-context.status",
|
|
536
|
+
category: "Magic Context",
|
|
537
|
+
onSelect() {
|
|
538
|
+
showStatusDialog(api)
|
|
539
|
+
},
|
|
540
|
+
},
|
|
541
|
+
{
|
|
542
|
+
title: "Magic Context: Recomp",
|
|
543
|
+
value: "magic-context.recomp",
|
|
544
|
+
category: "Magic Context",
|
|
545
|
+
onSelect() {
|
|
546
|
+
showRecompDialog(api)
|
|
547
|
+
},
|
|
548
|
+
},
|
|
549
|
+
])
|
|
550
|
+
return
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Neither API surface is present. The TUI host can still load — we only
|
|
554
|
+
// lose the command palette entry points. The sidebar (registered above
|
|
555
|
+
// via api.slots.register) remains visible. Status/Recomp are still
|
|
556
|
+
// reachable through the server-side `/ctx-status` and `/ctx-recomp`
|
|
557
|
+
// slash commands, which the server handler bridges to the TUI dialogs
|
|
558
|
+
// via RPC.
|
|
559
|
+
}
|
|
560
|
+
|
|
456
561
|
const tui: TuiPlugin = async (api, _options, meta) => {
|
|
457
562
|
// Initialize RPC client for server communication
|
|
458
563
|
const directory = api.state.path.directory ?? ""
|
|
@@ -465,24 +570,15 @@ const tui: TuiPlugin = async (api, _options, meta) => {
|
|
|
465
570
|
// are registered server-side so there's only one /ctx-* registration).
|
|
466
571
|
// The server detects TUI mode and sends dialog requests via RPC instead
|
|
467
572
|
// of sendIgnoredMessage.
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
{
|
|
478
|
-
title: "Magic Context: Recomp",
|
|
479
|
-
value: "magic-context.recomp",
|
|
480
|
-
category: "Magic Context",
|
|
481
|
-
onSelect() {
|
|
482
|
-
showRecompDialog(api)
|
|
483
|
-
},
|
|
484
|
-
},
|
|
485
|
-
])
|
|
573
|
+
//
|
|
574
|
+
// OpenCode 1.14.42 removed `api.command.register` entirely
|
|
575
|
+
// (anomalyco/opencode#26053). A later patch (1.14.44+) reinstated it as
|
|
576
|
+
// a deprecated shim that translates to `api.keymap.registerLayer`. To
|
|
577
|
+
// work across all hosts (1.14.0–1.14.41 with command-only, the broken
|
|
578
|
+
// 1.14.42–1.14.43, and 1.14.44+ where both exist), we prefer
|
|
579
|
+
// `api.keymap.registerLayer` and fall back to `api.command.register`
|
|
580
|
+
// only when keymap is missing.
|
|
581
|
+
registerCommandPaletteEntries(api)
|
|
486
582
|
|
|
487
583
|
// Poll for server→TUI messages: toasts and dialog requests.
|
|
488
584
|
// Single poller because consumeTuiMessages() is destructive (deletes consumed rows).
|