@justjoshn/pi-pstack 0.15.36-pi.1 → 0.15.36-pi.7
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/CHANGELOG.md +39 -0
- package/README.md +15 -12
- package/agents/ci-watcher.md +27 -0
- package/agents/thermo-nuclear-code-quality-review.md +23 -0
- package/assets/logo.png +0 -0
- package/automations/benny/FOR_AGENTS.md +1 -1
- package/automations/benny/README.md +2 -2
- package/automations/benny/skills/setup-benny/SKILL.md +5 -5
- package/bin/pstack-routines-supervisor +9 -0
- package/docs/guide/07-overnight.md +1 -1
- package/docs/guide/09-make-it-yours.md +1 -1
- package/docs/guide/10-recipes-and-pitfalls.md +1 -1
- package/docs/native-gaps.md +11 -5
- package/docs/parity-reverse-engineering.md +94 -0
- package/package.json +41 -6
- package/packaging/install-supervisor-unit.sh +164 -0
- package/packaging/launchd/com.pstack.routines-supervisor.plist.example +24 -0
- package/packaging/systemd/pstack-routines-supervisor.service.example +15 -0
- package/skills/architect/SKILL.md +1 -1
- package/skills/architect/references/runner-prompt.md +1 -1
- package/skills/arena/SKILL.md +3 -3
- package/skills/check-compiler-errors/SKILL.md +23 -0
- package/skills/create-skill/SKILL.md +515 -29
- package/skills/fix-ci/SKILL.md +29 -0
- package/skills/fix-merge-conflicts/SKILL.md +32 -0
- package/skills/get-pr-comments/SKILL.md +23 -0
- package/skills/how/SKILL.md +1 -1
- package/skills/ketchup-video/SKILL.md +2 -2
- package/skills/loop-on-ci/SKILL.md +50 -0
- package/skills/make-bot-ui/SKILL.md +2 -2
- package/skills/make-pr-easy-to-review/SKILL.md +59 -0
- package/skills/new-branch-and-pr/SKILL.md +29 -0
- package/skills/no-inline-imports/SKILL.md +12 -0
- package/skills/poteto-help/SKILL.md +4 -4
- package/skills/poteto-mode/playbooks/multi-phase-plan.md +1 -1
- package/skills/poteto-mode/playbooks/orchestrate.md +1 -1
- package/skills/pr-review-canvas/SKILL.md +176 -0
- package/skills/pr-review-canvas/renderer.js +170 -0
- package/skills/pr-review-canvas/styles.css +96 -0
- package/skills/pr-review-canvas/template.html +18 -0
- package/skills/reflect/SKILL.md +1 -1
- package/skills/review-and-ship/SKILL.md +41 -0
- package/skills/run-smoke-tests/SKILL.md +42 -0
- package/skills/statechart/scripts/test/cli.test.ts +1 -1
- package/skills/swarm/SKILL.md +2 -2
- package/skills/thermo-nuclear-code-quality-review/SKILL.md +192 -0
- package/skills/typescript-exhaustive-switch/SKILL.md +10 -0
- package/skills/verify-this/SKILL.md +74 -0
- package/skills/weekly-review/SKILL.md +32 -0
- package/skills/what-did-i-get-done/SKILL.md +31 -0
- package/skills/why/SKILL.md +1 -1
- package/skills/workflow-from-chats/SKILL.md +50 -0
- package/src/mode.ts +3 -3
- package/src/paths.ts +13 -0
- package/src/pi-command.ts +12 -0
- package/src/rule.ts +4 -4
- package/src/sections.ts +2 -1
- package/src/subscriptions/durable-store.ts +482 -0
- package/src/subscriptions/durable-types.ts +41 -0
- package/src/subscriptions/ensure-supervisor.ts +67 -0
- package/src/subscriptions/index.ts +231 -271
- package/src/subscriptions/supervisor-main.ts +88 -0
- package/src/subscriptions/supervisor.ts +271 -0
- package/src/subscriptions/webhook.ts +22 -9
- package/src/task/card.ts +29 -0
- package/src/task/records.ts +3 -8
- package/src/task/runner.ts +9 -14
- package/src/task/tool.ts +18 -21
- package/src/todo.ts +1 -2
- package/scripts/live-e2e.mjs +0 -257
- package/test/aliases.test.ts +0 -57
- package/test/ask.test.ts +0 -85
- package/test/autocomplete.test.ts +0 -39
- package/test/e2e/aliases.e2e.test.ts +0 -81
- package/test/e2e/ask.e2e.test.ts +0 -135
- package/test/e2e/harness.ts +0 -179
- package/test/e2e/host.e2e.test.ts +0 -193
- package/test/e2e/lifecycle.e2e.test.ts +0 -99
- package/test/e2e/mode.e2e.test.ts +0 -160
- package/test/e2e/package.e2e.test.ts +0 -67
- package/test/e2e/task.e2e.test.ts +0 -734
- package/test/e2e/todo.e2e.test.ts +0 -59
- package/test/fixtures/cursor-askquestion-calls.json +0 -806
- package/test/fixtures/fake-pi.mjs +0 -96
- package/test/fixtures/image-model.ts +0 -16
- package/test/fixtures/list-models.ts +0 -15
- package/test/fixtures/readonly-shell-exit.ts +0 -17
- package/test/fixtures/scripted-model.ts +0 -113
- package/test/loop.test.ts +0 -209
- package/test/mode.test.ts +0 -143
- package/test/plan.test.ts +0 -126
- package/test/readonly-shell-security.test.ts +0 -84
- package/test/readonly-shell.test.ts +0 -44
- package/test/records.test.ts +0 -143
- package/test/registry.test.ts +0 -18
- package/test/resources.test.ts +0 -162
- package/test/rule.test.ts +0 -92
- package/test/scripts.test.ts +0 -182
- package/test/sections.test.ts +0 -56
- package/test/subscriptions-cron.test.ts +0 -60
- package/test/subscriptions.test.ts +0 -598
- package/test/task-agents.test.ts +0 -49
- package/test/task-card.test.ts +0 -34
- package/test/task-fork.test.ts +0 -233
- package/test/task-format.test.ts +0 -65
- package/test/task-model.test.ts +0 -141
- package/test/task-runner.test.ts +0 -259
- package/test/task-schema.test.ts +0 -50
- package/test/task-status.test.ts +0 -155
- package/test/task-worktree.test.ts +0 -106
- package/test/todo.test.ts +0 -74
- package/test/visualize.test.ts +0 -152
- package/tsconfig.json +0 -5
- package/vitest.config.ts +0 -16
package/test/task-runner.test.ts
DELETED
|
@@ -1,259 +0,0 @@
|
|
|
1
|
-
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import { tmpdir } from 'node:os';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
|
-
|
|
5
|
-
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
|
6
|
-
import { PACKAGE_ROOT } from '../src/paths.ts';
|
|
7
|
-
import { childArgs, resolveAttachments, startChild, validateAgentId, withAttachments } from '../src/task/runner.ts';
|
|
8
|
-
|
|
9
|
-
const FAKE_PI = join(PACKAGE_ROOT, 'test/fixtures/fake-pi.mjs');
|
|
10
|
-
const base = { sessionDir: '/state/subagents', agentId: '11111111-2222-4333-8444-555555555555' };
|
|
11
|
-
|
|
12
|
-
describe('childArgs (SEC-001, TASK-008)', () => {
|
|
13
|
-
test('always runs one RPC session per agent ID with this package loaded, so its owner can steer and stop it (C-H4)', () => {
|
|
14
|
-
expect(childArgs(base)).toEqual(['--mode', 'rpc', '--session-dir', '/state/subagents', '--session-id', base.agentId, '-e', PACKAGE_ROOT]);
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
test('adds trust, model, thinking, and readonly tools in that order', () => {
|
|
18
|
-
expect(childArgs({ ...base, trusted: true, model: { provider: 'p', id: 'm', thinking: 'high' }, readonly: true })).toEqual([
|
|
19
|
-
'--mode',
|
|
20
|
-
'rpc',
|
|
21
|
-
'--session-dir',
|
|
22
|
-
'/state/subagents',
|
|
23
|
-
'--session-id',
|
|
24
|
-
base.agentId,
|
|
25
|
-
'-e',
|
|
26
|
-
PACKAGE_ROOT,
|
|
27
|
-
'--approve',
|
|
28
|
-
'--model',
|
|
29
|
-
'p/m',
|
|
30
|
-
'--thinking',
|
|
31
|
-
'high',
|
|
32
|
-
'--tools',
|
|
33
|
-
'read,grep,find,ls,bash,TodoWrite',
|
|
34
|
-
'--no-mcp',
|
|
35
|
-
]);
|
|
36
|
-
});
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
test('a writable launch adds back the tools a plan-mode launch took; a readonly one never does (C-M1)', () => {
|
|
40
|
-
expect([childArgs({ ...base, restoreTools: ['edit', 'write'] }).slice(-2), childArgs({ ...base, readonly: true, restoreTools: ['edit'] }).includes('+edit')]).toEqual([['--tools', '+edit,+write'], false]);
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
describe('attachments (TASK-016)', () => {
|
|
44
|
-
let dir = '';
|
|
45
|
-
beforeEach(() => {
|
|
46
|
-
dir = mkdtempSync(join(tmpdir(), 'attach-'));
|
|
47
|
-
});
|
|
48
|
-
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
49
|
-
|
|
50
|
-
test('builds the message Pi builds from @file arguments: the prompt, each text file in a <file> block, images as attachments', () => {
|
|
51
|
-
const png = Buffer.from('89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489', 'hex');
|
|
52
|
-
writeFileSync(join(dir, 'note.txt'), '\uFEFFhello\n');
|
|
53
|
-
writeFileSync(join(dir, 'empty.txt'), '');
|
|
54
|
-
writeFileSync(join(dir, 'shot.png'), png);
|
|
55
|
-
const input = withAttachments(' look at these\n', [join(dir, 'note.txt'), join(dir, 'empty.txt'), join(dir, 'shot.png')]);
|
|
56
|
-
expect(input).toEqual({
|
|
57
|
-
text: `look at these<file name="${join(dir, 'note.txt')}">\nhello\n\n</file>\n<file name="${join(dir, 'shot.png')}"></file>\n`,
|
|
58
|
-
images: [{ type: 'image', mimeType: 'image/png', data: png.toString('base64') }],
|
|
59
|
-
});
|
|
60
|
-
expect(withAttachments('plain', [])).toEqual({ text: 'plain', images: [] });
|
|
61
|
-
});
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
describe('input validation (SEC-001, TASK-016)', () => {
|
|
65
|
-
let dir = '';
|
|
66
|
-
beforeEach(() => {
|
|
67
|
-
dir = mkdtempSync(join(tmpdir(), 'attach-'));
|
|
68
|
-
});
|
|
69
|
-
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
70
|
-
|
|
71
|
-
test.for(['../escape', '', 'a/b', '-flag', 'x'.repeat(200)])('rejects the agent ID %j', (id) => {
|
|
72
|
-
expect(() => validateAgentId(id)).toThrow('is not a valid agent ID');
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
test('accepts a UUID agent ID', () => {
|
|
76
|
-
expect(validateAgentId(base.agentId)).toBe(base.agentId);
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
test('passes the parent project trust decision on (review r2: security)', () => {
|
|
80
|
-
expect([childArgs({ ...base, trusted: true }).includes('--approve'), childArgs({ ...base, trusted: false }).includes('--no-approve')]).toEqual([true, true]);
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
test('rejects a directory attachment and expands ~ as Pi read paths do (review r2: security 4)', () => {
|
|
84
|
-
mkdirSync(join(dir, 'adir'));
|
|
85
|
-
mkdirSync(join(dir, 'home'));
|
|
86
|
-
writeFileSync(join(dir, 'home/x.txt'), 'x');
|
|
87
|
-
expect(() => resolveAttachments(['adir'], dir)).toThrow(`file_attachments: ${join(dir, 'adir')} is not a file`);
|
|
88
|
-
expect(resolveAttachments(['~/x.txt'], dir, join(dir, 'home'))).toEqual([join(dir, 'home/x.txt')]);
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
test('resolves attachments against the working directory and rejects missing files', () => {
|
|
92
|
-
writeFileSync(join(dir, 'shot.png'), 'png');
|
|
93
|
-
expect(resolveAttachments(['shot.png', join(dir, 'shot.png')], dir)).toEqual([join(dir, 'shot.png'), join(dir, 'shot.png')]);
|
|
94
|
-
expect(() => resolveAttachments(['missing.png'], dir)).toThrow(`file_attachments: ${join(dir, 'missing.png')} does not exist`);
|
|
95
|
-
});
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
function run(mode: string, prompt = 'hello', signal?: AbortSignal, onEvent?: (event: { type: string }) => void) {
|
|
99
|
-
return startChild({ command: [process.execPath, FAKE_PI], args: ['--x', 'y'], prompt, cwd: tmpdir(), env: { ...process.env, FAKE_PI_MODE: mode }, ...(signal ? { signal } : {}), ...(onEvent ? { onEvent } : {}) });
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
const alive = (pid: number) => {
|
|
103
|
-
try {
|
|
104
|
-
process.kill(pid, 0);
|
|
105
|
-
return true;
|
|
106
|
-
} catch {
|
|
107
|
-
return false;
|
|
108
|
-
}
|
|
109
|
-
};
|
|
110
|
-
|
|
111
|
-
async function eventually(check: () => boolean, timeoutMs = 8_000): Promise<boolean> {
|
|
112
|
-
const deadline = Date.now() + timeoutMs;
|
|
113
|
-
while (Date.now() < deadline) {
|
|
114
|
-
if (check()) return true;
|
|
115
|
-
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
116
|
-
}
|
|
117
|
-
return check();
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
describe('startChild', () => {
|
|
121
|
-
test('sends the prompt as an RPC prompt and returns the final assistant text with summed usage', async () => {
|
|
122
|
-
const outcome = await run('echo', 'line one\n"quoted" $HOME').done;
|
|
123
|
-
expect(outcome.finalText).toBe('GOT:line one\n"quoted" $HOME|ARGS:--x y');
|
|
124
|
-
expect(outcome.usage).toEqual({ input: 20, output: 10, cacheRead: 2, cacheWrite: 4, totalTokens: 36, cost: { input: 0.2, output: 0.4, cacheRead: 0, cacheWrite: 0, total: 0.6 } });
|
|
125
|
-
expect(outcome.failure).toBeUndefined();
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
test('a command that cannot start has no pid and fails clearly (review recheck: no record for a child that never ran)', async () => {
|
|
129
|
-
const handle = startChild({ command: [join(tmpdir(), 'no-such-pi-binary')], args: [], prompt: 'x', cwd: tmpdir(), env: process.env });
|
|
130
|
-
expect(handle.pid).toBeUndefined();
|
|
131
|
-
expect((await handle.done).failure).toBe(`could not start pi: spawn ${join(tmpdir(), 'no-such-pi-binary')} ENOENT`);
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
test('a child that exits without reading a large prompt fails cleanly instead of crashing Pi (review r2: security 1)', async () => {
|
|
135
|
-
const handle = startChild({ command: [process.execPath, '-e', 'process.exit(3)'], args: [], prompt: 'x'.repeat(4 * 1024 * 1024), cwd: tmpdir(), env: process.env });
|
|
136
|
-
expect((await handle.done).failure).toBe('pi exited with code 3:');
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
test('an abort before the child reads its prompt does not crash Pi (review r2: security 1)', async () => {
|
|
140
|
-
const controller = new AbortController();
|
|
141
|
-
controller.abort();
|
|
142
|
-
const handle = startChild({ command: [process.execPath, FAKE_PI], args: [], prompt: 'x'.repeat(4 * 1024 * 1024), cwd: tmpdir(), env: { ...process.env, FAKE_PI_MODE: 'hang' }, signal: controller.signal });
|
|
143
|
-
expect((await handle.done).failure).toBe('Subagent was aborted');
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
test("counts the child's tool-result and compaction usage, as Pi's session totals do (review r2: architecture 2)", async () => {
|
|
147
|
-
const { usage } = await run('nested').done;
|
|
148
|
-
expect([usage.input, usage.output, usage.cacheRead, usage.cacheWrite, usage.totalTokens]).toEqual([40, 20, 4, 8, 72]);
|
|
149
|
-
expect(usage.cost.total).toBeCloseTo(1.2);
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
test('ignores a stdout line that is not JSON instead of crashing the parent (SEC-001)', async () => {
|
|
153
|
-
const outcome = await run('noise').done;
|
|
154
|
-
expect([outcome.finalText, outcome.failure]).toEqual(['after noise', undefined]);
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
test('reports an error stop reason as a failure (TASK-015)', async () => {
|
|
158
|
-
const outcome = await run('error').done;
|
|
159
|
-
expect(outcome.failure).toBe('provider exploded');
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
test('reports a nonzero exit with stderr as a failure (TASK-015)', async () => {
|
|
163
|
-
const outcome = await run('crash').done;
|
|
164
|
-
expect(outcome.failure).toBe('pi exited with code 3: fatal: no model');
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
test('abort kills the child and its descendants (TASK-013)', async () => {
|
|
168
|
-
const controller = new AbortController();
|
|
169
|
-
const pids: number[] = [];
|
|
170
|
-
const handle = startChild({
|
|
171
|
-
command: [process.execPath, FAKE_PI],
|
|
172
|
-
args: [],
|
|
173
|
-
prompt: 'x',
|
|
174
|
-
cwd: tmpdir(),
|
|
175
|
-
env: { ...process.env, FAKE_PI_MODE: 'hang' },
|
|
176
|
-
signal: controller.signal,
|
|
177
|
-
onEvent: (event) => {
|
|
178
|
-
if (event.type === 'fake_pids') pids.push(...(event['pids'] as number[]));
|
|
179
|
-
},
|
|
180
|
-
});
|
|
181
|
-
expect(await eventually(() => pids.length === 2)).toBe(true);
|
|
182
|
-
controller.abort();
|
|
183
|
-
const outcome = await handle.done;
|
|
184
|
-
expect(outcome.failure).toBe('Subagent was aborted');
|
|
185
|
-
expect(await eventually(() => pids.every((pid) => !alive(pid)))).toBe(true);
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
test('kill() stops a running child for session shutdown (TASK-013)', async () => {
|
|
189
|
-
const pids: number[] = [];
|
|
190
|
-
const handle = startChild({
|
|
191
|
-
command: [process.execPath, FAKE_PI],
|
|
192
|
-
args: [],
|
|
193
|
-
prompt: 'x',
|
|
194
|
-
cwd: tmpdir(),
|
|
195
|
-
env: { ...process.env, FAKE_PI_MODE: 'hang' },
|
|
196
|
-
onEvent: (event) => {
|
|
197
|
-
if (event.type === 'fake_pids') pids.push(...(event['pids'] as number[]));
|
|
198
|
-
},
|
|
199
|
-
});
|
|
200
|
-
expect(await eventually(() => pids.length === 2)).toBe(true);
|
|
201
|
-
handle.kill();
|
|
202
|
-
expect((await handle.done).failure).toBe('Subagent was aborted');
|
|
203
|
-
expect(await eventually(() => pids.every((pid) => !alive(pid)))).toBe(true);
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
test('sends attachment images with the prompt', async () => {
|
|
207
|
-
const outcome = await startChild({ command: [process.execPath, FAKE_PI], args: [], prompt: 'see', images: [{ type: 'image', mimeType: 'image/png', data: 'AA==' }], cwd: tmpdir(), env: { ...process.env, FAKE_PI_MODE: 'echo' } }).done;
|
|
208
|
-
expect(outcome.finalText).toBe('GOT:see|IMAGES:image/png|ARGS:');
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
test('answers a dialog request as dismissed, since nobody can answer a subagent (HOST-005)', async () => {
|
|
212
|
-
const outcome = await run('dialog').done;
|
|
213
|
-
expect(JSON.parse(outcome.finalText.slice('ANSWER:'.length))).toEqual({ type: 'extension_ui_response', id: 'dialog-1', cancelled: true });
|
|
214
|
-
});
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
describe('steer and stop (C-H4)', () => {
|
|
218
|
-
const started = (mode: string) => {
|
|
219
|
-
let begun: () => void = () => undefined;
|
|
220
|
-
const running = new Promise<void>((resolve) => {
|
|
221
|
-
begun = resolve;
|
|
222
|
-
});
|
|
223
|
-
const handle = run(mode, 'first', undefined, (event) => {
|
|
224
|
-
if (event.type === 'agent_start') begun();
|
|
225
|
-
});
|
|
226
|
-
return { handle, running };
|
|
227
|
-
};
|
|
228
|
-
|
|
229
|
-
test('steer delivers a message into the running turn, and the agent finishes with it', async () => {
|
|
230
|
-
const { handle, running } = started('wait');
|
|
231
|
-
await running;
|
|
232
|
-
await handle.steer('change course', []);
|
|
233
|
-
const outcome = await handle.done;
|
|
234
|
-
expect([outcome.finalText, outcome.failure]).toEqual(['STEERED:change course|steer', undefined]);
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
test('steer refuses once the agent has finished', async () => {
|
|
238
|
-
const handle = run('echo');
|
|
239
|
-
await handle.done;
|
|
240
|
-
await expect(handle.steer('too late', [])).rejects.toThrow('has finished');
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
test('stop aborts the run over RPC and the child exits', async () => {
|
|
244
|
-
const { handle, running } = started('wait');
|
|
245
|
-
await running;
|
|
246
|
-
handle.stop();
|
|
247
|
-
const outcome = await handle.done;
|
|
248
|
-
expect(outcome.failure).toBe('Subagent was stopped with stop_agent');
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
test('stop kills a child that ignores the abort after the grace period', async () => {
|
|
252
|
-
const { handle, running } = started('deaf');
|
|
253
|
-
await running;
|
|
254
|
-
const before = Date.now();
|
|
255
|
-
handle.stop();
|
|
256
|
-
const outcome = await handle.done;
|
|
257
|
-
expect([outcome.failure, Date.now() - before >= 2_500]).toEqual(['Subagent was stopped with stop_agent', true]);
|
|
258
|
-
});
|
|
259
|
-
});
|
package/test/task-schema.test.ts
DELETED
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from 'vitest';
|
|
2
|
-
import { effectiveReadonly, TaskParams, taskPreflight } from '../src/task/tool.ts';
|
|
3
|
-
|
|
4
|
-
describe('Task parameters (TASK-001)', () => {
|
|
5
|
-
test('requires description and prompt, like Cursor', () => {
|
|
6
|
-
expect(TaskParams.required).toEqual(['description', 'prompt']);
|
|
7
|
-
});
|
|
8
|
-
|
|
9
|
-
test('accepts Cursor parameters plus the readonly and environment fields pstack passes', () => {
|
|
10
|
-
const schema = JSON.parse(JSON.stringify(TaskParams)) as { properties: Record<string, { type?: string; enum?: string[] }> };
|
|
11
|
-
expect(Object.keys(schema.properties).toSorted()).toEqual(['cloud_base_branch', 'description', 'environment', 'file_attachments', 'fork', 'fork_turns', 'model', 'prompt', 'readonly', 'resume', 'run_in_background', 'subagent_type']);
|
|
12
|
-
expect(schema.properties['environment']?.enum).toEqual(['local', 'cloud']);
|
|
13
|
-
expect(schema.properties['run_in_background']?.type).toBe('boolean');
|
|
14
|
-
expect(schema.properties['fork_turns']).toMatchObject({ type: 'integer', minimum: 1 });
|
|
15
|
-
});
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
describe('Task preflight', () => {
|
|
19
|
-
const ok = { description: 'probe', prompt: 'p' };
|
|
20
|
-
|
|
21
|
-
test.for([{ cloud_base_branch: 'feature/x' }, { cloud_base_branch: 'feature/x', environment: 'local' as const }])('refuses cloud_base_branch without environment cloud: %j (TASK-010)', (extra) => {
|
|
22
|
-
expect(() => taskPreflight({ ...ok, ...extra }, 0)).toThrow('cloud_base_branch needs environment "cloud": it names the branch the subagent\'s worktree starts from.');
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
test('allows environment cloud with or without a base branch (TASK-010)', () => {
|
|
26
|
-
expect([taskPreflight({ ...ok, environment: 'cloud' }, 0), taskPreflight({ ...ok, environment: 'cloud', cloud_base_branch: 'main' }, 1)]).toEqual([undefined, undefined]);
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
test('refuses a grandchild launch (TASK-011)', () => {
|
|
30
|
-
expect(() => taskPreflight(ok, 2)).toThrow('A subagent launched by another subagent cannot launch further subagents.');
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
test('allows the main agent and direct subagents (TASK-011)', () => {
|
|
34
|
-
const outcome = (depth: number, environment?: 'local') => {
|
|
35
|
-
try {
|
|
36
|
-
taskPreflight(environment === undefined ? ok : { ...ok, environment }, depth);
|
|
37
|
-
return 'allowed';
|
|
38
|
-
} catch (error) {
|
|
39
|
-
return (error as Error).message;
|
|
40
|
-
}
|
|
41
|
-
};
|
|
42
|
-
expect([outcome(0, 'local'), outcome(1), outcome(2)]).toEqual(['allowed', 'allowed', 'A subagent launched by another subagent cannot launch further subagents.']);
|
|
43
|
-
});
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
describe('readonly (TASK-008; review: security)', () => {
|
|
47
|
-
test('a readonly agent type stays readonly whatever the call asks', () => {
|
|
48
|
-
expect([effectiveReadonly(true, false), effectiveReadonly(true, undefined), effectiveReadonly(false, true), effectiveReadonly(false, undefined)]).toEqual([true, true, true, false]);
|
|
49
|
-
});
|
|
50
|
-
});
|
package/test/task-status.test.ts
DELETED
|
@@ -1,155 +0,0 @@
|
|
|
1
|
-
import { spawnSync } from 'node:child_process';
|
|
2
|
-
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
-
import { tmpdir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
5
|
-
|
|
6
|
-
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
|
7
|
-
import { subagentDir } from '../src/paths.ts';
|
|
8
|
-
import { writeRecord } from '../src/task/records.ts';
|
|
9
|
-
import { agentStatus, lastAssistantText, listAgentStatuses, registerAgentStatus, statusText } from '../src/task/status.ts';
|
|
10
|
-
|
|
11
|
-
const idOf = (n: number) => `${String(n).padStart(8, '0')}-2222-4333-8444-555555555555`;
|
|
12
|
-
let dir = '';
|
|
13
|
-
|
|
14
|
-
function addAgent(n: number, extra: Record<string, unknown> = {}): string {
|
|
15
|
-
const agentId = idOf(n);
|
|
16
|
-
writeRecord(dir, { agentId, type: 'generalPurpose', readonly: false, cwd: '/w/app', createdAt: new Date(Date.UTC(2026, 9, 28, 10, n)).toISOString(), depth: 1, ...extra });
|
|
17
|
-
return agentId;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function addTranscript(agentId: string, texts: string[]): string {
|
|
21
|
-
const header = { type: 'session', version: 3, id: agentId, timestamp: '2026-10-28T10:00:00.000Z', cwd: '/w/app' };
|
|
22
|
-
const messages = texts.map((text, index) => ({
|
|
23
|
-
type: 'message',
|
|
24
|
-
id: `e${index}`,
|
|
25
|
-
parentId: index === 0 ? null : `e${index - 1}`,
|
|
26
|
-
timestamp: '2026-10-28T10:00:01.000Z',
|
|
27
|
-
message: { role: index % 2 === 0 ? 'user' : 'assistant', content: [{ type: 'text', text }] },
|
|
28
|
-
}));
|
|
29
|
-
const path = join(dir, `2026-10-28T10-00-00-000Z_${agentId}.jsonl`);
|
|
30
|
-
writeFileSync(path, `${[header, ...messages].map((entry) => JSON.stringify(entry)).join('\n')}\n`);
|
|
31
|
-
return path;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const deadPid = () => spawnSync(process.execPath, ['-e', 'process.stdout.write(String(process.pid))'], { encoding: 'utf8' }).stdout;
|
|
35
|
-
|
|
36
|
-
beforeEach(() => {
|
|
37
|
-
dir = mkdtempSync(join(tmpdir(), 'status-'));
|
|
38
|
-
});
|
|
39
|
-
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
40
|
-
|
|
41
|
-
describe('agent_status: one agent', () => {
|
|
42
|
-
test('running while the run lock names a live pid', () => {
|
|
43
|
-
const agentId = addAgent(1, { model: { provider: 'p', id: 'm', thinking: 'high' } });
|
|
44
|
-
addTranscript(agentId, ['task', 'working on it']);
|
|
45
|
-
writeFileSync(join(dir, `${agentId}.lock`), String(process.pid));
|
|
46
|
-
expect(agentStatus(dir, agentId)).toMatchObject({ agentId, state: 'running', pid: process.pid, type: 'generalPurpose', model: 'p/m:high', directory: '/w/app', createdAt: '2026-10-28T10:01:00.000Z', lastText: 'working on it' });
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
test('idle with no lock or a lock whose pid is dead', () => {
|
|
50
|
-
const agentId = addAgent(1);
|
|
51
|
-
addTranscript(agentId, ['task', 'done']);
|
|
52
|
-
expect(agentStatus(dir, agentId).state).toBe('idle');
|
|
53
|
-
writeFileSync(join(dir, `${agentId}.lock`), deadPid());
|
|
54
|
-
expect(agentStatus(dir, agentId).state).toBe('idle');
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
test('missing without a record or transcript', () => {
|
|
58
|
-
expect(agentStatus(dir, idOf(7))).toEqual({ agentId: idOf(7), state: 'missing' });
|
|
59
|
-
expect(statusText(agentStatus(dir, idOf(7)))).toContain('missing');
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
test('names the worktree directory and branch, and the fork source', () => {
|
|
63
|
-
const worktree = { path: '/wt/a', cwd: '/wt/a/pkg', branch: 'pstack/a', base: 'HEAD', commit: 'abc' };
|
|
64
|
-
const agentId = addAgent(1, { worktree, forkedFrom: idOf(9) });
|
|
65
|
-
const status = agentStatus(dir, agentId);
|
|
66
|
-
expect(status).toMatchObject({ directory: '/wt/a/pkg', branch: 'pstack/a', forkedFrom: idOf(9), model: "Pi's default model" });
|
|
67
|
-
expect(status.updatedAt).toBeDefined();
|
|
68
|
-
expect(statusText(status)).toContain('branch: pstack/a');
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
test('the last assistant text is the latest non-empty one, truncated to 500 characters', () => {
|
|
72
|
-
const agentId = addAgent(1);
|
|
73
|
-
const path = addTranscript(agentId, ['task', 'x'.repeat(800), 'more', '']);
|
|
74
|
-
expect(lastAssistantText(path)).toBe(`${'x'.repeat(500)}…`);
|
|
75
|
-
});
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
describe('agent_status: bounded transcript read (C-L3)', () => {
|
|
79
|
-
const line = (entry: unknown) => `${JSON.stringify(entry)}\n`;
|
|
80
|
-
const message = (role: string, text: string) => ({ type: 'message', message: { role, content: [{ type: 'text', text }] } });
|
|
81
|
-
const header = { type: 'session', version: 3, id: idOf(1), cwd: '/w/app' };
|
|
82
|
-
|
|
83
|
-
test('finds the last response behind a tool result larger than one read', () => {
|
|
84
|
-
const path = join(dir, 'big.jsonl');
|
|
85
|
-
writeFileSync(path, [line(header), line(message('assistant', 'early')), line(message('assistant', 'LAST-RESPONSE')), line(message('toolResult', 'x'.repeat(300_000)))].join(''));
|
|
86
|
-
expect(lastAssistantText(path)).toBe('LAST-RESPONSE');
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
test('reads at most the transcript tail, so a response buried under megabytes of tool output is not shown', () => {
|
|
90
|
-
const path = join(dir, 'huge.jsonl');
|
|
91
|
-
writeFileSync(path, [line(header), line(message('assistant', 'BURIED')), line(message('toolResult', 'x'.repeat(5 * 1024 * 1024)))].join(''));
|
|
92
|
-
expect({ last: lastAssistantText(path) }).toEqual({ last: undefined });
|
|
93
|
-
});
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
describe('agent_status: listing', () => {
|
|
97
|
-
test('lists agents newest first', () => {
|
|
98
|
-
for (const n of [2, 5, 3]) addAgent(n);
|
|
99
|
-
expect(listAgentStatuses(dir).agents.map((status) => status.agentId)).toEqual([idOf(5), idOf(3), idOf(2)]);
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
test('is bounded, reporting the total', () => {
|
|
103
|
-
for (let n = 1; n <= 25; n += 1) addAgent(n);
|
|
104
|
-
const { agents, total } = listAgentStatuses(dir);
|
|
105
|
-
expect([agents.length, total, agents[0]?.agentId]).toEqual([20, 25, idOf(25)]);
|
|
106
|
-
expect(listAgentStatuses(dir, 2).agents.map((status) => status.agentId)).toEqual([idOf(25), idOf(24)]);
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
test('skips a record without a creation time instead of failing the whole list (C-L3)', () => {
|
|
110
|
-
addAgent(2);
|
|
111
|
-
writeFileSync(join(dir, `${idOf(3)}.json`), JSON.stringify({ agentId: idOf(3), type: 'generalPurpose', readonly: false, cwd: '/w/app' }));
|
|
112
|
-
expect(listAgentStatuses(dir)).toMatchObject({ agents: [{ agentId: idOf(2) }], total: 1 });
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
test('an empty or absent directory lists nothing', () => {
|
|
116
|
-
expect(listAgentStatuses(join(dir, 'none'))).toEqual({ agents: [], total: 0 });
|
|
117
|
-
});
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
describe('agent_status tool', () => {
|
|
121
|
-
type Tool = { name: string; execute: (id: string, params: { agent_id?: string }, signal: undefined, onUpdate: undefined, ctx: { cwd: string }) => Promise<{ content: { text: string }[] }> };
|
|
122
|
-
const register = () => {
|
|
123
|
-
const tools: Tool[] = [];
|
|
124
|
-
registerAgentStatus({ registerTool: (tool: Tool) => tools.push(tool) } as never);
|
|
125
|
-
return tools[0] as Tool;
|
|
126
|
-
};
|
|
127
|
-
|
|
128
|
-
test('is annotated read-only and closed-world (C-L6)', () => {
|
|
129
|
-
const tools: { annotations?: unknown }[] = [];
|
|
130
|
-
registerAgentStatus({ registerTool: (tool: { annotations?: unknown }) => tools.push(tool) } as never);
|
|
131
|
-
expect(tools[0]?.annotations).toEqual({ readOnlyHint: true, openWorldHint: false });
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
test('reads the workspace subagent directory and never takes a run lock', async () => {
|
|
135
|
-
const previous = process.env['PI_CODING_AGENT_DIR'];
|
|
136
|
-
process.env['PI_CODING_AGENT_DIR'] = join(dir, 'agent');
|
|
137
|
-
try {
|
|
138
|
-
const tool = register();
|
|
139
|
-
expect(tool.name).toBe('agent_status');
|
|
140
|
-
const workspace = join(dir, 'w');
|
|
141
|
-
expect((await tool.execute('t', {}, undefined, undefined, { cwd: workspace })).content[0]?.text).toBe('No subagents in this workspace yet.');
|
|
142
|
-
dir = subagentDir(workspace);
|
|
143
|
-
const agentId = addAgent(1);
|
|
144
|
-
addTranscript(agentId, ['task', 'all done']);
|
|
145
|
-
const text = (await tool.execute('t', { agent_id: agentId }, undefined, undefined, { cwd: workspace })).content[0]?.text ?? '';
|
|
146
|
-
expect(text).toContain('state: idle');
|
|
147
|
-
expect(text).toContain('all done');
|
|
148
|
-
expect(listAgentStatuses(dir).agents[0]?.state).toBe('idle');
|
|
149
|
-
await expect(tool.execute('t', { agent_id: '../x' }, undefined, undefined, { cwd: workspace })).rejects.toThrow('is not a valid agent ID');
|
|
150
|
-
} finally {
|
|
151
|
-
if (previous === undefined) delete process.env['PI_CODING_AGENT_DIR'];
|
|
152
|
-
else process.env['PI_CODING_AGENT_DIR'] = previous;
|
|
153
|
-
}
|
|
154
|
-
});
|
|
155
|
-
});
|
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
import { execFileSync } from 'node:child_process';
|
|
2
|
-
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
-
import { tmpdir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
5
|
-
|
|
6
|
-
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
|
7
|
-
import { addWorktree, planWorktree } from '../src/task/worktree.ts';
|
|
8
|
-
|
|
9
|
-
const id = '11111111-2222-4333-8444-555555555555';
|
|
10
|
-
let root = '';
|
|
11
|
-
let repo = '';
|
|
12
|
-
let worktrees = '';
|
|
13
|
-
|
|
14
|
-
const git = (...args: string[]) => execFileSync('git', ['-C', repo, '-c', 'user.email=t@t', '-c', 'user.name=t', ...args], { encoding: 'utf8' }).trim();
|
|
15
|
-
const commitFile = (name: string, text: string, message: string) => {
|
|
16
|
-
writeFileSync(join(repo, name), text);
|
|
17
|
-
git('add', name);
|
|
18
|
-
git('commit', '-qm', message);
|
|
19
|
-
return git('rev-parse', 'HEAD');
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
beforeEach(() => {
|
|
23
|
-
root = realpathSync(mkdtempSync(join(tmpdir(), 'worktree-')));
|
|
24
|
-
repo = join(root, 'repo');
|
|
25
|
-
worktrees = join(root, 'worktrees');
|
|
26
|
-
mkdirSync(repo);
|
|
27
|
-
git('init', '-q', '-b', 'main');
|
|
28
|
-
});
|
|
29
|
-
afterEach(() => rmSync(root, { recursive: true, force: true }));
|
|
30
|
-
|
|
31
|
-
describe('planWorktree', () => {
|
|
32
|
-
test('without a base branch, branches from the parent HEAD commit', () => {
|
|
33
|
-
const head = commitFile('a.txt', 'A', 'first');
|
|
34
|
-
expect(planWorktree(repo, worktrees, id)).toEqual({ path: join(worktrees, id), cwd: join(worktrees, id), branch: `pstack/${id}`, base: 'HEAD', commit: head });
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
test('a parent inside a tracked subdirectory runs the child in the same subdirectory of the worktree', () => {
|
|
38
|
-
mkdirSync(join(repo, 'pkg/inner'), { recursive: true });
|
|
39
|
-
writeFileSync(join(repo, 'pkg/inner/x.txt'), 'X');
|
|
40
|
-
git('add', '.');
|
|
41
|
-
git('commit', '-qm', 'pkg');
|
|
42
|
-
expect(planWorktree(join(repo, 'pkg/inner'), worktrees, id).cwd).toBe(join(worktrees, id, 'pkg/inner'));
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
test('a subdirectory the base commit lacks runs the child at the worktree root', () => {
|
|
46
|
-
commitFile('a.txt', 'A', 'first');
|
|
47
|
-
mkdirSync(join(repo, 'untracked'));
|
|
48
|
-
expect(planWorktree(join(repo, 'untracked'), worktrees, id).cwd).toBe(join(worktrees, id));
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
test('a cloud_base_branch that names a local branch is the base', () => {
|
|
52
|
-
commitFile('a.txt', 'A', 'first');
|
|
53
|
-
git('checkout', '-qb', 'feature');
|
|
54
|
-
const feature = commitFile('a.txt', 'F', 'feature');
|
|
55
|
-
git('checkout', '-q', 'main');
|
|
56
|
-
expect(planWorktree(repo, worktrees, id, 'feature')).toMatchObject({ base: 'feature', commit: feature });
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
test('a cloud_base_branch that only exists on origin falls back to origin/<branch>', () => {
|
|
60
|
-
const first = commitFile('a.txt', 'A', 'first');
|
|
61
|
-
git('update-ref', 'refs/remotes/origin/remote-only', first);
|
|
62
|
-
commitFile('a.txt', 'B', 'second');
|
|
63
|
-
expect(planWorktree(repo, worktrees, id, 'remote-only')).toMatchObject({ base: 'origin/remote-only', commit: first });
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
test('a cloud_base_branch that resolves nowhere fails, naming both refs it tried', () => {
|
|
67
|
-
commitFile('a.txt', 'A', 'first');
|
|
68
|
-
expect(() => planWorktree(repo, worktrees, id, 'nope')).toThrow(`cloud_base_branch "nope" names no commit in ${repo}: neither nope nor origin/nope exists. Pass an existing branch or omit cloud_base_branch to start from HEAD.`);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
test('a directory outside any git repository fails', () => {
|
|
72
|
-
const plain = join(root, 'plain');
|
|
73
|
-
mkdirSync(plain);
|
|
74
|
-
expect(() => planWorktree(plain, worktrees, id)).toThrow(`environment "cloud" runs the subagent in its own git worktree, but ${plain} is not inside a git repository. Omit environment to run the subagent here.`);
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
test('a repository without commits fails, because HEAD names no commit to branch from', () => {
|
|
78
|
-
expect(() => planWorktree(repo, worktrees, id)).toThrow(`environment "cloud" branches the subagent's worktree from HEAD, but ${repo} has no commits yet.`);
|
|
79
|
-
});
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
describe('addWorktree', () => {
|
|
83
|
-
test('creates the branch at the base commit with only committed files, leaving the parent checkout alone', async () => {
|
|
84
|
-
const head = commitFile('a.txt', 'COMMITTED', 'first');
|
|
85
|
-
writeFileSync(join(repo, 'a.txt'), 'DIRTY');
|
|
86
|
-
const worktree = planWorktree(repo, worktrees, id);
|
|
87
|
-
await addWorktree(repo, worktree, undefined);
|
|
88
|
-
expect([git('rev-parse', `pstack/${id}`), readFileSync(join(worktree.path, 'a.txt'), 'utf8'), readFileSync(join(repo, 'a.txt'), 'utf8'), git('branch', '--show-current')]).toEqual([head, 'COMMITTED', 'DIRTY', 'main']);
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
test('reports git failure, such as a branch that already exists', async () => {
|
|
92
|
-
commitFile('a.txt', 'A', 'first');
|
|
93
|
-
git('branch', `pstack/${id}`);
|
|
94
|
-
await expect(addWorktree(repo, planWorktree(repo, worktrees, id), undefined)).rejects.toThrow(`Could not create the subagent's worktree at ${join(worktrees, id)}: fatal: a branch named 'pstack/${id}' already exists`);
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
test('an abort stops a slow checkout instead of blocking until it ends', async () => {
|
|
98
|
-
commitFile('a.txt', 'A', 'first');
|
|
99
|
-
writeFileSync(join(repo, '.git/hooks/post-checkout'), '#!/bin/sh\nsleep 30\n', { mode: 0o755 });
|
|
100
|
-
const controller = new AbortController();
|
|
101
|
-
setTimeout(() => controller.abort(), 200);
|
|
102
|
-
const started = Date.now();
|
|
103
|
-
await expect(addWorktree(repo, planWorktree(repo, worktrees, id), controller.signal)).rejects.toThrow(`Stopped creating the subagent's worktree at ${join(worktrees, id)}.`);
|
|
104
|
-
expect(Date.now() - started).toBeLessThan(5000);
|
|
105
|
-
});
|
|
106
|
-
});
|
package/test/todo.test.ts
DELETED
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from 'vitest';
|
|
2
|
-
import { applyTodoWrite, formatTodos, type TodoItem, todosFromBranch } from '../src/todo.ts';
|
|
3
|
-
|
|
4
|
-
const list: TodoItem[] = [
|
|
5
|
-
{ id: 'frame', content: 'Frame', status: 'completed' },
|
|
6
|
-
{ id: 'fan-out', content: 'Fan out', status: 'in_progress' },
|
|
7
|
-
];
|
|
8
|
-
|
|
9
|
-
describe('applyTodoWrite (TODO-001)', () => {
|
|
10
|
-
test('replaces the list when merge is false', () => {
|
|
11
|
-
expect(applyTodoWrite(list, [{ id: 'x', content: 'Only', status: 'pending' }], false)).toEqual([{ id: 'x', content: 'Only', status: 'pending' }]);
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
test('merges by id, keeps order, fills omitted fields, and appends new ids', () => {
|
|
15
|
-
expect(
|
|
16
|
-
applyTodoWrite(
|
|
17
|
-
list,
|
|
18
|
-
[
|
|
19
|
-
{ id: 'fan-out', status: 'completed' },
|
|
20
|
-
{ id: 'report', content: 'Report', status: 'pending' },
|
|
21
|
-
],
|
|
22
|
-
true,
|
|
23
|
-
),
|
|
24
|
-
).toEqual([
|
|
25
|
-
{ id: 'frame', content: 'Frame', status: 'completed' },
|
|
26
|
-
{ id: 'fan-out', content: 'Fan out', status: 'completed' },
|
|
27
|
-
{ id: 'report', content: 'Report', status: 'pending' },
|
|
28
|
-
]);
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
test('rejects a new item without content or status', () => {
|
|
32
|
-
expect(() => applyTodoWrite(list, [{ id: 'new', status: 'pending' }], true)).toThrow('Todo "new" is new, so it needs both content and status.');
|
|
33
|
-
expect(() => applyTodoWrite([], [{ id: 'a', content: 'A' }], false)).toThrow('Todo "a" is new, so it needs both content and status.');
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
test('rejects duplicate ids in one call', () => {
|
|
37
|
-
expect(() =>
|
|
38
|
-
applyTodoWrite(
|
|
39
|
-
[],
|
|
40
|
-
[
|
|
41
|
-
{ id: 'a', content: 'A', status: 'pending' },
|
|
42
|
-
{ id: 'a', content: 'B', status: 'pending' },
|
|
43
|
-
],
|
|
44
|
-
false,
|
|
45
|
-
),
|
|
46
|
-
).toThrow('Todo id "a" appears more than once in this call.');
|
|
47
|
-
});
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
describe('formatTodos', () => {
|
|
51
|
-
test('summarizes counts and marks each status', () => {
|
|
52
|
-
const text = formatTodos([...list, { id: 'skip', content: 'skip: not needed', status: 'cancelled' }, { id: 'later', content: 'Later', status: 'pending' }]);
|
|
53
|
-
expect(text).toBe('Todos (1/4 completed, 1 in progress):\n[x] frame: Frame\n[>] fan-out: Fan out\n[-] skip: skip: not needed\n[ ] later: Later');
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
test('reports an empty list', () => {
|
|
57
|
-
expect(formatTodos([])).toBe('Todos: none.');
|
|
58
|
-
});
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
describe('todosFromBranch (TODO-002)', () => {
|
|
62
|
-
test('takes the last TodoWrite result on the branch and ignores other tools', () => {
|
|
63
|
-
const entry = (toolName: string, todos: TodoItem[]) => ({ type: 'message', message: { role: 'toolResult', toolName, details: { todos } } });
|
|
64
|
-
const branch = [entry('TodoWrite', list), entry('read', []), entry('TodoWrite', [list[0] as TodoItem]), { type: 'custom', customType: 'x' }];
|
|
65
|
-
expect(todosFromBranch(branch)).toEqual([list[0]]);
|
|
66
|
-
expect(todosFromBranch([])).toEqual([]);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
test('skips a failed TodoWrite, whose details Pi stores as {} (reviews: parity 2, architecture 1)', () => {
|
|
70
|
-
const ok = { type: 'message', message: { role: 'toolResult', toolName: 'TodoWrite', details: { todos: list } } };
|
|
71
|
-
const failed = { type: 'message', message: { role: 'toolResult', toolName: 'TodoWrite', isError: true, details: {} } };
|
|
72
|
-
expect(todosFromBranch([ok, failed])).toEqual(list);
|
|
73
|
-
});
|
|
74
|
-
});
|