gsd-pi 2.75.0-dev.2203010a0 → 2.75.0-dev.96d4bb599

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/onboarding.d.ts +5 -1
  2. package/dist/onboarding.js +5 -3
  3. package/dist/resources/extensions/gsd/auto-model-selection.js +1 -1
  4. package/dist/resources/extensions/gsd/bootstrap/db-tools.js +23 -19
  5. package/dist/resources/extensions/gsd/bootstrap/memory-tools.js +128 -0
  6. package/dist/resources/extensions/gsd/bootstrap/register-extension.js +2 -0
  7. package/dist/resources/extensions/gsd/bootstrap/system-context.js +17 -4
  8. package/dist/resources/extensions/gsd/commands/handlers/onboarding.js +52 -68
  9. package/dist/resources/extensions/gsd/commands/handlers/ops.js +5 -0
  10. package/dist/resources/extensions/gsd/commands-memory.js +462 -0
  11. package/dist/resources/extensions/gsd/gsd-db.js +237 -4
  12. package/dist/resources/extensions/gsd/memory-embeddings.js +219 -0
  13. package/dist/resources/extensions/gsd/memory-extractor.js +78 -27
  14. package/dist/resources/extensions/gsd/memory-ingest.js +218 -0
  15. package/dist/resources/extensions/gsd/memory-relations.js +189 -0
  16. package/dist/resources/extensions/gsd/memory-source-store.js +113 -0
  17. package/dist/resources/extensions/gsd/memory-store.js +299 -6
  18. package/dist/resources/extensions/gsd/model-router.js +9 -5
  19. package/dist/resources/extensions/gsd/notification-overlay.js +7 -22
  20. package/dist/resources/extensions/gsd/tools/memory-tools.js +306 -0
  21. package/dist/resources/extensions/gsd/tools/skip-slice.js +78 -0
  22. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  23. package/dist/web/standalone/.next/BUILD_ID +1 -1
  24. package/dist/web/standalone/.next/app-path-routes-manifest.json +14 -14
  25. package/dist/web/standalone/.next/build-manifest.json +2 -2
  26. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  27. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  28. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  29. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  36. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/index.html +1 -1
  44. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  46. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  47. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  48. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  49. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  50. package/dist/web/standalone/.next/server/app-paths-manifest.json +14 -14
  51. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  52. package/dist/web/standalone/.next/server/middleware-manifest.json +5 -5
  53. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  54. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  55. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  56. package/package.json +1 -1
  57. package/packages/mcp-server/dist/server.d.ts.map +1 -1
  58. package/packages/mcp-server/dist/server.js +12 -10
  59. package/packages/mcp-server/dist/server.js.map +1 -1
  60. package/packages/mcp-server/dist/session-manager.d.ts.map +1 -1
  61. package/packages/mcp-server/dist/session-manager.js +8 -1
  62. package/packages/mcp-server/dist/session-manager.js.map +1 -1
  63. package/packages/mcp-server/dist/workflow-tools.d.ts +1 -0
  64. package/packages/mcp-server/dist/workflow-tools.d.ts.map +1 -1
  65. package/packages/mcp-server/dist/workflow-tools.js +113 -14
  66. package/packages/mcp-server/dist/workflow-tools.js.map +1 -1
  67. package/packages/mcp-server/src/mcp-server.test.ts +40 -4
  68. package/packages/mcp-server/src/server.ts +12 -10
  69. package/packages/mcp-server/src/session-manager.ts +10 -3
  70. package/packages/mcp-server/src/workflow-tools.test.ts +91 -1
  71. package/packages/mcp-server/src/workflow-tools.ts +128 -18
  72. package/packages/mcp-server/tsconfig.tsbuildinfo +1 -1
  73. package/src/resources/extensions/gsd/auto-model-selection.ts +1 -1
  74. package/src/resources/extensions/gsd/bootstrap/db-tools.ts +24 -20
  75. package/src/resources/extensions/gsd/bootstrap/memory-tools.ts +158 -0
  76. package/src/resources/extensions/gsd/bootstrap/register-extension.ts +2 -0
  77. package/src/resources/extensions/gsd/bootstrap/system-context.ts +20 -4
  78. package/src/resources/extensions/gsd/commands/handlers/onboarding.ts +65 -98
  79. package/src/resources/extensions/gsd/commands/handlers/ops.ts +5 -0
  80. package/src/resources/extensions/gsd/commands-memory.ts +551 -0
  81. package/src/resources/extensions/gsd/gsd-db.ts +273 -4
  82. package/src/resources/extensions/gsd/memory-embeddings.ts +235 -0
  83. package/src/resources/extensions/gsd/memory-extractor.ts +100 -34
  84. package/src/resources/extensions/gsd/memory-ingest.ts +286 -0
  85. package/src/resources/extensions/gsd/memory-relations.ts +240 -0
  86. package/src/resources/extensions/gsd/memory-source-store.ts +138 -0
  87. package/src/resources/extensions/gsd/memory-store.ts +351 -7
  88. package/src/resources/extensions/gsd/model-router.ts +10 -5
  89. package/src/resources/extensions/gsd/notification-overlay.ts +9 -19
  90. package/src/resources/extensions/gsd/tests/auto-model-selection.test.ts +12 -0
  91. package/src/resources/extensions/gsd/tests/complete-slice.test.ts +2 -2
  92. package/src/resources/extensions/gsd/tests/complete-task.test.ts +2 -2
  93. package/src/resources/extensions/gsd/tests/escalation.test.ts +2 -2
  94. package/src/resources/extensions/gsd/tests/gsd-db.test.ts +1 -1
  95. package/src/resources/extensions/gsd/tests/md-importer.test.ts +1 -1
  96. package/src/resources/extensions/gsd/tests/memory-embeddings.test.ts +213 -0
  97. package/src/resources/extensions/gsd/tests/memory-ingest.test.ts +153 -0
  98. package/src/resources/extensions/gsd/tests/memory-maintenance.test.ts +107 -0
  99. package/src/resources/extensions/gsd/tests/memory-relations.test.ts +175 -0
  100. package/src/resources/extensions/gsd/tests/memory-store.test.ts +2 -2
  101. package/src/resources/extensions/gsd/tests/memory-tools.test.ts +295 -0
  102. package/src/resources/extensions/gsd/tests/model-router.test.ts +50 -0
  103. package/src/resources/extensions/gsd/tests/notification-overlay.test.ts +56 -37
  104. package/src/resources/extensions/gsd/tests/skip-slice-cascades-tasks.test.ts +125 -0
  105. package/src/resources/extensions/gsd/tools/memory-tools.ts +380 -0
  106. package/src/resources/extensions/gsd/tools/skip-slice.ts +133 -0
  107. package/src/resources/extensions/gsd/workflow-logger.ts +3 -1
  108. /package/dist/web/standalone/.next/static/{8FZqxNe9FxQDmsbRzR8tA → o61X3klsB6C0UE0X1x3PA}/_buildManifest.js +0 -0
  109. /package/dist/web/standalone/.next/static/{8FZqxNe9FxQDmsbRzR8tA → o61X3klsB6C0UE0X1x3PA}/_ssgManifest.js +0 -0
@@ -0,0 +1,295 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import { closeDatabase, openDatabase } from '../gsd-db.ts';
5
+ import { createMemory, supersedeMemory } from '../memory-store.ts';
6
+ import {
7
+ executeGsdGraph,
8
+ executeMemoryCapture,
9
+ executeMemoryQuery,
10
+ } from '../tools/memory-tools.ts';
11
+
12
+ // ═══════════════════════════════════════════════════════════════════════════
13
+ // capture_thought
14
+ // ═══════════════════════════════════════════════════════════════════════════
15
+
16
+ test('memory-tools: capture_thought creates a memory with a MEM id', () => {
17
+ openDatabase(':memory:');
18
+
19
+ const result = executeMemoryCapture({
20
+ category: 'gotcha',
21
+ content: 'sql.js FTS5 virtual tables need explicit triggers.',
22
+ });
23
+
24
+ assert.ok(!result.isError, 'capture should not error');
25
+ assert.equal(result.details.operation, 'memory_capture');
26
+ assert.equal(result.details.id, 'MEM001');
27
+ assert.equal(result.details.category, 'gotcha');
28
+ assert.equal(result.details.confidence, 0.8);
29
+
30
+ closeDatabase();
31
+ });
32
+
33
+ test('memory-tools: capture_thought rejects invalid category', () => {
34
+ openDatabase(':memory:');
35
+
36
+ const result = executeMemoryCapture({
37
+ category: 'opinion', // not in the allow-list
38
+ content: 'some content',
39
+ });
40
+
41
+ assert.ok(result.isError, 'invalid category should error');
42
+ assert.equal(result.details.error, 'invalid_category');
43
+
44
+ closeDatabase();
45
+ });
46
+
47
+ test('memory-tools: capture_thought rejects missing fields', () => {
48
+ openDatabase(':memory:');
49
+
50
+ const empty = executeMemoryCapture({ category: '', content: '' });
51
+ assert.ok(empty.isError, 'missing fields should error');
52
+ assert.equal(empty.details.error, 'missing_fields');
53
+
54
+ closeDatabase();
55
+ });
56
+
57
+ test('memory-tools: capture_thought clamps confidence to the 0.1–0.99 range', () => {
58
+ openDatabase(':memory:');
59
+
60
+ const hi = executeMemoryCapture({
61
+ category: 'convention',
62
+ content: 'clamp test high',
63
+ confidence: 42,
64
+ });
65
+ assert.ok(!hi.isError);
66
+ assert.equal(hi.details.confidence, 0.99);
67
+
68
+ const lo = executeMemoryCapture({
69
+ category: 'convention',
70
+ content: 'clamp test low',
71
+ confidence: -5,
72
+ });
73
+ assert.ok(!lo.isError);
74
+ assert.equal(lo.details.confidence, 0.1);
75
+
76
+ closeDatabase();
77
+ });
78
+
79
+ test('memory-tools: capture_thought fails gracefully when DB is closed', () => {
80
+ closeDatabase();
81
+
82
+ const result = executeMemoryCapture({
83
+ category: 'gotcha',
84
+ content: 'db closed',
85
+ });
86
+
87
+ assert.ok(result.isError, 'db-closed capture should error');
88
+ assert.equal(result.details.error, 'db_unavailable');
89
+ });
90
+
91
+ // ═══════════════════════════════════════════════════════════════════════════
92
+ // memory_query
93
+ // ═══════════════════════════════════════════════════════════════════════════
94
+
95
+ test('memory-tools: memory_query keyword-matches and ranks by confidence × hits', () => {
96
+ openDatabase(':memory:');
97
+
98
+ createMemory({ category: 'gotcha', content: 'sql.js FTS5 triggers must mirror insert/update/delete', confidence: 0.9 });
99
+ createMemory({ category: 'pattern', content: 'use prepared statements for hot paths', confidence: 0.7 });
100
+ createMemory({ category: 'convention', content: 'prefer async iterators for streaming', confidence: 0.8 });
101
+
102
+ const result = executeMemoryQuery({ query: 'sql triggers', k: 5 });
103
+ assert.ok(!result.isError);
104
+ const hits = result.details.hits as Array<{ id: string; content: string; reason: string }>;
105
+ assert.ok(hits.length >= 1, 'should return at least one keyword hit');
106
+ assert.equal(hits[0].id, 'MEM001', 'most-relevant memory should rank first');
107
+ assert.equal(hits[0].reason, 'keyword');
108
+
109
+ closeDatabase();
110
+ });
111
+
112
+ test('memory-tools: memory_query returns empty list when no keywords match', () => {
113
+ openDatabase(':memory:');
114
+ createMemory({ category: 'gotcha', content: 'unrelated content' });
115
+
116
+ const result = executeMemoryQuery({ query: 'totally different phrase', k: 5 });
117
+ assert.ok(!result.isError);
118
+ const hits = result.details.hits as unknown[];
119
+ assert.equal(hits.length, 0);
120
+ assert.equal(result.content[0].text, 'No matching memories.');
121
+
122
+ closeDatabase();
123
+ });
124
+
125
+ test('memory-tools: memory_query with empty query returns ranked memories', () => {
126
+ openDatabase(':memory:');
127
+ createMemory({ category: 'gotcha', content: 'first', confidence: 0.95 });
128
+ createMemory({ category: 'pattern', content: 'second', confidence: 0.5 });
129
+
130
+ const result = executeMemoryQuery({ query: ' ' });
131
+ assert.ok(!result.isError);
132
+ const hits = result.details.hits as Array<{ id: string; reason: string }>;
133
+ assert.equal(hits.length, 2);
134
+ assert.equal(hits[0].id, 'MEM001', 'higher-confidence memory ranks first under empty query');
135
+ assert.equal(hits[0].reason, 'ranked');
136
+
137
+ closeDatabase();
138
+ });
139
+
140
+ test('memory-tools: memory_query filters by category', () => {
141
+ openDatabase(':memory:');
142
+ createMemory({ category: 'gotcha', content: 'db pragma needed' });
143
+ createMemory({ category: 'pattern', content: 'db pragma needed' });
144
+
145
+ const result = executeMemoryQuery({ query: 'db pragma', category: 'gotcha' });
146
+ assert.ok(!result.isError);
147
+ const hits = result.details.hits as Array<{ id: string; category: string }>;
148
+ assert.equal(hits.length, 1);
149
+ assert.equal(hits[0].category, 'gotcha');
150
+
151
+ closeDatabase();
152
+ });
153
+
154
+ test('memory-tools: memory_query filters by scope and tag', () => {
155
+ openDatabase(':memory:');
156
+ createMemory({ category: 'gotcha', content: 'shared fact', scope: 'project', tags: ['db', 'sqlite'] });
157
+ createMemory({ category: 'gotcha', content: 'global fact', scope: 'global', tags: ['db'] });
158
+ createMemory({ category: 'gotcha', content: 'other scope', scope: 'project', tags: ['net'] });
159
+
160
+ const projectScoped = executeMemoryQuery({ query: 'fact', scope: 'project' });
161
+ assert.ok(!projectScoped.isError);
162
+ const projectHits = projectScoped.details.hits as Array<{ id: string }>;
163
+ assert.equal(projectHits.length, 1);
164
+ assert.equal(projectHits[0].id, 'MEM001');
165
+
166
+ const tagged = executeMemoryQuery({ query: 'fact', tag: 'sqlite' });
167
+ assert.ok(!tagged.isError);
168
+ const taggedHits = tagged.details.hits as Array<{ id: string }>;
169
+ assert.equal(taggedHits.length, 1);
170
+ assert.equal(taggedHits[0].id, 'MEM001');
171
+
172
+ closeDatabase();
173
+ });
174
+
175
+ test('memory-tools: capture_thought stores scope and tags', () => {
176
+ openDatabase(':memory:');
177
+ const result = executeMemoryCapture({
178
+ category: 'architecture',
179
+ content: 'use WAL journaling by default',
180
+ scope: 'global',
181
+ tags: ['sqlite', 'wal'],
182
+ });
183
+ assert.ok(!result.isError);
184
+ assert.equal(result.details.scope, 'global');
185
+ assert.deepEqual(result.details.tags, ['sqlite', 'wal']);
186
+
187
+ const lookup = executeMemoryQuery({ query: 'WAL', scope: 'global', tag: 'wal' });
188
+ const hits = lookup.details.hits as Array<{ id: string }>;
189
+ assert.equal(hits.length, 1);
190
+ assert.equal(hits[0].id, 'MEM001');
191
+
192
+ closeDatabase();
193
+ });
194
+
195
+ test('memory-tools: memory_query clamps k to the 1–50 range', () => {
196
+ openDatabase(':memory:');
197
+ for (let i = 0; i < 10; i++) {
198
+ createMemory({ category: 'pattern', content: `pattern ${i}` });
199
+ }
200
+
201
+ const tooLow = executeMemoryQuery({ query: 'pattern', k: 0 });
202
+ assert.ok(!tooLow.isError);
203
+ assert.equal((tooLow.details.hits as unknown[]).length, 1);
204
+
205
+ const sane = executeMemoryQuery({ query: 'pattern', k: 999 });
206
+ assert.ok(!sane.isError);
207
+ assert.equal((sane.details.hits as unknown[]).length, 10);
208
+
209
+ closeDatabase();
210
+ });
211
+
212
+ test('memory-tools: memory_query can reinforce returned hits', () => {
213
+ openDatabase(':memory:');
214
+ createMemory({ category: 'gotcha', content: 'needs reinforcement' });
215
+
216
+ executeMemoryQuery({ query: 'reinforcement', reinforce_hits: true });
217
+ const follow = executeMemoryQuery({ query: 'reinforcement' });
218
+ const hits = follow.details.hits as Array<{ hit_count: number }>;
219
+ assert.equal(hits.length, 1);
220
+ assert.ok(hits[0].hit_count >= 1, 'hit_count should increment on reinforce');
221
+
222
+ closeDatabase();
223
+ });
224
+
225
+ test('memory-tools: memory_query ignores superseded memories by default', () => {
226
+ openDatabase(':memory:');
227
+ createMemory({ category: 'convention', content: 'old way of doing X' });
228
+ createMemory({ category: 'convention', content: 'new way of doing X' });
229
+ supersedeMemory('MEM001', 'MEM002');
230
+
231
+ const active = executeMemoryQuery({ query: 'doing' });
232
+ const activeHits = active.details.hits as Array<{ id: string }>;
233
+ assert.equal(activeHits.length, 1);
234
+ assert.equal(activeHits[0].id, 'MEM002');
235
+
236
+ const withSuperseded = executeMemoryQuery({ query: 'doing', include_superseded: true });
237
+ const allHits = withSuperseded.details.hits as Array<{ id: string }>;
238
+ assert.equal(allHits.length, 2);
239
+
240
+ closeDatabase();
241
+ });
242
+
243
+ // ═══════════════════════════════════════════════════════════════════════════
244
+ // gsd_graph
245
+ // ═══════════════════════════════════════════════════════════════════════════
246
+
247
+ test('memory-tools: gsd_graph build acknowledges request', () => {
248
+ openDatabase(':memory:');
249
+ const result = executeGsdGraph({ mode: 'build' });
250
+ assert.ok(!result.isError);
251
+ assert.equal(result.details.operation, 'gsd_graph');
252
+ assert.equal(result.details.mode, 'build');
253
+ closeDatabase();
254
+ });
255
+
256
+ test('memory-tools: gsd_graph query rejects missing memoryId', () => {
257
+ openDatabase(':memory:');
258
+ const result = executeGsdGraph({ mode: 'query' });
259
+ assert.ok(result.isError);
260
+ assert.equal(result.details.error, 'missing_memory_id');
261
+ closeDatabase();
262
+ });
263
+
264
+ test('memory-tools: gsd_graph query returns node + supersedes edge', () => {
265
+ openDatabase(':memory:');
266
+ createMemory({ category: 'convention', content: 'first' });
267
+ createMemory({ category: 'convention', content: 'second' });
268
+ supersedeMemory('MEM001', 'MEM002');
269
+
270
+ const result = executeGsdGraph({ mode: 'query', memoryId: 'MEM001', depth: 1 });
271
+ assert.ok(!result.isError);
272
+ const nodes = result.details.nodes as Array<{ id: string }>;
273
+ const edges = result.details.edges as Array<{ from: string; to: string; rel: string }>;
274
+
275
+ const nodeIds = nodes.map((n) => n.id).sort();
276
+ assert.deepEqual(nodeIds, ['MEM001', 'MEM002']);
277
+ assert.ok(edges.some((e) => e.from === 'MEM001' && e.to === 'MEM002' && e.rel === 'supersedes'));
278
+
279
+ closeDatabase();
280
+ });
281
+
282
+ test('memory-tools: gsd_graph query returns empty when memoryId does not exist', () => {
283
+ openDatabase(':memory:');
284
+ const result = executeGsdGraph({ mode: 'query', memoryId: 'MEM999' });
285
+ assert.ok(!result.isError, 'missing memory is not an error, just empty');
286
+ assert.equal((result.details.nodes as unknown[]).length, 0);
287
+ closeDatabase();
288
+ });
289
+
290
+ test('memory-tools: gsd_graph errors when DB is closed', () => {
291
+ closeDatabase();
292
+ const result = executeGsdGraph({ mode: 'query', memoryId: 'MEM001' });
293
+ assert.ok(result.isError);
294
+ assert.equal(result.details.error, 'db_unavailable');
295
+ });
@@ -126,6 +126,43 @@ test("uses explicit tier_models when configured", () => {
126
126
  assert.equal(result.wasDowngraded, true);
127
127
  });
128
128
 
129
+ test("preserves explicit provider-qualified tier_models when duplicate bare IDs exist", () => {
130
+ const config: DynamicRoutingConfig = {
131
+ ...defaultRoutingConfig(),
132
+ enabled: true,
133
+ capability_routing: false,
134
+ tier_models: {
135
+ light: "custom-openai/gpt-5.3-codex-spark",
136
+ standard: "custom-openai/gpt-5.4",
137
+ },
138
+ };
139
+ const providerModels = [
140
+ "openai-codex/gpt-5.4",
141
+ "custom-openai/gpt-5.4",
142
+ "openai-codex/gpt-5.3-codex-spark",
143
+ "custom-openai/gpt-5.3-codex-spark",
144
+ "custom-anthropic/claude-opus-4-7",
145
+ ];
146
+
147
+ const standard = resolveModelForComplexity(
148
+ makeClassification("standard"),
149
+ { primary: "custom-anthropic/claude-opus-4-7", fallbacks: [] },
150
+ config,
151
+ providerModels,
152
+ );
153
+ assert.equal(standard.modelId, "custom-openai/gpt-5.4");
154
+ assert.equal(standard.wasDowngraded, true);
155
+
156
+ const light = resolveModelForComplexity(
157
+ makeClassification("light"),
158
+ { primary: "custom-anthropic/claude-opus-4-7", fallbacks: [] },
159
+ config,
160
+ providerModels,
161
+ );
162
+ assert.equal(light.modelId, "custom-openai/gpt-5.3-codex-spark");
163
+ assert.equal(light.wasDowngraded, true);
164
+ });
165
+
129
166
  // ─── Fallback chain construction ─────────────────────────────────────────────
130
167
 
131
168
  test("fallback chain includes configured primary as last resort", () => {
@@ -218,6 +255,19 @@ test("defaultRoutingConfig includes capability_routing: true", () => {
218
255
  assert.equal(config.capability_routing, true);
219
256
  });
220
257
 
258
+ test("scoreEligibleModels uses bare capability profiles for provider-qualified IDs", () => {
259
+ const scored = scoreEligibleModels(
260
+ ["custom-openai/gpt-5.4", "custom-openai/gpt-5.3-codex-spark"],
261
+ { coding: 1 },
262
+ );
263
+
264
+ assert.equal(scored[0]?.modelId, "custom-openai/gpt-5.4");
265
+ assert.ok(
266
+ (scored[0]?.score ?? 0) > (scored[1]?.score ?? 0),
267
+ "provider-qualified IDs should still use the built-in bare model capability profile",
268
+ );
269
+ });
270
+
221
271
  test("scoreModel computes weighted average of capability × requirement", () => {
222
272
  const caps: ModelCapabilities = {
223
273
  coding: 90, debugging: 80, research: 70,
@@ -1,31 +1,21 @@
1
1
  // GSD Extension — Notification Overlay Tests
2
- // Tests for message wrapping and content-fit sizing in the notification panel.
2
+ // Tests for message wrapping in the notification panel.
3
+ // Mirrors the private wrapText from notification-overlay.ts so its contract
4
+ // can be exercised without exporting internals.
3
5
 
4
6
  import { describe, test } from "node:test";
5
7
  import assert from "node:assert/strict";
6
8
 
7
- // The wrapText function is private to the module, so we test the overlay's
8
- // render output indirectly. We also extract and test wrapText logic directly.
9
+ import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@gsd/pi-tui";
9
10
 
10
- // ── wrapText logic (mirrors the private function) ───────────────────────────
11
+ // ── wrapText logic (mirrors the private function in notification-overlay.ts) ──
11
12
 
12
13
  function wrapText(text: string, maxWidth: number): string[] {
13
- if (text.length <= maxWidth) return [text];
14
- const words = text.split(/\s+/);
15
- const lines: string[] = [];
16
- let current = "";
17
- for (const word of words) {
18
- if (current.length === 0) {
19
- current = word;
20
- } else if (current.length + 1 + word.length <= maxWidth) {
21
- current += " " + word;
22
- } else {
23
- lines.push(current);
24
- current = word;
25
- }
26
- }
27
- if (current.length > 0) lines.push(current);
28
- return lines.map((l) => l.length > maxWidth ? l.slice(0, maxWidth - 1) + "…" : l);
14
+ if (maxWidth <= 0) return [text];
15
+ const lines = wrapTextWithAnsi(text, maxWidth);
16
+ return lines.map((l) =>
17
+ visibleWidth(l) > maxWidth ? truncateToWidth(l, maxWidth, "") : l,
18
+ );
29
19
  }
30
20
 
31
21
  describe("notification overlay — wrapText", () => {
@@ -34,31 +24,26 @@ describe("notification overlay — wrapText", () => {
34
24
  assert.deepStrictEqual(result, ["hello world"]);
35
25
  });
36
26
 
37
- test("long text wraps at word boundaries", () => {
27
+ test("long text wraps at word boundaries without exceeding maxWidth", () => {
38
28
  const text = "This is a long notification message that should wrap across multiple lines";
39
29
  const result = wrapText(text, 40);
40
30
  assert.ok(result.length > 1, `expected multiple lines, got ${result.length}`);
41
31
  for (const line of result) {
42
- assert.ok(line.length <= 40, `line exceeds maxWidth: "${line}" (${line.length})`);
32
+ assert.ok(
33
+ visibleWidth(line) <= 40,
34
+ `line exceeds maxWidth: "${line}" (${visibleWidth(line)})`,
35
+ );
43
36
  }
44
37
  });
45
38
 
46
- test("single word exceeding maxWidth is truncated", () => {
39
+ test("single word exceeding maxWidth is broken to fit column budget", () => {
47
40
  const result = wrapText("superlongwordthatexceedsmaxwidth", 10);
48
- assert.equal(result.length, 1);
49
- assert.equal(result[0]!.length, 10);
50
- assert.ok(result[0]!.endsWith("…"));
51
- });
52
-
53
- test("empty string returns single empty line", () => {
54
- const result = wrapText("", 80);
55
- assert.deepStrictEqual(result, [""]);
56
- });
57
-
58
- test("exact-fit text returns single line", () => {
59
- const text = "exactly twenty chars";
60
- const result = wrapText(text, 20);
61
- assert.deepStrictEqual(result, [text]);
41
+ for (const line of result) {
42
+ assert.ok(
43
+ visibleWidth(line) <= 10,
44
+ `line exceeds maxWidth: "${line}" (${visibleWidth(line)})`,
45
+ );
46
+ }
62
47
  });
63
48
 
64
49
  test("preserves all words across wrapped lines", () => {
@@ -70,4 +55,38 @@ describe("notification overlay — wrapText", () => {
70
55
  assert.ok(rejoined.includes(w), `missing word: ${w}`);
71
56
  }
72
57
  });
58
+
59
+ // Regression for #4465 — the previous .length-based wrapper could allow
60
+ // lines to bleed past the panel border when measured in terminal columns.
61
+ // Verify that every wrapped line stays within the column budget, including
62
+ // for the real-world long multi-provider notification payload.
63
+ test("regression #4465: long notification stays within column budget", () => {
64
+ const msg =
65
+ "GSD API Key Manager LLM Providers ✗ anthropic — not configured " +
66
+ "(console.anthropic.com) ✗ openai — not configured " +
67
+ "(platform.openai.com/api-keys) ✓ github-copilot — OAuth (expires in 13m) " +
68
+ "✓ openai-codex — OAuth (expires in 99h 9m) ✓ google-gemini-cli — OAuth " +
69
+ "(expired — will auto-refresh) ✓ google-antigravity — OAuth " +
70
+ "(expired — will auto-refresh) ✗ google — not configured " +
71
+ "(aistudio.google.com/apikey) ✗ groq — not configured";
72
+ const maxWidth = 118;
73
+ const result = wrapText(msg, maxWidth);
74
+ for (const line of result) {
75
+ assert.ok(
76
+ visibleWidth(line) <= maxWidth,
77
+ `line exceeds column budget: visibleWidth=${visibleWidth(line)} max=${maxWidth}: "${line}"`,
78
+ );
79
+ }
80
+ });
81
+
82
+ test("unbreakable long token (URL) is clamped to maxWidth", () => {
83
+ const url = "https://example.com/" + "a".repeat(200);
84
+ const result = wrapText(url, 40);
85
+ for (const line of result) {
86
+ assert.ok(
87
+ visibleWidth(line) <= 40,
88
+ `line exceeds maxWidth: visibleWidth=${visibleWidth(line)} line="${line}"`,
89
+ );
90
+ }
91
+ });
73
92
  });
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Regression test for #4375: gsd_skip_slice must cascade "skipped" status to
3
+ * all non-closed tasks in the slice.
4
+ *
5
+ * Without the cascade, executeCompleteMilestone's deep-task check finds
6
+ * pending tasks inside a skipped slice and refuses to complete the milestone,
7
+ * causing auto-mode to loop on complete-milestone until stuck-recovery aborts.
8
+ */
9
+
10
+ import { describe, test, beforeEach, afterEach } from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import { mkdtempSync, rmSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { tmpdir } from "node:os";
15
+
16
+ import {
17
+ openDatabase,
18
+ closeDatabase,
19
+ insertMilestone,
20
+ insertSlice,
21
+ insertTask,
22
+ getSlice,
23
+ getSliceTasks,
24
+ updateSliceStatus,
25
+ updateTaskStatus,
26
+ } from "../gsd-db.ts";
27
+ import { handleSkipSlice } from "../tools/skip-slice.ts";
28
+
29
+ describe("handleSkipSlice cascades skip to tasks (#4375)", () => {
30
+ let dir: string;
31
+ let dbPath: string;
32
+
33
+ beforeEach(() => {
34
+ dir = mkdtempSync(join(tmpdir(), "gsd-skip-cascade-"));
35
+ dbPath = join(dir, "test.db");
36
+ openDatabase(dbPath);
37
+ insertMilestone({ id: "M001", title: "Test", status: "active" });
38
+ insertSlice({ milestoneId: "M001", id: "S03", title: "Deferred", status: "pending", sequence: 3 });
39
+ insertTask({ milestoneId: "M001", sliceId: "S03", id: "T01", title: "task 1", status: "pending", sequence: 1 });
40
+ insertTask({ milestoneId: "M001", sliceId: "S03", id: "T02", title: "task 2", status: "active", sequence: 2 });
41
+ insertTask({ milestoneId: "M001", sliceId: "S03", id: "T03", title: "task 3", status: "pending", sequence: 3 });
42
+ });
43
+
44
+ afterEach(() => {
45
+ closeDatabase();
46
+ rmSync(dir, { recursive: true, force: true });
47
+ });
48
+
49
+ test("sets slice to skipped and cascades pending/active tasks to skipped", () => {
50
+ const result = handleSkipSlice({ milestoneId: "M001", sliceId: "S03", reason: "deferred" });
51
+
52
+ assert.equal(result.error, undefined, `expected success, got error: ${result.error ?? ""}`);
53
+ assert.equal(result.sliceId, "S03");
54
+ assert.equal(result.milestoneId, "M001");
55
+ assert.equal(result.tasksSkipped, 3, "all three non-closed tasks should be cascaded");
56
+
57
+ const slice = getSlice("M001", "S03");
58
+ assert.equal(slice?.status, "skipped", "slice status must be skipped");
59
+
60
+ const tasks = getSliceTasks("M001", "S03");
61
+ for (const t of tasks) {
62
+ assert.equal(t.status, "skipped", `task ${t.id} must be skipped after cascade`);
63
+ }
64
+ });
65
+
66
+ test("does not downgrade already-closed tasks", () => {
67
+ updateTaskStatus("M001", "S03", "T01", "complete", new Date().toISOString());
68
+
69
+ const result = handleSkipSlice({ milestoneId: "M001", sliceId: "S03" });
70
+
71
+ assert.equal(result.error, undefined);
72
+ assert.equal(result.tasksSkipped, 2, "only the two non-closed tasks should be cascaded");
73
+
74
+ const tasks = getSliceTasks("M001", "S03");
75
+ const byId = new Map(tasks.map((t) => [t.id, t.status]));
76
+ assert.equal(byId.get("T01"), "complete", "completed task must not be downgraded");
77
+ assert.equal(byId.get("T02"), "skipped");
78
+ assert.equal(byId.get("T03"), "skipped");
79
+ });
80
+
81
+ test("re-running skip on an already-skipped slice heals leftover pending tasks (#4375 recovery)", () => {
82
+ const first = handleSkipSlice({ milestoneId: "M001", sliceId: "S03" });
83
+ assert.equal(first.error, undefined);
84
+ assert.equal(first.tasksSkipped, 3);
85
+
86
+ // Simulate historical inconsistent state: slice skipped but a task was
87
+ // later recreated or reset to pending by some migration/bug path.
88
+ updateTaskStatus("M001", "S03", "T01", "pending");
89
+
90
+ const second = handleSkipSlice({ milestoneId: "M001", sliceId: "S03" });
91
+ assert.equal(second.error, undefined, "re-running must succeed (no 'already skipped' hard error)");
92
+ assert.equal(second.tasksSkipped, 1, "only the leftover pending task should be cascaded");
93
+ assert.equal(second.wasAlreadySkipped, true);
94
+
95
+ const tasks = getSliceTasks("M001", "S03");
96
+ for (const t of tasks) {
97
+ assert.equal(t.status, "skipped", `task ${t.id} must be skipped after recovery`);
98
+ }
99
+ });
100
+
101
+ test("refuses to skip an already-complete slice", () => {
102
+ updateSliceStatus("M001", "S03", "complete");
103
+
104
+ const result = handleSkipSlice({ milestoneId: "M001", sliceId: "S03" });
105
+ assert.ok(result.error, "expected error when slice is already complete");
106
+ assert.match(result.error!, /already complete/i);
107
+ assert.equal(result.errorCode, "already_complete");
108
+ });
109
+
110
+ test("refuses to skip a slice in the legacy 'done' status", () => {
111
+ updateSliceStatus("M001", "S03", "done");
112
+
113
+ const result = handleSkipSlice({ milestoneId: "M001", sliceId: "S03" });
114
+ assert.ok(result.error, "expected error when slice is already done");
115
+ assert.match(result.error!, /already complete/i);
116
+ assert.equal(result.errorCode, "already_complete");
117
+ });
118
+
119
+ test("returns error for unknown slice", () => {
120
+ const result = handleSkipSlice({ milestoneId: "M001", sliceId: "S99" });
121
+ assert.ok(result.error);
122
+ assert.match(result.error!, /not found/i);
123
+ assert.equal(result.errorCode, "slice_not_found");
124
+ });
125
+ });