@abarcenas/ai-workflow-template 1.49.0 → 2.0.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 (35) hide show
  1. package/.agents/instructions/knowledgebase.instructions.md +2 -2
  2. package/.agents/instructions/learned-knowledge.instructions.md +47 -2
  3. package/.agents/instructions/memory-schema.instructions.md +1 -1
  4. package/.github/workflows/npm-publish.yml +69 -1
  5. package/.opencode/plugins/graphify.js +22 -0
  6. package/LICENSE +15 -0
  7. package/README.md +56 -22
  8. package/memory-bank/.vocabulary.json +3 -0
  9. package/opencode.mcp.example.json +13 -0
  10. package/package.json +20 -10
  11. package/scripts/bump-version.js +18 -2
  12. package/scripts/mcp/playwright-mcp-launcher.js +45 -2
  13. package/scripts/bump-version.test.js +0 -238
  14. package/scripts/knowledgebase-cli.test.js +0 -576
  15. package/scripts/knowledgebase-index.test.js +0 -759
  16. package/scripts/mcp/playwright-mcp-launcher.test.js +0 -217
  17. package/scripts/mcp-knowledgebase-server.test.js +0 -745
  18. package/scripts/mcp-memory-server.test.js +0 -398
  19. package/scripts/memory-cli.test.js +0 -297
  20. package/scripts/memory-index.test.js +0 -1173
  21. package/scripts/normalize-memory.test.js +0 -753
  22. package/scripts/setup/discover.test.js +0 -530
  23. package/scripts/setup/hooks.test.js +0 -722
  24. package/scripts/setup/husky-init.test.js +0 -311
  25. package/scripts/setup/index.test.js +0 -1021
  26. package/scripts/setup/knowledgebase.test.js +0 -352
  27. package/scripts/setup/prepare.test.js +0 -718
  28. package/scripts/setup/skills-phase.test.js +0 -1274
  29. package/scripts/setup/sync-phase.test.js +0 -373
  30. package/scripts/setup/ui.test.js +0 -579
  31. package/scripts/setup/utils.test.js +0 -586
  32. package/scripts/sync-unit.test.js +0 -1881
  33. package/scripts/sync.test.js +0 -472
  34. package/scripts/validate-memory-schema.test.js +0 -328
  35. package/scripts/vocab-sync.test.js +0 -902
@@ -1,745 +0,0 @@
1
- // ═════════════════════════════════════════════════════════════════════════════
2
- // mcp-knowledgebase-server.test.js — Unit tests for the MCP knowledgebase server
3
- // ═════════════════════════════════════════════════════════════════════════════
4
- //
5
- // The MCP server is a stdio process, so its tool-call handler is exported as
6
- // `handleToolCall` for testability and the stdio bootstrap (main) is guarded to
7
- // direct-run only. The core engine (./knowledgebase-index.js) is fully mocked
8
- // so tests verify handler logic — registration order, guards, formatting, and
9
- // graceful degradation — without a database or stdio connection.
10
- //
11
- // Covered scenarios (per plan T3 / TEST-01..08 plus degradation + integrity):
12
- // 1. knowledgebase_index calls registerProject BEFORE upsertChunks with (projectId, projectId)
13
- // 2. knowledgebase_index skips registerProject when chunks are empty (guard placement)
14
- // 3. knowledgebase_index reads the default file when no content is provided
15
- // 4. knowledgebase_index returns an error response when the default file is missing
16
- // 5. knowledgebase_index returns formatted indexed/updated/skipped counts
17
- // 6. knowledgebase_index validates projectId is required
18
- // 6b. knowledgebase_index rejects whitespace-only projectId
19
- // 6c. knowledgebase_index trims whitespace from projectId before downstream use
20
- // 7. knowledgebase_search returns formatted results with similarity scores (regression)
21
- // 8. knowledgebase_stats returns formatted stats (regression)
22
- // 9. knowledgebase_search degrades gracefully when the pool/DATABASE_URL is unavailable
23
- // 10. knowledgebase_list degrades gracefully when the pool/DATABASE_URL is unavailable
24
- // 11. import integrity — registerProject is exported from knowledgebase-index.js (smoke)
25
- // 12. knowledgebase_search "No results found" when the pool IS available (empty-kb branch)
26
- // 13. knowledgebase_stats not-available message when zero counts AND no pool
27
- // 14. knowledgebase_list formats indexed projects when projects exist
28
- // 15. unknown tool name returns a caught error response (default branch + outer catch)
29
- // 16. knowledgebase_index returns an error response when upsertChunks rejects
30
- // 17. redactConnectionString FAILS CLOSED — unix-socket authority postgres://user:secret@/var/run/postgresql in an error message is redacted (no raw credentials)
31
- // 18. redactConnectionString FAILS CLOSED — postgresql://user:secret@/tmp?host=/tmp variant is redacted (query string now also redacted → ?***)
32
- // 19. redactConnectionString redacts EVERY postgres:// token in a message (not just the first)
33
- // 20. knowledgebase_search drops a whitespace-only projectId (match-all, graceful degradation) — trimmed value passed to the engine
34
- // 21. knowledgebase_search honors an explicit limit: 0 (nullish coalescing, not silently 5)
35
- // 22. redactConnectionString redacts query-string params in URL tokens (belt-and-braces) — ?password=... does not survive
36
- // ═════════════════════════════════════════════════════════════════════════════
37
-
38
- import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'
39
-
40
- // ═════════════════════════════════════════════════════════════════════════════
41
- // Module mocks — core engine fully mocked; only readFileSync is stubbed in
42
- // node:fs so the handler's default-file reads can be controlled hermetically.
43
- // ═════════════════════════════════════════════════════════════════════════════
44
-
45
- vi.mock('./knowledgebase-index.js', () => ({
46
- search: vi.fn(),
47
- upsertChunks: vi.fn(),
48
- chunkLearnedKnowledge: vi.fn(),
49
- getStats: vi.fn(),
50
- listProjects: vi.fn(),
51
- registerProject: vi.fn(),
52
- getPool: vi.fn(),
53
- closePool: vi.fn(),
54
- }))
55
-
56
- vi.mock('node:fs', async () => {
57
- const actual = await vi.importActual('node:fs')
58
- return {
59
- ...actual,
60
- readFileSync: vi.fn(),
61
- }
62
- })
63
-
64
- // ═════════════════════════════════════════════════════════════════════════════
65
- // Fixtures
66
- // ═════════════════════════════════════════════════════════════════════════════
67
-
68
- const DEFAULT_KB_FILE = '.agents/instructions/learned-knowledge.instructions.md'
69
-
70
- const markdownWithSession = [
71
- '# Previous Work Summary',
72
- '',
73
- '## Session: 2026-08-02 — MCP registerProject Fix',
74
- '',
75
- '**Pipeline:** fix',
76
- '',
77
- '**New knowledge:**',
78
- '- registerProject must be called before upsertChunks',
79
- '- The MCP server now registers the project row before inserting chunks',
80
- '',
81
- ].join('\n')
82
-
83
- const sampleChunks = [
84
- {
85
- project_id: 'test-project',
86
- session_date: '2026-08-02',
87
- content: 'registerProject must be called before upsertChunks',
88
- content_hash: 'abc123',
89
- },
90
- ]
91
-
92
- const sampleResults = [
93
- {
94
- project_id: 'test-project',
95
- session_date: '2026-08-01',
96
- session_title: 'Session: 2026-08-01',
97
- pipeline: 'test-pipeline',
98
- content: 'Short test content for verification',
99
- similarity: 0.87,
100
- },
101
- {
102
- project_id: 'test-project',
103
- session_date: '2026-07-31',
104
- session_title: 'Session: 2026-07-31',
105
- pipeline: 'test-pipeline',
106
- content: 'Another chunk of knowledge for the second result row',
107
- similarity: 0.72,
108
- },
109
- ]
110
-
111
- const sampleStats = {
112
- total_projects: 2,
113
- total_chunks: 23,
114
- db_size: '128 kB',
115
- last_sync: '2026-08-02T12:00:00Z',
116
- }
117
-
118
- /**
119
- * Build an MCP tool-call request object as the SDK would deliver it.
120
- * @param {string} name - tool name
121
- * @param {Record<string, any>} args - tool arguments
122
- * @returns {{ params: { name: string, arguments: Record<string, any> } }}
123
- */
124
- function buildRequest(name, args = {}) {
125
- return { params: { name, arguments: args } }
126
- }
127
-
128
- // ═════════════════════════════════════════════════════════════════════════════
129
- // Tests
130
- // ═════════════════════════════════════════════════════════════════════════════
131
-
132
- describe('mcp-knowledgebase-server', () => {
133
- let mod
134
- let kb
135
- let readFileSyncMock
136
-
137
- beforeEach(async () => {
138
- vi.resetModules()
139
- vi.clearAllMocks()
140
- mod = await import('./mcp-knowledgebase-server.js')
141
- kb = await import('./knowledgebase-index.js')
142
- readFileSyncMock = (await import('node:fs')).readFileSync
143
- // Default: reading the file succeeds with the fixture. Individual tests
144
- // override readFileSyncMock.mockImplementation when they need ENOENT.
145
- readFileSyncMock.mockReturnValue(markdownWithSession)
146
- })
147
-
148
- afterEach(() => {
149
- vi.restoreAllMocks()
150
- })
151
-
152
- // ── TEST-01: registerProject is called before upsertChunks ─────────────
153
-
154
- it('knowledgebase_index: calls registerProject before upsertChunks with (projectId, projectId)', async () => {
155
- kb.chunkLearnedKnowledge.mockReturnValue(sampleChunks)
156
- kb.registerProject.mockResolvedValue({
157
- id: 'test-project',
158
- first_indexed_at: '2026-08-02T00:00:00Z',
159
- })
160
- kb.upsertChunks.mockResolvedValue({ inserted: 1, updated: 0, skipped: 0 })
161
-
162
- const response = await mod.handleToolCall(
163
- buildRequest('knowledgebase_index', {
164
- projectId: 'test-project',
165
- content: markdownWithSession,
166
- }),
167
- )
168
-
169
- // registerProject called with the CLI convention (projectId, projectId)
170
- expect(kb.registerProject).toHaveBeenCalledTimes(1)
171
- expect(kb.registerProject).toHaveBeenCalledWith(
172
- 'test-project',
173
- 'test-project',
174
- )
175
-
176
- // upsertChunks still called with the parsed chunks after registration
177
- expect(kb.upsertChunks).toHaveBeenCalledTimes(1)
178
- expect(kb.upsertChunks).toHaveBeenCalledWith(sampleChunks)
179
-
180
- // Order assertion: registerProject resolved BEFORE upsertChunks started
181
- const registerOrder = kb.registerProject.mock.invocationCallOrder[0]
182
- const upsertOrder = kb.upsertChunks.mock.invocationCallOrder[0]
183
- expect(registerOrder).toBeLessThan(upsertOrder)
184
-
185
- // Response shape delivered to the MCP client
186
- expect(response.content).toBeInstanceOf(Array)
187
- expect(response.content[0].type).toBe('text')
188
- expect(response.isError).toBeUndefined()
189
- })
190
-
191
- // ── TEST-02: registerProject skipped when chunks are empty ─────────────
192
-
193
- it('knowledgebase_index: does NOT call registerProject when chunks are empty', async () => {
194
- kb.chunkLearnedKnowledge.mockReturnValue([])
195
-
196
- const response = await mod.handleToolCall(
197
- buildRequest('knowledgebase_index', {
198
- projectId: 'test-project',
199
- content: markdownWithSession,
200
- }),
201
- )
202
-
203
- expect(kb.registerProject).not.toHaveBeenCalled()
204
- expect(kb.upsertChunks).not.toHaveBeenCalled()
205
-
206
- expect(response.content[0].text).toContain(
207
- 'No valid knowledge chunks found for project "test-project"',
208
- )
209
- expect(response.isError).toBeUndefined()
210
- })
211
-
212
- // ── TEST-03: default file read when no content provided ────────────────
213
-
214
- it('knowledgebase_index: reads the default file when no content is provided', async () => {
215
- kb.chunkLearnedKnowledge.mockReturnValue(sampleChunks)
216
- kb.registerProject.mockResolvedValue({
217
- id: 'test-project',
218
- first_indexed_at: '2026-08-02T00:00:00Z',
219
- })
220
- kb.upsertChunks.mockResolvedValue({ inserted: 1, updated: 0, skipped: 0 })
221
-
222
- const response = await mod.handleToolCall(
223
- buildRequest('knowledgebase_index', { projectId: 'test-project' }),
224
- )
225
-
226
- expect(readFileSyncMock).toHaveBeenCalledTimes(1)
227
- expect(readFileSyncMock).toHaveBeenCalledWith(
228
- expect.stringContaining(DEFAULT_KB_FILE),
229
- 'utf8',
230
- )
231
- // The file contents flow into chunkLearnedKnowledge
232
- expect(kb.chunkLearnedKnowledge).toHaveBeenCalledWith(
233
- markdownWithSession,
234
- 'test-project',
235
- )
236
- expect(response.isError).toBeUndefined()
237
- })
238
-
239
- // ── TEST-04: error response when default file is missing ───────────────
240
-
241
- it('knowledgebase_index: returns error response when default file is missing and no content', async () => {
242
- readFileSyncMock.mockImplementation(() => {
243
- const err = new Error('ENOENT: no such file or directory, open ...')
244
- err.code = 'ENOENT'
245
- throw err
246
- })
247
-
248
- const response = await mod.handleToolCall(
249
- buildRequest('knowledgebase_index', { projectId: 'test-project' }),
250
- )
251
-
252
- expect(response.isError).toBe(true)
253
- expect(response.content[0].text).toContain('could not be read')
254
- expect(response.content[0].text).toContain(DEFAULT_KB_FILE)
255
-
256
- // No engine calls happen before the early error return
257
- expect(kb.chunkLearnedKnowledge).not.toHaveBeenCalled()
258
- expect(kb.registerProject).not.toHaveBeenCalled()
259
- expect(kb.upsertChunks).not.toHaveBeenCalled()
260
- })
261
-
262
- // ── TEST-05: formatted counts response ─────────────────────────────────
263
-
264
- it('knowledgebase_index: returns formatted indexed/updated/skipped counts', async () => {
265
- kb.chunkLearnedKnowledge.mockReturnValue(sampleChunks)
266
- kb.registerProject.mockResolvedValue({
267
- id: 'test-project',
268
- first_indexed_at: '2026-08-02T00:00:00Z',
269
- })
270
- kb.upsertChunks.mockResolvedValue({ inserted: 3, updated: 2, skipped: 1 })
271
-
272
- const response = await mod.handleToolCall(
273
- buildRequest('knowledgebase_index', {
274
- projectId: 'test-project',
275
- content: markdownWithSession,
276
- }),
277
- )
278
-
279
- expect(response.content[0].text).toBe(
280
- 'Indexed 3 chunks, updated 2, skipped 1 for project "test-project".',
281
- )
282
- expect(response.content[0].text).toContain('test-project')
283
- expect(response.isError).toBeUndefined()
284
- })
285
-
286
- // ── TEST-06: projectId validation ──────────────────────────────────────
287
-
288
- it('knowledgebase_index: returns error response when projectId is missing', async () => {
289
- const response = await mod.handleToolCall(
290
- buildRequest('knowledgebase_index', { content: markdownWithSession }),
291
- )
292
-
293
- expect(response.isError).toBe(true)
294
- expect(response.content[0].text).toContain(
295
- 'Error: projectId is required for knowledgebase_index.',
296
- )
297
-
298
- // No engine calls happen for an invalid request
299
- expect(kb.chunkLearnedKnowledge).not.toHaveBeenCalled()
300
- expect(kb.registerProject).not.toHaveBeenCalled()
301
- expect(kb.upsertChunks).not.toHaveBeenCalled()
302
- })
303
-
304
- // ── TEST-06b: whitespace-only projectId validation ────────────────────
305
-
306
- it('knowledgebase_index: returns error response when projectId is whitespace-only', async () => {
307
- const response = await mod.handleToolCall(
308
- buildRequest('knowledgebase_index', {
309
- projectId: ' ',
310
- content: markdownWithSession,
311
- }),
312
- )
313
-
314
- expect(response.isError).toBe(true)
315
- expect(response.content[0].text).toContain(
316
- 'Error: projectId is required for knowledgebase_index.',
317
- )
318
-
319
- // No engine calls happen for an invalid request
320
- expect(kb.chunkLearnedKnowledge).not.toHaveBeenCalled()
321
- expect(kb.registerProject).not.toHaveBeenCalled()
322
- expect(kb.upsertChunks).not.toHaveBeenCalled()
323
- })
324
-
325
- // ── TEST-06c: projectId trimmed before downstream use ─────────────────
326
-
327
- it('knowledgebase_index: trims surrounding whitespace from projectId before use', async () => {
328
- kb.chunkLearnedKnowledge.mockReturnValue(sampleChunks)
329
- kb.registerProject.mockResolvedValue({
330
- id: 'test-project',
331
- first_indexed_at: '2026-08-02T00:00:00Z',
332
- })
333
- kb.upsertChunks.mockResolvedValue({ inserted: 1, updated: 0, skipped: 0 })
334
-
335
- const response = await mod.handleToolCall(
336
- buildRequest('knowledgebase_index', {
337
- projectId: ' test-project ',
338
- content: markdownWithSession,
339
- }),
340
- )
341
-
342
- // The trimmed value flows into chunking, registration, and the response
343
- expect(kb.chunkLearnedKnowledge).toHaveBeenCalledWith(
344
- markdownWithSession,
345
- 'test-project',
346
- )
347
- expect(kb.registerProject).toHaveBeenCalledWith('test-project', 'test-project')
348
- expect(kb.upsertChunks).toHaveBeenCalledWith(sampleChunks)
349
- expect(response.content[0].text).toContain(
350
- 'for project "test-project".',
351
- )
352
- expect(response.isError).toBeUndefined()
353
- })
354
-
355
- // ── TEST-07: knowledgebase_search formatting (regression) ──────────────
356
-
357
- it('knowledgebase_search: returns formatted results with similarity scores', async () => {
358
- kb.search.mockResolvedValue(sampleResults)
359
-
360
- const response = await mod.handleToolCall(
361
- buildRequest('knowledgebase_search', { query: 'test query' }),
362
- )
363
-
364
- expect(kb.search).toHaveBeenCalledWith('test query', {
365
- project_id: undefined,
366
- threshold: 0.1,
367
- limit: 5,
368
- })
369
-
370
- expect(response.content[0].type).toBe('text')
371
- expect(response.content[0].text).toContain('### Result 1 (similarity: 0.87)')
372
- expect(response.content[0].text).toContain('### Result 2 (similarity: 0.72)')
373
- expect(response.content[0].text).toContain('**Project:** `test-project`')
374
- expect(response.content[0].text).toContain('**Date:** 2026-08-01')
375
- expect(response.content[0].text).toContain('**Pipeline:** test-pipeline')
376
- expect(response.content[0].text).toContain('---')
377
- expect(response.isError).toBeUndefined()
378
- })
379
-
380
- // ── TEST-08: knowledgebase_stats formatting (regression) ───────────────
381
-
382
- it('knowledgebase_stats: returns formatted stats', async () => {
383
- kb.getStats.mockResolvedValue(sampleStats)
384
-
385
- const response = await mod.handleToolCall(
386
- buildRequest('knowledgebase_stats', {}),
387
- )
388
-
389
- expect(kb.getStats).toHaveBeenCalledTimes(1)
390
- expect(response.content[0].type).toBe('text')
391
- expect(response.content[0].text).toContain('**Knowledgebase Stats**')
392
- expect(response.content[0].text).toContain('- Total projects: 2')
393
- expect(response.content[0].text).toContain('- Total chunks: 23')
394
- expect(response.content[0].text).toContain('- Database size: 128 kB')
395
- expect(response.content[0].text).toContain('- Last sync: 2026-08-02T12:00:00Z')
396
- expect(response.isError).toBeUndefined()
397
- })
398
-
399
- // ── TEST-09: graceful degradation — search with no pool ────────────────
400
-
401
- it('knowledgebase_search: degrades gracefully when pool/DATABASE_URL unavailable (never throws)', async () => {
402
- kb.search.mockResolvedValue([])
403
- kb.getPool.mockResolvedValue(null)
404
-
405
- const response = await mod.handleToolCall(
406
- buildRequest('knowledgebase_search', { query: 'anything' }),
407
- )
408
-
409
- expect(response.content[0].text).toContain(
410
- 'Knowledgebase not available: DATABASE_URL is not configured.',
411
- )
412
- expect(response.isError).toBeUndefined()
413
- expect(kb.getPool).toHaveBeenCalledTimes(1)
414
- })
415
-
416
- // ── TEST-10: graceful degradation — list with no pool ──────────────────
417
-
418
- it('knowledgebase_list: degrades gracefully when pool/DATABASE_URL unavailable (never throws)', async () => {
419
- kb.listProjects.mockResolvedValue([])
420
- kb.getPool.mockResolvedValue(null)
421
-
422
- const response = await mod.handleToolCall(
423
- buildRequest('knowledgebase_list', {}),
424
- )
425
-
426
- expect(response.content[0].text).toContain(
427
- 'Knowledgebase not available: DATABASE_URL is not configured.',
428
- )
429
- expect(response.isError).toBeUndefined()
430
- })
431
-
432
- // ── TEST-11: import integrity smoke check ──────────────────────────────
433
-
434
- it('import integrity: registerProject is exported from the real knowledgebase-index.js', async () => {
435
- // Bypass the mock to smoke-check the real module's public surface
436
- const real = await vi.importActual('./knowledgebase-index.js')
437
-
438
- expect(typeof real.registerProject).toBe('function')
439
- expect(typeof real.upsertChunks).toBe('function')
440
- expect(typeof real.chunkLearnedKnowledge).toBe('function')
441
- })
442
-
443
- // ── TEST-12: search — empty results with an available pool ─────────────
444
-
445
- it('knowledgebase_search: returns "No results found" when pool IS available', async () => {
446
- // search() returns [] but the pool exists → the empty-KB branch (not the
447
- // "DATABASE_URL not configured" branch) must be hit
448
- kb.search.mockResolvedValue([])
449
- kb.getPool.mockResolvedValue({})
450
-
451
- const response = await mod.handleToolCall(
452
- buildRequest('knowledgebase_search', { query: 'anything' }),
453
- )
454
-
455
- expect(response.content[0].text).toBe(
456
- 'No results found. The knowledgebase may be empty.',
457
- )
458
- expect(response.isError).toBeUndefined()
459
- expect(kb.getPool).toHaveBeenCalledTimes(1)
460
- })
461
-
462
- // ── TEST-13: stats — zero counts with no pool ──────────────────────────
463
-
464
- it('knowledgebase_stats: returns not-available message when zero counts AND no pool', async () => {
465
- kb.getStats.mockResolvedValue({
466
- total_projects: 0,
467
- total_chunks: 0,
468
- db_size: '0 B',
469
- last_sync: null,
470
- })
471
- kb.getPool.mockResolvedValue(null)
472
-
473
- const response = await mod.handleToolCall(
474
- buildRequest('knowledgebase_stats', {}),
475
- )
476
-
477
- expect(response.content[0].text).toContain(
478
- 'Knowledgebase not available: DATABASE_URL is not configured.',
479
- )
480
- expect(response.isError).toBeUndefined()
481
- })
482
-
483
- // ── TEST-14: list — formatted projects when projects exist ─────────────
484
-
485
- it('knowledgebase_list: formats indexed projects with chunk counts', async () => {
486
- kb.listProjects.mockResolvedValue([
487
- {
488
- project_id: 'project-a',
489
- name: 'Project A',
490
- chunk_count: 5,
491
- last_indexed: '2026-08-02T00:00:00Z',
492
- },
493
- {
494
- project_id: 'project-b',
495
- name: null, // exercises the name || project_id fallback
496
- chunk_count: 0,
497
- last_indexed: null, // exercises the last_indexed || 'Never' fallback
498
- },
499
- ])
500
-
501
- const response = await mod.handleToolCall(
502
- buildRequest('knowledgebase_list', {}),
503
- )
504
-
505
- expect(response.content[0].type).toBe('text')
506
- expect(response.content[0].text).toContain('**Indexed Projects:**')
507
- expect(response.content[0].text).toContain(
508
- '- Project A (5 chunks, last indexed: 2026-08-02T00:00:00Z)',
509
- )
510
- expect(response.content[0].text).toContain(
511
- '- project-b (0 chunks, last indexed: Never)',
512
- )
513
- expect(response.isError).toBeUndefined()
514
- })
515
-
516
- // ── TEST-15: unknown tool — outer catch returns error response ─────────
517
-
518
- it('unknown tool: returns a caught error response (default branch)', async () => {
519
- const response = await mod.handleToolCall(
520
- buildRequest('knowledgebase_bogus', {}),
521
- )
522
-
523
- expect(response.isError).toBe(true)
524
- expect(response.content[0].text).toBe(
525
- 'Error: Unknown tool: knowledgebase_bogus',
526
- )
527
- // The default branch throws — no engine function should have been touched
528
- expect(kb.search).not.toHaveBeenCalled()
529
- expect(kb.upsertChunks).not.toHaveBeenCalled()
530
- })
531
-
532
- // ── TEST-16: index — error path when upsertChunks rejects ──────────────
533
-
534
- it('knowledgebase_index: returns error response when upsertChunks fails', async () => {
535
- kb.chunkLearnedKnowledge.mockReturnValue(sampleChunks)
536
- kb.registerProject.mockResolvedValue({
537
- id: 'test-project',
538
- first_indexed_at: '2026-08-02T00:00:00Z',
539
- })
540
- kb.upsertChunks.mockRejectedValue(new Error('DB connection failed'))
541
-
542
- const response = await mod.handleToolCall(
543
- buildRequest('knowledgebase_index', {
544
- projectId: 'test-project',
545
- content: markdownWithSession,
546
- }),
547
- )
548
-
549
- // registerProject still ran before the failure (the fix order is preserved)
550
- expect(kb.registerProject).toHaveBeenCalledTimes(1)
551
- expect(kb.upsertChunks).toHaveBeenCalledTimes(1)
552
- expect(response.isError).toBe(true)
553
- expect(response.content[0].text).toContain(
554
- 'Error: DB connection failed',
555
- )
556
- })
557
-
558
- // ── TEST-17: redactConnectionString FAILS CLOSED — unix-socket authority ─
559
-
560
- it('redactConnectionString: redacts unix-socket authority postgres://user:secret@/var/run/postgresql (fail-closed)', async () => {
561
- // `new URL()` throws on this libpq bare-authority form — the helper must
562
- // still strip the credentials instead of returning the message unchanged.
563
- kb.chunkLearnedKnowledge.mockReturnValue(sampleChunks)
564
- kb.registerProject.mockResolvedValue({
565
- id: 'test-project',
566
- first_indexed_at: '2026-08-02T00:00:00Z',
567
- })
568
- kb.upsertChunks.mockRejectedValue(
569
- new Error('connect ECONNREFUSED postgres://user:secret@/var/run/postgresql'),
570
- )
571
-
572
- const response = await mod.handleToolCall(
573
- buildRequest('knowledgebase_index', {
574
- projectId: 'test-project',
575
- content: markdownWithSession,
576
- }),
577
- )
578
-
579
- expect(response.isError).toBe(true)
580
- // Credentials redacted, host/path preserved
581
- expect(response.content[0].text).toContain(
582
- 'postgres://***@/var/run/postgresql',
583
- )
584
- // No raw credentials anywhere in the error text
585
- expect(response.content[0].text).not.toContain('user:secret')
586
- })
587
-
588
- // ── TEST-18: redactConnectionString FAILS CLOSED — ?host= variant ───────
589
-
590
- it('redactConnectionString: redacts postgresql://user:secret@/tmp?host=/tmp (fail-closed)', async () => {
591
- // The alternate unix-socket form (`postgresql://user:secret@/tmp?host=/tmp`)
592
- // is also unparseable by `new URL()` — credentials must not leak, and the
593
- // query string is redacted too (→ ?***) as belt-and-braces.
594
- kb.chunkLearnedKnowledge.mockReturnValue(sampleChunks)
595
- kb.registerProject.mockResolvedValue({
596
- id: 'test-project',
597
- first_indexed_at: '2026-08-02T00:00:00Z',
598
- })
599
- kb.upsertChunks.mockRejectedValue(
600
- new Error('connect ECONNREFUSED postgresql://user:secret@/tmp?host=/tmp'),
601
- )
602
-
603
- const response = await mod.handleToolCall(
604
- buildRequest('knowledgebase_index', {
605
- projectId: 'test-project',
606
- content: markdownWithSession,
607
- }),
608
- )
609
-
610
- expect(response.isError).toBe(true)
611
- expect(response.content[0].text).toContain(
612
- 'postgresql://***@/tmp?***',
613
- )
614
- expect(response.content[0].text).not.toContain('user:secret')
615
- expect(response.content[0].text).not.toContain('host=/tmp')
616
- })
617
-
618
- // ── TEST-19: redactConnectionString redacts EVERY token ─────────────────
619
-
620
- it('redactConnectionString: redacts EVERY postgres:// token in a message', async () => {
621
- // A message containing multiple connection-string tokens must have all of
622
- // them redacted, not just the first (replace-all strategy).
623
- kb.chunkLearnedKnowledge.mockReturnValue(sampleChunks)
624
- kb.registerProject.mockResolvedValue({
625
- id: 'test-project',
626
- first_indexed_at: '2026-08-02T00:00:00Z',
627
- })
628
- kb.upsertChunks.mockRejectedValue(
629
- new Error(
630
- 'primary postgres://u1:p1@host1/db1 secondary postgres://u2:p2@host2/db2',
631
- ),
632
- )
633
-
634
- const response = await mod.handleToolCall(
635
- buildRequest('knowledgebase_index', {
636
- projectId: 'test-project',
637
- content: markdownWithSession,
638
- }),
639
- )
640
-
641
- expect(response.isError).toBe(true)
642
- expect(response.content[0].text).toContain('postgres://***@host1/db1')
643
- expect(response.content[0].text).toContain('postgres://***@host2/db2')
644
- expect(response.content[0].text).not.toContain('u1:p1')
645
- expect(response.content[0].text).not.toContain('u2:p2')
646
- })
647
-
648
- // ── TEST-20: search — whitespace-only projectId is dropped (match-all) ───
649
-
650
- it('knowledgebase_search: drops a whitespace-only projectId (match-all, graceful)', async () => {
651
- // A whitespace-only projectId is not a meaningful filter — it is dropped
652
- // to match-all (undefined), mirroring the INDEX handler's trim/validate
653
- // parity while staying graceful (no hard error for a read-only filter).
654
- kb.search.mockResolvedValue(sampleResults)
655
-
656
- const response = await mod.handleToolCall(
657
- buildRequest('knowledgebase_search', {
658
- query: 'test query',
659
- projectId: ' ',
660
- }),
661
- )
662
-
663
- expect(kb.search).toHaveBeenCalledWith('test query', {
664
- project_id: undefined,
665
- threshold: 0.1,
666
- limit: 5,
667
- })
668
- expect(response.isError).toBeUndefined()
669
- })
670
-
671
- // ── TEST-20b: search — surrounding whitespace trimmed from projectId ─────
672
-
673
- it('knowledgebase_search: trims surrounding whitespace from projectId before filtering', async () => {
674
- kb.search.mockResolvedValue(sampleResults)
675
-
676
- const response = await mod.handleToolCall(
677
- buildRequest('knowledgebase_search', {
678
- query: 'test query',
679
- projectId: ' test-project ',
680
- }),
681
- )
682
-
683
- expect(kb.search).toHaveBeenCalledWith('test query', {
684
- project_id: 'test-project',
685
- threshold: 0.1,
686
- limit: 5,
687
- })
688
- expect(response.isError).toBeUndefined()
689
- })
690
-
691
- // ── TEST-21: search — explicit limit 0 is honored (?? not ||) ────────────
692
-
693
- it('knowledgebase_search: honors an explicit limit of 0 (nullish coalescing)', async () => {
694
- // `args.limit || 5` would silently coerce an explicit 0 to 5; `?? 5`
695
- // preserves it so the engine receives limit: 0 (LIMIT 0).
696
- kb.search.mockResolvedValue(sampleResults)
697
-
698
- const response = await mod.handleToolCall(
699
- buildRequest('knowledgebase_search', {
700
- query: 'test query',
701
- limit: 0,
702
- }),
703
- )
704
-
705
- expect(kb.search).toHaveBeenCalledWith('test query', {
706
- project_id: undefined,
707
- threshold: 0.1,
708
- limit: 0,
709
- })
710
- expect(response.isError).toBeUndefined()
711
- })
712
-
713
- // ── TEST-22: redactConnectionString redacts query-string params ──────────
714
-
715
- it('redactConnectionString: redacts query-string params in URL tokens (belt-and-braces)', async () => {
716
- // A hypothetical ?password=... query param in a connection string must not
717
- // survive redaction — the entire query string is replaced with ?***.
718
- kb.chunkLearnedKnowledge.mockReturnValue(sampleChunks)
719
- kb.registerProject.mockResolvedValue({
720
- id: 'test-project',
721
- first_indexed_at: '2026-08-02T00:00:00Z',
722
- })
723
- kb.upsertChunks.mockRejectedValue(
724
- new Error(
725
- 'connect ECONNREFUSED postgres://user:secret@host:5432/kb?password=hunter2',
726
- ),
727
- )
728
-
729
- const response = await mod.handleToolCall(
730
- buildRequest('knowledgebase_index', {
731
- projectId: 'test-project',
732
- content: markdownWithSession,
733
- }),
734
- )
735
-
736
- expect(response.isError).toBe(true)
737
- // userinfo redacted and host/path preserved
738
- expect(response.content[0].text).toContain(
739
- 'postgres://***@host:5432/kb?***',
740
- )
741
- // neither the password value nor the query key survives
742
- expect(response.content[0].text).not.toContain('hunter2')
743
- expect(response.content[0].text).not.toContain('password=')
744
- })
745
- })