@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.
Files changed (130) hide show
  1. package/README.md +1 -1
  2. package/dist/config/index.d.ts.map +1 -1
  3. package/dist/features/magic-context/compaction-marker.d.ts +17 -0
  4. package/dist/features/magic-context/compaction-marker.d.ts.map +1 -1
  5. package/dist/features/magic-context/compartment-storage.d.ts +11 -0
  6. package/dist/features/magic-context/compartment-storage.d.ts.map +1 -1
  7. package/dist/features/magic-context/dreamer/queue.d.ts.map +1 -1
  8. package/dist/features/magic-context/dreamer/runner.d.ts +15 -0
  9. package/dist/features/magic-context/dreamer/runner.d.ts.map +1 -1
  10. package/dist/features/magic-context/memory/embedding-identity.d.ts +11 -0
  11. package/dist/features/magic-context/memory/embedding-identity.d.ts.map +1 -0
  12. package/dist/features/magic-context/memory/embedding-local.d.ts.map +1 -1
  13. package/dist/features/magic-context/memory/embedding-openai.d.ts.map +1 -1
  14. package/dist/features/magic-context/memory/embedding.d.ts.map +1 -1
  15. package/dist/features/magic-context/migrations.d.ts.map +1 -1
  16. package/dist/features/magic-context/sidekick/agent.d.ts.map +1 -1
  17. package/dist/features/magic-context/storage-db.d.ts.map +1 -1
  18. package/dist/features/magic-context/storage-meta-persisted.d.ts +70 -0
  19. package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
  20. package/dist/features/magic-context/storage-meta-shared.d.ts +1 -0
  21. package/dist/features/magic-context/storage-meta-shared.d.ts.map +1 -1
  22. package/dist/features/magic-context/storage-meta.d.ts +1 -1
  23. package/dist/features/magic-context/storage-meta.d.ts.map +1 -1
  24. package/dist/features/magic-context/storage.d.ts +1 -1
  25. package/dist/features/magic-context/storage.d.ts.map +1 -1
  26. package/dist/features/magic-context/tool-definition-tokens.d.ts.map +1 -1
  27. package/dist/features/magic-context/types.d.ts +1 -0
  28. package/dist/features/magic-context/types.d.ts.map +1 -1
  29. package/dist/features/magic-context/user-memory/review-user-memories.d.ts +2 -0
  30. package/dist/features/magic-context/user-memory/review-user-memories.d.ts.map +1 -1
  31. package/dist/hooks/magic-context/cache-busting-signals.d.ts +10 -0
  32. package/dist/hooks/magic-context/cache-busting-signals.d.ts.map +1 -0
  33. package/dist/hooks/magic-context/command-handler.d.ts +2 -0
  34. package/dist/hooks/magic-context/command-handler.d.ts.map +1 -1
  35. package/dist/hooks/magic-context/compaction-marker-manager.d.ts +50 -0
  36. package/dist/hooks/magic-context/compaction-marker-manager.d.ts.map +1 -1
  37. package/dist/hooks/magic-context/compartment-runner-compressor.d.ts +1 -0
  38. package/dist/hooks/magic-context/compartment-runner-compressor.d.ts.map +1 -1
  39. package/dist/hooks/magic-context/compartment-runner-historian.d.ts +7 -0
  40. package/dist/hooks/magic-context/compartment-runner-historian.d.ts.map +1 -1
  41. package/dist/hooks/magic-context/compartment-runner-incremental.d.ts +1 -1
  42. package/dist/hooks/magic-context/compartment-runner-incremental.d.ts.map +1 -1
  43. package/dist/hooks/magic-context/compartment-runner-partial-recomp.d.ts.map +1 -1
  44. package/dist/hooks/magic-context/compartment-runner-recomp.d.ts.map +1 -1
  45. package/dist/hooks/magic-context/compartment-runner-types.d.ts +18 -7
  46. package/dist/hooks/magic-context/compartment-runner-types.d.ts.map +1 -1
  47. package/dist/hooks/magic-context/compartment-runner.d.ts +7 -2
  48. package/dist/hooks/magic-context/compartment-runner.d.ts.map +1 -1
  49. package/dist/hooks/magic-context/event-handler.d.ts.map +1 -1
  50. package/dist/hooks/magic-context/historian-state-file.d.ts +25 -11
  51. package/dist/hooks/magic-context/historian-state-file.d.ts.map +1 -1
  52. package/dist/hooks/magic-context/hook-handlers.d.ts +11 -4
  53. package/dist/hooks/magic-context/hook-handlers.d.ts.map +1 -1
  54. package/dist/hooks/magic-context/hook.d.ts.map +1 -1
  55. package/dist/hooks/magic-context/inject-compartments.d.ts +2 -1
  56. package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
  57. package/dist/hooks/magic-context/live-session-state.d.ts +3 -1
  58. package/dist/hooks/magic-context/live-session-state.d.ts.map +1 -1
  59. package/dist/hooks/magic-context/system-prompt-hash.d.ts.map +1 -1
  60. package/dist/hooks/magic-context/todo-view.d.ts +102 -0
  61. package/dist/hooks/magic-context/todo-view.d.ts.map +1 -0
  62. package/dist/hooks/magic-context/transform-compartment-phase.d.ts +11 -4
  63. package/dist/hooks/magic-context/transform-compartment-phase.d.ts.map +1 -1
  64. package/dist/hooks/magic-context/transform-message-helpers.d.ts +22 -0
  65. package/dist/hooks/magic-context/transform-message-helpers.d.ts.map +1 -1
  66. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +15 -1
  67. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
  68. package/dist/hooks/magic-context/transform.d.ts +4 -0
  69. package/dist/hooks/magic-context/transform.d.ts.map +1 -1
  70. package/dist/index.js +1789 -711
  71. package/dist/plugin/dream-timer.d.ts.map +1 -1
  72. package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
  73. package/dist/plugin/rpc-handlers.d.ts +2 -1
  74. package/dist/plugin/rpc-handlers.d.ts.map +1 -1
  75. package/dist/plugin/sidebar-snapshot-cache.d.ts.map +1 -1
  76. package/dist/shared/conflict-detector.d.ts +49 -0
  77. package/dist/shared/conflict-detector.d.ts.map +1 -1
  78. package/dist/shared/conflict-fixer.d.ts +1 -1
  79. package/dist/shared/conflict-fixer.d.ts.map +1 -1
  80. package/dist/shared/data-path.d.ts +84 -0
  81. package/dist/shared/data-path.d.ts.map +1 -1
  82. package/dist/shared/index.d.ts +1 -0
  83. package/dist/shared/index.d.ts.map +1 -1
  84. package/dist/shared/logger.d.ts +6 -0
  85. package/dist/shared/logger.d.ts.map +1 -1
  86. package/dist/shared/model-suggestion-retry.d.ts +37 -0
  87. package/dist/shared/model-suggestion-retry.d.ts.map +1 -1
  88. package/dist/shared/models-dev-cache.d.ts.map +1 -1
  89. package/dist/shared/resolve-fallbacks.d.ts +32 -0
  90. package/dist/shared/resolve-fallbacks.d.ts.map +1 -0
  91. package/dist/shared/rpc-client.d.ts +2 -1
  92. package/dist/shared/rpc-client.d.ts.map +1 -1
  93. package/dist/shared/rpc-notifications.d.ts +3 -2
  94. package/dist/shared/rpc-notifications.d.ts.map +1 -1
  95. package/dist/shared/rpc-server.d.ts +3 -0
  96. package/dist/shared/rpc-server.d.ts.map +1 -1
  97. package/dist/shared/rpc-types.d.ts +1 -0
  98. package/dist/shared/rpc-types.d.ts.map +1 -1
  99. package/dist/shared/rpc-utils.d.ts +13 -2
  100. package/dist/shared/rpc-utils.d.ts.map +1 -1
  101. package/dist/shared/stable-json.d.ts +21 -0
  102. package/dist/shared/stable-json.d.ts.map +1 -0
  103. package/dist/shared/tag-transcript.d.ts.map +1 -1
  104. package/dist/tools/ctx-memory/tools.d.ts.map +1 -1
  105. package/dist/tui/data/context-db.d.ts.map +1 -1
  106. package/package.json +1 -1
  107. package/src/shared/conflict-detector.ts +4 -4
  108. package/src/shared/conflict-fixer.test.ts +124 -0
  109. package/src/shared/conflict-fixer.ts +34 -28
  110. package/src/shared/data-path.test.ts +38 -0
  111. package/src/shared/data-path.ts +99 -0
  112. package/src/shared/index.ts +1 -0
  113. package/src/shared/logger.ts +29 -3
  114. package/src/shared/model-suggestion-retry.test.ts +251 -0
  115. package/src/shared/model-suggestion-retry.ts +194 -6
  116. package/src/shared/models-dev-cache.ts +7 -7
  117. package/src/shared/resolve-fallbacks.test.ts +136 -0
  118. package/src/shared/resolve-fallbacks.ts +76 -0
  119. package/src/shared/rpc-client.test.ts +161 -0
  120. package/src/shared/rpc-client.ts +82 -22
  121. package/src/shared/rpc-notifications.test.ts +20 -0
  122. package/src/shared/rpc-notifications.ts +9 -6
  123. package/src/shared/rpc-server.ts +42 -4
  124. package/src/shared/rpc-types.ts +1 -0
  125. package/src/shared/rpc-utils.ts +59 -3
  126. package/src/shared/stable-json.test.ts +87 -0
  127. package/src/shared/stable-json.ts +37 -0
  128. package/src/shared/tag-transcript.ts +3 -2
  129. package/src/tui/data/context-db.ts +20 -1
  130. 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
- export async function promptSyncWithModelSuggestionRetry(
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
- options: PromptRetryOptions = {},
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, options.signal);
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("[model-suggestion-retry] Model not found, retrying with suggestion", {
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
- let raw = readFileSync(configPath, "utf-8");
199
- // Strip JSONC single-line comments while preserving // inside strings.
200
- raw = raw.replace(/"(?:[^"\\]|\\.)*"|\/\/.*$/gm, (match) =>
201
- match.startsWith('"') ? match : "",
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
+ });