@cortexkit/opencode-magic-context 0.17.2 → 0.19.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/README.md +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/features/magic-context/compaction-marker.d.ts +17 -0
- package/dist/features/magic-context/compaction-marker.d.ts.map +1 -1
- package/dist/features/magic-context/compartment-storage.d.ts +11 -0
- package/dist/features/magic-context/compartment-storage.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/queue.d.ts.map +1 -1
- 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/memory/embedding-identity.d.ts +11 -0
- package/dist/features/magic-context/memory/embedding-identity.d.ts.map +1 -0
- package/dist/features/magic-context/memory/embedding-local.d.ts.map +1 -1
- package/dist/features/magic-context/memory/embedding-openai.d.ts.map +1 -1
- package/dist/features/magic-context/memory/embedding.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 +70 -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/tool-definition-tokens.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/cache-busting-signals.d.ts +10 -0
- package/dist/hooks/magic-context/cache-busting-signals.d.ts.map +1 -0
- 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/compaction-marker-manager.d.ts +50 -0
- package/dist/hooks/magic-context/compaction-marker-manager.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 +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 +18 -7
- package/dist/hooks/magic-context/compartment-runner-types.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner.d.ts +7 -2
- package/dist/hooks/magic-context/compartment-runner.d.ts.map +1 -1
- package/dist/hooks/magic-context/event-handler.d.ts.map +1 -1
- package/dist/hooks/magic-context/historian-state-file.d.ts +25 -11
- package/dist/hooks/magic-context/historian-state-file.d.ts.map +1 -1
- package/dist/hooks/magic-context/hook-handlers.d.ts +11 -4
- 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/inject-compartments.d.ts +2 -1
- package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
- package/dist/hooks/magic-context/live-session-state.d.ts +3 -1
- package/dist/hooks/magic-context/live-session-state.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 +11 -4
- 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 +15 -1
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform.d.ts +4 -0
- package/dist/hooks/magic-context/transform.d.ts.map +1 -1
- package/dist/index.js +1789 -711
- 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/plugin/sidebar-snapshot-cache.d.ts.map +1 -1
- package/dist/shared/conflict-detector.d.ts +49 -0
- package/dist/shared/conflict-detector.d.ts.map +1 -1
- package/dist/shared/conflict-fixer.d.ts +1 -1
- package/dist/shared/conflict-fixer.d.ts.map +1 -1
- package/dist/shared/data-path.d.ts +84 -0
- package/dist/shared/data-path.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/logger.d.ts +6 -0
- package/dist/shared/logger.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/rpc-client.d.ts +2 -1
- package/dist/shared/rpc-client.d.ts.map +1 -1
- package/dist/shared/rpc-notifications.d.ts +3 -2
- package/dist/shared/rpc-notifications.d.ts.map +1 -1
- package/dist/shared/rpc-server.d.ts +3 -0
- package/dist/shared/rpc-server.d.ts.map +1 -1
- package/dist/shared/rpc-types.d.ts +1 -0
- package/dist/shared/rpc-types.d.ts.map +1 -1
- package/dist/shared/rpc-utils.d.ts +13 -2
- package/dist/shared/rpc-utils.d.ts.map +1 -1
- package/dist/shared/stable-json.d.ts +21 -0
- package/dist/shared/stable-json.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/dist/tui/data/context-db.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/shared/conflict-detector.ts +4 -4
- package/src/shared/conflict-fixer.test.ts +124 -0
- package/src/shared/conflict-fixer.ts +34 -28
- package/src/shared/data-path.test.ts +38 -0
- package/src/shared/data-path.ts +99 -0
- package/src/shared/index.ts +1 -0
- package/src/shared/logger.ts +29 -3
- 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/rpc-client.test.ts +161 -0
- package/src/shared/rpc-client.ts +82 -22
- package/src/shared/rpc-notifications.test.ts +20 -0
- package/src/shared/rpc-notifications.ts +9 -6
- package/src/shared/rpc-server.ts +42 -4
- package/src/shared/rpc-types.ts +1 -0
- package/src/shared/rpc-utils.ts +59 -3
- package/src/shared/stable-json.test.ts +87 -0
- package/src/shared/stable-json.ts +37 -0
- package/src/shared/tag-transcript.ts +3 -2
- package/src/tui/data/context-db.ts +20 -1
- package/src/tui/index.tsx +114 -18
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { promptSyncWithModelSuggestionRetry } from "./model-suggestion-retry";
|
|
4
|
+
|
|
5
|
+
type PromptCall = {
|
|
6
|
+
body: { model?: { providerID: string; modelID: string } };
|
|
7
|
+
signal?: AbortSignal;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
function createClient(prompt: ReturnType<typeof mock>) {
|
|
11
|
+
return {
|
|
12
|
+
session: {
|
|
13
|
+
prompt,
|
|
14
|
+
},
|
|
15
|
+
} as never;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function createArgs(model?: { providerID: string; modelID: string }) {
|
|
19
|
+
return {
|
|
20
|
+
path: { id: "ses-test" },
|
|
21
|
+
body: model ? { model } : {},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("promptSyncWithModelSuggestionRetry", () => {
|
|
26
|
+
test("primary succeeds, no fallback iteration", async () => {
|
|
27
|
+
const prompt = mock(async () => ({}));
|
|
28
|
+
const client = createClient(prompt);
|
|
29
|
+
|
|
30
|
+
await promptSyncWithModelSuggestionRetry(client, createArgs(), {
|
|
31
|
+
fallbackModels: ["anthropic/claude-sonnet-4-6"],
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
expect(prompt).toHaveBeenCalledTimes(1);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("primary succeeds with no fallbacks configured", async () => {
|
|
38
|
+
const prompt = mock(async () => ({}));
|
|
39
|
+
const client = createClient(prompt);
|
|
40
|
+
|
|
41
|
+
await promptSyncWithModelSuggestionRetry(client, createArgs());
|
|
42
|
+
|
|
43
|
+
expect(prompt).toHaveBeenCalledTimes(1);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("primary fails, fallback[0] succeeds", async () => {
|
|
47
|
+
const prompt = mock(async () => {
|
|
48
|
+
if (prompt.mock.calls.length === 1) throw new Error("primary failed");
|
|
49
|
+
return {};
|
|
50
|
+
});
|
|
51
|
+
const client = createClient(prompt);
|
|
52
|
+
|
|
53
|
+
await promptSyncWithModelSuggestionRetry(client, createArgs(), {
|
|
54
|
+
fallbackModels: ["anthropic/claude-sonnet-4-6"],
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
expect(prompt).toHaveBeenCalledTimes(2);
|
|
58
|
+
expect((prompt.mock.calls[1]?.[0] as PromptCall).body.model).toEqual({
|
|
59
|
+
providerID: "anthropic",
|
|
60
|
+
modelID: "claude-sonnet-4-6",
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("primary fails, fallback[0] fails, fallback[1] succeeds", async () => {
|
|
65
|
+
const prompt = mock(async () => {
|
|
66
|
+
if (prompt.mock.calls.length <= 2)
|
|
67
|
+
throw new Error(`failed ${prompt.mock.calls.length}`);
|
|
68
|
+
return {};
|
|
69
|
+
});
|
|
70
|
+
const client = createClient(prompt);
|
|
71
|
+
|
|
72
|
+
await promptSyncWithModelSuggestionRetry(client, createArgs(), {
|
|
73
|
+
fallbackModels: ["anthropic/claude-sonnet-4-6", "google/gemini-3-flash"],
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
expect(prompt).toHaveBeenCalledTimes(3);
|
|
77
|
+
expect((prompt.mock.calls[2]?.[0] as PromptCall).body.model).toEqual({
|
|
78
|
+
providerID: "google",
|
|
79
|
+
modelID: "gemini-3-flash",
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("all attempts fail throws the last fallback error", async () => {
|
|
84
|
+
const primaryError = new Error("primary failed");
|
|
85
|
+
const firstFallbackError = new Error("fallback 0 failed");
|
|
86
|
+
const lastFallbackError = new Error("fallback 1 failed");
|
|
87
|
+
const errors = [primaryError, firstFallbackError, lastFallbackError];
|
|
88
|
+
const prompt = mock(async () => {
|
|
89
|
+
throw errors[prompt.mock.calls.length - 1];
|
|
90
|
+
});
|
|
91
|
+
const client = createClient(prompt);
|
|
92
|
+
|
|
93
|
+
await expect(
|
|
94
|
+
promptSyncWithModelSuggestionRetry(client, createArgs(), {
|
|
95
|
+
fallbackModels: ["anthropic/claude-sonnet-4-6", "google/gemini-3-flash"],
|
|
96
|
+
}),
|
|
97
|
+
).rejects.toBe(lastFallbackError);
|
|
98
|
+
expect(prompt).toHaveBeenCalledTimes(3);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("abort signal short-circuits", async () => {
|
|
102
|
+
const controller = new AbortController();
|
|
103
|
+
controller.abort();
|
|
104
|
+
const prompt = mock(async () => {
|
|
105
|
+
throw new Error("provider noticed abort");
|
|
106
|
+
});
|
|
107
|
+
const client = createClient(prompt);
|
|
108
|
+
|
|
109
|
+
await expect(
|
|
110
|
+
promptSyncWithModelSuggestionRetry(client, createArgs(), {
|
|
111
|
+
signal: controller.signal,
|
|
112
|
+
fallbackModels: ["anthropic/claude-sonnet-4-6"],
|
|
113
|
+
}),
|
|
114
|
+
).rejects.toThrow("prompt aborted by external signal");
|
|
115
|
+
// Pre-aborted signal MUST short-circuit before any upstream prompt
|
|
116
|
+
// call — Audit Finding #1 hardening. No round-trip wasted on a
|
|
117
|
+
// request the caller has already cancelled.
|
|
118
|
+
expect(prompt).toHaveBeenCalledTimes(0);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("AbortError name short-circuits", async () => {
|
|
122
|
+
const abortError = new Error("aborted by provider");
|
|
123
|
+
abortError.name = "AbortError";
|
|
124
|
+
const prompt = mock(async () => {
|
|
125
|
+
throw abortError;
|
|
126
|
+
});
|
|
127
|
+
const client = createClient(prompt);
|
|
128
|
+
|
|
129
|
+
await expect(
|
|
130
|
+
promptSyncWithModelSuggestionRetry(client, createArgs(), {
|
|
131
|
+
fallbackModels: ["anthropic/claude-sonnet-4-6"],
|
|
132
|
+
}),
|
|
133
|
+
).rejects.toBe(abortError);
|
|
134
|
+
expect(prompt).toHaveBeenCalledTimes(1);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("timeout short-circuits", async () => {
|
|
138
|
+
const timeoutError = new Error("prompt timed out after 5000ms");
|
|
139
|
+
const prompt = mock(async () => {
|
|
140
|
+
throw timeoutError;
|
|
141
|
+
});
|
|
142
|
+
const client = createClient(prompt);
|
|
143
|
+
|
|
144
|
+
await expect(
|
|
145
|
+
promptSyncWithModelSuggestionRetry(client, createArgs(), {
|
|
146
|
+
fallbackModels: ["anthropic/claude-sonnet-4-6"],
|
|
147
|
+
}),
|
|
148
|
+
).rejects.toBe(timeoutError);
|
|
149
|
+
expect(prompt).toHaveBeenCalledTimes(1);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("context overflow short-circuits", async () => {
|
|
153
|
+
const overflowError = new Error("prompt is too long: 50000 tokens > 32000");
|
|
154
|
+
const prompt = mock(async () => {
|
|
155
|
+
throw overflowError;
|
|
156
|
+
});
|
|
157
|
+
const client = createClient(prompt);
|
|
158
|
+
|
|
159
|
+
await expect(
|
|
160
|
+
promptSyncWithModelSuggestionRetry(client, createArgs(), {
|
|
161
|
+
fallbackModels: ["anthropic/claude-sonnet-4-6"],
|
|
162
|
+
}),
|
|
163
|
+
).rejects.toBe(overflowError);
|
|
164
|
+
expect(prompt).toHaveBeenCalledTimes(1);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("suggestion retry within attempt succeeds", async () => {
|
|
168
|
+
const suggestionError = new Error("model not found");
|
|
169
|
+
suggestionError.name = "ProviderModelNotFoundError";
|
|
170
|
+
Object.assign(suggestionError, {
|
|
171
|
+
data: {
|
|
172
|
+
providerID: "anthropic",
|
|
173
|
+
modelID: "claude-sonnet-4-6",
|
|
174
|
+
suggestions: ["claude-sonnet-4-7"],
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
const prompt = mock(async () => {
|
|
178
|
+
if (prompt.mock.calls.length === 1) throw suggestionError;
|
|
179
|
+
return {};
|
|
180
|
+
});
|
|
181
|
+
const client = createClient(prompt);
|
|
182
|
+
|
|
183
|
+
await promptSyncWithModelSuggestionRetry(
|
|
184
|
+
client,
|
|
185
|
+
createArgs({ providerID: "anthropic", modelID: "claude-sonnet-4-6" }),
|
|
186
|
+
{ fallbackModels: ["google/gemini-3-flash"] },
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
expect(prompt).toHaveBeenCalledTimes(2);
|
|
190
|
+
expect((prompt.mock.calls[1]?.[0] as PromptCall).body.model).toEqual({
|
|
191
|
+
providerID: "anthropic",
|
|
192
|
+
modelID: "claude-sonnet-4-7",
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("invalid fallback specs are skipped", async () => {
|
|
197
|
+
const prompt = mock(async () => {
|
|
198
|
+
if (prompt.mock.calls.length === 1) throw new Error("primary failed");
|
|
199
|
+
return {};
|
|
200
|
+
});
|
|
201
|
+
const client = createClient(prompt);
|
|
202
|
+
|
|
203
|
+
await promptSyncWithModelSuggestionRetry(client, createArgs(), {
|
|
204
|
+
fallbackModels: ["no-slash", "/leading", "valid/model"],
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
expect(prompt).toHaveBeenCalledTimes(2);
|
|
208
|
+
expect((prompt.mock.calls[1]?.[0] as PromptCall).body.model).toEqual({
|
|
209
|
+
providerID: "valid",
|
|
210
|
+
modelID: "model",
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("iteration order respected", async () => {
|
|
215
|
+
const prompt = mock(async () => {
|
|
216
|
+
throw new Error(`failed ${prompt.mock.calls.length}`);
|
|
217
|
+
});
|
|
218
|
+
const client = createClient(prompt);
|
|
219
|
+
|
|
220
|
+
await expect(
|
|
221
|
+
promptSyncWithModelSuggestionRetry(client, createArgs(), {
|
|
222
|
+
fallbackModels: [
|
|
223
|
+
"anthropic/claude-sonnet-4-6",
|
|
224
|
+
"google/gemini-3-flash",
|
|
225
|
+
"openrouter/qwen3-coder",
|
|
226
|
+
],
|
|
227
|
+
}),
|
|
228
|
+
).rejects.toThrow("failed 4");
|
|
229
|
+
|
|
230
|
+
expect(prompt).toHaveBeenCalledTimes(4);
|
|
231
|
+
expect(prompt.mock.calls.map((call) => (call[0] as PromptCall).body.model)).toEqual([
|
|
232
|
+
undefined,
|
|
233
|
+
{ providerID: "anthropic", modelID: "claude-sonnet-4-6" },
|
|
234
|
+
{ providerID: "google", modelID: "gemini-3-flash" },
|
|
235
|
+
{ providerID: "openrouter", modelID: "qwen3-coder" },
|
|
236
|
+
]);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("empty fallbackModels = legacy", async () => {
|
|
240
|
+
const originalError = new Error("primary failed without suggestion");
|
|
241
|
+
const prompt = mock(async () => {
|
|
242
|
+
throw originalError;
|
|
243
|
+
});
|
|
244
|
+
const client = createClient(prompt);
|
|
245
|
+
|
|
246
|
+
await expect(
|
|
247
|
+
promptSyncWithModelSuggestionRetry(client, createArgs(), { fallbackModels: [] }),
|
|
248
|
+
).rejects.toBe(originalError);
|
|
249
|
+
expect(prompt).toHaveBeenCalledTimes(1);
|
|
250
|
+
});
|
|
251
|
+
});
|
|
@@ -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
|
+
});
|