@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-card.test.ts
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
import { expect, test } from 'vitest';
|
|
2
|
-
import { taskCardText } from '../src/task/card.ts';
|
|
3
|
-
|
|
4
|
-
const result = (model: unknown, text: string) => ({ content: [{ type: 'text' as const, text }], details: { subagentType: 'poteto-agent', model } });
|
|
5
|
-
|
|
6
|
-
test('the task card names the model that ran, with its thinking level (TASK-017)', () => {
|
|
7
|
-
expect(taskCardText(result({ provider: 'claude-subscription', id: 'claude-opus-5-5', thinking: 'high' }, 'done'), true).split('\n')[0]).toBe('poteto-agent · claude-subscription/claude-opus-5-5:high');
|
|
8
|
-
});
|
|
9
|
-
|
|
10
|
-
test('a card without a resolved model says Pi picked its default (TASK-017)', () => {
|
|
11
|
-
expect(taskCardText(result(null, 'done'), true).split('\n')[0]).toBe("poteto-agent · Pi's default model");
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
test('the card keeps the result text, shortened until expanded (TASK-017)', () => {
|
|
15
|
-
const long = Array.from({ length: 30 }, (_, index) => `line ${index}`).join('\n');
|
|
16
|
-
expect(taskCardText(result(null, long), true).split('\n')).toHaveLength(31);
|
|
17
|
-
const collapsed = taskCardText(result(null, long), false).split('\n');
|
|
18
|
-
expect(collapsed.slice(1, 3)).toEqual(['line 0', 'line 1']);
|
|
19
|
-
expect(collapsed.at(-1)).toBe('… 20 more lines');
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
test('a result without Task details renders its text alone (TASK-017)', () => {
|
|
23
|
-
expect(taskCardText({ content: [{ type: 'text', text: 'Invalid model' }], details: undefined }, false)).toBe('Invalid model');
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
test('a cloud agent card names its worktree branch (TASK-010)', () => {
|
|
27
|
-
const details = { subagentType: 'poteto-agent', model: null, worktree: { path: '/w', cwd: '/w', branch: 'pstack/x', base: 'HEAD', commit: 'c' } };
|
|
28
|
-
expect(taskCardText({ content: [{ type: 'text', text: 'done' }], details }, true).split('\n')[0]).toBe("poteto-agent · Pi's default model · branch pstack/x");
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
test('the card leads with the Task description, as Cursor names the subagent by it', () => {
|
|
32
|
-
const details = { subagentType: 'poteto-agent', model: null, description: 'Audit skill corpus' };
|
|
33
|
-
expect(taskCardText({ content: [{ type: 'text', text: 'done' }], details }, true).split('\n')[0]).toBe("Audit skill corpus · poteto-agent · Pi's default model");
|
|
34
|
-
});
|
package/test/task-fork.test.ts
DELETED
|
@@ -1,233 +0,0 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import { tmpdir } from 'node:os';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
|
-
|
|
5
|
-
import { type ExtensionContext, SessionManager } from '@earendil-works/pi-coding-agent';
|
|
6
|
-
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
|
7
|
-
import { subagentDir } from '../src/paths.ts';
|
|
8
|
-
import { type AgentDefinition, GENERAL_PURPOSE } from '../src/task/agents.ts';
|
|
9
|
-
import { type AgentRecord, acquireRunLock, forkTranscript, transcriptFor, writeRecord } from '../src/task/records.ts';
|
|
10
|
-
import { planFork, prepareFork, taskPreflight } from '../src/task/tool.ts';
|
|
11
|
-
import type { Worktree } from '../src/task/worktree.ts';
|
|
12
|
-
|
|
13
|
-
const source = '11111111-2222-4333-8444-555555555555';
|
|
14
|
-
const forkId = '99999999-2222-4333-8444-555555555555';
|
|
15
|
-
const reviewer: AgentDefinition = { type: 'reviewer', description: 'r', readonly: true, background: false, prompt: '' };
|
|
16
|
-
const agents = [GENERAL_PURPOSE, reviewer];
|
|
17
|
-
const notRunning = () => false;
|
|
18
|
-
|
|
19
|
-
let root = '';
|
|
20
|
-
let workspace = '';
|
|
21
|
-
let dir = '';
|
|
22
|
-
let previousAgentDir: string | undefined;
|
|
23
|
-
|
|
24
|
-
const ctx = () =>
|
|
25
|
-
({ cwd: workspace, model: undefined, thinkingLevel: undefined, modelRegistry: { getAvailable: () => [] }, sessionManager: { getSessionId: () => 'parent-session' }, isProjectTrusted: () => true }) as unknown as ExtensionContext;
|
|
26
|
-
const params = (extra: Record<string, unknown> = {}) => ({ description: 'fork it', prompt: 'next task', fork: source, ...extra });
|
|
27
|
-
|
|
28
|
-
function writeTranscript(agentId: string, cwd: string): string {
|
|
29
|
-
const header = { type: 'session', version: 3, id: agentId, timestamp: '2026-10-28T10:00:00.000Z', cwd };
|
|
30
|
-
const user = { type: 'message', id: 'e1', parentId: null, timestamp: '2026-10-28T10:00:01.000Z', message: { role: 'user', content: 'first task', timestamp: 1 } };
|
|
31
|
-
const assistant = { type: 'message', id: 'e2', parentId: 'e1', timestamp: '2026-10-28T10:00:02.000Z', message: { role: 'assistant', content: [{ type: 'text', text: 'built it' }], timestamp: 2 } };
|
|
32
|
-
const path = join(dir, `2026-10-28T10-00-00-000Z_${agentId}.jsonl`);
|
|
33
|
-
writeFileSync(path, `${[header, user, assistant].map((entry) => JSON.stringify(entry)).join('\n')}\n`);
|
|
34
|
-
return path;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function writeSource(extra: Partial<AgentRecord> = {}, runCwd: string = workspace): string {
|
|
38
|
-
writeRecord(dir, { agentId: source, type: 'generalPurpose', readonly: false, cwd: workspace, createdAt: '2026-10-28T10:00:00.000Z', depth: 1, model: { provider: 'p', id: 'm', thinking: 'high' }, ...extra });
|
|
39
|
-
return writeTranscript(source, runCwd);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const lines = (path: string) =>
|
|
43
|
-
readFileSync(path, 'utf8')
|
|
44
|
-
.trim()
|
|
45
|
-
.split('\n')
|
|
46
|
-
.map((line) => JSON.parse(line) as Record<string, unknown>);
|
|
47
|
-
|
|
48
|
-
beforeEach(() => {
|
|
49
|
-
root = realpathSync(mkdtempSync(join(tmpdir(), 'fork-')));
|
|
50
|
-
workspace = join(root, 'workspace');
|
|
51
|
-
mkdirSync(workspace);
|
|
52
|
-
previousAgentDir = process.env['PI_CODING_AGENT_DIR'];
|
|
53
|
-
process.env['PI_CODING_AGENT_DIR'] = join(root, 'agent');
|
|
54
|
-
dir = subagentDir(workspace);
|
|
55
|
-
mkdirSync(dir, { recursive: true });
|
|
56
|
-
});
|
|
57
|
-
afterEach(() => {
|
|
58
|
-
if (previousAgentDir === undefined) delete process.env['PI_CODING_AGENT_DIR'];
|
|
59
|
-
else process.env['PI_CODING_AGENT_DIR'] = previousAgentDir;
|
|
60
|
-
rmSync(root, { recursive: true, force: true });
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
describe('Task fork preflight', () => {
|
|
64
|
-
test('refuses fork with resume', () => {
|
|
65
|
-
expect(() => taskPreflight(params({ resume: source }), 0)).toThrow('fork and resume cannot be combined');
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
test.for([{ environment: 'cloud' as const }, { environment: 'local' as const }, { environment: 'cloud' as const, cloud_base_branch: 'main' }])('refuses fork with %j: the fork runs where the source ran', (extra) => {
|
|
69
|
-
expect(() => taskPreflight(params(extra), 0)).toThrow('fork runs where the source agent ran, so it takes no environment or cloud_base_branch.');
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
test('refuses fork_turns without fork (fork from a turn)', () => {
|
|
73
|
-
expect(() => taskPreflight({ description: 'd', prompt: 'p', fork_turns: 1 }, 0)).toThrow('fork_turns needs fork: it says how many user turns of the source agent to copy.');
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
test('refuses a fork from a grandchild, as resume does', () => {
|
|
77
|
-
expect(() => taskPreflight(params(), 2)).toThrow('cannot launch further subagents');
|
|
78
|
-
});
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
describe('planFork', () => {
|
|
82
|
-
test('a new agent ID that starts from the source record: type, model, readonly, directory, depth', () => {
|
|
83
|
-
writeSource();
|
|
84
|
-
const plan = planFork(params(), ctx(), agents, notRunning, 0, forkId);
|
|
85
|
-
expect(plan).toMatchObject({ kind: 'fork', agentId: forkId, forkedFrom: source, model: { provider: 'p', id: 'm', thinking: 'high' }, readonly: false, workspace, worktree: undefined, depth: 1 });
|
|
86
|
-
expect(plan.agent.type).toBe('generalPurpose');
|
|
87
|
-
expect(plan.notes).toEqual([`Forked from agent ${source}.`]);
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
test("runs in the source's worktree and records the same branch", () => {
|
|
91
|
-
const tree = join(root, 'worktrees', source);
|
|
92
|
-
mkdirSync(tree, { recursive: true });
|
|
93
|
-
const worktree: Worktree = { path: tree, cwd: tree, branch: `pstack/${source}`, base: 'HEAD', commit: 'abc' };
|
|
94
|
-
writeSource({ worktree }, tree);
|
|
95
|
-
const plan = planFork(params(), ctx(), agents, notRunning, 0, forkId);
|
|
96
|
-
expect(plan.worktree).toEqual(worktree);
|
|
97
|
-
expect(plan.workspace).toBe(workspace);
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
test('readonly can be added but never lifted', () => {
|
|
101
|
-
writeSource({ readonly: true });
|
|
102
|
-
expect(planFork(params({ readonly: false }), ctx(), agents, notRunning, 0, forkId).readonly).toBe(true);
|
|
103
|
-
writeSource({ readonly: false });
|
|
104
|
-
expect(planFork(params({ readonly: true }), ctx(), agents, notRunning, 0, forkId).readonly).toBe(true);
|
|
105
|
-
expect(planFork(params({ subagent_type: 'generalPurpose', readonly: false }), ctx(), agents, notRunning, 0, forkId).readonly).toBe(false);
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
test('a readonly source type keeps the fork readonly even under another type', () => {
|
|
109
|
-
writeSource({ type: 'reviewer', readonly: false });
|
|
110
|
-
expect(planFork(params({ subagent_type: 'generalPurpose' }), ctx(), agents, notRunning, 0, forkId).readonly).toBe(true);
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
test('keeps the deeper of the source depth and the caller depth', () => {
|
|
114
|
-
writeSource({ depth: 2 });
|
|
115
|
-
expect(planFork(params(), ctx(), agents, notRunning, 0, forkId).depth).toBe(2);
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
test('refuses while the source is running in this runtime', () => {
|
|
119
|
-
writeSource();
|
|
120
|
-
expect(() => planFork(params(), ctx(), agents, (id) => id === source, 0, forkId)).toThrow(`Agent ${source} is still running.`);
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
test('refuses an unknown source like resume does', () => {
|
|
124
|
-
expect(() => planFork(params(), ctx(), agents, notRunning, 0, forkId)).toThrow(`Unknown agent ID ${source}.`);
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
test('refuses a source without a transcript', () => {
|
|
128
|
-
writeRecord(dir, { agentId: source, type: 'generalPurpose', readonly: false, cwd: workspace, createdAt: '2026-10-28T10:00:00.000Z' });
|
|
129
|
-
expect(() => planFork(params(), ctx(), agents, notRunning, 0, forkId)).toThrow('has no transcript to fork');
|
|
130
|
-
});
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
describe('forkTranscript', () => {
|
|
134
|
-
test('copies the source session under the new ID, with the source as its parent and unchanged', () => {
|
|
135
|
-
const tree = join(root, 'tree');
|
|
136
|
-
const sourcePath = writeSource({}, tree);
|
|
137
|
-
const before = readFileSync(sourcePath, 'utf8');
|
|
138
|
-
const copy = forkTranscript(dir, source, forkId);
|
|
139
|
-
expect(copy).toBe(transcriptFor(dir, forkId));
|
|
140
|
-
expect(copy.endsWith(`_${forkId}.jsonl`)).toBe(true);
|
|
141
|
-
const [header, ...entries] = lines(copy);
|
|
142
|
-
expect(header).toMatchObject({ type: 'session', id: forkId, cwd: tree, parentSession: sourcePath });
|
|
143
|
-
expect(entries).toEqual(lines(sourcePath).slice(1));
|
|
144
|
-
expect(readFileSync(sourcePath, 'utf8')).toBe(before);
|
|
145
|
-
// What `pi --session-id <fork> --session-dir <dir>` run in the source's directory opens.
|
|
146
|
-
expect(SessionManager.findById(tree, forkId, dir)).toBe(copy);
|
|
147
|
-
expect(SessionManager.findById(tree, source, dir)).toBe(sourcePath);
|
|
148
|
-
});
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
describe('forkTranscript: fork_turns', () => {
|
|
152
|
-
function writeTurns(turns: number): string {
|
|
153
|
-
const header = { type: 'session', version: 3, id: source, timestamp: '2026-10-28T10:00:00.000Z', cwd: workspace };
|
|
154
|
-
const entries = Array.from({ length: turns }, (_, turn) => [
|
|
155
|
-
{ type: 'message', id: `u${turn}`, parentId: turn === 0 ? null : `a${turn - 1}`, timestamp: '2026-10-28T10:00:01.000Z', message: { role: 'user', content: `task ${turn}`, timestamp: 1 } },
|
|
156
|
-
{ type: 'message', id: `a${turn}`, parentId: `u${turn}`, timestamp: '2026-10-28T10:00:02.000Z', message: { role: 'assistant', content: [{ type: 'text', text: `done ${turn}` }], timestamp: 2 } },
|
|
157
|
-
]).flat();
|
|
158
|
-
const path = join(dir, `2026-10-28T10-00-00-000Z_${source}.jsonl`);
|
|
159
|
-
writeFileSync(path, `${[header, ...entries].map((entry) => JSON.stringify(entry)).join('\n')}\n`);
|
|
160
|
-
return path;
|
|
161
|
-
}
|
|
162
|
-
const userTexts = (path: string) =>
|
|
163
|
-
SessionManager.open(path)
|
|
164
|
-
.buildSessionContext()
|
|
165
|
-
.messages.filter((message) => message.role === 'user')
|
|
166
|
-
.map((message) => message.content);
|
|
167
|
-
|
|
168
|
-
test('copies only the first N user turns, and the copy opens at the end of turn N', () => {
|
|
169
|
-
const sourcePath = writeTurns(3);
|
|
170
|
-
const copy = forkTranscript(dir, source, forkId, 2);
|
|
171
|
-
expect(
|
|
172
|
-
lines(copy)
|
|
173
|
-
.slice(1)
|
|
174
|
-
.map((entry) => entry['id']),
|
|
175
|
-
).toEqual(['u0', 'a0', 'u1', 'a1']);
|
|
176
|
-
expect(userTexts(copy)).toEqual(['task 0', 'task 1']);
|
|
177
|
-
expect(userTexts(sourcePath)).toEqual(['task 0', 'task 1', 'task 2']);
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
test('a turn count past the end copies the whole transcript', () => {
|
|
181
|
-
writeTurns(2);
|
|
182
|
-
expect(userTexts(forkTranscript(dir, source, forkId, 5))).toEqual(['task 0', 'task 1']);
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
test('prepareFork applies fork_turns and names it in the notes', () => {
|
|
186
|
-
writeSource();
|
|
187
|
-
writeTurns(3);
|
|
188
|
-
const { plan, lock } = prepareFork(params({ fork_turns: 1 }), ctx(), { agents, depth: 0 }, notRunning);
|
|
189
|
-
expect(userTexts(transcriptFor(dir, plan.agentId) ?? '')).toEqual(['task 0']);
|
|
190
|
-
expect(plan.notes[0]).toBe(`Forked from agent ${source}, keeping its first 1 user turn.`);
|
|
191
|
-
lock.release();
|
|
192
|
-
});
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
describe('prepareFork: plan mode', () => {
|
|
196
|
-
test('forks in plan mode, marking the launch for plan mode while the record keeps the source readonly (C-H3, C-M1)', () => {
|
|
197
|
-
writeSource();
|
|
198
|
-
const { plan, lock } = prepareFork(params(), ctx(), { agents, depth: 0, planMode: () => true }, notRunning);
|
|
199
|
-
expect([plan.planMode, plan.readonly, plan.worktree]).toEqual([true, false, undefined]);
|
|
200
|
-
lock.release();
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
test("a plan-mode fork of an isolated worker runs readonly in that worker's worktree", () => {
|
|
204
|
-
const tree = join(root, 'worktrees', source);
|
|
205
|
-
mkdirSync(tree, { recursive: true });
|
|
206
|
-
const worktree: Worktree = { path: tree, cwd: tree, branch: `pstack/${source}`, base: 'HEAD', commit: 'abc' };
|
|
207
|
-
writeSource({ worktree }, tree);
|
|
208
|
-
const { plan, lock } = prepareFork(params(), ctx(), { agents, depth: 0, planMode: () => true }, notRunning);
|
|
209
|
-
expect([plan.planMode, plan.readonly, plan.worktree]).toEqual([true, false, worktree]);
|
|
210
|
-
lock.release();
|
|
211
|
-
});
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
describe('prepareFork: run locks', () => {
|
|
215
|
-
test('copies the transcript, holds the new agent lock, and leaves the source unlocked', () => {
|
|
216
|
-
writeSource();
|
|
217
|
-
const { plan, lock } = prepareFork(params(), ctx(), { agents, depth: 0 }, notRunning);
|
|
218
|
-
expect(plan.agentId).not.toBe(source);
|
|
219
|
-
expect(transcriptFor(dir, plan.agentId)).toBeDefined();
|
|
220
|
-
expect(existsSync(join(dir, `${plan.agentId}.lock`))).toBe(true);
|
|
221
|
-
expect(existsSync(join(dir, `${source}.lock`))).toBe(false);
|
|
222
|
-
lock.release();
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
test('refuses while another process holds the source run lock, copying nothing', () => {
|
|
226
|
-
writeSource();
|
|
227
|
-
const held = acquireRunLock(dir, source);
|
|
228
|
-
expect(() => prepareFork(params(), ctx(), { agents, depth: 0 }, notRunning)).toThrow(`Agent ${source} is still running. Wait for its completion before forking it.`);
|
|
229
|
-
held.release();
|
|
230
|
-
const leftovers = readdirSync(dir).filter((name) => name.endsWith('.jsonl') || name.endsWith('.lock'));
|
|
231
|
-
expect(leftovers).toEqual([`2026-10-28T10-00-00-000Z_${source}.jsonl`]);
|
|
232
|
-
});
|
|
233
|
-
});
|
package/test/task-format.test.ts
DELETED
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from '@earendil-works/pi-coding-agent';
|
|
2
|
-
import { describe, expect, test } from 'vitest';
|
|
3
|
-
import { backgroundText, bounded, foregroundText, notificationText, worktreeLine, worktreePreface } from '../src/task/format.ts';
|
|
4
|
-
|
|
5
|
-
const id = '11111111-2222-4333-8444-555555555555';
|
|
6
|
-
|
|
7
|
-
describe('Task texts (TASK-002, TASK-003, SEC-001)', () => {
|
|
8
|
-
test('foreground output keeps the Cursor structure', () => {
|
|
9
|
-
expect(foregroundText(id, 'DONE', '/s/x.jsonl', ['Note: n'])).toBe(
|
|
10
|
-
`This is the output of the subagent:\n\nresponse:\n<response>\nDONE\n</response>\n\nAgent ID: ${id} (pass it as \`resume\` to send a follow-up after it completes)\nTranscript: /s/x.jsonl\nNote: n`,
|
|
11
|
-
);
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
test('background launch names the agent and says not to poll', () => {
|
|
15
|
-
expect(backgroundText(id, undefined)).toBe(
|
|
16
|
-
`Subagent is running in the background. Its final response arrives automatically after your turn ends, so do not wait or poll for it. End your turn or work on something else.\n\nAgent ID: ${id} (pass it as \`resume\` to send a follow-up after it completes)`,
|
|
17
|
-
);
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
test('the notification carries every Cursor field', () => {
|
|
21
|
-
expect(notificationText({ status: 'error', agentId: id, title: 't', toolCallId: 'c1', detail: 'boom' }).split('\n').slice(2)).toEqual([
|
|
22
|
-
'<task>',
|
|
23
|
-
'kind: subagent',
|
|
24
|
-
'status: error',
|
|
25
|
-
`task_id: ${id}`,
|
|
26
|
-
'title: t',
|
|
27
|
-
'tool_call_id: c1',
|
|
28
|
-
'detail: boom',
|
|
29
|
-
'</task>',
|
|
30
|
-
]);
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
test("bounds model-facing output with Pi's own limits and points to the transcript (review r2: architecture 6)", () => {
|
|
34
|
-
const lines = Array.from({ length: 3_000 }, (_, index) => `line ${index}`).join('\n');
|
|
35
|
-
const text = bounded(lines, '/s/x.jsonl');
|
|
36
|
-
expect(text.split('\n').slice(-2)).toEqual([`line ${DEFAULT_MAX_LINES - 1}`, '[truncated: showing 2000 of 3000 lines; the full response is in /s/x.jsonl]']);
|
|
37
|
-
expect(bounded('short', undefined)).toBe('short');
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
test('cuts a single oversized line at the byte limit instead of dropping it', () => {
|
|
41
|
-
const text = bounded('é'.repeat(40_000), '/s/x.jsonl');
|
|
42
|
-
expect([Buffer.byteLength(text.split('\n[truncated')[0] ?? '', 'utf8'), text.endsWith('[truncated: showing 50.0KB of 78.1KB; the full response is in /s/x.jsonl]')]).toEqual([DEFAULT_MAX_BYTES, true]);
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
test('cuts on a character boundary in linear time', () => {
|
|
46
|
-
const started = performance.now();
|
|
47
|
-
const text = bounded('😀'.repeat(2_000_000), undefined);
|
|
48
|
-
const kept = text.split('\n[truncated')[0] ?? '';
|
|
49
|
-
expect([Buffer.byteLength(kept, 'utf8'), kept.endsWith('😀'), performance.now() - started < 2_000]).toEqual([51_200, true, true]);
|
|
50
|
-
});
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
describe('cloud worktree texts (TASK-010)', () => {
|
|
54
|
-
const worktree = { path: '/w/wt', cwd: '/w/wt/pkg', branch: `pstack/${id}`, base: 'main', commit: 'abc123' };
|
|
55
|
-
|
|
56
|
-
test('the result line names the worktree and the branch to merge', () => {
|
|
57
|
-
expect(worktreeLine(worktree)).toBe(`Worktree: /w/wt on branch pstack/${id}, from main at abc123. Merge pstack/${id} to take the subagent's commits.`);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
test("the child's prompt opens with where it runs and that it must commit", () => {
|
|
61
|
-
expect(worktreePreface(worktree, 'do it')).toBe(
|
|
62
|
-
`You are running in an isolated git worktree at /w/wt (working directory /w/wt/pkg) on branch pstack/${id}, created from main at abc123. Uncommitted changes in the parent's checkout are not here. Commit your changes to pstack/${id} before you finish, because the parent agent merges from that branch.\n\ndo it`,
|
|
63
|
-
);
|
|
64
|
-
});
|
|
65
|
-
});
|
package/test/task-model.test.ts
DELETED
|
@@ -1,141 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from 'vitest';
|
|
2
|
-
import { type AvailableModel, resolveSubagentModel } from '../src/task/models.ts';
|
|
3
|
-
|
|
4
|
-
const FULL = ['low', 'medium', 'high', 'xhigh', 'max'];
|
|
5
|
-
const available: AvailableModel[] = [
|
|
6
|
-
{ provider: 'claude-subscription', id: 'claude-opus-5-5', reasoning: true, levels: FULL },
|
|
7
|
-
{ provider: 'claude-subscription', id: 'claude-haiku-4-5', reasoning: true, levels: FULL },
|
|
8
|
-
{ provider: 'grok-build', id: 'grok-4.7', reasoning: true, levels: ['low', 'medium', 'high', 'xhigh'] },
|
|
9
|
-
{ provider: 'deepseek', id: 'deepseek-flash', reasoning: true, levels: ['off', 'low', 'high', 'max'] },
|
|
10
|
-
{ provider: 'other', id: 'deepseek-flash', reasoning: false, levels: ['off'] },
|
|
11
|
-
];
|
|
12
|
-
const parent = { provider: 'claude-subscription', id: 'claude-haiku-4-5', thinking: 'low' as const };
|
|
13
|
-
|
|
14
|
-
describe('resolveSubagentModel (TASK-007)', () => {
|
|
15
|
-
test.for([undefined, '', 'inherit', 'inherit-parent', 'auto', 'default'])('%s runs on the parent model and thinking level', (value) => {
|
|
16
|
-
expect(resolveSubagentModel(value, available, parent)).toEqual({ provider: 'claude-subscription', id: 'claude-haiku-4-5', thinking: 'low' });
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
test('inherit with no parent model leaves the choice to the child', () => {
|
|
20
|
-
expect([resolveSubagentModel('inherit', available, undefined), resolveSubagentModel('inherit', available, parent)]).toEqual([undefined, parent]);
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
test.for([
|
|
24
|
-
{ value: 'claude-opus-5-5-xhigh', expected: { provider: 'claude-subscription', id: 'claude-opus-5-5', thinking: 'xhigh' } },
|
|
25
|
-
{ value: 'claude-opus-5-5-max-fast', expected: { provider: 'claude-subscription', id: 'claude-opus-5-5', thinking: 'max' } },
|
|
26
|
-
{ value: 'grok-4.7-xhigh-fast', expected: { provider: 'grok-build', id: 'grok-4.7', thinking: 'xhigh' } },
|
|
27
|
-
{ value: 'claude-opus-5-5-thinking-high', expected: { provider: 'claude-subscription', id: 'claude-opus-5-5', thinking: 'high' } },
|
|
28
|
-
{ value: 'claude-opus-5-5', expected: { provider: 'claude-subscription', id: 'claude-opus-5-5' } },
|
|
29
|
-
])('maps the Cursor slug $value (TASK-007, RULE-003)', ({ value, expected }) => {
|
|
30
|
-
expect(resolveSubagentModel(value, available, parent)).toEqual(expected);
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
test('maps Cursor slugs with none, minimal, or a bare -fast suffix (Cursor CLI 2026.10.22 model list)', () => {
|
|
34
|
-
const local = [...available, { provider: 'openai', id: 'gpt-5.3-codex', reasoning: true, levels: ['minimal', 'low', 'high'] }];
|
|
35
|
-
expect([
|
|
36
|
-
resolveSubagentModel('grok-4.7-fast', local, parent),
|
|
37
|
-
resolveSubagentModel('deepseek/deepseek-flash-none', local, parent),
|
|
38
|
-
resolveSubagentModel('gpt-5.3-codex-minimal', local, parent),
|
|
39
|
-
resolveSubagentModel('gpt-5.3-codex-minimal-fast', local, parent),
|
|
40
|
-
]).toEqual([
|
|
41
|
-
{ provider: 'grok-build', id: 'grok-4.7' },
|
|
42
|
-
{ provider: 'deepseek', id: 'deepseek-flash', thinking: 'off' },
|
|
43
|
-
{ provider: 'openai', id: 'gpt-5.3-codex', thinking: 'minimal' },
|
|
44
|
-
{ provider: 'openai', id: 'gpt-5.3-codex', thinking: 'minimal' },
|
|
45
|
-
]);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
test.for([
|
|
49
|
-
{ value: 'deepseek/deepseek-flash', expected: { provider: 'deepseek', id: 'deepseek-flash' } },
|
|
50
|
-
{ value: 'deepseek/deepseek-flash:high', expected: { provider: 'deepseek', id: 'deepseek-flash', thinking: 'high' } },
|
|
51
|
-
{ value: 'claude-opus-5-5:medium', expected: { provider: 'claude-subscription', id: 'claude-opus-5-5', thinking: 'medium' } },
|
|
52
|
-
])('maps the Pi spec $value', ({ value, expected }) => {
|
|
53
|
-
expect(resolveSubagentModel(value, available, parent)).toEqual(expected);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
test('selects model ids that contain a colon, with or without a thinking level (review: security 5)', () => {
|
|
57
|
-
const local = [...available, { provider: 'ollama', id: 'llama3:8b', reasoning: true, levels: ['off', 'high'] }];
|
|
58
|
-
expect([resolveSubagentModel('ollama/llama3:8b', local, parent), resolveSubagentModel('llama3:8b', local, parent), resolveSubagentModel('llama3:8b:high', local, parent)]).toEqual([
|
|
59
|
-
{ provider: 'ollama', id: 'llama3:8b' },
|
|
60
|
-
{ provider: 'ollama', id: 'llama3:8b' },
|
|
61
|
-
{ provider: 'ollama', id: 'llama3:8b', thinking: 'high' },
|
|
62
|
-
]);
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
test('prefers a model whose own id ends in -fast for a -fast slug (review: parity)', () => {
|
|
66
|
-
const fast = [...available, { provider: 'grok-build', id: 'grok-4.7-fast', reasoning: true, levels: ['low', 'xhigh'] }];
|
|
67
|
-
expect(resolveSubagentModel('grok-4.7-xhigh-fast', fast, parent)).toEqual({ provider: 'grok-build', id: 'grok-4.7-fast', thinking: 'xhigh' });
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
test.for([
|
|
71
|
-
{ value: 'claude-opus-5-5[effort=high]', expected: { provider: 'claude-subscription', id: 'claude-opus-5-5', thinking: 'high' } },
|
|
72
|
-
{ value: 'claude-opus-5-5[]', expected: { provider: 'claude-subscription', id: 'claude-opus-5-5' } },
|
|
73
|
-
{ value: 'claude-opus-5-5[effort=high,context=300k]', expected: { provider: 'claude-subscription', id: 'claude-opus-5-5', thinking: 'high' } },
|
|
74
|
-
{ value: 'grok-4.7[fast=false]', expected: { provider: 'grok-build', id: 'grok-4.7' } },
|
|
75
|
-
{ value: 'grok-4.7[fast=true, effort=low]', expected: { provider: 'grok-build', id: 'grok-4.7', thinking: 'low' } },
|
|
76
|
-
{ value: 'deepseek/deepseek-flash[effort=max]', expected: { provider: 'deepseek', id: 'deepseek-flash', thinking: 'max' } },
|
|
77
|
-
])('maps the Cursor model parameters in $value', ({ value, expected }) => {
|
|
78
|
-
expect(resolveSubagentModel(value, available, parent)).toEqual(expected);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
test('fast=true picks a model whose own id ends in -fast, and fast=false keeps the standard one', () => {
|
|
82
|
-
const fast = [...available, { provider: 'grok-build', id: 'grok-4.7-fast', reasoning: true, levels: ['low', 'xhigh'] }];
|
|
83
|
-
expect([resolveSubagentModel('grok-4.7[fast=true,effort=xhigh]', fast, parent), resolveSubagentModel('grok-4.7[fast=false]', fast, parent)]).toEqual([
|
|
84
|
-
{ provider: 'grok-build', id: 'grok-4.7-fast', thinking: 'xhigh' },
|
|
85
|
-
{ provider: 'grok-build', id: 'grok-4.7' },
|
|
86
|
-
]);
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
test.for(['claude-opus-5-5[speed=1]', 'claude-opus-5-5[effort=turbo]', 'claude-opus-5-5[fast=maybe]', 'claude-opus-5-5[effort]', 'claude-opus-5-5-high[effort=low]'])('rejects the model parameters in %s with the allowed list', (value) => {
|
|
90
|
-
expect(() => resolveSubagentModel(value, available, parent)).toThrow(`Invalid model selection "${value}".`);
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
test('rejects an effort parameter the model does not support', () => {
|
|
94
|
-
expect(() => resolveSubagentModel('grok-4.7[effort=max]', available, parent)).toThrow('grok-build/grok-4.7 does not support thinking level max; it supports low, medium, high, xhigh.');
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
test("an id that two providers share resolves to the parent chat's provider in every form (C-M5)", () => {
|
|
98
|
-
const deepseekParent = { provider: 'deepseek', id: 'deepseek-flash', thinking: 'high' as const };
|
|
99
|
-
expect(['deepseek-flash', 'deepseek-flash:max', 'deepseek-flash[effort=low]', 'deepseek-flash-max'].map((value) => resolveSubagentModel(value, available, deepseekParent))).toEqual([
|
|
100
|
-
{ provider: 'deepseek', id: 'deepseek-flash' },
|
|
101
|
-
{ provider: 'deepseek', id: 'deepseek-flash', thinking: 'max' },
|
|
102
|
-
{ provider: 'deepseek', id: 'deepseek-flash', thinking: 'low' },
|
|
103
|
-
{ provider: 'deepseek', id: 'deepseek-flash', thinking: 'max' },
|
|
104
|
-
]);
|
|
105
|
-
expect(resolveSubagentModel('deepseek-flash', available, { provider: 'other', id: 'x' })).toEqual({ provider: 'other', id: 'deepseek-flash' });
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
test('rejects an id that two providers share, naming both', () => {
|
|
109
|
-
expect(() => resolveSubagentModel('deepseek-flash', available, parent)).toThrow('Model "deepseek-flash" matches more than one provider: deepseek/deepseek-flash, other/deepseek-flash. Use <provider>/<model-id>.');
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
test('rejects an unknown model with the allowed list in Cursor format', () => {
|
|
113
|
-
expect(() => resolveSubagentModel('no-such-model-xyz', available, parent)).toThrow(
|
|
114
|
-
[
|
|
115
|
-
'Invalid model selection "no-such-model-xyz". Model could not be resolved to a valid subagent model.',
|
|
116
|
-
'Allowed model slugs:',
|
|
117
|
-
'- inherit',
|
|
118
|
-
'- claude-subscription/claude-opus-5-5 (thinking: low, medium, high, xhigh, max)',
|
|
119
|
-
'- claude-subscription/claude-haiku-4-5 (thinking: low, medium, high, xhigh, max)',
|
|
120
|
-
'- grok-build/grok-4.7 (thinking: low, medium, high, xhigh)',
|
|
121
|
-
'- deepseek/deepseek-flash (thinking: off, low, high, max)',
|
|
122
|
-
'- other/deepseek-flash',
|
|
123
|
-
'',
|
|
124
|
-
'Add a thinking level as <model>:<level>, for example claude-opus-5-5:xhigh, or as <model>[effort=<level>].',
|
|
125
|
-
'The `model` parameter is optional. If omitted, the subagent uses the same model as the parent agent.',
|
|
126
|
-
].join('\n'),
|
|
127
|
-
);
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
test.for([
|
|
131
|
-
{ value: 'grok-4.7-max-fast', reason: 'grok-build/grok-4.7 does not support thinking level max; it supports low, medium, high, xhigh.' },
|
|
132
|
-
{ value: 'deepseek/deepseek-flash:xhigh', reason: 'deepseek/deepseek-flash does not support thinking level xhigh; it supports off, low, high, max.' },
|
|
133
|
-
{ value: 'other/deepseek-flash:high', reason: 'other/deepseek-flash does not support thinking level high; it supports off.' },
|
|
134
|
-
])('rejects $value, a thinking level the model does not support, as Cursor rejects an unlisted slug (review r2: parity 3)', ({ value, reason }) => {
|
|
135
|
-
expect(() => resolveSubagentModel(value, available, parent)).toThrow(`Invalid model selection "${value}". ${reason}\nAllowed model slugs:`);
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
test('rejects an unknown effort suffix rather than guessing', () => {
|
|
139
|
-
expect(() => resolveSubagentModel('claude-opus-5-5-turbo', available, parent)).toThrow('Invalid model selection "claude-opus-5-5-turbo".');
|
|
140
|
-
});
|
|
141
|
-
});
|