oh-my-claude-sisyphus 3.7.15 → 3.7.16
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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +7 -4
- package/bridge/mcp-server.cjs +57 -1
- package/dist/__tests__/hooks.test.js +32 -16
- package/dist/__tests__/hooks.test.js.map +1 -1
- package/dist/__tests__/installer.test.js +1 -1
- package/dist/__tests__/installer.test.js.map +1 -1
- package/dist/__tests__/lsp-servers.test.d.ts +2 -0
- package/dist/__tests__/lsp-servers.test.d.ts.map +1 -0
- package/dist/__tests__/lsp-servers.test.js +118 -0
- package/dist/__tests__/lsp-servers.test.js.map +1 -0
- package/dist/__tests__/task-continuation.test.d.ts +2 -0
- package/dist/__tests__/task-continuation.test.d.ts.map +1 -0
- package/dist/__tests__/task-continuation.test.js +740 -0
- package/dist/__tests__/task-continuation.test.js.map +1 -0
- package/dist/hooks/persistent-mode/index.d.ts.map +1 -1
- package/dist/hooks/persistent-mode/index.js +6 -3
- package/dist/hooks/persistent-mode/index.js.map +1 -1
- package/dist/hooks/todo-continuation/index.d.ts +111 -3
- package/dist/hooks/todo-continuation/index.d.ts.map +1 -1
- package/dist/hooks/todo-continuation/index.js +204 -23
- package/dist/hooks/todo-continuation/index.js.map +1 -1
- package/dist/installer/index.d.ts +1 -1
- package/dist/installer/index.d.ts.map +1 -1
- package/dist/installer/index.js +1 -1
- package/dist/installer/index.js.map +1 -1
- package/dist/tools/lsp/client.d.ts.map +1 -1
- package/dist/tools/lsp/client.js +15 -1
- package/dist/tools/lsp/client.js.map +1 -1
- package/dist/tools/lsp/servers.d.ts.map +1 -1
- package/dist/tools/lsp/servers.js +62 -1
- package/dist/tools/lsp/servers.js.map +1 -1
- package/package.json +1 -1
- package/templates/hooks/persistent-mode.mjs +57 -7
- package/templates/hooks/persistent-mode.sh +62 -5
- package/templates/hooks/stop-continuation.mjs +65 -8
- package/templates/hooks/stop-continuation.sh +57 -4
|
@@ -0,0 +1,740 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import * as os from 'os';
|
|
5
|
+
import { checkIncompleteTodos, isValidTask, readTaskFiles, getTaskDirectory, isTaskIncomplete, checkIncompleteTasks, checkLegacyTodos, isUserAbort, createTodoContinuationHook, formatTodoStatus, getNextPendingTodo, isValidSessionId } from '../hooks/todo-continuation/index.js';
|
|
6
|
+
// Mock fs and os modules
|
|
7
|
+
vi.mock('fs');
|
|
8
|
+
vi.mock('os');
|
|
9
|
+
describe('Task System Support', () => {
|
|
10
|
+
const mockHomedir = '/home/testuser';
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
vi.mocked(os.homedir).mockReturnValue(mockHomedir);
|
|
13
|
+
vi.clearAllMocks();
|
|
14
|
+
});
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
vi.restoreAllMocks();
|
|
17
|
+
});
|
|
18
|
+
describe('getTaskDirectory', () => {
|
|
19
|
+
it('should return correct path for session ID', () => {
|
|
20
|
+
const sessionId = 'abc123';
|
|
21
|
+
const result = getTaskDirectory(sessionId);
|
|
22
|
+
expect(result).toBe(path.join(mockHomedir, '.claude', 'tasks', sessionId));
|
|
23
|
+
});
|
|
24
|
+
it('should handle session ID with special characters', () => {
|
|
25
|
+
const sessionId = 'session-123_test';
|
|
26
|
+
const result = getTaskDirectory(sessionId);
|
|
27
|
+
expect(result).toContain(sessionId);
|
|
28
|
+
});
|
|
29
|
+
it('should handle empty session ID', () => {
|
|
30
|
+
const sessionId = '';
|
|
31
|
+
const result = getTaskDirectory(sessionId);
|
|
32
|
+
// After security validation: empty string is invalid → returns ''
|
|
33
|
+
expect(result).toBe('');
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
describe('isValidTask', () => {
|
|
37
|
+
it('should return true for valid Task object', () => {
|
|
38
|
+
const validTask = {
|
|
39
|
+
id: '1',
|
|
40
|
+
subject: 'Test task',
|
|
41
|
+
status: 'pending'
|
|
42
|
+
};
|
|
43
|
+
expect(isValidTask(validTask)).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
it('should return true for Task with all optional fields', () => {
|
|
46
|
+
const fullTask = {
|
|
47
|
+
id: '1',
|
|
48
|
+
subject: 'Test task',
|
|
49
|
+
description: 'A detailed description',
|
|
50
|
+
activeForm: 'Testing task',
|
|
51
|
+
status: 'pending',
|
|
52
|
+
blocks: ['2', '3'],
|
|
53
|
+
blockedBy: ['0']
|
|
54
|
+
};
|
|
55
|
+
expect(isValidTask(fullTask)).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
it('should return false for null', () => {
|
|
58
|
+
expect(isValidTask(null)).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
it('should return false for undefined', () => {
|
|
61
|
+
expect(isValidTask(undefined)).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
it('should return false for missing id', () => {
|
|
64
|
+
expect(isValidTask({ subject: 'Test', status: 'pending' })).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
it('should return false for empty id', () => {
|
|
67
|
+
expect(isValidTask({ id: '', subject: 'Test', status: 'pending' })).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
it('should return false for missing subject', () => {
|
|
70
|
+
expect(isValidTask({ id: '1', status: 'pending' })).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
it('should return false for empty subject', () => {
|
|
73
|
+
expect(isValidTask({ id: '1', subject: '', status: 'pending' })).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
it('should return false for missing status', () => {
|
|
76
|
+
expect(isValidTask({ id: '1', subject: 'Test' })).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
it('should return false for invalid status', () => {
|
|
79
|
+
expect(isValidTask({ id: '1', subject: 'Test', status: 'invalid' })).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
it('should accept all valid status values', () => {
|
|
82
|
+
expect(isValidTask({ id: '1', subject: 'Test', status: 'pending' })).toBe(true);
|
|
83
|
+
expect(isValidTask({ id: '1', subject: 'Test', status: 'in_progress' })).toBe(true);
|
|
84
|
+
expect(isValidTask({ id: '1', subject: 'Test', status: 'completed' })).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
it('should return false for non-object types', () => {
|
|
87
|
+
expect(isValidTask('string')).toBe(false);
|
|
88
|
+
expect(isValidTask(123)).toBe(false);
|
|
89
|
+
expect(isValidTask(true)).toBe(false);
|
|
90
|
+
expect(isValidTask([])).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
it('should return false for id with wrong type', () => {
|
|
93
|
+
expect(isValidTask({ id: 123, subject: 'Test', status: 'pending' })).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
it('should return false for subject with wrong type', () => {
|
|
96
|
+
expect(isValidTask({ id: '1', subject: 123, status: 'pending' })).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
describe('isTaskIncomplete', () => {
|
|
100
|
+
it('should return true for pending task', () => {
|
|
101
|
+
const task = { id: '1', subject: 'Test', status: 'pending' };
|
|
102
|
+
expect(isTaskIncomplete(task)).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
it('should return true for in_progress task', () => {
|
|
105
|
+
const task = { id: '1', subject: 'Test', status: 'in_progress' };
|
|
106
|
+
expect(isTaskIncomplete(task)).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
it('should return false for completed task', () => {
|
|
109
|
+
const task = { id: '1', subject: 'Test', status: 'completed' };
|
|
110
|
+
expect(isTaskIncomplete(task)).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
describe('readTaskFiles', () => {
|
|
114
|
+
it('should return empty array when directory does not exist', () => {
|
|
115
|
+
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
116
|
+
const result = readTaskFiles('session123');
|
|
117
|
+
expect(result).toEqual([]);
|
|
118
|
+
});
|
|
119
|
+
it('should read valid task files', () => {
|
|
120
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
121
|
+
vi.mocked(fs.readdirSync).mockReturnValue(['1.json', '2.json']);
|
|
122
|
+
vi.mocked(fs.readFileSync).mockImplementation((filePath) => {
|
|
123
|
+
if (filePath.includes('1.json')) {
|
|
124
|
+
return JSON.stringify({ id: '1', subject: 'Task 1', status: 'pending' });
|
|
125
|
+
}
|
|
126
|
+
return JSON.stringify({ id: '2', subject: 'Task 2', status: 'completed' });
|
|
127
|
+
});
|
|
128
|
+
const result = readTaskFiles('session123');
|
|
129
|
+
expect(result).toHaveLength(2);
|
|
130
|
+
expect(result[0].id).toBe('1');
|
|
131
|
+
expect(result[1].id).toBe('2');
|
|
132
|
+
});
|
|
133
|
+
it('should skip .lock files', () => {
|
|
134
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
135
|
+
vi.mocked(fs.readdirSync).mockReturnValue(['1.json', '.lock']);
|
|
136
|
+
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ id: '1', subject: 'Task', status: 'pending' }));
|
|
137
|
+
const result = readTaskFiles('session123');
|
|
138
|
+
expect(result).toHaveLength(1);
|
|
139
|
+
});
|
|
140
|
+
it('should skip non-json files', () => {
|
|
141
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
142
|
+
vi.mocked(fs.readdirSync).mockReturnValue(['1.json', '2.txt', 'README.md']);
|
|
143
|
+
vi.mocked(fs.readFileSync).mockImplementation((filePath) => {
|
|
144
|
+
if (filePath.includes('1.json')) {
|
|
145
|
+
return JSON.stringify({ id: '1', subject: 'Task 1', status: 'pending' });
|
|
146
|
+
}
|
|
147
|
+
return 'not json';
|
|
148
|
+
});
|
|
149
|
+
const result = readTaskFiles('session123');
|
|
150
|
+
expect(result).toHaveLength(1);
|
|
151
|
+
});
|
|
152
|
+
it('should skip invalid JSON files', () => {
|
|
153
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
154
|
+
vi.mocked(fs.readdirSync).mockReturnValue(['1.json', '2.json']);
|
|
155
|
+
vi.mocked(fs.readFileSync).mockImplementation((filePath) => {
|
|
156
|
+
if (filePath.includes('1.json')) {
|
|
157
|
+
return 'not valid json';
|
|
158
|
+
}
|
|
159
|
+
return JSON.stringify({ id: '2', subject: 'Task 2', status: 'pending' });
|
|
160
|
+
});
|
|
161
|
+
const result = readTaskFiles('session123');
|
|
162
|
+
expect(result).toHaveLength(1);
|
|
163
|
+
expect(result[0].id).toBe('2');
|
|
164
|
+
});
|
|
165
|
+
it('should skip files with invalid task structure', () => {
|
|
166
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
167
|
+
vi.mocked(fs.readdirSync).mockReturnValue(['1.json', '2.json', '3.json']);
|
|
168
|
+
vi.mocked(fs.readFileSync).mockImplementation((filePath) => {
|
|
169
|
+
if (filePath.includes('1.json')) {
|
|
170
|
+
return JSON.stringify({ id: '1', subject: 'Valid', status: 'pending' });
|
|
171
|
+
}
|
|
172
|
+
else if (filePath.includes('2.json')) {
|
|
173
|
+
return JSON.stringify({ id: '', subject: 'Invalid', status: 'pending' });
|
|
174
|
+
}
|
|
175
|
+
return JSON.stringify({ subject: 'Missing ID', status: 'pending' });
|
|
176
|
+
});
|
|
177
|
+
const result = readTaskFiles('session123');
|
|
178
|
+
expect(result).toHaveLength(1);
|
|
179
|
+
expect(result[0].id).toBe('1');
|
|
180
|
+
});
|
|
181
|
+
it('should handle directory read errors gracefully', () => {
|
|
182
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
183
|
+
vi.mocked(fs.readdirSync).mockImplementation(() => {
|
|
184
|
+
throw new Error('Permission denied');
|
|
185
|
+
});
|
|
186
|
+
const result = readTaskFiles('session123');
|
|
187
|
+
expect(result).toEqual([]);
|
|
188
|
+
});
|
|
189
|
+
it('should handle file read errors gracefully', () => {
|
|
190
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
191
|
+
vi.mocked(fs.readdirSync).mockReturnValue(['1.json', '2.json']);
|
|
192
|
+
vi.mocked(fs.readFileSync).mockImplementation((filePath) => {
|
|
193
|
+
if (filePath.includes('1.json')) {
|
|
194
|
+
throw new Error('File read error');
|
|
195
|
+
}
|
|
196
|
+
return JSON.stringify({ id: '2', subject: 'Task 2', status: 'pending' });
|
|
197
|
+
});
|
|
198
|
+
const result = readTaskFiles('session123');
|
|
199
|
+
expect(result).toHaveLength(1);
|
|
200
|
+
expect(result[0].id).toBe('2');
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
describe('checkIncompleteTasks', () => {
|
|
204
|
+
it('should count only incomplete tasks', () => {
|
|
205
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
206
|
+
vi.mocked(fs.readdirSync).mockReturnValue(['1.json', '2.json', '3.json']);
|
|
207
|
+
vi.mocked(fs.readFileSync).mockImplementation((filePath) => {
|
|
208
|
+
if (filePath.includes('1.json')) {
|
|
209
|
+
return JSON.stringify({ id: '1', subject: 'Task 1', status: 'pending' });
|
|
210
|
+
}
|
|
211
|
+
if (filePath.includes('2.json')) {
|
|
212
|
+
return JSON.stringify({ id: '2', subject: 'Task 2', status: 'completed' });
|
|
213
|
+
}
|
|
214
|
+
return JSON.stringify({ id: '3', subject: 'Task 3', status: 'in_progress' });
|
|
215
|
+
});
|
|
216
|
+
const result = checkIncompleteTasks('session123');
|
|
217
|
+
expect(result.count).toBe(2);
|
|
218
|
+
expect(result.total).toBe(3);
|
|
219
|
+
expect(result.tasks).toHaveLength(2);
|
|
220
|
+
});
|
|
221
|
+
it('should return zero when all tasks complete', () => {
|
|
222
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
223
|
+
vi.mocked(fs.readdirSync).mockReturnValue(['1.json', '2.json']);
|
|
224
|
+
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ id: '1', subject: 'Task', status: 'completed' }));
|
|
225
|
+
const result = checkIncompleteTasks('session123');
|
|
226
|
+
expect(result.count).toBe(0);
|
|
227
|
+
expect(result.total).toBe(2);
|
|
228
|
+
});
|
|
229
|
+
it('should return correct tasks array', () => {
|
|
230
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
231
|
+
vi.mocked(fs.readdirSync).mockReturnValue(['1.json', '2.json']);
|
|
232
|
+
vi.mocked(fs.readFileSync).mockImplementation((filePath) => {
|
|
233
|
+
if (filePath.includes('1.json')) {
|
|
234
|
+
return JSON.stringify({ id: '1', subject: 'Pending', status: 'pending' });
|
|
235
|
+
}
|
|
236
|
+
return JSON.stringify({ id: '2', subject: 'Complete', status: 'completed' });
|
|
237
|
+
});
|
|
238
|
+
const result = checkIncompleteTasks('session123');
|
|
239
|
+
expect(result.tasks[0].subject).toBe('Pending');
|
|
240
|
+
expect(result.tasks[0].status).toBe('pending');
|
|
241
|
+
});
|
|
242
|
+
it('should handle empty task directory', () => {
|
|
243
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
244
|
+
vi.mocked(fs.readdirSync).mockReturnValue([]);
|
|
245
|
+
const result = checkIncompleteTasks('session123');
|
|
246
|
+
expect(result.count).toBe(0);
|
|
247
|
+
expect(result.total).toBe(0);
|
|
248
|
+
expect(result.tasks).toEqual([]);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
describe('checkIncompleteTodos with dual-mode', () => {
|
|
252
|
+
it('should return source: none when no tasks or todos', async () => {
|
|
253
|
+
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
254
|
+
const result = await checkIncompleteTodos('session123');
|
|
255
|
+
expect(result.source).toBe('none');
|
|
256
|
+
expect(result.count).toBe(0);
|
|
257
|
+
});
|
|
258
|
+
it('should return source: task when only Tasks have incomplete items', async () => {
|
|
259
|
+
vi.mocked(fs.existsSync).mockImplementation((p) => {
|
|
260
|
+
return p.includes('/tasks/');
|
|
261
|
+
});
|
|
262
|
+
vi.mocked(fs.readdirSync).mockReturnValue(['1.json']);
|
|
263
|
+
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ id: '1', subject: 'Task', status: 'pending' }));
|
|
264
|
+
const result = await checkIncompleteTodos('session123');
|
|
265
|
+
expect(result.source).toBe('task');
|
|
266
|
+
expect(result.count).toBe(1);
|
|
267
|
+
});
|
|
268
|
+
it('should return source: todo when only legacy todos exist', async () => {
|
|
269
|
+
vi.mocked(fs.existsSync).mockImplementation((p) => {
|
|
270
|
+
return p.includes('/todos/') || p.includes('todos.json');
|
|
271
|
+
});
|
|
272
|
+
vi.mocked(fs.readdirSync).mockReturnValue(['session123.json']);
|
|
273
|
+
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify([{ content: 'Todo', status: 'pending' }]));
|
|
274
|
+
const result = await checkIncompleteTodos('session123');
|
|
275
|
+
expect(result.source).toBe('todo');
|
|
276
|
+
expect(result.count).toBe(1);
|
|
277
|
+
});
|
|
278
|
+
it('should return source: both when both systems have incomplete items', async () => {
|
|
279
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
280
|
+
vi.mocked(fs.readdirSync).mockImplementation((dirPath) => {
|
|
281
|
+
if (dirPath.includes('/tasks/')) {
|
|
282
|
+
return ['1.json'];
|
|
283
|
+
}
|
|
284
|
+
return ['session123.json'];
|
|
285
|
+
});
|
|
286
|
+
vi.mocked(fs.readFileSync).mockImplementation((filePath) => {
|
|
287
|
+
if (filePath.includes('/tasks/')) {
|
|
288
|
+
return JSON.stringify({ id: '1', subject: 'Task', status: 'pending' });
|
|
289
|
+
}
|
|
290
|
+
return JSON.stringify([{ content: 'Todo', status: 'pending' }]);
|
|
291
|
+
});
|
|
292
|
+
const result = await checkIncompleteTodos('session123');
|
|
293
|
+
expect(result.source).toBe('both');
|
|
294
|
+
expect(result.count).toBeGreaterThan(0);
|
|
295
|
+
});
|
|
296
|
+
it('should prioritize tasks over legacy todos', async () => {
|
|
297
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
298
|
+
vi.mocked(fs.readdirSync).mockImplementation((dirPath) => {
|
|
299
|
+
if (dirPath.includes('/tasks/')) {
|
|
300
|
+
return ['1.json'];
|
|
301
|
+
}
|
|
302
|
+
return ['session123.json'];
|
|
303
|
+
});
|
|
304
|
+
vi.mocked(fs.readFileSync).mockImplementation((filePath) => {
|
|
305
|
+
if (filePath.includes('/tasks/')) {
|
|
306
|
+
return JSON.stringify({ id: '1', subject: 'Task Subject', status: 'pending' });
|
|
307
|
+
}
|
|
308
|
+
return JSON.stringify([{ content: 'Legacy Todo', status: 'pending' }]);
|
|
309
|
+
});
|
|
310
|
+
const result = await checkIncompleteTodos('session123');
|
|
311
|
+
expect(result.todos[0].content).toBe('Task Subject');
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
describe('isUserAbort', () => {
|
|
315
|
+
it('should return false for undefined context', () => {
|
|
316
|
+
expect(isUserAbort(undefined)).toBe(false);
|
|
317
|
+
});
|
|
318
|
+
it('should return true for user_requested flag (snake_case)', () => {
|
|
319
|
+
const context = { user_requested: true };
|
|
320
|
+
expect(isUserAbort(context)).toBe(true);
|
|
321
|
+
});
|
|
322
|
+
it('should return true for userRequested flag (camelCase)', () => {
|
|
323
|
+
const context = { userRequested: true };
|
|
324
|
+
expect(isUserAbort(context)).toBe(true);
|
|
325
|
+
});
|
|
326
|
+
it('should detect user_cancel in stop_reason', () => {
|
|
327
|
+
const context = { stop_reason: 'user_cancel' };
|
|
328
|
+
expect(isUserAbort(context)).toBe(true);
|
|
329
|
+
});
|
|
330
|
+
it('should detect user_interrupt in stopReason', () => {
|
|
331
|
+
const context = { stopReason: 'user_interrupt' };
|
|
332
|
+
expect(isUserAbort(context)).toBe(true);
|
|
333
|
+
});
|
|
334
|
+
it('should detect ctrl_c pattern', () => {
|
|
335
|
+
const context = { stop_reason: 'ctrl_c' };
|
|
336
|
+
expect(isUserAbort(context)).toBe(true);
|
|
337
|
+
});
|
|
338
|
+
it('should detect abort pattern', () => {
|
|
339
|
+
const context = { stop_reason: 'aborted' };
|
|
340
|
+
expect(isUserAbort(context)).toBe(true);
|
|
341
|
+
});
|
|
342
|
+
it('should detect cancel pattern', () => {
|
|
343
|
+
const context = { stop_reason: 'operation_cancelled' };
|
|
344
|
+
expect(isUserAbort(context)).toBe(true);
|
|
345
|
+
});
|
|
346
|
+
it('should be case insensitive', () => {
|
|
347
|
+
expect(isUserAbort({ stop_reason: 'USER_CANCEL' })).toBe(true);
|
|
348
|
+
expect(isUserAbort({ stop_reason: 'Abort' })).toBe(true);
|
|
349
|
+
});
|
|
350
|
+
it('should return false for normal completion', () => {
|
|
351
|
+
const context = { stop_reason: 'end_turn' };
|
|
352
|
+
expect(isUserAbort(context)).toBe(false);
|
|
353
|
+
});
|
|
354
|
+
it('should return false for max_tokens', () => {
|
|
355
|
+
const context = { stop_reason: 'max_tokens' };
|
|
356
|
+
expect(isUserAbort(context)).toBe(false);
|
|
357
|
+
});
|
|
358
|
+
it('should handle empty context object', () => {
|
|
359
|
+
expect(isUserAbort({})).toBe(false);
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
describe('createTodoContinuationHook', () => {
|
|
363
|
+
it('should create hook with checkIncomplete method', () => {
|
|
364
|
+
const hook = createTodoContinuationHook('/test/dir');
|
|
365
|
+
expect(hook).toHaveProperty('checkIncomplete');
|
|
366
|
+
expect(typeof hook.checkIncomplete).toBe('function');
|
|
367
|
+
});
|
|
368
|
+
it('should call checkIncompleteTodos with directory', async () => {
|
|
369
|
+
const testDir = '/test/dir';
|
|
370
|
+
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
371
|
+
const hook = createTodoContinuationHook(testDir);
|
|
372
|
+
const result = await hook.checkIncomplete('session123');
|
|
373
|
+
expect(result).toBeDefined();
|
|
374
|
+
expect(result.source).toBe('none');
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
describe('formatTodoStatus', () => {
|
|
378
|
+
it('should format when all tasks complete', () => {
|
|
379
|
+
const result = {
|
|
380
|
+
count: 0,
|
|
381
|
+
todos: [],
|
|
382
|
+
total: 5,
|
|
383
|
+
source: 'task'
|
|
384
|
+
};
|
|
385
|
+
expect(formatTodoStatus(result)).toBe('All tasks complete (5 total)');
|
|
386
|
+
});
|
|
387
|
+
it('should format with incomplete tasks', () => {
|
|
388
|
+
const result = {
|
|
389
|
+
count: 3,
|
|
390
|
+
todos: [],
|
|
391
|
+
total: 10,
|
|
392
|
+
source: 'task'
|
|
393
|
+
};
|
|
394
|
+
expect(formatTodoStatus(result)).toBe('7/10 completed, 3 remaining');
|
|
395
|
+
});
|
|
396
|
+
it('should handle zero total tasks', () => {
|
|
397
|
+
const result = {
|
|
398
|
+
count: 0,
|
|
399
|
+
todos: [],
|
|
400
|
+
total: 0,
|
|
401
|
+
source: 'none'
|
|
402
|
+
};
|
|
403
|
+
expect(formatTodoStatus(result)).toBe('All tasks complete (0 total)');
|
|
404
|
+
});
|
|
405
|
+
it('should handle all tasks incomplete', () => {
|
|
406
|
+
const result = {
|
|
407
|
+
count: 5,
|
|
408
|
+
todos: [],
|
|
409
|
+
total: 5,
|
|
410
|
+
source: 'task'
|
|
411
|
+
};
|
|
412
|
+
expect(formatTodoStatus(result)).toBe('0/5 completed, 5 remaining');
|
|
413
|
+
});
|
|
414
|
+
it('should handle single task remaining', () => {
|
|
415
|
+
const result = {
|
|
416
|
+
count: 1,
|
|
417
|
+
todos: [],
|
|
418
|
+
total: 10,
|
|
419
|
+
source: 'task'
|
|
420
|
+
};
|
|
421
|
+
expect(formatTodoStatus(result)).toBe('9/10 completed, 1 remaining');
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
describe('getNextPendingTodo', () => {
|
|
425
|
+
it('should return in_progress todo first', () => {
|
|
426
|
+
const todos = [
|
|
427
|
+
{ content: 'Task 1', status: 'pending' },
|
|
428
|
+
{ content: 'Task 2', status: 'in_progress' },
|
|
429
|
+
{ content: 'Task 3', status: 'pending' }
|
|
430
|
+
];
|
|
431
|
+
const result = {
|
|
432
|
+
count: 3,
|
|
433
|
+
todos,
|
|
434
|
+
total: 3,
|
|
435
|
+
source: 'todo'
|
|
436
|
+
};
|
|
437
|
+
const next = getNextPendingTodo(result);
|
|
438
|
+
expect(next).not.toBeNull();
|
|
439
|
+
expect(next.content).toBe('Task 2');
|
|
440
|
+
expect(next.status).toBe('in_progress');
|
|
441
|
+
});
|
|
442
|
+
it('should return first pending when no in_progress', () => {
|
|
443
|
+
const todos = [
|
|
444
|
+
{ content: 'Task 1', status: 'pending' },
|
|
445
|
+
{ content: 'Task 2', status: 'pending' },
|
|
446
|
+
{ content: 'Task 3', status: 'completed' }
|
|
447
|
+
];
|
|
448
|
+
const result = {
|
|
449
|
+
count: 2,
|
|
450
|
+
todos: todos.filter(t => t.status !== 'completed'),
|
|
451
|
+
total: 3,
|
|
452
|
+
source: 'todo'
|
|
453
|
+
};
|
|
454
|
+
const next = getNextPendingTodo(result);
|
|
455
|
+
expect(next).not.toBeNull();
|
|
456
|
+
expect(next.content).toBe('Task 1');
|
|
457
|
+
expect(next.status).toBe('pending');
|
|
458
|
+
});
|
|
459
|
+
it('should return null when no todos', () => {
|
|
460
|
+
const result = {
|
|
461
|
+
count: 0,
|
|
462
|
+
todos: [],
|
|
463
|
+
total: 0,
|
|
464
|
+
source: 'none'
|
|
465
|
+
};
|
|
466
|
+
const next = getNextPendingTodo(result);
|
|
467
|
+
expect(next).toBeNull();
|
|
468
|
+
});
|
|
469
|
+
it('should return null when all completed', () => {
|
|
470
|
+
const result = {
|
|
471
|
+
count: 0,
|
|
472
|
+
todos: [],
|
|
473
|
+
total: 3,
|
|
474
|
+
source: 'task'
|
|
475
|
+
};
|
|
476
|
+
const next = getNextPendingTodo(result);
|
|
477
|
+
expect(next).toBeNull();
|
|
478
|
+
});
|
|
479
|
+
it('should handle todos with priority field', () => {
|
|
480
|
+
const todos = [
|
|
481
|
+
{ content: 'Task 1', status: 'pending', priority: 'low' },
|
|
482
|
+
{ content: 'Task 2', status: 'in_progress', priority: 'high' }
|
|
483
|
+
];
|
|
484
|
+
const result = {
|
|
485
|
+
count: 2,
|
|
486
|
+
todos,
|
|
487
|
+
total: 2,
|
|
488
|
+
source: 'todo'
|
|
489
|
+
};
|
|
490
|
+
const next = getNextPendingTodo(result);
|
|
491
|
+
expect(next).not.toBeNull();
|
|
492
|
+
expect(next.content).toBe('Task 2');
|
|
493
|
+
});
|
|
494
|
+
it('should handle todos with id field', () => {
|
|
495
|
+
const todos = [
|
|
496
|
+
{ content: 'Task 1', status: 'pending', id: 'todo-1' },
|
|
497
|
+
{ content: 'Task 2', status: 'pending', id: 'todo-2' }
|
|
498
|
+
];
|
|
499
|
+
const result = {
|
|
500
|
+
count: 2,
|
|
501
|
+
todos,
|
|
502
|
+
total: 2,
|
|
503
|
+
source: 'todo'
|
|
504
|
+
};
|
|
505
|
+
const next = getNextPendingTodo(result);
|
|
506
|
+
expect(next).not.toBeNull();
|
|
507
|
+
expect(next.id).toBe('todo-1');
|
|
508
|
+
});
|
|
509
|
+
it('should prefer in_progress over multiple pending', () => {
|
|
510
|
+
const todos = [
|
|
511
|
+
{ content: 'Task 1', status: 'pending' },
|
|
512
|
+
{ content: 'Task 2', status: 'pending' },
|
|
513
|
+
{ content: 'Task 3', status: 'pending' },
|
|
514
|
+
{ content: 'Task 4', status: 'in_progress' }
|
|
515
|
+
];
|
|
516
|
+
const result = {
|
|
517
|
+
count: 4,
|
|
518
|
+
todos,
|
|
519
|
+
total: 4,
|
|
520
|
+
source: 'todo'
|
|
521
|
+
};
|
|
522
|
+
const next = getNextPendingTodo(result);
|
|
523
|
+
expect(next).not.toBeNull();
|
|
524
|
+
expect(next.content).toBe('Task 4');
|
|
525
|
+
expect(next.status).toBe('in_progress');
|
|
526
|
+
});
|
|
527
|
+
});
|
|
528
|
+
describe('checkLegacyTodos', () => {
|
|
529
|
+
it('should read from session-specific location', () => {
|
|
530
|
+
vi.mocked(fs.existsSync).mockImplementation((p) => {
|
|
531
|
+
return p.includes('session123.json');
|
|
532
|
+
});
|
|
533
|
+
vi.mocked(fs.readdirSync).mockReturnValue(['session123.json']);
|
|
534
|
+
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify([{ content: 'Todo', status: 'pending' }]));
|
|
535
|
+
const result = checkLegacyTodos('session123');
|
|
536
|
+
expect(result.count).toBe(1);
|
|
537
|
+
});
|
|
538
|
+
it('should read from project .omc directory', () => {
|
|
539
|
+
vi.mocked(fs.existsSync).mockImplementation((p) => {
|
|
540
|
+
return p.includes('.omc/todos.json');
|
|
541
|
+
});
|
|
542
|
+
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify([{ content: 'Todo', status: 'pending' }]));
|
|
543
|
+
const result = checkLegacyTodos(undefined, '/project/dir');
|
|
544
|
+
expect(result.count).toBe(1);
|
|
545
|
+
});
|
|
546
|
+
it('should deduplicate todos from multiple sources', () => {
|
|
547
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
548
|
+
vi.mocked(fs.readdirSync).mockReturnValue(['session123.json']);
|
|
549
|
+
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify([{ content: 'Same Todo', status: 'pending' }]));
|
|
550
|
+
const result = checkLegacyTodos('session123', '/project/dir');
|
|
551
|
+
// Should only count unique todos
|
|
552
|
+
expect(result.count).toBeGreaterThanOrEqual(1);
|
|
553
|
+
});
|
|
554
|
+
it('should handle object format with todos array', () => {
|
|
555
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
556
|
+
vi.mocked(fs.readdirSync).mockReturnValue(['session123.json']);
|
|
557
|
+
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ todos: [{ content: 'Todo', status: 'pending' }] }));
|
|
558
|
+
const result = checkLegacyTodos('session123');
|
|
559
|
+
expect(result.count).toBe(1);
|
|
560
|
+
});
|
|
561
|
+
it('should filter out cancelled todos', () => {
|
|
562
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
563
|
+
vi.mocked(fs.readdirSync).mockReturnValue(['session123.json']);
|
|
564
|
+
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify([
|
|
565
|
+
{ content: 'Pending', status: 'pending' },
|
|
566
|
+
{ content: 'Cancelled', status: 'cancelled' },
|
|
567
|
+
{ content: 'Completed', status: 'completed' }
|
|
568
|
+
]));
|
|
569
|
+
const result = checkLegacyTodos('session123');
|
|
570
|
+
expect(result.count).toBe(1);
|
|
571
|
+
expect(result.total).toBe(3);
|
|
572
|
+
});
|
|
573
|
+
});
|
|
574
|
+
describe('Integration: Task and Todo Systems', () => {
|
|
575
|
+
it('should prefer tasks when both exist and tasks have incomplete items', async () => {
|
|
576
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
577
|
+
vi.mocked(fs.readdirSync).mockImplementation((dirPath) => {
|
|
578
|
+
if (dirPath.includes('/tasks/')) {
|
|
579
|
+
return ['1.json'];
|
|
580
|
+
}
|
|
581
|
+
return ['session123.json'];
|
|
582
|
+
});
|
|
583
|
+
vi.mocked(fs.readFileSync).mockImplementation((filePath) => {
|
|
584
|
+
if (filePath.includes('/tasks/')) {
|
|
585
|
+
return JSON.stringify({ id: '1', subject: 'Task', status: 'pending' });
|
|
586
|
+
}
|
|
587
|
+
return JSON.stringify([{ content: 'Todo', status: 'completed' }]);
|
|
588
|
+
});
|
|
589
|
+
const result = await checkIncompleteTodos('session123');
|
|
590
|
+
expect(result.source).toBe('task');
|
|
591
|
+
expect(result.count).toBe(1);
|
|
592
|
+
});
|
|
593
|
+
it('should handle user abort during check', async () => {
|
|
594
|
+
const stopContext = { user_requested: true };
|
|
595
|
+
const result = await checkIncompleteTodos('session123', undefined, stopContext);
|
|
596
|
+
expect(result.count).toBe(0);
|
|
597
|
+
expect(result.source).toBe('none');
|
|
598
|
+
});
|
|
599
|
+
it('should convert tasks to todo format in result', async () => {
|
|
600
|
+
vi.mocked(fs.existsSync).mockImplementation((p) => p.includes('/tasks/'));
|
|
601
|
+
vi.mocked(fs.readdirSync).mockReturnValue(['1.json']);
|
|
602
|
+
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ id: 'task-1', subject: 'Task Subject', status: 'pending' }));
|
|
603
|
+
const result = await checkIncompleteTodos('session123');
|
|
604
|
+
expect(result.todos[0].content).toBe('Task Subject');
|
|
605
|
+
expect(result.todos[0].id).toBe('task-1');
|
|
606
|
+
expect(result.todos[0].status).toBe('pending');
|
|
607
|
+
});
|
|
608
|
+
});
|
|
609
|
+
describe('Edge Cases', () => {
|
|
610
|
+
it('should handle malformed JSON gracefully', () => {
|
|
611
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
612
|
+
vi.mocked(fs.readdirSync).mockReturnValue(['bad.json', 'good.json']);
|
|
613
|
+
vi.mocked(fs.readFileSync).mockImplementation((filePath) => {
|
|
614
|
+
if (filePath.includes('bad.json')) {
|
|
615
|
+
return '{invalid json}';
|
|
616
|
+
}
|
|
617
|
+
return JSON.stringify({ id: '1', subject: 'Good', status: 'pending' });
|
|
618
|
+
});
|
|
619
|
+
const result = readTaskFiles('session123');
|
|
620
|
+
expect(result).toHaveLength(1);
|
|
621
|
+
expect(result[0].id).toBe('1');
|
|
622
|
+
});
|
|
623
|
+
it('should handle very long file lists', () => {
|
|
624
|
+
const manyFiles = Array.from({ length: 1000 }, (_, i) => `${i}.json`);
|
|
625
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
626
|
+
vi.mocked(fs.readdirSync).mockReturnValue(manyFiles);
|
|
627
|
+
vi.mocked(fs.readFileSync).mockImplementation((filePath) => {
|
|
628
|
+
const match = filePath.match(/(\d+)\.json/);
|
|
629
|
+
const id = match ? match[1] : '0';
|
|
630
|
+
return JSON.stringify({ id, subject: `Task ${id}`, status: 'pending' });
|
|
631
|
+
});
|
|
632
|
+
const result = readTaskFiles('session123');
|
|
633
|
+
expect(result).toHaveLength(1000);
|
|
634
|
+
});
|
|
635
|
+
it('should handle unicode in task subjects', () => {
|
|
636
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
637
|
+
vi.mocked(fs.readdirSync).mockReturnValue(['1.json']);
|
|
638
|
+
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ id: '1', subject: 'Task with émojis 🚀', status: 'pending' }));
|
|
639
|
+
const result = readTaskFiles('session123');
|
|
640
|
+
expect(result[0].subject).toBe('Task with émojis 🚀');
|
|
641
|
+
});
|
|
642
|
+
it('should handle tasks with blocks and blockedBy', () => {
|
|
643
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
644
|
+
vi.mocked(fs.readdirSync).mockReturnValue(['1.json']);
|
|
645
|
+
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({
|
|
646
|
+
id: '1',
|
|
647
|
+
subject: 'Task',
|
|
648
|
+
status: 'pending',
|
|
649
|
+
blocks: ['2', '3'],
|
|
650
|
+
blockedBy: ['0']
|
|
651
|
+
}));
|
|
652
|
+
const result = readTaskFiles('session123');
|
|
653
|
+
expect(result[0].blocks).toEqual(['2', '3']);
|
|
654
|
+
expect(result[0].blockedBy).toEqual(['0']);
|
|
655
|
+
});
|
|
656
|
+
});
|
|
657
|
+
describe('Security: Session ID Validation', () => {
|
|
658
|
+
it('should reject path traversal attempts with ../', () => {
|
|
659
|
+
expect(isValidSessionId('../../../etc')).toBe(false);
|
|
660
|
+
});
|
|
661
|
+
it('should reject path traversal with encoded characters', () => {
|
|
662
|
+
expect(isValidSessionId('..%2F..%2F')).toBe(false);
|
|
663
|
+
});
|
|
664
|
+
it('should reject session IDs starting with dot', () => {
|
|
665
|
+
expect(isValidSessionId('.hidden')).toBe(false);
|
|
666
|
+
});
|
|
667
|
+
it('should reject session IDs starting with hyphen', () => {
|
|
668
|
+
expect(isValidSessionId('-invalid')).toBe(false);
|
|
669
|
+
});
|
|
670
|
+
it('should reject empty session ID', () => {
|
|
671
|
+
expect(isValidSessionId('')).toBe(false);
|
|
672
|
+
});
|
|
673
|
+
it('should reject null/undefined', () => {
|
|
674
|
+
expect(isValidSessionId(null)).toBe(false);
|
|
675
|
+
expect(isValidSessionId(undefined)).toBe(false);
|
|
676
|
+
});
|
|
677
|
+
it('should reject session IDs with slashes', () => {
|
|
678
|
+
expect(isValidSessionId('abc/def')).toBe(false);
|
|
679
|
+
expect(isValidSessionId('abc\\def')).toBe(false);
|
|
680
|
+
});
|
|
681
|
+
it('should reject session IDs with special characters', () => {
|
|
682
|
+
expect(isValidSessionId('abc$def')).toBe(false);
|
|
683
|
+
expect(isValidSessionId('abc;def')).toBe(false);
|
|
684
|
+
expect(isValidSessionId('abc|def')).toBe(false);
|
|
685
|
+
});
|
|
686
|
+
it('should accept valid alphanumeric session IDs', () => {
|
|
687
|
+
expect(isValidSessionId('abc123')).toBe(true);
|
|
688
|
+
expect(isValidSessionId('session-123')).toBe(true);
|
|
689
|
+
expect(isValidSessionId('session_123')).toBe(true);
|
|
690
|
+
expect(isValidSessionId('ABC123xyz')).toBe(true);
|
|
691
|
+
});
|
|
692
|
+
it('should accept session IDs up to 256 characters', () => {
|
|
693
|
+
const longId = 'a'.repeat(256);
|
|
694
|
+
expect(isValidSessionId(longId)).toBe(true);
|
|
695
|
+
});
|
|
696
|
+
it('should reject session IDs over 256 characters', () => {
|
|
697
|
+
const tooLongId = 'a'.repeat(257);
|
|
698
|
+
expect(isValidSessionId(tooLongId)).toBe(false);
|
|
699
|
+
});
|
|
700
|
+
it('should accept numeric session IDs starting with digit', () => {
|
|
701
|
+
expect(isValidSessionId('123456')).toBe(true);
|
|
702
|
+
});
|
|
703
|
+
});
|
|
704
|
+
describe('Security: getTaskDirectory with validation', () => {
|
|
705
|
+
it('should return empty string for invalid session ID', () => {
|
|
706
|
+
const result = getTaskDirectory('../../../etc/passwd');
|
|
707
|
+
expect(result).toBe('');
|
|
708
|
+
});
|
|
709
|
+
it('should return valid path for valid session ID', () => {
|
|
710
|
+
const result = getTaskDirectory('valid-session-123');
|
|
711
|
+
expect(result).toContain('valid-session-123');
|
|
712
|
+
expect(result).toContain('.claude/tasks');
|
|
713
|
+
});
|
|
714
|
+
});
|
|
715
|
+
describe('Security: readTaskFiles with validation', () => {
|
|
716
|
+
it('should return empty array for path traversal attempt', () => {
|
|
717
|
+
const result = readTaskFiles('../../../etc');
|
|
718
|
+
expect(result).toEqual([]);
|
|
719
|
+
});
|
|
720
|
+
});
|
|
721
|
+
describe('Security: checkIncompleteTasks with validation', () => {
|
|
722
|
+
it('should return zero count for invalid session ID', () => {
|
|
723
|
+
const result = checkIncompleteTasks('../../../etc');
|
|
724
|
+
expect(result.count).toBe(0);
|
|
725
|
+
expect(result.tasks).toEqual([]);
|
|
726
|
+
expect(result.total).toBe(0);
|
|
727
|
+
});
|
|
728
|
+
});
|
|
729
|
+
describe('Task status: deleted handling', () => {
|
|
730
|
+
it('should treat deleted status as valid task', () => {
|
|
731
|
+
const task = { id: '1', subject: 'Test', status: 'deleted' };
|
|
732
|
+
expect(isValidTask(task)).toBe(true);
|
|
733
|
+
});
|
|
734
|
+
it('should treat deleted task as complete (not incomplete)', () => {
|
|
735
|
+
const task = { id: '1', subject: 'Test', status: 'deleted' };
|
|
736
|
+
expect(isTaskIncomplete(task)).toBe(false);
|
|
737
|
+
});
|
|
738
|
+
});
|
|
739
|
+
});
|
|
740
|
+
//# sourceMappingURL=task-continuation.test.js.map
|