vellum 0.2.13 → 0.2.14

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 (207) hide show
  1. package/README.md +32 -0
  2. package/bun.lock +2 -2
  3. package/docs/skills.md +4 -4
  4. package/package.json +2 -2
  5. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +213 -3
  6. package/src/__tests__/app-git-history.test.ts +176 -0
  7. package/src/__tests__/app-git-service.test.ts +169 -0
  8. package/src/__tests__/assistant-events-sse-hardening.test.ts +315 -0
  9. package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +8 -8
  10. package/src/__tests__/browser-skill-endstate.test.ts +6 -6
  11. package/src/__tests__/call-bridge.test.ts +105 -13
  12. package/src/__tests__/call-domain.test.ts +163 -0
  13. package/src/__tests__/call-orchestrator.test.ts +113 -0
  14. package/src/__tests__/call-routes-http.test.ts +246 -6
  15. package/src/__tests__/channel-approval-routes.test.ts +438 -0
  16. package/src/__tests__/channel-approval.test.ts +266 -0
  17. package/src/__tests__/channel-approvals.test.ts +393 -0
  18. package/src/__tests__/channel-delivery-store.test.ts +447 -0
  19. package/src/__tests__/checker.test.ts +607 -1048
  20. package/src/__tests__/cli.test.ts +1 -56
  21. package/src/__tests__/config-schema.test.ts +137 -18
  22. package/src/__tests__/conflict-intent-tokenization.test.ts +141 -0
  23. package/src/__tests__/conflict-policy.test.ts +121 -0
  24. package/src/__tests__/conflict-store.test.ts +2 -0
  25. package/src/__tests__/contacts-tools.test.ts +3 -3
  26. package/src/__tests__/contradiction-checker.test.ts +99 -1
  27. package/src/__tests__/credential-security-invariants.test.ts +22 -6
  28. package/src/__tests__/credential-vault-unit.test.ts +780 -0
  29. package/src/__tests__/elevenlabs-client.test.ts +62 -0
  30. package/src/__tests__/ephemeral-permissions.test.ts +73 -23
  31. package/src/__tests__/filesystem-tools.test.ts +579 -0
  32. package/src/__tests__/gateway-only-enforcement.test.ts +114 -4
  33. package/src/__tests__/handlers-add-trust-rule-metadata.test.ts +202 -0
  34. package/src/__tests__/handlers-cu-observation-blob.test.ts +2 -1
  35. package/src/__tests__/handlers-ipc-blob-probe.test.ts +2 -1
  36. package/src/__tests__/handlers-slack-config.test.ts +2 -1
  37. package/src/__tests__/handlers-telegram-config.test.ts +855 -0
  38. package/src/__tests__/handlers-twitter-config.test.ts +141 -1
  39. package/src/__tests__/hooks-runner.test.ts +6 -2
  40. package/src/__tests__/host-file-edit-tool.test.ts +124 -0
  41. package/src/__tests__/host-file-read-tool.test.ts +62 -0
  42. package/src/__tests__/host-file-write-tool.test.ts +59 -0
  43. package/src/__tests__/host-shell-tool.test.ts +251 -0
  44. package/src/__tests__/ingress-reconcile.test.ts +581 -0
  45. package/src/__tests__/ipc-snapshot.test.ts +100 -41
  46. package/src/__tests__/ipc-validate.test.ts +50 -0
  47. package/src/__tests__/key-migration.test.ts +23 -0
  48. package/src/__tests__/memory-regressions.test.ts +99 -0
  49. package/src/__tests__/memory-retrieval.benchmark.test.ts +1 -1
  50. package/src/__tests__/oauth-callback-registry.test.ts +11 -4
  51. package/src/__tests__/playbook-execution.test.ts +502 -0
  52. package/src/__tests__/playbook-tools.test.ts +4 -6
  53. package/src/__tests__/public-ingress-urls.test.ts +34 -0
  54. package/src/__tests__/qdrant-manager.test.ts +267 -0
  55. package/src/__tests__/recurrence-engine-rruleset.test.ts +97 -0
  56. package/src/__tests__/recurrence-engine.test.ts +9 -0
  57. package/src/__tests__/recurrence-types.test.ts +8 -0
  58. package/src/__tests__/registry.test.ts +1 -1
  59. package/src/__tests__/runtime-runs.test.ts +1 -25
  60. package/src/__tests__/schedule-store.test.ts +16 -14
  61. package/src/__tests__/schedule-tools.test.ts +83 -0
  62. package/src/__tests__/scheduler-recurrence.test.ts +111 -10
  63. package/src/__tests__/secret-allowlist.test.ts +18 -17
  64. package/src/__tests__/secret-ingress-handler.test.ts +11 -0
  65. package/src/__tests__/secret-scanner.test.ts +43 -0
  66. package/src/__tests__/session-conflict-gate.test.ts +442 -6
  67. package/src/__tests__/session-init.benchmark.test.ts +3 -0
  68. package/src/__tests__/session-process-bridge.test.ts +242 -0
  69. package/src/__tests__/session-skill-tools.test.ts +1 -1
  70. package/src/__tests__/shell-identity.test.ts +256 -0
  71. package/src/__tests__/skill-projection.benchmark.test.ts +11 -1
  72. package/src/__tests__/subagent-tools.test.ts +637 -54
  73. package/src/__tests__/task-management-tools.test.ts +936 -0
  74. package/src/__tests__/task-runner.test.ts +2 -2
  75. package/src/__tests__/terminal-tools.test.ts +840 -0
  76. package/src/__tests__/tool-executor-shell-integration.test.ts +301 -0
  77. package/src/__tests__/tool-executor.test.ts +85 -151
  78. package/src/__tests__/tool-permission-simulate-handler.test.ts +336 -0
  79. package/src/__tests__/trust-store.test.ts +27 -453
  80. package/src/__tests__/twilio-provider.test.ts +153 -3
  81. package/src/__tests__/twilio-routes-elevenlabs.test.ts +375 -0
  82. package/src/__tests__/twilio-routes-twiml.test.ts +4 -4
  83. package/src/__tests__/twilio-routes.test.ts +17 -262
  84. package/src/__tests__/twitter-auth-handler.test.ts +2 -1
  85. package/src/__tests__/twitter-cli-error-shaping.test.ts +208 -0
  86. package/src/__tests__/twitter-cli-routing.test.ts +252 -0
  87. package/src/__tests__/twitter-oauth-client.test.ts +209 -0
  88. package/src/__tests__/workspace-policy.test.ts +213 -0
  89. package/src/calls/call-bridge.ts +92 -19
  90. package/src/calls/call-domain.ts +157 -5
  91. package/src/calls/call-orchestrator.ts +93 -7
  92. package/src/calls/call-store.ts +6 -0
  93. package/src/calls/elevenlabs-client.ts +8 -0
  94. package/src/calls/elevenlabs-config.ts +7 -5
  95. package/src/calls/twilio-provider.ts +91 -0
  96. package/src/calls/twilio-routes.ts +32 -37
  97. package/src/calls/types.ts +3 -1
  98. package/src/calls/voice-quality.ts +29 -7
  99. package/src/cli/twitter.ts +200 -21
  100. package/src/cli.ts +1 -20
  101. package/src/config/bundled-skills/contacts/tools/contact-merge.ts +52 -4
  102. package/src/config/bundled-skills/contacts/tools/contact-search.ts +55 -4
  103. package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +61 -4
  104. package/src/config/bundled-skills/messaging/SKILL.md +17 -2
  105. package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +4 -1
  106. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
  107. package/src/config/bundled-skills/messaging/tools/shared.ts +5 -0
  108. package/src/config/bundled-skills/phone-calls/SKILL.md +142 -34
  109. package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +95 -6
  110. package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +51 -6
  111. package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +73 -6
  112. package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +110 -6
  113. package/src/config/bundled-skills/public-ingress/SKILL.md +22 -5
  114. package/src/config/bundled-skills/twitter/SKILL.md +103 -17
  115. package/src/config/defaults.ts +10 -4
  116. package/src/config/schema.ts +80 -21
  117. package/src/config/types.ts +1 -0
  118. package/src/config/vellum-skills/telegram-setup/SKILL.md +56 -61
  119. package/src/daemon/assistant-attachments.ts +4 -2
  120. package/src/daemon/handlers/apps.ts +69 -0
  121. package/src/daemon/handlers/config.ts +543 -24
  122. package/src/daemon/handlers/index.ts +1 -0
  123. package/src/daemon/handlers/sessions.ts +22 -6
  124. package/src/daemon/handlers/shared.ts +2 -1
  125. package/src/daemon/handlers/skills.ts +5 -20
  126. package/src/daemon/ipc-contract-inventory.json +28 -0
  127. package/src/daemon/ipc-contract.ts +168 -10
  128. package/src/daemon/ipc-validate.ts +17 -0
  129. package/src/daemon/lifecycle.ts +2 -0
  130. package/src/daemon/server.ts +78 -72
  131. package/src/daemon/session-attachments.ts +1 -1
  132. package/src/daemon/session-conflict-gate.ts +62 -6
  133. package/src/daemon/session-notifiers.ts +1 -1
  134. package/src/daemon/session-process.ts +62 -3
  135. package/src/daemon/session-tool-setup.ts +1 -2
  136. package/src/daemon/tls-certs.ts +189 -0
  137. package/src/daemon/video-thumbnail.ts +5 -3
  138. package/src/hooks/manager.ts +5 -9
  139. package/src/memory/app-git-service.ts +295 -0
  140. package/src/memory/app-store.ts +21 -0
  141. package/src/memory/conflict-intent.ts +47 -4
  142. package/src/memory/conflict-policy.ts +73 -0
  143. package/src/memory/conflict-store.ts +9 -1
  144. package/src/memory/contradiction-checker.ts +28 -0
  145. package/src/memory/conversation-key-store.ts +15 -0
  146. package/src/memory/db.ts +81 -0
  147. package/src/memory/embedding-local.ts +3 -13
  148. package/src/memory/external-conversation-store.ts +234 -0
  149. package/src/memory/job-handlers/conflict.ts +22 -2
  150. package/src/memory/jobs-worker.ts +67 -28
  151. package/src/memory/runs-store.ts +54 -7
  152. package/src/memory/schema.ts +20 -0
  153. package/src/messaging/provider.ts +9 -0
  154. package/src/messaging/providers/telegram-bot/adapter.ts +162 -0
  155. package/src/messaging/providers/telegram-bot/client.ts +104 -0
  156. package/src/messaging/providers/telegram-bot/types.ts +15 -0
  157. package/src/messaging/registry.ts +1 -0
  158. package/src/permissions/checker.ts +48 -44
  159. package/src/permissions/prompter.ts +0 -4
  160. package/src/permissions/shell-identity.ts +227 -0
  161. package/src/permissions/trust-store.ts +76 -53
  162. package/src/permissions/types.ts +0 -19
  163. package/src/permissions/workspace-policy.ts +114 -0
  164. package/src/providers/retry.ts +12 -37
  165. package/src/runtime/assistant-event-hub.ts +41 -4
  166. package/src/runtime/channel-approval-parser.ts +60 -0
  167. package/src/runtime/channel-approval-types.ts +71 -0
  168. package/src/runtime/channel-approvals.ts +145 -0
  169. package/src/runtime/gateway-client.ts +16 -0
  170. package/src/runtime/http-server.ts +29 -9
  171. package/src/runtime/routes/call-routes.ts +52 -2
  172. package/src/runtime/routes/channel-routes.ts +296 -16
  173. package/src/runtime/routes/events-routes.ts +97 -28
  174. package/src/runtime/routes/run-routes.ts +2 -7
  175. package/src/runtime/run-orchestrator.ts +0 -3
  176. package/src/schedule/recurrence-engine.ts +26 -2
  177. package/src/schedule/recurrence-types.ts +1 -1
  178. package/src/schedule/schedule-store.ts +12 -3
  179. package/src/security/secret-scanner.ts +7 -0
  180. package/src/tasks/ephemeral-permissions.ts +0 -2
  181. package/src/tasks/task-scheduler.ts +2 -1
  182. package/src/tools/calls/call-start.ts +8 -0
  183. package/src/tools/execution-target.ts +21 -0
  184. package/src/tools/execution-timeout.ts +49 -0
  185. package/src/tools/executor.ts +6 -135
  186. package/src/tools/network/web-search.ts +9 -32
  187. package/src/tools/policy-context.ts +29 -0
  188. package/src/tools/schedule/update.ts +8 -1
  189. package/src/tools/terminal/parser.ts +16 -18
  190. package/src/tools/types.ts +4 -11
  191. package/src/twitter/oauth-client.ts +102 -0
  192. package/src/twitter/router.ts +101 -0
  193. package/src/util/debounce.ts +88 -0
  194. package/src/util/network-info.ts +47 -0
  195. package/src/util/platform.ts +29 -4
  196. package/src/util/promise-guard.ts +37 -0
  197. package/src/util/retry.ts +98 -0
  198. package/src/util/truncate.ts +1 -1
  199. package/src/workspace/git-service.ts +129 -112
  200. package/src/tools/contacts/contact-merge.ts +0 -55
  201. package/src/tools/contacts/contact-search.ts +0 -58
  202. package/src/tools/contacts/contact-upsert.ts +0 -64
  203. package/src/tools/playbooks/index.ts +0 -4
  204. package/src/tools/playbooks/playbook-create.ts +0 -96
  205. package/src/tools/playbooks/playbook-delete.ts +0 -52
  206. package/src/tools/playbooks/playbook-list.ts +0 -74
  207. package/src/tools/playbooks/playbook-update.ts +0 -111
@@ -0,0 +1,936 @@
1
+ import { describe, test, expect, beforeEach, afterAll, mock } from 'bun:test';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ const testDir = mkdtempSync(join(tmpdir(), 'task-mgmt-test-'));
7
+
8
+ mock.module('../util/platform.js', () => ({
9
+ getDataDir: () => testDir,
10
+ isMacOS: () => process.platform === 'darwin',
11
+ isLinux: () => process.platform === 'linux',
12
+ isWindows: () => process.platform === 'win32',
13
+ getSocketPath: () => join(testDir, 'test.sock'),
14
+ getPidPath: () => join(testDir, 'test.pid'),
15
+ getDbPath: () => join(testDir, 'test.db'),
16
+ getLogPath: () => join(testDir, 'test.log'),
17
+ ensureDataDir: () => {},
18
+ migrateToDataLayout: () => {},
19
+ migrateToWorkspaceLayout: () => {},
20
+ }));
21
+
22
+ mock.module('../util/logger.js', () => ({
23
+ getLogger: () => new Proxy({} as Record<string, unknown>, {
24
+ get: () => () => {},
25
+ }),
26
+ }));
27
+
28
+ mock.module('../config/loader.js', () => ({
29
+ getConfig: () => ({ memory: {} }),
30
+ }));
31
+
32
+ mock.module('../tools/registry.js', () => ({
33
+ registerTool: () => {},
34
+ getTool: () => undefined,
35
+ getAllTools: () => [],
36
+ }));
37
+
38
+ import { initializeDb, getDb, resetDb } from '../memory/db.js';
39
+ import { createTask, getTask, listTasks, deleteTask, deleteTasks, createTaskRun, getTaskRun, updateTaskRun } from '../tasks/task-store.js';
40
+ import { createWorkItem, getWorkItem, listWorkItems, updateWorkItem, deleteWorkItem, removeWorkItemFromQueue, resolveWorkItem, findActiveWorkItemsByTitle, findActiveWorkItemsByTaskId, identifyEntityById } from '../work-items/work-item-store.js';
41
+ import { renderTemplate } from '../tasks/task-runner.js';
42
+ import { executeTaskList } from '../tools/tasks/task-list.js';
43
+ import { executeTaskDelete } from '../tools/tasks/task-delete.js';
44
+ import { executeTaskRun } from '../tools/tasks/task-run.js';
45
+ import { executeTaskListShow } from '../tools/tasks/work-item-list.js';
46
+ import { executeTaskListAdd } from '../tools/tasks/work-item-enqueue.js';
47
+ import { executeTaskListUpdate } from '../tools/tasks/work-item-update.js';
48
+ import { executeTaskListRemove } from '../tools/tasks/work-item-remove.js';
49
+ import type { ToolContext } from '../tools/types.js';
50
+
51
+ initializeDb();
52
+
53
+ afterAll(() => {
54
+ resetDb();
55
+ try { rmSync(testDir, { recursive: true }); } catch { /* best effort */ }
56
+ });
57
+
58
+ const ctx: ToolContext = {
59
+ workingDir: '/tmp',
60
+ sessionId: 'test-session',
61
+ conversationId: 'test-conversation',
62
+ };
63
+
64
+ function clearTables() {
65
+ const db = getDb();
66
+ db.run('DELETE FROM work_items');
67
+ db.run('DELETE FROM task_runs');
68
+ db.run('DELETE FROM tasks');
69
+ }
70
+
71
+ // ═══════════════════════════════════════════════════════════════════
72
+ // Task Store — CRUD
73
+ // ═══════════════════════════════════════════════════════════════════
74
+
75
+ describe('task-store CRUD', () => {
76
+ beforeEach(clearTables);
77
+
78
+ test('createTask returns task with generated id and defaults', () => {
79
+ const task = createTask({ title: 'My Task', template: 'do something' });
80
+ expect(task.id).toBeTruthy();
81
+ expect(task.title).toBe('My Task');
82
+ expect(task.template).toBe('do something');
83
+ expect(task.status).toBe('active');
84
+ expect(task.inputSchema).toBeNull();
85
+ expect(task.contextFlags).toBeNull();
86
+ expect(task.requiredTools).toBeNull();
87
+ expect(task.createdFromConversationId).toBeNull();
88
+ expect(task.createdAt).toBeGreaterThan(0);
89
+ expect(task.updatedAt).toBe(task.createdAt);
90
+ });
91
+
92
+ test('createTask with all optional fields', () => {
93
+ const task = createTask({
94
+ title: 'Full Task',
95
+ template: 'run {{file_path}}',
96
+ inputSchema: { type: 'object', properties: { file_path: { type: 'string' } } },
97
+ contextFlags: ['needs_network'],
98
+ requiredTools: ['bash', 'file_read'],
99
+ createdFromConversationId: 'conv-123',
100
+ });
101
+ expect(task.inputSchema).toBe(JSON.stringify({ type: 'object', properties: { file_path: { type: 'string' } } }));
102
+ expect(task.contextFlags).toBe(JSON.stringify(['needs_network']));
103
+ expect(task.requiredTools).toBe(JSON.stringify(['bash', 'file_read']));
104
+ expect(task.createdFromConversationId).toBe('conv-123');
105
+ });
106
+
107
+ test('getTask retrieves existing task', () => {
108
+ const created = createTask({ title: 'Get Me', template: 'hello' });
109
+ const fetched = getTask(created.id);
110
+ expect(fetched).toBeDefined();
111
+ expect(fetched!.id).toBe(created.id);
112
+ expect(fetched!.title).toBe('Get Me');
113
+ });
114
+
115
+ test('getTask returns undefined for non-existent id', () => {
116
+ expect(getTask('nonexistent-id')).toBeUndefined();
117
+ });
118
+
119
+ test('listTasks returns all tasks', () => {
120
+ createTask({ title: 'First', template: 'a' });
121
+ createTask({ title: 'Second', template: 'b' });
122
+ createTask({ title: 'Third', template: 'c' });
123
+ const all = listTasks();
124
+ expect(all).toHaveLength(3);
125
+ const titles = all.map(t => t.title).sort();
126
+ expect(titles).toEqual(['First', 'Second', 'Third']);
127
+ });
128
+
129
+ test('listTasks returns empty array when no tasks', () => {
130
+ expect(listTasks()).toHaveLength(0);
131
+ });
132
+
133
+ test('deleteTask removes a task and returns true', () => {
134
+ const task = createTask({ title: 'Delete Me', template: 'x' });
135
+ expect(deleteTask(task.id)).toBe(true);
136
+ expect(getTask(task.id)).toBeUndefined();
137
+ });
138
+
139
+ test('deleteTask returns false for non-existent id', () => {
140
+ expect(deleteTask('nonexistent')).toBe(false);
141
+ });
142
+
143
+ test('deleteTask cascades to work items and task runs', () => {
144
+ const task = createTask({ title: 'Cascade', template: 'y' });
145
+ const workItem = createWorkItem({ taskId: task.id, title: 'WI' });
146
+ const run = createTaskRun(task.id);
147
+
148
+ expect(getWorkItem(workItem.id)).toBeDefined();
149
+ expect(getTaskRun(run.id)).toBeDefined();
150
+
151
+ deleteTask(task.id);
152
+
153
+ expect(getWorkItem(workItem.id)).toBeUndefined();
154
+ expect(getTaskRun(run.id)).toBeUndefined();
155
+ });
156
+
157
+ test('deleteTasks removes multiple tasks', () => {
158
+ const t1 = createTask({ title: 'A', template: 'a' });
159
+ const t2 = createTask({ title: 'B', template: 'b' });
160
+ const t3 = createTask({ title: 'C', template: 'c' });
161
+ const count = deleteTasks([t1.id, t2.id]);
162
+ expect(count).toBe(2);
163
+ expect(getTask(t1.id)).toBeUndefined();
164
+ expect(getTask(t2.id)).toBeUndefined();
165
+ expect(getTask(t3.id)).toBeDefined();
166
+ });
167
+
168
+ test('deleteTasks with empty array returns 0', () => {
169
+ expect(deleteTasks([])).toBe(0);
170
+ });
171
+
172
+ test('deleteTasks with non-existent ids returns 0', () => {
173
+ expect(deleteTasks(['fake-1', 'fake-2'])).toBe(0);
174
+ });
175
+ });
176
+
177
+ // ═══════════════════════════════════════════════════════════════════
178
+ // TaskRun Store — CRUD
179
+ // ═══════════════════════════════════════════════════════════════════
180
+
181
+ describe('task-run store', () => {
182
+ beforeEach(clearTables);
183
+
184
+ test('createTaskRun creates a run with pending status', () => {
185
+ const task = createTask({ title: 'T', template: 't' });
186
+ const run = createTaskRun(task.id);
187
+ expect(run.id).toBeTruthy();
188
+ expect(run.taskId).toBe(task.id);
189
+ expect(run.status).toBe('pending');
190
+ expect(run.startedAt).toBeNull();
191
+ expect(run.finishedAt).toBeNull();
192
+ expect(run.error).toBeNull();
193
+ });
194
+
195
+ test('updateTaskRun modifies fields', () => {
196
+ const task = createTask({ title: 'T', template: 't' });
197
+ const run = createTaskRun(task.id);
198
+ const now = Date.now();
199
+ updateTaskRun(run.id, { status: 'running', startedAt: now, conversationId: 'conv-1' });
200
+ const updated = getTaskRun(run.id);
201
+ expect(updated!.status).toBe('running');
202
+ expect(updated!.startedAt).toBe(now);
203
+ expect(updated!.conversationId).toBe('conv-1');
204
+ });
205
+
206
+ test('getTaskRun returns undefined for non-existent id', () => {
207
+ expect(getTaskRun('nonexistent')).toBeUndefined();
208
+ });
209
+ });
210
+
211
+ // ═══════════════════════════════════════════════════════════════════
212
+ // Work Item Store — CRUD
213
+ // ═══════════════════════════════════════════════════════════════════
214
+
215
+ describe('work-item store CRUD', () => {
216
+ beforeEach(clearTables);
217
+
218
+ test('createWorkItem with defaults', () => {
219
+ const task = createTask({ title: 'T', template: 't' });
220
+ const item = createWorkItem({ taskId: task.id, title: 'Work Item' });
221
+ expect(item.id).toBeTruthy();
222
+ expect(item.taskId).toBe(task.id);
223
+ expect(item.title).toBe('Work Item');
224
+ expect(item.status).toBe('queued');
225
+ expect(item.priorityTier).toBe(1);
226
+ expect(item.notes).toBeNull();
227
+ expect(item.sortIndex).toBeNull();
228
+ });
229
+
230
+ test('createWorkItem with all options', () => {
231
+ const task = createTask({ title: 'T', template: 't' });
232
+ const item = createWorkItem({
233
+ taskId: task.id,
234
+ title: 'Full WI',
235
+ notes: 'Important',
236
+ priorityTier: 0,
237
+ sortIndex: 5,
238
+ requiredTools: JSON.stringify(['bash']),
239
+ });
240
+ expect(item.notes).toBe('Important');
241
+ expect(item.priorityTier).toBe(0);
242
+ expect(item.sortIndex).toBe(5);
243
+ expect(item.requiredTools).toBe(JSON.stringify(['bash']));
244
+ });
245
+
246
+ test('getWorkItem retrieves by id', () => {
247
+ const task = createTask({ title: 'T', template: 't' });
248
+ const item = createWorkItem({ taskId: task.id, title: 'WI' });
249
+ const fetched = getWorkItem(item.id);
250
+ expect(fetched).toBeDefined();
251
+ expect(fetched!.id).toBe(item.id);
252
+ });
253
+
254
+ test('getWorkItem returns undefined for missing id', () => {
255
+ expect(getWorkItem('missing')).toBeUndefined();
256
+ });
257
+
258
+ test('listWorkItems returns all items ordered by priority then sortIndex then updatedAt', () => {
259
+ const task = createTask({ title: 'T', template: 't' });
260
+ createWorkItem({ taskId: task.id, title: 'Low', priorityTier: 2 });
261
+ createWorkItem({ taskId: task.id, title: 'High', priorityTier: 0 });
262
+ createWorkItem({ taskId: task.id, title: 'Medium', priorityTier: 1 });
263
+ const items = listWorkItems();
264
+ expect(items).toHaveLength(3);
265
+ expect(items[0].title).toBe('High');
266
+ expect(items[1].title).toBe('Medium');
267
+ expect(items[2].title).toBe('Low');
268
+ });
269
+
270
+ test('listWorkItems filters by status', () => {
271
+ const task = createTask({ title: 'T', template: 't' });
272
+ createWorkItem({ taskId: task.id, title: 'Queued' });
273
+ const running = createWorkItem({ taskId: task.id, title: 'Running' });
274
+ updateWorkItem(running.id, { status: 'running' });
275
+ const queued = listWorkItems({ status: 'queued' });
276
+ expect(queued).toHaveLength(1);
277
+ expect(queued[0].title).toBe('Queued');
278
+ });
279
+
280
+ test('updateWorkItem modifies fields and returns updated item', () => {
281
+ const task = createTask({ title: 'T', template: 't' });
282
+ const item = createWorkItem({ taskId: task.id, title: 'WI' });
283
+ const updated = updateWorkItem(item.id, { notes: 'Updated notes', priorityTier: 0 });
284
+ expect(updated).toBeDefined();
285
+ expect(updated!.notes).toBe('Updated notes');
286
+ expect(updated!.priorityTier).toBe(0);
287
+ });
288
+
289
+ test('deleteWorkItem removes the item', () => {
290
+ const task = createTask({ title: 'T', template: 't' });
291
+ const item = createWorkItem({ taskId: task.id, title: 'WI' });
292
+ deleteWorkItem(item.id);
293
+ expect(getWorkItem(item.id)).toBeUndefined();
294
+ });
295
+
296
+ test('removeWorkItemFromQueue succeeds for existing item', () => {
297
+ const task = createTask({ title: 'T', template: 't' });
298
+ const item = createWorkItem({ taskId: task.id, title: 'Remove Me' });
299
+ const result = removeWorkItemFromQueue(item.id);
300
+ expect(result.success).toBe(true);
301
+ expect(result.title).toBe('Remove Me');
302
+ expect(getWorkItem(item.id)).toBeUndefined();
303
+ });
304
+
305
+ test('removeWorkItemFromQueue fails for non-existent item', () => {
306
+ const result = removeWorkItemFromQueue('fake-id');
307
+ expect(result.success).toBe(false);
308
+ });
309
+ });
310
+
311
+ // ═══════════════════════════════════════════════════════════════════
312
+ // Work Item Selectors
313
+ // ═══════════════════════════════════════════════════════════════════
314
+
315
+ describe('work-item resolveWorkItem', () => {
316
+ beforeEach(clearTables);
317
+
318
+ test('resolves by workItemId', () => {
319
+ const task = createTask({ title: 'T', template: 't' });
320
+ const item = createWorkItem({ taskId: task.id, title: 'WI' });
321
+ const result = resolveWorkItem({ workItemId: item.id });
322
+ expect(result.status).toBe('found');
323
+ if (result.status === 'found') {
324
+ expect(result.workItem.id).toBe(item.id);
325
+ }
326
+ });
327
+
328
+ test('resolves by taskId when single match', () => {
329
+ const task = createTask({ title: 'T', template: 't' });
330
+ const item = createWorkItem({ taskId: task.id, title: 'WI' });
331
+ const result = resolveWorkItem({ taskId: task.id });
332
+ expect(result.status).toBe('found');
333
+ if (result.status === 'found') {
334
+ expect(result.workItem.id).toBe(item.id);
335
+ }
336
+ });
337
+
338
+ test('resolves by title (case-insensitive exact match)', () => {
339
+ const task = createTask({ title: 'T', template: 't' });
340
+ createWorkItem({ taskId: task.id, title: 'My Task' });
341
+ const result = resolveWorkItem({ title: 'my task' });
342
+ expect(result.status).toBe('found');
343
+ });
344
+
345
+ test('returns not_found for non-existent workItemId', () => {
346
+ const result = resolveWorkItem({ workItemId: 'nonexistent' });
347
+ expect(result.status).toBe('not_found');
348
+ });
349
+
350
+ test('returns not_found for done items looked up by workItemId', () => {
351
+ const task = createTask({ title: 'T', template: 't' });
352
+ const item = createWorkItem({ taskId: task.id, title: 'WI' });
353
+ updateWorkItem(item.id, { status: 'done' });
354
+ const result = resolveWorkItem({ workItemId: item.id });
355
+ expect(result.status).toBe('not_found');
356
+ });
357
+
358
+ test('returns not_found when no selector fields provided', () => {
359
+ const result = resolveWorkItem({});
360
+ expect(result.status).toBe('not_found');
361
+ });
362
+
363
+ test('returns ambiguous when multiple items match taskId', () => {
364
+ const task = createTask({ title: 'T', template: 't' });
365
+ createWorkItem({ taskId: task.id, title: 'WI 1' });
366
+ createWorkItem({ taskId: task.id, title: 'WI 2' });
367
+ const result = resolveWorkItem({ taskId: task.id });
368
+ expect(result.status).toBe('ambiguous');
369
+ });
370
+
371
+ test('disambiguates by priorityTier', () => {
372
+ const task = createTask({ title: 'T', template: 't' });
373
+ createWorkItem({ taskId: task.id, title: 'WI High', priorityTier: 0 });
374
+ createWorkItem({ taskId: task.id, title: 'WI Low', priorityTier: 2 });
375
+ const result = resolveWorkItem({ taskId: task.id, priorityTier: 0 });
376
+ expect(result.status).toBe('found');
377
+ if (result.status === 'found') {
378
+ expect(result.workItem.title).toBe('WI High');
379
+ }
380
+ });
381
+
382
+ test('disambiguates by status', () => {
383
+ const task = createTask({ title: 'T', template: 't' });
384
+ const _item1 = createWorkItem({ taskId: task.id, title: 'Same' });
385
+ const item2 = createWorkItem({ taskId: task.id, title: 'Same' });
386
+ updateWorkItem(item2.id, { status: 'running' });
387
+ const result = resolveWorkItem({ title: 'Same', status: 'running' });
388
+ expect(result.status).toBe('found');
389
+ if (result.status === 'found') {
390
+ expect(result.workItem.id).toBe(item2.id);
391
+ }
392
+ });
393
+ });
394
+
395
+ describe('findActiveWorkItemsByTitle', () => {
396
+ beforeEach(clearTables);
397
+
398
+ test('finds items with matching title (case-insensitive)', () => {
399
+ const task = createTask({ title: 'T', template: 't' });
400
+ createWorkItem({ taskId: task.id, title: 'Build App' });
401
+ const results = findActiveWorkItemsByTitle('build app');
402
+ expect(results).toHaveLength(1);
403
+ });
404
+
405
+ test('excludes done and archived items', () => {
406
+ const task = createTask({ title: 'T', template: 't' });
407
+ const item = createWorkItem({ taskId: task.id, title: 'Build App' });
408
+ updateWorkItem(item.id, { status: 'done' });
409
+ const results = findActiveWorkItemsByTitle('Build App');
410
+ expect(results).toHaveLength(0);
411
+ });
412
+ });
413
+
414
+ describe('findActiveWorkItemsByTaskId', () => {
415
+ beforeEach(clearTables);
416
+
417
+ test('finds active items for a task', () => {
418
+ const task = createTask({ title: 'T', template: 't' });
419
+ createWorkItem({ taskId: task.id, title: 'WI1' });
420
+ createWorkItem({ taskId: task.id, title: 'WI2' });
421
+ const results = findActiveWorkItemsByTaskId(task.id);
422
+ expect(results).toHaveLength(2);
423
+ });
424
+
425
+ test('excludes done items', () => {
426
+ const task = createTask({ title: 'T', template: 't' });
427
+ const item = createWorkItem({ taskId: task.id, title: 'WI1' });
428
+ updateWorkItem(item.id, { status: 'done' });
429
+ const results = findActiveWorkItemsByTaskId(task.id);
430
+ expect(results).toHaveLength(0);
431
+ });
432
+ });
433
+
434
+ describe('identifyEntityById', () => {
435
+ beforeEach(clearTables);
436
+
437
+ test('identifies a task template', () => {
438
+ const task = createTask({ title: 'My Template', template: 't' });
439
+ const entity = identifyEntityById(task.id);
440
+ expect(entity.type).toBe('task_template');
441
+ expect(entity.title).toBe('My Template');
442
+ });
443
+
444
+ test('identifies a work item', () => {
445
+ const task = createTask({ title: 'T', template: 't' });
446
+ const item = createWorkItem({ taskId: task.id, title: 'My Work Item' });
447
+ const entity = identifyEntityById(item.id);
448
+ expect(entity.type).toBe('work_item');
449
+ expect(entity.title).toBe('My Work Item');
450
+ });
451
+
452
+ test('returns unknown for non-existent id', () => {
453
+ const entity = identifyEntityById('nonexistent');
454
+ expect(entity.type).toBe('unknown');
455
+ });
456
+ });
457
+
458
+ // ═══════════════════════════════════════════════════════════════════
459
+ // renderTemplate
460
+ // ═══════════════════════════════════════════════════════════════════
461
+
462
+ describe('renderTemplate', () => {
463
+ test('replaces known placeholders', () => {
464
+ expect(renderTemplate('Hello {{name}}', { name: 'World' })).toBe('Hello World');
465
+ });
466
+
467
+ test('leaves unknown placeholders unchanged', () => {
468
+ expect(renderTemplate('{{unknown}} text', {})).toBe('{{unknown}} text');
469
+ });
470
+
471
+ test('handles multiple placeholders', () => {
472
+ const result = renderTemplate('{{a}} and {{b}}', { a: 'X', b: 'Y' });
473
+ expect(result).toBe('X and Y');
474
+ });
475
+
476
+ test('handles template with no placeholders', () => {
477
+ expect(renderTemplate('plain text', {})).toBe('plain text');
478
+ });
479
+
480
+ test('handles empty template', () => {
481
+ expect(renderTemplate('', {})).toBe('');
482
+ });
483
+ });
484
+
485
+ // ═══════════════════════════════════════════════════════════════════
486
+ // Tool: executeTaskList
487
+ // ═══════════════════════════════════════════════════════════════════
488
+
489
+ describe('executeTaskList tool', () => {
490
+ beforeEach(clearTables);
491
+
492
+ test('returns message when no tasks exist', async () => {
493
+ const result = await executeTaskList({}, ctx);
494
+ expect(result.isError).toBe(false);
495
+ expect(result.content).toContain('No task templates found');
496
+ });
497
+
498
+ test('lists existing tasks', async () => {
499
+ createTask({ title: 'Task Alpha', template: 'alpha template' });
500
+ createTask({ title: 'Task Beta', template: 'beta template', requiredTools: ['bash'] });
501
+ const result = await executeTaskList({}, ctx);
502
+ expect(result.isError).toBe(false);
503
+ expect(result.content).toContain('2 task template(s)');
504
+ expect(result.content).toContain('Task Alpha');
505
+ expect(result.content).toContain('Task Beta');
506
+ expect(result.content).toContain('bash');
507
+ });
508
+
509
+ test('shows input schema properties', async () => {
510
+ createTask({
511
+ title: 'With Schema',
512
+ template: '{{file_path}}',
513
+ inputSchema: { type: 'object', properties: { file_path: { type: 'string' } } },
514
+ });
515
+ const result = await executeTaskList({}, ctx);
516
+ expect(result.content).toContain('file_path');
517
+ });
518
+ });
519
+
520
+ // ═══════════════════════════════════════════════════════════════════
521
+ // Tool: executeTaskDelete
522
+ // ═══════════════════════════════════════════════════════════════════
523
+
524
+ describe('executeTaskDelete tool', () => {
525
+ beforeEach(clearTables);
526
+
527
+ test('rejects missing task_ids', async () => {
528
+ const result = await executeTaskDelete({}, ctx);
529
+ expect(result.isError).toBe(true);
530
+ expect(result.content).toContain('task_ids must be a non-empty array');
531
+ });
532
+
533
+ test('rejects empty array', async () => {
534
+ const result = await executeTaskDelete({ task_ids: [] }, ctx);
535
+ expect(result.isError).toBe(true);
536
+ });
537
+
538
+ test('rejects array of non-strings', async () => {
539
+ const result = await executeTaskDelete({ task_ids: [123, null] }, ctx);
540
+ expect(result.isError).toBe(true);
541
+ expect(result.content).toContain('at least one non-empty string');
542
+ });
543
+
544
+ test('deletes a single task', async () => {
545
+ const task = createTask({ title: 'Delete Target', template: 'x' });
546
+ const result = await executeTaskDelete({ task_ids: [task.id] }, ctx);
547
+ expect(result.isError).toBe(false);
548
+ expect(result.content).toContain('Deleted task');
549
+ expect(result.content).toContain('Delete Target');
550
+ expect(getTask(task.id)).toBeUndefined();
551
+ });
552
+
553
+ test('returns error for non-existent task id (single)', async () => {
554
+ const result = await executeTaskDelete({ task_ids: ['nonexistent'] }, ctx);
555
+ expect(result.isError).toBe(true);
556
+ expect(result.content).toContain('No task template or work item found');
557
+ });
558
+
559
+ test('falls back to work item removal for single id', async () => {
560
+ const task = createTask({ title: 'T', template: 't' });
561
+ const item = createWorkItem({ taskId: task.id, title: 'Fallback WI' });
562
+ const result = await executeTaskDelete({ task_ids: [item.id] }, ctx);
563
+ expect(result.isError).toBe(false);
564
+ expect(result.content).toContain('Removed');
565
+ expect(getWorkItem(item.id)).toBeUndefined();
566
+ });
567
+
568
+ test('deletes multiple tasks in batch', async () => {
569
+ const t1 = createTask({ title: 'Batch A', template: 'a' });
570
+ const t2 = createTask({ title: 'Batch B', template: 'b' });
571
+ const result = await executeTaskDelete({ task_ids: [t1.id, t2.id] }, ctx);
572
+ expect(result.isError).toBe(false);
573
+ expect(result.content).toContain('Deleted 2 task(s)');
574
+ });
575
+
576
+ test('batch delete with no matches returns error', async () => {
577
+ const result = await executeTaskDelete({ task_ids: ['fake1', 'fake2'] }, ctx);
578
+ expect(result.isError).toBe(true);
579
+ expect(result.content).toContain('No matching tasks found');
580
+ });
581
+ });
582
+
583
+ // ═══════════════════════════════════════════════════════════════════
584
+ // Tool: executeTaskRun
585
+ // ═══════════════════════════════════════════════════════════════════
586
+
587
+ describe('executeTaskRun tool', () => {
588
+ beforeEach(clearTables);
589
+
590
+ test('rejects when neither task_name nor task_id provided', async () => {
591
+ const result = await executeTaskRun({}, ctx);
592
+ expect(result.isError).toBe(true);
593
+ expect(result.content).toContain('At least one of task_name or task_id');
594
+ });
595
+
596
+ test('resolves by task_id and renders template', async () => {
597
+ const task = createTask({ title: 'Greet', template: 'Hello {{name}}' });
598
+ const result = await executeTaskRun({ task_id: task.id, inputs: { name: 'World' } }, ctx);
599
+ expect(result.isError).toBe(false);
600
+ expect(result.content).toContain('Hello World');
601
+ expect(result.content).toContain('Greet');
602
+ });
603
+
604
+ test('resolves by task_name (case-insensitive substring)', async () => {
605
+ createTask({ title: 'Deploy Application', template: 'deploying...' });
606
+ const result = await executeTaskRun({ task_name: 'deploy' }, ctx);
607
+ expect(result.isError).toBe(false);
608
+ expect(result.content).toContain('deploying...');
609
+ });
610
+
611
+ test('returns error for non-existent task_id', async () => {
612
+ const result = await executeTaskRun({ task_id: 'nonexistent' }, ctx);
613
+ expect(result.isError).toBe(true);
614
+ expect(result.content).toContain('No task template found');
615
+ });
616
+
617
+ test('returns error for non-matching task_name', async () => {
618
+ createTask({ title: 'Alpha', template: 'a' });
619
+ const result = await executeTaskRun({ task_name: 'zzz' }, ctx);
620
+ expect(result.isError).toBe(true);
621
+ expect(result.content).toContain('No task template matching');
622
+ });
623
+
624
+ test('returns error for missing required inputs', async () => {
625
+ const task = createTask({
626
+ title: 'With Input',
627
+ template: '{{url}}',
628
+ inputSchema: { type: 'object', properties: { url: { type: 'string' } } },
629
+ });
630
+ const result = await executeTaskRun({ task_id: task.id }, ctx);
631
+ expect(result.isError).toBe(true);
632
+ expect(result.content).toContain('Missing required inputs');
633
+ expect(result.content).toContain('url');
634
+ });
635
+
636
+ test('includes required tools in output', async () => {
637
+ const task = createTask({ title: 'T', template: 't', requiredTools: ['bash', 'file_read'] });
638
+ const result = await executeTaskRun({ task_id: task.id }, ctx);
639
+ expect(result.isError).toBe(false);
640
+ expect(result.content).toContain('bash');
641
+ expect(result.content).toContain('file_read');
642
+ });
643
+ });
644
+
645
+ // ═══════════════════════════════════════════════════════════════════
646
+ // Tool: executeTaskListShow (work-item-list)
647
+ // ═══════════════════════════════════════════════════════════════════
648
+
649
+ describe('executeTaskListShow tool', () => {
650
+ beforeEach(clearTables);
651
+
652
+ test('shows empty message when no work items', async () => {
653
+ const result = await executeTaskListShow({}, ctx);
654
+ expect(result.isError).toBe(false);
655
+ expect(result.content).toContain('no tasks queued');
656
+ });
657
+
658
+ test('lists work items with priority labels', async () => {
659
+ const task = createTask({ title: 'T', template: 't' });
660
+ createWorkItem({ taskId: task.id, title: 'High Item', priorityTier: 0 });
661
+ createWorkItem({ taskId: task.id, title: 'Low Item', priorityTier: 2 });
662
+ const result = await executeTaskListShow({}, ctx);
663
+ expect(result.isError).toBe(false);
664
+ expect(result.content).toContain('2 items');
665
+ expect(result.content).toContain('High Item');
666
+ expect(result.content).toContain('Low Item');
667
+ });
668
+
669
+ test('filters by single status string', async () => {
670
+ const task = createTask({ title: 'T', template: 't' });
671
+ createWorkItem({ taskId: task.id, title: 'Queued' });
672
+ const running = createWorkItem({ taskId: task.id, title: 'Running' });
673
+ updateWorkItem(running.id, { status: 'running' });
674
+ const result = await executeTaskListShow({ status: 'running' }, ctx);
675
+ expect(result.isError).toBe(false);
676
+ expect(result.content).toContain('1 running item');
677
+ expect(result.content).toContain('Running');
678
+ });
679
+
680
+ test('filters by status array', async () => {
681
+ const task = createTask({ title: 'T', template: 't' });
682
+ createWorkItem({ taskId: task.id, title: 'Queued' });
683
+ const running = createWorkItem({ taskId: task.id, title: 'Running' });
684
+ updateWorkItem(running.id, { status: 'running' });
685
+ const failed = createWorkItem({ taskId: task.id, title: 'Failed' });
686
+ updateWorkItem(failed.id, { status: 'failed' });
687
+ const result = await executeTaskListShow({ status: ['running', 'failed'] }, ctx);
688
+ expect(result.isError).toBe(false);
689
+ expect(result.content).toContain('2 matching items');
690
+ });
691
+
692
+ test('shows no items matching filter', async () => {
693
+ const task = createTask({ title: 'T', template: 't' });
694
+ createWorkItem({ taskId: task.id, title: 'Queued' });
695
+ const result = await executeTaskListShow({ status: 'running' }, ctx);
696
+ expect(result.content).toContain('no items matching filter');
697
+ });
698
+ });
699
+
700
+ // ═══════════════════════════════════════════════════════════════════
701
+ // Tool: executeTaskListAdd (work-item-enqueue)
702
+ // ═══════════════════════════════════════════════════════════════════
703
+
704
+ describe('executeTaskListAdd tool', () => {
705
+ beforeEach(clearTables);
706
+
707
+ test('rejects when no identifiers provided', async () => {
708
+ const result = await executeTaskListAdd({}, ctx);
709
+ expect(result.isError).toBe(true);
710
+ expect(result.content).toContain('must provide either task_id, task_name, or title');
711
+ });
712
+
713
+ test('creates ad-hoc work item from title alone', async () => {
714
+ const result = await executeTaskListAdd({ title: 'Ad-hoc Task' }, ctx);
715
+ expect(result.isError).toBe(false);
716
+ expect(result.content).toContain('Enqueued work item');
717
+ expect(result.content).toContain('Ad-hoc Task');
718
+ const items = listWorkItems();
719
+ expect(items).toHaveLength(1);
720
+ expect(items[0].title).toBe('Ad-hoc Task');
721
+ });
722
+
723
+ test('ad-hoc task with notes and priority', async () => {
724
+ const result = await executeTaskListAdd({
725
+ title: 'Priority Task',
726
+ notes: 'Important task',
727
+ priority_tier: 0,
728
+ }, ctx);
729
+ expect(result.isError).toBe(false);
730
+ expect(result.content).toContain('high');
731
+ const items = listWorkItems();
732
+ expect(items[0].priorityTier).toBe(0);
733
+ expect(items[0].notes).toBe('Important task');
734
+ });
735
+
736
+ test('enqueues from existing task by task_id', async () => {
737
+ const task = createTask({ title: 'Template Task', template: 'do stuff' });
738
+ const result = await executeTaskListAdd({ task_id: task.id }, ctx);
739
+ expect(result.isError).toBe(false);
740
+ expect(result.content).toContain('Enqueued work item');
741
+ expect(result.content).toContain('Template Task');
742
+ });
743
+
744
+ test('enqueues from existing task by task_name', async () => {
745
+ createTask({ title: 'Deploy App', template: 'deploy' });
746
+ const result = await executeTaskListAdd({ task_name: 'deploy' }, ctx);
747
+ expect(result.isError).toBe(false);
748
+ expect(result.content).toContain('Enqueued work item');
749
+ expect(result.content).toContain('Deploy App');
750
+ });
751
+
752
+ test('returns error for non-existent task_id', async () => {
753
+ const result = await executeTaskListAdd({ task_id: 'nonexistent' }, ctx);
754
+ expect(result.isError).toBe(true);
755
+ expect(result.content).toContain('No task definition found');
756
+ });
757
+
758
+ test('returns error for non-matching task_name', async () => {
759
+ const result = await executeTaskListAdd({ task_name: 'zzz' }, ctx);
760
+ expect(result.isError).toBe(true);
761
+ expect(result.content).toContain('No task definition found matching');
762
+ });
763
+
764
+ test('detects duplicates and reuses existing by default (reuse_existing)', async () => {
765
+ const _task = createTask({ title: 'T', template: 't' });
766
+ await executeTaskListAdd({ title: 'Dup Item' }, ctx);
767
+ const result = await executeTaskListAdd({ title: 'Dup Item' }, ctx);
768
+ expect(result.isError).toBe(false);
769
+ expect(result.content).toContain('already exists');
770
+ // Only one work item should exist
771
+ const items = listWorkItems();
772
+ expect(items).toHaveLength(1);
773
+ });
774
+
775
+ test('creates duplicate when if_exists=create_duplicate', async () => {
776
+ await executeTaskListAdd({ title: 'Dup Item' }, ctx);
777
+ const result = await executeTaskListAdd({ title: 'Dup Item', if_exists: 'create_duplicate' }, ctx);
778
+ expect(result.isError).toBe(false);
779
+ expect(result.content).toContain('Enqueued work item');
780
+ const items = listWorkItems();
781
+ expect(items).toHaveLength(2);
782
+ });
783
+
784
+ test('update_existing modifies the existing item', async () => {
785
+ await executeTaskListAdd({ title: 'Update Target', priority_tier: 1 }, ctx);
786
+ const result = await executeTaskListAdd({
787
+ title: 'Update Target',
788
+ priority_tier: 0,
789
+ if_exists: 'update_existing',
790
+ }, ctx);
791
+ expect(result.isError).toBe(false);
792
+ expect(result.content).toContain('Reused existing task');
793
+ const items = listWorkItems();
794
+ expect(items).toHaveLength(1);
795
+ expect(items[0].priorityTier).toBe(0);
796
+ });
797
+
798
+ test('reports ambiguity when multiple tasks match task_name', async () => {
799
+ createTask({ title: 'Deploy Staging', template: 'a' });
800
+ createTask({ title: 'Deploy Production', template: 'b' });
801
+ const result = await executeTaskListAdd({ task_name: 'deploy' }, ctx);
802
+ expect(result.isError).toBe(true);
803
+ expect(result.content).toContain('Multiple task definitions match');
804
+ });
805
+
806
+ test('allows title override when using task_id', async () => {
807
+ const task = createTask({ title: 'Original', template: 'do' });
808
+ const result = await executeTaskListAdd({ task_id: task.id, title: 'Custom Title' }, ctx);
809
+ expect(result.isError).toBe(false);
810
+ expect(result.content).toContain('Custom Title');
811
+ });
812
+ });
813
+
814
+ // ═══════════════════════════════════════════════════════════════════
815
+ // Tool: executeTaskListUpdate (work-item-update)
816
+ // ═══════════════════════════════════════════════════════════════════
817
+
818
+ describe('executeTaskListUpdate tool', () => {
819
+ beforeEach(clearTables);
820
+
821
+ test('updates status of a work item', async () => {
822
+ const task = createTask({ title: 'T', template: 't' });
823
+ const item = createWorkItem({ taskId: task.id, title: 'Update Me' });
824
+ const result = await executeTaskListUpdate({ work_item_id: item.id, status: 'running' }, ctx);
825
+ expect(result.isError).toBe(false);
826
+ expect(result.content).toContain('status \u2192 running');
827
+ const updated = getWorkItem(item.id);
828
+ expect(updated!.status).toBe('running');
829
+ });
830
+
831
+ test('updates priority of a work item', async () => {
832
+ const task = createTask({ title: 'T', template: 't' });
833
+ const item = createWorkItem({ taskId: task.id, title: 'Update Me' });
834
+ const result = await executeTaskListUpdate({ work_item_id: item.id, priority_tier: 0 }, ctx);
835
+ expect(result.isError).toBe(false);
836
+ expect(result.content).toContain('priority \u2192 high');
837
+ });
838
+
839
+ test('updates notes', async () => {
840
+ const task = createTask({ title: 'T', template: 't' });
841
+ const item = createWorkItem({ taskId: task.id, title: 'Update Me' });
842
+ const result = await executeTaskListUpdate({ work_item_id: item.id, notes: 'New notes' }, ctx);
843
+ expect(result.isError).toBe(false);
844
+ expect(result.content).toContain('notes updated');
845
+ });
846
+
847
+ test('rejects direct transition to done status', async () => {
848
+ const task = createTask({ title: 'T', template: 't' });
849
+ const item = createWorkItem({ taskId: task.id, title: 'No Done' });
850
+ const result = await executeTaskListUpdate({ work_item_id: item.id, status: 'done' }, ctx);
851
+ expect(result.isError).toBe(true);
852
+ expect(result.content).toContain("Cannot set status to 'done' directly");
853
+ });
854
+
855
+ test('rejects update with no fields', async () => {
856
+ const task = createTask({ title: 'T', template: 't' });
857
+ const item = createWorkItem({ taskId: task.id, title: 'No Update' });
858
+ const result = await executeTaskListUpdate({ work_item_id: item.id }, ctx);
859
+ expect(result.isError).toBe(true);
860
+ expect(result.content).toContain('No updates specified');
861
+ });
862
+
863
+ test('returns error for non-existent work item', async () => {
864
+ const result = await executeTaskListUpdate({ work_item_id: 'nonexistent', status: 'running' }, ctx);
865
+ expect(result.isError).toBe(true);
866
+ });
867
+
868
+ test('resolves by title', async () => {
869
+ const task = createTask({ title: 'T', template: 't' });
870
+ createWorkItem({ taskId: task.id, title: 'Find Me By Title' });
871
+ const result = await executeTaskListUpdate({ title: 'Find Me By Title', status: 'running' }, ctx);
872
+ expect(result.isError).toBe(false);
873
+ expect(result.content).toContain('status \u2192 running');
874
+ });
875
+
876
+ test('reports entity mismatch when task template id used as work_item_id', async () => {
877
+ const task = createTask({ title: 'Template', template: 't' });
878
+ const result = await executeTaskListUpdate({ work_item_id: task.id, status: 'running' }, ctx);
879
+ expect(result.isError).toBe(true);
880
+ expect(result.content).toContain('Entity mismatch');
881
+ expect(result.content).toContain('task template');
882
+ });
883
+ });
884
+
885
+ // ═══════════════════════════════════════════════════════════════════
886
+ // Tool: executeTaskListRemove (work-item-remove)
887
+ // ═══════════════════════════════════════════════════════════════════
888
+
889
+ describe('executeTaskListRemove tool', () => {
890
+ beforeEach(clearTables);
891
+
892
+ test('removes work item by work_item_id', async () => {
893
+ const task = createTask({ title: 'T', template: 't' });
894
+ const item = createWorkItem({ taskId: task.id, title: 'Remove This' });
895
+ const result = await executeTaskListRemove({ work_item_id: item.id }, ctx);
896
+ expect(result.isError).toBe(false);
897
+ expect(result.content).toContain('Removed');
898
+ expect(result.content).toContain('Remove This');
899
+ expect(getWorkItem(item.id)).toBeUndefined();
900
+ });
901
+
902
+ test('removes work item by title', async () => {
903
+ const task = createTask({ title: 'T', template: 't' });
904
+ createWorkItem({ taskId: task.id, title: 'Remove By Name' });
905
+ const result = await executeTaskListRemove({ title: 'Remove By Name' }, ctx);
906
+ expect(result.isError).toBe(false);
907
+ expect(result.content).toContain('Removed');
908
+ });
909
+
910
+ test('returns error for non-existent work item', async () => {
911
+ const result = await executeTaskListRemove({ work_item_id: 'nonexistent' }, ctx);
912
+ expect(result.isError).toBe(true);
913
+ });
914
+
915
+ test('returns error when no selector provided', async () => {
916
+ const result = await executeTaskListRemove({}, ctx);
917
+ expect(result.isError).toBe(true);
918
+ });
919
+
920
+ test('reports entity mismatch when task template id used', async () => {
921
+ const task = createTask({ title: 'Template Not WI', template: 't' });
922
+ const result = await executeTaskListRemove({ work_item_id: task.id }, ctx);
923
+ expect(result.isError).toBe(true);
924
+ expect(result.content).toContain('Entity mismatch');
925
+ expect(result.content).toContain('task template');
926
+ });
927
+
928
+ test('reports ambiguity when multiple items match', async () => {
929
+ const task = createTask({ title: 'T', template: 't' });
930
+ createWorkItem({ taskId: task.id, title: 'Ambiguous' });
931
+ createWorkItem({ taskId: task.id, title: 'Ambiguous' });
932
+ const result = await executeTaskListRemove({ title: 'Ambiguous' }, ctx);
933
+ expect(result.isError).toBe(true);
934
+ expect(result.content).toContain('Multiple items match');
935
+ });
936
+ });