gsd-pi 2.73.0-dev.e1c09f2 → 2.73.1-dev.1040fb0

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 (109) hide show
  1. package/dist/resources/extensions/claude-code-cli/stream-adapter.js +9 -3
  2. package/dist/resources/extensions/gsd/auto-model-selection.js +54 -11
  3. package/dist/resources/extensions/gsd/auto-start.js +20 -6
  4. package/dist/resources/extensions/gsd/auto.js +5 -1
  5. package/dist/resources/extensions/gsd/bootstrap/crash-log.js +31 -0
  6. package/dist/resources/extensions/gsd/bootstrap/register-extension.js +18 -7
  7. package/dist/resources/extensions/gsd/commands-handlers.js +8 -2
  8. package/dist/resources/extensions/gsd/crash-recovery.js +51 -0
  9. package/dist/resources/extensions/gsd/gsd-db.js +36 -2
  10. package/dist/resources/extensions/gsd/milestone-actions.js +19 -1
  11. package/dist/resources/extensions/gsd/preferences-models.js +43 -0
  12. package/dist/resources/extensions/gsd/preferences-types.js +1 -0
  13. package/dist/resources/extensions/gsd/preferences-validation.js +22 -0
  14. package/dist/update-check.d.ts +1 -0
  15. package/dist/update-check.js +13 -5
  16. package/dist/update-cmd.js +4 -3
  17. package/dist/web/standalone/.next/BUILD_ID +1 -1
  18. package/dist/web/standalone/.next/app-path-routes-manifest.json +15 -15
  19. package/dist/web/standalone/.next/build-manifest.json +2 -2
  20. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  21. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  22. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  23. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  24. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  25. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  26. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  27. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  28. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  29. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  30. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/index.html +1 -1
  38. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app-paths-manifest.json +15 -15
  45. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  46. package/dist/web/standalone/.next/server/middleware-manifest.json +5 -5
  47. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  48. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  49. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  50. package/package.json +1 -1
  51. package/packages/pi-ai/dist/index.d.ts +1 -0
  52. package/packages/pi-ai/dist/index.d.ts.map +1 -1
  53. package/packages/pi-ai/dist/index.js +1 -0
  54. package/packages/pi-ai/dist/index.js.map +1 -1
  55. package/packages/pi-ai/dist/utils/overflow.d.ts.map +1 -1
  56. package/packages/pi-ai/dist/utils/overflow.js +12 -0
  57. package/packages/pi-ai/dist/utils/overflow.js.map +1 -1
  58. package/packages/pi-ai/dist/utils/tests/overflow.test.d.ts +2 -0
  59. package/packages/pi-ai/dist/utils/tests/overflow.test.d.ts.map +1 -0
  60. package/packages/pi-ai/dist/utils/tests/overflow.test.js +50 -0
  61. package/packages/pi-ai/dist/utils/tests/overflow.test.js.map +1 -0
  62. package/packages/pi-ai/src/index.ts +4 -0
  63. package/packages/pi-ai/src/utils/overflow.ts +14 -1
  64. package/packages/pi-ai/src/utils/tests/overflow.test.ts +58 -0
  65. package/packages/pi-coding-agent/dist/core/chat-controller-ordering.test.js +175 -8
  66. package/packages/pi-coding-agent/dist/core/chat-controller-ordering.test.js.map +1 -1
  67. package/packages/pi-coding-agent/dist/core/compaction/utils.js +5 -5
  68. package/packages/pi-coding-agent/dist/core/compaction/utils.js.map +1 -1
  69. package/packages/pi-coding-agent/dist/core/compaction-utils.test.d.ts +2 -0
  70. package/packages/pi-coding-agent/dist/core/compaction-utils.test.d.ts.map +1 -0
  71. package/packages/pi-coding-agent/dist/core/compaction-utils.test.js +45 -0
  72. package/packages/pi-coding-agent/dist/core/compaction-utils.test.js.map +1 -0
  73. package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.d.ts +12 -2
  74. package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.d.ts.map +1 -1
  75. package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.js +51 -26
  76. package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.js.map +1 -1
  77. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.d.ts.map +1 -1
  78. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js +73 -12
  79. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js.map +1 -1
  80. package/packages/pi-coding-agent/package.json +1 -1
  81. package/packages/pi-coding-agent/src/core/chat-controller-ordering.test.ts +198 -8
  82. package/packages/pi-coding-agent/src/core/compaction/utils.ts +5 -5
  83. package/packages/pi-coding-agent/src/core/compaction-utils.test.ts +50 -0
  84. package/packages/pi-coding-agent/src/modes/interactive/components/assistant-message.ts +62 -26
  85. package/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts +92 -17
  86. package/pkg/package.json +1 -1
  87. package/src/resources/extensions/claude-code-cli/stream-adapter.ts +12 -4
  88. package/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts +23 -2
  89. package/src/resources/extensions/gsd/auto-model-selection.ts +85 -11
  90. package/src/resources/extensions/gsd/auto-start.ts +27 -6
  91. package/src/resources/extensions/gsd/auto.ts +5 -0
  92. package/src/resources/extensions/gsd/bootstrap/crash-log.ts +32 -0
  93. package/src/resources/extensions/gsd/bootstrap/register-extension.ts +19 -7
  94. package/src/resources/extensions/gsd/commands-handlers.ts +8 -2
  95. package/src/resources/extensions/gsd/crash-recovery.ts +59 -0
  96. package/src/resources/extensions/gsd/gsd-db.ts +52 -2
  97. package/src/resources/extensions/gsd/milestone-actions.ts +19 -1
  98. package/src/resources/extensions/gsd/preferences-models.ts +41 -0
  99. package/src/resources/extensions/gsd/preferences-types.ts +12 -0
  100. package/src/resources/extensions/gsd/preferences-validation.ts +23 -0
  101. package/src/resources/extensions/gsd/tests/auto-start-model-capture.test.ts +51 -2
  102. package/src/resources/extensions/gsd/tests/crash-handler-secondary.test.ts +235 -0
  103. package/src/resources/extensions/gsd/tests/flat-rate-routing-guard.test.ts +137 -1
  104. package/src/resources/extensions/gsd/tests/gsd-db.test.ts +59 -1
  105. package/src/resources/extensions/gsd/tests/model-isolation.test.ts +91 -2
  106. package/src/resources/extensions/gsd/tests/park-milestone.test.ts +64 -0
  107. package/src/resources/extensions/gsd/tests/preferences.test.ts +47 -0
  108. /package/dist/web/standalone/.next/static/{_XD_gUDcZNBbWV5rI8RgS → 5dzOW4v8Vz23I5xRsiNSk}/_buildManifest.js +0 -0
  109. /package/dist/web/standalone/.next/static/{_XD_gUDcZNBbWV5rI8RgS → 5dzOW4v8Vz23I5xRsiNSk}/_ssgManifest.js +0 -0
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Regression tests for #3348 secondary issues — crash handler gaps surfaced after #3696
3
+ *
4
+ * 1. register-extension.ts: writeCrashLog writes to ~/.gsd/crash/ directory
5
+ * 2. register-extension.ts: _gsdRejectionGuard registered for unhandledRejection
6
+ * 3. register-extension.ts: _gsdEpipeGuard exits with code 1 for unrecoverable errors (no log-and-continue)
7
+ * 4. crash-recovery.ts: emitCrashRecoveredUnitEnd closes open unit-start journal entries
8
+ */
9
+
10
+ import { describe, test } from 'node:test';
11
+ import assert from 'node:assert/strict';
12
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { tmpdir } from 'node:os';
15
+ import { randomUUID } from 'node:crypto';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { dirname } from 'node:path';
18
+
19
+ const __filename = fileURLToPath(import.meta.url);
20
+ const __dirname = dirname(__filename);
21
+
22
+ function makeTmpBase(): string {
23
+ const base = join(tmpdir(), `gsd-test-${randomUUID()}`);
24
+ mkdirSync(join(base, '.gsd'), { recursive: true });
25
+ return base;
26
+ }
27
+
28
+ // ─── register-extension source assertions ────────────────────────────────────
29
+
30
+ const registerExtSrc = readFileSync(
31
+ join(__dirname, '..', 'bootstrap', 'register-extension.ts'),
32
+ 'utf-8',
33
+ );
34
+
35
+ describe('register-extension crash handler secondary fixes (#3348)', () => {
36
+ test('writeCrashLog is exported and writes a file to the crash directory', async () => {
37
+ // Dynamic import so GSD_HOME can be pointed at a temp dir without polluting ~/.gsd
38
+ const tmpHome = join(tmpdir(), `gsd-crash-test-${randomUUID()}`);
39
+ const origHome = process.env.GSD_HOME;
40
+ process.env.GSD_HOME = tmpHome;
41
+ try {
42
+ const { writeCrashLog } = await import('../bootstrap/crash-log.ts');
43
+ const err = new Error('test crash from secondary regression test');
44
+ writeCrashLog(err, 'uncaughtException');
45
+
46
+ const crashDir = join(tmpHome, 'crash');
47
+ assert.ok(existsSync(crashDir), 'crash directory should be created');
48
+
49
+ const logs = readdirSync(crashDir).filter((f) => f.endsWith('.log'));
50
+ assert.equal(logs.length, 1, 'exactly one crash log should be written');
51
+
52
+ const content = readFileSync(join(crashDir, logs[0]), 'utf-8');
53
+ assert.ok(content.includes('test crash from secondary regression test'), 'log should contain error message');
54
+ assert.ok(content.includes('uncaughtException'), 'log should identify the source');
55
+ assert.ok(content.includes('pid:'), 'log should include process pid');
56
+ } finally {
57
+ process.env.GSD_HOME = origHome;
58
+ rmSync(tmpHome, { recursive: true, force: true });
59
+ }
60
+ });
61
+
62
+ test('_gsdRejectionGuard is registered for unhandledRejection', () => {
63
+ assert.match(
64
+ registerExtSrc,
65
+ /_gsdRejectionGuard/,
66
+ '_gsdRejectionGuard handler should be defined',
67
+ );
68
+ assert.match(
69
+ registerExtSrc,
70
+ /unhandledRejection/,
71
+ 'installEpipeGuard should register an unhandledRejection handler',
72
+ );
73
+ });
74
+
75
+ test('_gsdEpipeGuard calls process.exit(1) for unrecoverable errors, not log-and-continue', () => {
76
+ // The original #3696 fix replaced "throw err" with a log-and-continue.
77
+ // The secondary fix replaces that with writeCrashLog + process.exit(1).
78
+ assert.ok(
79
+ !registerExtSrc.includes('process.stderr.write(`[gsd] uncaught extension error (non-fatal)'),
80
+ '_gsdEpipeGuard should NOT log errors as non-fatal and continue',
81
+ );
82
+ assert.match(
83
+ registerExtSrc,
84
+ /process\.exit\(1\)/,
85
+ '_gsdEpipeGuard should call process.exit(1) for unrecoverable errors',
86
+ );
87
+ });
88
+
89
+ test('writeCrashLog never throws even when directory is unwritable', async () => {
90
+ const { writeCrashLog } = await import('../bootstrap/crash-log.ts');
91
+ const origHome = process.env.GSD_HOME;
92
+ // Point at a path that will fail to mkdir (e.g. a file that exists as non-dir)
93
+ const tmpFile = join(tmpdir(), `gsd-not-a-dir-${randomUUID()}`);
94
+ // Don't create it — mkdirSync with bad path should be caught internally
95
+ process.env.GSD_HOME = join(tmpFile, 'nested', 'deeply');
96
+ try {
97
+ // Should not throw
98
+ assert.doesNotThrow(() => {
99
+ writeCrashLog(new Error('should not throw'), 'test');
100
+ });
101
+ } finally {
102
+ process.env.GSD_HOME = origHome;
103
+ }
104
+ });
105
+ });
106
+
107
+ // ─── emitCrashRecoveredUnitEnd ────────────────────────────────────────────────
108
+
109
+ describe('emitCrashRecoveredUnitEnd (#3348)', () => {
110
+ test('emits synthetic unit-end when unit-start has no matching unit-end', async () => {
111
+ const base = makeTmpBase();
112
+ try {
113
+ const { emitJournalEvent, queryJournal } = await import('../journal.ts');
114
+ const { emitCrashRecoveredUnitEnd } = await import('../crash-recovery.ts');
115
+
116
+ const flowId = randomUUID();
117
+ const unitStartSeq = 5;
118
+
119
+ // Emit a unit-start with no corresponding unit-end (simulating a crash)
120
+ emitJournalEvent(base, {
121
+ ts: new Date().toISOString(),
122
+ flowId,
123
+ seq: unitStartSeq,
124
+ eventType: 'unit-start',
125
+ data: { unitType: 'execute-task', unitId: 'M001/S01/T01' },
126
+ });
127
+
128
+ const lock = {
129
+ pid: 99999,
130
+ startedAt: new Date().toISOString(),
131
+ unitType: 'execute-task',
132
+ unitId: 'M001/S01/T01',
133
+ unitStartedAt: new Date().toISOString(),
134
+ };
135
+
136
+ emitCrashRecoveredUnitEnd(base, lock);
137
+
138
+ const events = queryJournal(base);
139
+ const ends = events.filter((e) => e.eventType === 'unit-end');
140
+ assert.equal(ends.length, 1, 'should emit exactly one unit-end');
141
+ assert.equal(ends[0].data?.unitId, 'M001/S01/T01');
142
+ assert.equal(ends[0].data?.status, 'crash-recovered');
143
+ assert.equal(ends[0].causedBy?.flowId, flowId);
144
+ assert.equal(ends[0].causedBy?.seq, unitStartSeq);
145
+ assert.ok(ends[0].seq > unitStartSeq, 'unit-end seq must be higher than unit-start seq');
146
+ } finally {
147
+ rmSync(base, { recursive: true, force: true });
148
+ }
149
+ });
150
+
151
+ test('is a no-op when unit-end was already emitted (e.g. hard timeout fired)', async () => {
152
+ const base = makeTmpBase();
153
+ try {
154
+ const { emitJournalEvent, queryJournal } = await import('../journal.ts');
155
+ const { emitCrashRecoveredUnitEnd } = await import('../crash-recovery.ts');
156
+
157
+ const flowId = randomUUID();
158
+ emitJournalEvent(base, {
159
+ ts: new Date().toISOString(),
160
+ flowId,
161
+ seq: 3,
162
+ eventType: 'unit-start',
163
+ data: { unitType: 'plan-slice', unitId: 'M001/S02' },
164
+ });
165
+ // Hard timeout already emitted a unit-end
166
+ emitJournalEvent(base, {
167
+ ts: new Date().toISOString(),
168
+ flowId,
169
+ seq: 4,
170
+ eventType: 'unit-end',
171
+ data: { unitType: 'plan-slice', unitId: 'M001/S02', status: 'cancelled' },
172
+ causedBy: { flowId, seq: 3 },
173
+ });
174
+
175
+ const lock = {
176
+ pid: 99999,
177
+ startedAt: new Date().toISOString(),
178
+ unitType: 'plan-slice',
179
+ unitId: 'M001/S02',
180
+ unitStartedAt: new Date().toISOString(),
181
+ };
182
+ emitCrashRecoveredUnitEnd(base, lock);
183
+
184
+ const ends = queryJournal(base).filter((e) => e.eventType === 'unit-end');
185
+ assert.equal(ends.length, 1, 'should not emit a duplicate unit-end');
186
+ assert.equal(ends[0].data?.status, 'cancelled', 'original unit-end should be preserved');
187
+ } finally {
188
+ rmSync(base, { recursive: true, force: true });
189
+ }
190
+ });
191
+
192
+ test('is a no-op for "starting" pseudo-units (bootstrap crash)', async () => {
193
+ const base = makeTmpBase();
194
+ try {
195
+ const { queryJournal } = await import('../journal.ts');
196
+ const { emitCrashRecoveredUnitEnd } = await import('../crash-recovery.ts');
197
+
198
+ const lock = {
199
+ pid: 99999,
200
+ startedAt: new Date().toISOString(),
201
+ unitType: 'starting',
202
+ unitId: 'bootstrap',
203
+ unitStartedAt: new Date().toISOString(),
204
+ };
205
+ emitCrashRecoveredUnitEnd(base, lock);
206
+
207
+ const events = queryJournal(base);
208
+ assert.equal(events.length, 0, 'should emit nothing for starting/bootstrap pseudo-units');
209
+ } finally {
210
+ rmSync(base, { recursive: true, force: true });
211
+ }
212
+ });
213
+
214
+ test('is a no-op when no unit-start exists in the journal', async () => {
215
+ const base = makeTmpBase();
216
+ try {
217
+ const { queryJournal } = await import('../journal.ts');
218
+ const { emitCrashRecoveredUnitEnd } = await import('../crash-recovery.ts');
219
+
220
+ const lock = {
221
+ pid: 99999,
222
+ startedAt: new Date().toISOString(),
223
+ unitType: 'execute-task',
224
+ unitId: 'M002/S01/T03',
225
+ unitStartedAt: new Date().toISOString(),
226
+ };
227
+ emitCrashRecoveredUnitEnd(base, lock);
228
+
229
+ const events = queryJournal(base);
230
+ assert.equal(events.length, 0, 'should emit nothing when there is no journal entry to close');
231
+ } finally {
232
+ rmSync(base, { recursive: true, force: true });
233
+ }
234
+ });
235
+ });
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { describe, test } from "node:test";
8
8
  import assert from "node:assert/strict";
9
- import { isFlatRateProvider, resolvePreferredModelConfig } from "../auto-model-selection.ts";
9
+ import { buildFlatRateContext, isFlatRateProvider, resolvePreferredModelConfig } from "../auto-model-selection.ts";
10
10
 
11
11
  describe("flat-rate provider routing guard (#3453)", () => {
12
12
 
@@ -48,3 +48,139 @@ describe("flat-rate provider routing guard (#3453)", () => {
48
48
  assert.equal(result, undefined, "Should not create routing config for copilot");
49
49
  });
50
50
  });
51
+
52
+ describe("flat-rate provider extensibility (any/all/custom)", () => {
53
+ test("regression: built-in providers still flat-rate with no context", () => {
54
+ assert.equal(isFlatRateProvider("github-copilot"), true);
55
+ assert.equal(isFlatRateProvider("copilot"), true);
56
+ assert.equal(isFlatRateProvider("claude-code"), true);
57
+ });
58
+
59
+ test("regression: non-flat-rate API providers return false with no context", () => {
60
+ assert.equal(isFlatRateProvider("anthropic"), false);
61
+ assert.equal(isFlatRateProvider("openai"), false);
62
+ assert.equal(isFlatRateProvider("google-vertex"), false);
63
+ });
64
+
65
+ test("auto-detection: externalCli auth mode marks provider flat-rate", () => {
66
+ // Any provider registered with authMode: "externalCli" is a local
67
+ // CLI wrapper around the user's subscription — every request costs
68
+ // the same regardless of model, so dynamic routing provides no benefit.
69
+ assert.equal(
70
+ isFlatRateProvider("my-private-cli", { authMode: "externalCli" }),
71
+ true,
72
+ );
73
+ });
74
+
75
+ test("auto-detection: non-externalCli auth modes do not mark provider flat-rate", () => {
76
+ assert.equal(
77
+ isFlatRateProvider("my-http-proxy", { authMode: "apiKey" }),
78
+ false,
79
+ );
80
+ assert.equal(
81
+ isFlatRateProvider("my-http-proxy", { authMode: "oauth" }),
82
+ false,
83
+ );
84
+ assert.equal(
85
+ isFlatRateProvider("my-http-proxy", { authMode: "none" }),
86
+ false,
87
+ );
88
+ });
89
+
90
+ test("user preference: custom provider listed in userFlatRate is flat-rate", () => {
91
+ assert.equal(
92
+ isFlatRateProvider("my-ollama-proxy", { userFlatRate: ["my-ollama-proxy"] }),
93
+ true,
94
+ );
95
+ });
96
+
97
+ test("user preference: case-insensitive match against userFlatRate list", () => {
98
+ assert.equal(
99
+ isFlatRateProvider("My-Proxy", { userFlatRate: ["my-proxy"] }),
100
+ true,
101
+ );
102
+ assert.equal(
103
+ isFlatRateProvider("my-proxy", { userFlatRate: ["MY-PROXY"] }),
104
+ true,
105
+ );
106
+ });
107
+
108
+ test("user preference: provider not in userFlatRate list is not flat-rate", () => {
109
+ assert.equal(
110
+ isFlatRateProvider("other-proxy", { userFlatRate: ["my-proxy"] }),
111
+ false,
112
+ );
113
+ });
114
+
115
+ test("combined signals: built-in list wins even when context is empty", () => {
116
+ assert.equal(
117
+ isFlatRateProvider("claude-code", { authMode: "apiKey", userFlatRate: [] }),
118
+ true,
119
+ );
120
+ });
121
+
122
+ test("combined signals: externalCli auto-detection wins alongside userFlatRate miss", () => {
123
+ assert.equal(
124
+ isFlatRateProvider("my-cli", {
125
+ authMode: "externalCli",
126
+ userFlatRate: ["a-different-cli"],
127
+ }),
128
+ true,
129
+ );
130
+ });
131
+ });
132
+
133
+ describe("buildFlatRateContext()", () => {
134
+ test("builds a context from ctx.modelRegistry.getProviderAuthMode + prefs", () => {
135
+ const ctx = {
136
+ modelRegistry: {
137
+ getProviderAuthMode: (p: string) =>
138
+ p === "my-cli" ? "externalCli" : "apiKey",
139
+ },
140
+ };
141
+ const prefs = { flat_rate_providers: ["my-proxy"] };
142
+
143
+ const ctxForCli = buildFlatRateContext("my-cli", ctx, prefs);
144
+ assert.equal(ctxForCli.authMode, "externalCli");
145
+ assert.deepEqual(ctxForCli.userFlatRate, ["my-proxy"]);
146
+ assert.equal(isFlatRateProvider("my-cli", ctxForCli), true);
147
+
148
+ const ctxForProxy = buildFlatRateContext("my-proxy", ctx, prefs);
149
+ assert.equal(ctxForProxy.authMode, "apiKey");
150
+ assert.equal(isFlatRateProvider("my-proxy", ctxForProxy), true);
151
+
152
+ const ctxForOther = buildFlatRateContext("anthropic", ctx, prefs);
153
+ assert.equal(ctxForOther.authMode, "apiKey");
154
+ assert.equal(isFlatRateProvider("anthropic", ctxForOther), false);
155
+ });
156
+
157
+ test("survives missing ctx and missing prefs", () => {
158
+ const empty = buildFlatRateContext("anything");
159
+ assert.equal(empty.authMode, undefined);
160
+ assert.equal(empty.userFlatRate, undefined);
161
+ assert.equal(isFlatRateProvider("anything", empty), false);
162
+ });
163
+
164
+ test("survives a registry lookup that throws", () => {
165
+ const ctx = {
166
+ modelRegistry: {
167
+ getProviderAuthMode: () => {
168
+ throw new Error("registry boom");
169
+ },
170
+ },
171
+ };
172
+ const result = buildFlatRateContext("anything", ctx);
173
+ // Error must be swallowed — authMode left undefined, function returns.
174
+ assert.equal(result.authMode, undefined);
175
+ });
176
+
177
+ test("registry returning a non-canonical auth mode is ignored", () => {
178
+ const ctx = {
179
+ modelRegistry: {
180
+ getProviderAuthMode: () => "weird-mode",
181
+ },
182
+ };
183
+ const result = buildFlatRateContext("anything", ctx);
184
+ assert.equal(result.authMode, undefined);
185
+ });
186
+ });
@@ -15,10 +15,14 @@ import {
15
15
  getRequirementById,
16
16
  getActiveDecisions,
17
17
  getActiveRequirements,
18
- getTask,
19
18
  transaction,
20
19
  _getAdapter,
21
20
  _resetProvider,
21
+ insertMilestone,
22
+ insertSlice,
23
+ insertTask,
24
+ getTask,
25
+ getSliceTasks,
22
26
  } from '../gsd-db.ts';
23
27
 
24
28
  // ═══════════════════════════════════════════════════════════════════════════
@@ -460,6 +464,60 @@ describe('gsd-db', () => {
460
464
  assert.ok(!wasDbOpenAttempted(), 'wasDbOpenAttempted should reset after closeDatabase');
461
465
  });
462
466
 
467
+ test('gsd-db: rowToTask tolerates corrupt comma-separated task arrays', () => {
468
+ openDatabase(':memory:');
469
+ insertMilestone({ id: 'M001', status: 'active' });
470
+ insertSlice({ milestoneId: 'M001', id: 'S01', status: 'active' });
471
+ insertTask({
472
+ milestoneId: 'M001',
473
+ sliceId: 'S01',
474
+ id: 'T01',
475
+ title: 'Recover corrupt arrays',
476
+ planning: {
477
+ description: 'desc',
478
+ estimate: 'small',
479
+ files: ['src/original.ts'],
480
+ verify: 'npm test',
481
+ inputs: ['docs/original.md'],
482
+ expectedOutput: ['dist/original.md'],
483
+ observabilityImpact: '',
484
+ },
485
+ });
486
+
487
+ const adapter = _getAdapter()!;
488
+ adapter.prepare(
489
+ `UPDATE tasks
490
+ SET files = ?, inputs = ?, expected_output = ?, key_files = ?, key_decisions = ?
491
+ WHERE milestone_id = ? AND slice_id = ? AND id = ?`,
492
+ ).run(
493
+ 'src-erf/Models/foo.cs, src-erf/Models/bar.cs',
494
+ 'docs/input-a.md, docs/input-b.md',
495
+ 'dist/out-a.md, dist/out-b.md',
496
+ 'src/resources/extensions/gsd/gsd-db.ts, src/resources/extensions/gsd/state.ts',
497
+ '"decision-1"',
498
+ 'M001',
499
+ 'S01',
500
+ 'T01',
501
+ );
502
+
503
+ const task = getTask('M001', 'S01', 'T01');
504
+ assert.ok(task, 'getTask should still return the corrupt row');
505
+ assert.deepStrictEqual(task!.files, ['src-erf/Models/foo.cs', 'src-erf/Models/bar.cs']);
506
+ assert.deepStrictEqual(task!.inputs, ['docs/input-a.md', 'docs/input-b.md']);
507
+ assert.deepStrictEqual(task!.expected_output, ['dist/out-a.md', 'dist/out-b.md']);
508
+ assert.deepStrictEqual(
509
+ task!.key_files,
510
+ ['src/resources/extensions/gsd/gsd-db.ts', 'src/resources/extensions/gsd/state.ts'],
511
+ );
512
+ assert.deepStrictEqual(task!.key_decisions, ['decision-1']);
513
+
514
+ const sliceTasks = getSliceTasks('M001', 'S01');
515
+ assert.equal(sliceTasks.length, 1, 'getSliceTasks should also survive corrupt rows');
516
+ assert.deepStrictEqual(sliceTasks[0]!.files, task!.files);
517
+
518
+ closeDatabase();
519
+ });
520
+
463
521
  // ─── Final Report ──────────────────────────────────────────────────────────
464
522
 
465
523
  });
@@ -1,6 +1,8 @@
1
1
  /**
2
- * Tests for model config isolation between concurrent instances (#650, #1065)
3
- * and session-scoped model precedence behavior.
2
+ * Tests for model config isolation between concurrent instances (#650, #1065),
3
+ * session-scoped model precedence behavior including manual session override,
4
+ * GSD preferences override of settings.json defaults (#3517), and custom
5
+ * provider precedence over PREFERENCES.md when set via `/gsd model` (#4122).
4
6
  */
5
7
 
6
8
  import { describe, it, beforeEach, afterEach } from "node:test";
@@ -214,3 +216,90 @@ describe("manual session model override precedence", () => {
214
216
  "should be null when no model source is available");
215
217
  });
216
218
  });
219
+
220
+ // ─── Custom provider session model wins over PREFERENCES.md (#4122) ─────────
221
+
222
+ describe("custom provider session model overrides PREFERENCES.md (#4122)", () => {
223
+ // Mirrors the auto-start.ts logic:
224
+ // sessionProviderIsCustom && ctx.model
225
+ // ? ctx.model
226
+ // : (preferredModel ?? ctx.model ?? null)
227
+ function selectStartModel(args: {
228
+ ctxModel: { provider: string; id: string } | null;
229
+ preferredModel: { provider: string; id: string } | undefined;
230
+ sessionProviderIsCustom: boolean;
231
+ }): { provider: string; id: string } | null {
232
+ const { ctxModel, preferredModel, sessionProviderIsCustom } = args;
233
+ if (sessionProviderIsCustom && ctxModel) {
234
+ return { provider: ctxModel.provider, id: ctxModel.id };
235
+ }
236
+ return preferredModel
237
+ ?? (ctxModel ? { provider: ctxModel.provider, id: ctxModel.id } : null);
238
+ }
239
+
240
+ it("custom provider from /gsd model wins over PREFERENCES.md built-in default", () => {
241
+ // User runs `/gsd model ollama/llama3.1:8b`, then `/gsd auto`.
242
+ // PREFERENCES.md still has the project-template claude-code default.
243
+ const ctxModel = { provider: "ollama", id: "llama3.1:8b" };
244
+ const preferredModel = { provider: "claude-code", id: "claude-sonnet-4-6" };
245
+
246
+ const snapshot = selectStartModel({
247
+ ctxModel,
248
+ preferredModel,
249
+ sessionProviderIsCustom: true,
250
+ });
251
+
252
+ assert.equal(snapshot?.provider, "ollama",
253
+ "custom-provider session model must win over PREFERENCES.md");
254
+ assert.equal(snapshot?.id, "llama3.1:8b",
255
+ "custom-provider session model id must be preserved");
256
+ assert.notEqual(snapshot?.provider, "claude-code",
257
+ "claude-code from PREFERENCES.md must NOT be selected when session is custom");
258
+ });
259
+
260
+ it("built-in session provider still defers to PREFERENCES.md (#3517 preserved)", () => {
261
+ // ctx.model is a built-in provider (claude-code) but PREFERENCES.md has
262
+ // an explicit openai-codex preference. PREFERENCES.md should still win.
263
+ const ctxModel = { provider: "claude-code", id: "claude-sonnet-4-6" };
264
+ const preferredModel = { provider: "openai-codex", id: "gpt-5.4" };
265
+
266
+ const snapshot = selectStartModel({
267
+ ctxModel,
268
+ preferredModel,
269
+ sessionProviderIsCustom: false,
270
+ });
271
+
272
+ assert.equal(snapshot?.provider, "openai-codex",
273
+ "PREFERENCES.md must still win when session provider is built-in");
274
+ assert.equal(snapshot?.id, "gpt-5.4");
275
+ });
276
+
277
+ it("custom provider with no PREFERENCES.md still uses ctx.model", () => {
278
+ const ctxModel = { provider: "vllm", id: "qwen2.5-coder:32b" };
279
+
280
+ const snapshot = selectStartModel({
281
+ ctxModel,
282
+ preferredModel: undefined,
283
+ sessionProviderIsCustom: true,
284
+ });
285
+
286
+ assert.equal(snapshot?.provider, "vllm");
287
+ assert.equal(snapshot?.id, "qwen2.5-coder:32b");
288
+ });
289
+
290
+ it("null ctx.model with custom flag falls through to preferredModel", () => {
291
+ // Defensive: sessionProviderIsCustom can only be true if ctx.model exists,
292
+ // but verify the guard works if that invariant is ever broken.
293
+ const preferredModel = { provider: "claude-code", id: "claude-sonnet-4-6" };
294
+
295
+ const snapshot = selectStartModel({
296
+ ctxModel: null,
297
+ preferredModel,
298
+ sessionProviderIsCustom: true,
299
+ });
300
+
301
+ assert.equal(snapshot?.provider, "claude-code",
302
+ "should fall back to preferredModel when ctx.model is null");
303
+ });
304
+ });
305
+
@@ -3,10 +3,22 @@ import assert from 'node:assert/strict';
3
3
  import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { tmpdir } from 'node:os';
6
+ import { execSync } from 'node:child_process';
6
7
 
7
8
  import { deriveState, invalidateStateCache, getActiveMilestoneId } from '../state.ts';
8
9
  import { clearPathCache } from '../paths.ts';
9
10
  import { parkMilestone, unparkMilestone, discardMilestone, isParked, getParkedReason } from '../milestone-actions.ts';
11
+ import {
12
+ closeDatabase,
13
+ getMilestone,
14
+ getMilestoneSlices,
15
+ getSliceTasks,
16
+ insertMilestone,
17
+ insertSlice,
18
+ insertTask,
19
+ openDatabase,
20
+ } from "../gsd-db.ts";
21
+ import { createWorktree } from "../worktree-manager.ts";
10
22
 
11
23
 
12
24
 
@@ -60,9 +72,29 @@ function createMilestone(base: string, mid: string, opts?: { withRoadmap?: boole
60
72
  }
61
73
 
62
74
  function cleanup(base: string): void {
75
+ try {
76
+ closeDatabase();
77
+ } catch {
78
+ // ignore
79
+ }
63
80
  rmSync(base, { recursive: true, force: true });
64
81
  }
65
82
 
83
+ function run(cmd: string, cwd: string): string {
84
+ return execSync(cmd, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim();
85
+ }
86
+
87
+ function initGitRepo(base: string): void {
88
+ writeFileSync(join(base, "README.md"), "# test\n", "utf-8");
89
+ writeFileSync(join(base, ".gsd", "STATE.md"), "# State\n", "utf-8");
90
+ run("git init", base);
91
+ run("git config user.email test@test.com", base);
92
+ run("git config user.name Test", base);
93
+ run("git add .", base);
94
+ run('git commit -m "init"', base);
95
+ run("git branch -M main", base);
96
+ }
97
+
66
98
  function clearCaches(): void {
67
99
  clearPathCache();
68
100
  invalidateStateCache();
@@ -294,6 +326,38 @@ test('discardMilestone updates queue order', () => {
294
326
  }
295
327
  });
296
328
 
329
+ test('discardMilestone removes DB rows, worktree, and milestone branch', () => {
330
+ const base = createFixtureBase();
331
+ try {
332
+ createMilestone(base, 'M001', { withRoadmap: true });
333
+ initGitRepo(base);
334
+ clearCaches();
335
+
336
+ assert.ok(openDatabase(join(base, '.gsd', 'gsd.db')), 'database opens');
337
+ insertMilestone({ id: 'M001', title: 'Discard me', status: 'active' });
338
+ insertSlice({ milestoneId: 'M001', id: 'S01', title: 'Only slice', status: 'pending' });
339
+ insertTask({ milestoneId: 'M001', sliceId: 'S01', id: 'T01', title: 'Only task', status: 'pending' });
340
+
341
+ const wt = createWorktree(base, 'M001', { branch: 'milestone/M001' });
342
+ assert.ok(existsSync(wt.path), 'worktree exists before discard');
343
+ assert.ok(run('git branch', base).includes('milestone/M001'), 'milestone branch exists before discard');
344
+ assert.ok(getMilestone('M001'), 'milestone exists in DB before discard');
345
+ assert.equal(getMilestoneSlices('M001').length, 1, 'slice exists in DB before discard');
346
+ assert.equal(getSliceTasks('M001', 'S01').length, 1, 'task exists in DB before discard');
347
+
348
+ const success = discardMilestone(base, 'M001');
349
+ assert.ok(success, 'discardMilestone returns true');
350
+
351
+ assert.equal(getMilestone('M001'), null, 'milestone row removed from DB');
352
+ assert.equal(getMilestoneSlices('M001').length, 0, 'slice rows removed from DB');
353
+ assert.equal(getSliceTasks('M001', 'S01').length, 0, 'task rows removed from DB');
354
+ assert.ok(!existsSync(wt.path), 'worktree removed after discard');
355
+ assert.ok(!run('git branch', base).includes('milestone/M001'), 'milestone branch removed after discard');
356
+ } finally {
357
+ cleanup(base);
358
+ }
359
+ });
360
+
297
361
  // ─── Test 12: All milestones parked → no active milestone ─────────────
298
362
  test('All milestones parked → no active', async () => {
299
363
  const base = createFixtureBase();