claude-code-session-manager 0.92.1 → 0.94.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/DataModel-B0LDnnSL.js +1 -0
- package/dist/assets/{History-BOR_fJNr.js → History-C685Kytt.js} +2 -2
- package/dist/assets/{Hooks-BtD436iL.js → Hooks-CwLnp0Z_.js} +3 -3
- package/dist/assets/HostBilko-CwEHEKYk.js +1 -0
- package/dist/assets/{Library-DeKdY-tD.js → Library-BU9np05E.js} +25 -25
- package/dist/assets/{MarkdownEditor-CbzC-Oqf.js → MarkdownEditor-B74ogK2f.js} +1 -1
- package/dist/assets/McpServers-BLM3ftzP.js +2 -0
- package/dist/assets/{Memory-B68AJjg2.js → Memory-DAP1t8bA.js} +6 -6
- package/dist/assets/{Permissions-Cr0RRvNy.js → Permissions-3pGLJmxQ.js} +3 -3
- package/dist/assets/Plugins-Q1KoLEzn.js +2 -0
- package/dist/assets/ProvenanceBadge-BnjBQiV9.js +1 -0
- package/dist/assets/{SaveBar-87ZJfscX.js → SaveBar-Zq2NfcPf.js} +1 -1
- package/dist/assets/Scheduler-D488ebKm.js +16 -0
- package/dist/assets/{ScopeSwitcher-CKzpqjJ_.js → ScopeSwitcher-Cnojv22D.js} +1 -1
- package/dist/assets/Settings-D5Lhj7ga.js +3 -0
- package/dist/assets/{SkillReferenceGraph-D_-wg4r9.js → SkillReferenceGraph-BRDiE6Hv.js} +2 -2
- package/dist/assets/Skills-BQDqp-EN.js +3 -0
- package/dist/assets/SystemPrompt-tFr19Od6.js +1 -0
- package/dist/assets/TagLibrary-BjyrnaRE.js +1 -0
- package/dist/assets/{TiptapBody-DYXIp8aG.js → TiptapBody-DGzSg3BO.js} +1 -1
- package/dist/assets/{Toggle-CDXR5l3m.js → Toggle-DzoROibf.js} +1 -1
- package/dist/assets/index-CCl4tz-u.js +3079 -0
- package/dist/assets/{index-D3P_jldk.css → index-CKH5Uxik.css} +1 -1
- package/dist/assets/settingsSchema-BYKVe1WM.js +1 -0
- package/dist/index.html +2 -2
- package/package.json +6 -3
- package/scripts/README.md +3 -0
- package/src/main/__tests__/agentEffortResolve.test.cjs +117 -0
- package/src/main/__tests__/agentLibrary.test.cjs +21 -0
- package/src/main/__tests__/agentModelResolve.test.cjs +16 -0
- package/src/main/__tests__/agentOverlayWrite.test.cjs +95 -0
- package/src/main/__tests__/chat-cancel-terminal.test.cjs +5 -2
- package/src/main/__tests__/chat-exit-close-race.test.cjs +8 -2
- package/src/main/__tests__/chat-mcp-consent-notice.test.cjs +5 -2
- package/src/main/__tests__/chatRunner-session-flag-retry.test.cjs +44 -0
- package/src/main/__tests__/intradayRefresh.test.cjs +39 -0
- package/src/main/__tests__/openExternalApp-spawn-error.test.cjs +25 -0
- package/src/main/__tests__/opsErrorLog.test.cjs +22 -0
- package/src/main/__tests__/personaMerge.test.cjs +169 -0
- package/src/main/__tests__/prdCreatePlanId.test.cjs +132 -0
- package/src/main/__tests__/promptSessionsCreateEpicHandler.test.cjs +13 -0
- package/src/main/__tests__/pty-epic-worktree-spawn-cwd.test.cjs +21 -0
- package/src/main/__tests__/rateLimitPollerStreak.test.cjs +14 -0
- package/src/main/__tests__/runVerify-atomic-verdicts.test.cjs +26 -0
- package/src/main/__tests__/runVerify-landed-commit-outranks.test.cjs +11 -1
- package/src/main/__tests__/runVerify-transcript-commit-evidence.test.cjs +11 -1
- package/src/main/__tests__/runVerify.test.cjs +12 -1
- package/src/main/__tests__/scheduler-prd-persona-spawn.test.cjs +47 -2
- package/src/main/__tests__/transcriptsUsageFor.test.cjs +112 -1
- package/src/main/agentLibrary.cjs +86 -40
- package/src/main/build-info.json +4 -4
- package/src/main/chatRunner.cjs +50 -10
- package/src/main/git.cjs +9 -2
- package/src/main/historyAggregator.cjs +2 -19
- package/src/main/index.cjs +17 -8
- package/src/main/ipcSchemas.cjs +25 -3
- package/src/main/lib/__tests__/childWithLog.test.cjs +180 -0
- package/src/main/lib/__tests__/delegationReadiness.test.cjs +19 -0
- package/src/main/lib/__tests__/effectiveModelInfo.test.cjs +10 -5
- package/src/main/lib/__tests__/gitCacheBound.test.cjs +69 -0
- package/src/main/lib/__tests__/modelCatalog.test.cjs +202 -0
- package/src/main/lib/agentEffortResolve.cjs +74 -0
- package/src/main/lib/agentModelResolve.cjs +52 -59
- package/src/main/lib/agentPersonaSchema.cjs +5 -0
- package/src/main/lib/childWithLog.cjs +92 -51
- package/src/main/lib/delegationReadiness.cjs +3 -1
- package/src/main/lib/effectiveModelInfo.cjs +38 -20
- package/src/main/lib/epicMint.cjs +5 -2
- package/src/main/lib/epicSpawnPlan.cjs +27 -8
- package/src/main/lib/epicTranscriptPath.cjs +5 -1
- package/src/main/lib/epicWorktreeMerge.cjs +7 -6
- package/src/main/lib/headTailBuffer.cjs +43 -0
- package/src/main/lib/intradayRefresh.cjs +33 -0
- package/src/main/lib/lruCache.cjs +39 -0
- package/src/main/lib/modelCatalog.cjs +243 -0
- package/src/main/lib/openExternalApp.cjs +27 -9
- package/src/main/lib/opsErrorLog.cjs +22 -0
- package/src/main/lib/personaMerge.cjs +166 -0
- package/src/main/lib/prdCreate.cjs +23 -5
- package/src/main/lib/prdDisposition.cjs +46 -0
- package/src/main/lib/prdFrontmatter.cjs +6 -2
- package/src/main/lib/promptSessionSchema.cjs +5 -0
- package/src/main/lib/promptSessionsCreateEpic.cjs +5 -3
- package/src/main/lib/rendererRecovery.cjs +141 -0
- package/src/main/lib/scheduleJobSchema.cjs +3 -0
- package/src/main/runVerify.cjs +3 -1
- package/src/main/scheduler/prdParser.cjs +2 -0
- package/src/main/scheduler.cjs +381 -262
- package/src/main/templates/PRD_AUTHORING.md +4 -0
- package/src/main/transcripts.cjs +54 -11
- package/src/preload/api.d.ts +56 -3
- package/src/preload/index.cjs +4 -0
- package/dist/assets/AgentLibrary-Ci6u03bq.js +0 -3
- package/dist/assets/DataModel-BIqcjsv2.js +0 -1
- package/dist/assets/HostBilko-CMe5cH3H.js +0 -1
- package/dist/assets/ListDetail-CtVjrHKB.js +0 -1
- package/dist/assets/McpServers-DKN75Shn.js +0 -2
- package/dist/assets/Panel-C2YTW-qP.js +0 -1
- package/dist/assets/Plugins-DbZkJs5l.js +0 -2
- package/dist/assets/ProvenanceBadge-kJ__BOQv.js +0 -1
- package/dist/assets/Scheduler-DdEaxku8.js +0 -16
- package/dist/assets/Settings-CjywVsNB.js +0 -3
- package/dist/assets/Skills-Cd_yPO_l.js +0 -3
- package/dist/assets/SystemPrompt-2z6eGPSF.js +0 -1
- package/dist/assets/TagLibrary-DcaWQSp4.js +0 -1
- package/dist/assets/index-D5H_H5wC.js +0 -3074
- package/dist/assets/settingsSchema-JRDfjgHb.js +0 -3
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agentOverlayWrite.test.cjs — Agent Library's per-project OVERRIDE write path
|
|
3
|
+
* (savePersona with `projectName`): frontmatter-only overlay, removal on clear,
|
|
4
|
+
* full-body refusal, and end-to-end resolution for a scheduled PRD.
|
|
5
|
+
*
|
|
6
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/agentOverlayWrite.test.cjs
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
import { test, expect, afterEach } from 'vitest';
|
|
12
|
+
const fs = require('node:fs');
|
|
13
|
+
const fsp = require('node:fs/promises');
|
|
14
|
+
const os = require('node:os');
|
|
15
|
+
const path = require('node:path');
|
|
16
|
+
const { savePersona, listPersonas, getPersonaBody } = require('../agentLibrary.cjs');
|
|
17
|
+
const { resolvePrdPersonaForSpawn } = require('../lib/agentModelResolve.cjs');
|
|
18
|
+
|
|
19
|
+
const tmpDirs = [];
|
|
20
|
+
afterEach(async () => {
|
|
21
|
+
while (tmpDirs.length) await fsp.rm(tmpDirs.pop(), { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
function setup() {
|
|
25
|
+
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-ov-cwd-'));
|
|
26
|
+
const globalDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-ov-global-'));
|
|
27
|
+
tmpDirs.push(cwd, globalDir);
|
|
28
|
+
const globalFile = path.join(globalDir, 'dev-lead.md');
|
|
29
|
+
fs.writeFileSync(globalFile, '---\nname: dev-lead\ndescription: d\nmodel: sonnet\n---\nGlobal body.\n');
|
|
30
|
+
const overlayFile = path.join(cwd, '.claude', 'agents', 'dev-lead.md');
|
|
31
|
+
const deps = { globalDir, loadSessions: async () => ({ tabs: [{ cwd }] }), validatePath: (p) => p,
|
|
32
|
+
writeTextAtomic: async (p, t) => { await fsp.mkdir(path.dirname(p), { recursive: true }); await fsp.writeFile(p, t); } };
|
|
33
|
+
const projectName = path.basename(cwd);
|
|
34
|
+
const save = (over) => savePersona({ name: 'dev-lead', projectName, description: '', tools: [], color: '', tags: [], body: '', ...over, ...deps });
|
|
35
|
+
return { cwd, globalFile, overlayFile, deps, save, projectName };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
test('setting a model writes a frontmatter-only overlay with only that key; global untouched', async () => {
|
|
39
|
+
const { overlayFile, globalFile, save } = setup();
|
|
40
|
+
const before = fs.readFileSync(globalFile, 'utf8');
|
|
41
|
+
await save({ model: 'claude-opus-4-6', effort: 'inherit' });
|
|
42
|
+
expect(fs.readFileSync(overlayFile, 'utf8')).toBe('---\nmodel: claude-opus-4-6\n---\n');
|
|
43
|
+
expect(fs.readFileSync(globalFile, 'utf8')).toBe(before);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('adding effort adds only that key', async () => {
|
|
47
|
+
const { overlayFile, save } = setup();
|
|
48
|
+
await save({ model: 'claude-opus-4-6' });
|
|
49
|
+
await save({ model: 'claude-opus-4-6', effort: 'high' });
|
|
50
|
+
expect(fs.readFileSync(overlayFile, 'utf8')).toBe('---\nmodel: claude-opus-4-6\neffort: high\n---\n');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('clearing both removes the overlay file', async () => {
|
|
54
|
+
const { overlayFile, save } = setup();
|
|
55
|
+
await save({ model: 'claude-opus-4-6', effort: 'high' });
|
|
56
|
+
const r = await save({ model: 'inherit', effort: 'inherit' });
|
|
57
|
+
expect(r.removed).toBe(true);
|
|
58
|
+
expect(fs.existsSync(overlayFile)).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('a full-body overlay is refused, not rewritten', async () => {
|
|
62
|
+
const { overlayFile, save } = setup();
|
|
63
|
+
fs.mkdirSync(path.dirname(overlayFile), { recursive: true });
|
|
64
|
+
const full = '---\nname: dev-lead\nmodel: haiku\n---\nProject body.\n';
|
|
65
|
+
fs.writeFileSync(overlayFile, full);
|
|
66
|
+
await expect(save({ model: 'opus' })).rejects.toThrow(/full-body/);
|
|
67
|
+
expect(fs.readFileSync(overlayFile, 'utf8')).toBe(full);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('unknown project is rejected', async () => {
|
|
71
|
+
const { save } = setup();
|
|
72
|
+
await expect(save({ projectName: 'nope', model: 'opus' })).rejects.toThrow(/project not open/);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('listPersonas counts a frontmatter-only overlay and reports global → project values', async () => {
|
|
76
|
+
const { save, deps, projectName } = setup();
|
|
77
|
+
await save({ model: 'claude-opus-4-6' });
|
|
78
|
+
const [p] = await listPersonas(deps);
|
|
79
|
+
expect(p.model).toBe('sonnet');
|
|
80
|
+
expect(p.overridingProjects).toEqual([projectName]);
|
|
81
|
+
expect(p.overrideDetails[0]).toMatchObject({ fields: ['model'], values: { model: 'claude-opus-4-6' }, bodyOverridden: false });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('scheduled-PRD model resolution returns the overlay model; global persona stays sonnet', async () => {
|
|
85
|
+
const { cwd, save, deps } = setup();
|
|
86
|
+
await save({ model: 'claude-opus-4-6' });
|
|
87
|
+
const r = await resolvePrdPersonaForSpawn({
|
|
88
|
+
cwd, agentType: 'dev-lead', fallbackModel: 'fallback',
|
|
89
|
+
deps: { getPersonaBody: (a) => getPersonaBody({ ...a, ...deps }) },
|
|
90
|
+
});
|
|
91
|
+
expect(r.model).toBe('claude-opus-4-6');
|
|
92
|
+
expect(r.systemPrompt).toContain('Global body.');
|
|
93
|
+
const [g] = await listPersonas(deps);
|
|
94
|
+
expect(g.model).toBe('sonnet');
|
|
95
|
+
});
|
|
@@ -37,6 +37,9 @@ process.env.SM_CLAUDE_BIN = stubPath;
|
|
|
37
37
|
|
|
38
38
|
const cr = require('../chatRunner.cjs');
|
|
39
39
|
|
|
40
|
+
// Scratch project dir under os.tmpdir() — never the real repo root (opsErrorLog would pollute it).
|
|
41
|
+
const scratchCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-chat-cwd-'));
|
|
42
|
+
|
|
40
43
|
const tick = () => new Promise((r) => setImmediate(r));
|
|
41
44
|
|
|
42
45
|
test('cancelling an ACTIVE run broadcasts a terminal event (unsticks the UI)', async () => {
|
|
@@ -50,7 +53,7 @@ test('cancelling an ACTIVE run broadcasts a terminal event (unsticks the UI)', a
|
|
|
50
53
|
},
|
|
51
54
|
});
|
|
52
55
|
|
|
53
|
-
cr.run({ tabId: 'T', sessionId: 'S', prompt: 'hello', cwd:
|
|
56
|
+
cr.run({ tabId: 'T', sessionId: 'S', prompt: 'hello', cwd: scratchCwd, resume: false });
|
|
54
57
|
|
|
55
58
|
// Let the spawn happen and started event fire.
|
|
56
59
|
for (let i = 0; i < 6; i++) await tick();
|
|
@@ -86,7 +89,7 @@ test('cancel() resolves only AFTER the terminal broadcast fires, not immediately
|
|
|
86
89
|
},
|
|
87
90
|
});
|
|
88
91
|
|
|
89
|
-
cr.run({ tabId: 'T2', sessionId: 'S2', prompt: 'hello', cwd:
|
|
92
|
+
cr.run({ tabId: 'T2', sessionId: 'S2', prompt: 'hello', cwd: scratchCwd, resume: false });
|
|
90
93
|
|
|
91
94
|
for (let i = 0; i < 6; i++) await tick();
|
|
92
95
|
|
|
@@ -32,6 +32,9 @@ import { test, afterAll } from 'vitest';
|
|
|
32
32
|
const assert = require('node:assert/strict');
|
|
33
33
|
const EventEmitter = require('node:events');
|
|
34
34
|
const cp = require('node:child_process');
|
|
35
|
+
const fs = require('node:fs');
|
|
36
|
+
const os = require('node:os');
|
|
37
|
+
const path = require('node:path');
|
|
35
38
|
|
|
36
39
|
// Replace spawn BEFORE chatRunner.cjs is first required, so the module's
|
|
37
40
|
// top-level `const { spawn } = require('node:child_process')` destructuring
|
|
@@ -50,6 +53,9 @@ cp.spawn = () => {
|
|
|
50
53
|
|
|
51
54
|
const cr = require('../chatRunner.cjs');
|
|
52
55
|
|
|
56
|
+
// Scratch project dir under os.tmpdir() — never the real repo root (opsErrorLog would pollute it).
|
|
57
|
+
const scratchCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-chat-cwd-'));
|
|
58
|
+
|
|
53
59
|
const tick = () => new Promise((r) => setImmediate(r));
|
|
54
60
|
|
|
55
61
|
test('a final-text-only turn whose stdout chunk lands after exit still surfaces chat:run:complete', async () => {
|
|
@@ -63,7 +69,7 @@ test('a final-text-only turn whose stdout chunk lands after exit still surfaces
|
|
|
63
69
|
},
|
|
64
70
|
});
|
|
65
71
|
|
|
66
|
-
cr.run({ tabId: 'T2', sessionId: 'S2', prompt: 'show me a sample post', cwd:
|
|
72
|
+
cr.run({ tabId: 'T2', sessionId: 'S2', prompt: 'show me a sample post', cwd: scratchCwd, resume: false });
|
|
67
73
|
|
|
68
74
|
for (let i = 0; i < 20 && !nextChild; i++) await tick();
|
|
69
75
|
const child = nextChild;
|
|
@@ -108,7 +114,7 @@ test('a crashed process with no result event ever emitted still fires the fallba
|
|
|
108
114
|
},
|
|
109
115
|
});
|
|
110
116
|
|
|
111
|
-
cr.run({ tabId: 'T3', sessionId: 'S3', prompt: 'anything', cwd:
|
|
117
|
+
cr.run({ tabId: 'T3', sessionId: 'S3', prompt: 'anything', cwd: scratchCwd, resume: false });
|
|
112
118
|
|
|
113
119
|
for (let i = 0; i < 20 && !nextChild; i++) await tick();
|
|
114
120
|
const child = nextChild;
|
|
@@ -18,6 +18,9 @@ const assert = require('node:assert/strict');
|
|
|
18
18
|
const fs = require('node:fs');
|
|
19
19
|
const os = require('node:os');
|
|
20
20
|
const path = require('node:path');
|
|
21
|
+
|
|
22
|
+
// Scratch project dir under os.tmpdir() — never the real repo root (opsErrorLog would pollute it).
|
|
23
|
+
const scratchCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-chat-cwd-'));
|
|
21
24
|
const claudeStub = require('../../../tests/helpers/claudeStub.cjs');
|
|
22
25
|
|
|
23
26
|
function writeStub(lines) {
|
|
@@ -70,7 +73,7 @@ test('MCP consent denial in a tool_result surfaces a chat:run:notice event', asy
|
|
|
70
73
|
},
|
|
71
74
|
});
|
|
72
75
|
|
|
73
|
-
cr.run({ tabId: 'T-notice', sessionId: 'S-notice', prompt: 'do a design thing', cwd:
|
|
76
|
+
cr.run({ tabId: 'T-notice', sessionId: 'S-notice', prompt: 'do a design thing', cwd: scratchCwd, resume: false });
|
|
74
77
|
|
|
75
78
|
for (let i = 0; i < 60 && !events.some((e) => isTerminal(e.channel)); i++) {
|
|
76
79
|
await new Promise((r) => setTimeout(r, 25));
|
|
@@ -120,7 +123,7 @@ test('a normal tool_result with no consent marker does not fire chat:run:notice'
|
|
|
120
123
|
},
|
|
121
124
|
});
|
|
122
125
|
|
|
123
|
-
cr.run({ tabId: 'T-clean', sessionId: 'S-clean', prompt: 'do a normal thing', cwd:
|
|
126
|
+
cr.run({ tabId: 'T-clean', sessionId: 'S-clean', prompt: 'do a normal thing', cwd: scratchCwd, resume: false });
|
|
124
127
|
|
|
125
128
|
for (let i = 0; i < 60 && !events.some((e) => isTerminal(e.channel)); i++) {
|
|
126
129
|
await new Promise((r) => setTimeout(r, 25));
|
|
@@ -206,3 +206,47 @@ test('non-zero exit with unrelated stderr keeps the generic fallback message', a
|
|
|
206
206
|
fs.rmSync(cwd, { recursive: true, force: true });
|
|
207
207
|
}
|
|
208
208
|
});
|
|
209
|
+
|
|
210
|
+
// ---------- persona effort → --effort (agentEffortResolve.cjs) ----------
|
|
211
|
+
|
|
212
|
+
async function chatArgvForPersona(effortLine) {
|
|
213
|
+
attachRecorder();
|
|
214
|
+
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-chat-effort-cwd-'));
|
|
215
|
+
const logFile = path.join(cwd, 'argv.log');
|
|
216
|
+
const sessionId = `effort-sess-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
|
|
217
|
+
fs.mkdirSync(path.join(tmpHome, '.claude', 'agents'), { recursive: true });
|
|
218
|
+
fs.writeFileSync(
|
|
219
|
+
path.join(tmpHome, '.claude', 'agents', 'effort-persona.md'),
|
|
220
|
+
['---', 'name: effort-persona', 'model: opus', ...(effortLine ? [effortLine] : []), '---', 'body'].join('\n'),
|
|
221
|
+
);
|
|
222
|
+
const opsDir = path.join(cwd, 'session-manager-operations', 'prompt-sessions');
|
|
223
|
+
fs.mkdirSync(opsDir, { recursive: true });
|
|
224
|
+
fs.writeFileSync(path.join(opsDir, 'active-index.json'), JSON.stringify({
|
|
225
|
+
sessions: { e1: { id: 'e1', claudeSessionId: sessionId, agentType: 'effort-persona' } }, events: {},
|
|
226
|
+
}));
|
|
227
|
+
// First call succeeds outright: firstStderr unused because alwaysFail/n===1 → use a passing stub.
|
|
228
|
+
const stubPath = path.join(os.tmpdir(), `sm-effort-stub-${process.pid}-${Math.floor(Math.random() * 1e9)}.cjs`);
|
|
229
|
+
fs.writeFileSync(stubPath, `#!${process.execPath}\nrequire('fs').appendFileSync(${JSON.stringify(logFile)}, JSON.stringify(process.argv.slice(2)) + '\\n');\nprocess.stdout.write(JSON.stringify({ type: 'result', subtype: 'success', result: 'ok' }) + '\\n');\n`, { mode: 0o755 });
|
|
230
|
+
process.env.SM_CLAUDE_BIN = stubPath;
|
|
231
|
+
try {
|
|
232
|
+
curTab = 'tab-effort';
|
|
233
|
+
cr.run({ tabId: 'tab-effort', sessionId, prompt: 'hello', cwd, resume: false });
|
|
234
|
+
await waitFor(() => terminals().length >= 1 && fs.existsSync(logFile));
|
|
235
|
+
return spawns(logFile)[0];
|
|
236
|
+
} finally {
|
|
237
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
238
|
+
fs.rmSync(stubPath, { force: true });
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
test('chat spawn: persona effort adds --effort <level> beside --model and --session-id', async () => {
|
|
243
|
+
const argv = await chatArgvForPersona('effort: xhigh');
|
|
244
|
+
expect(argv[argv.indexOf('--effort') + 1]).toBe('xhigh');
|
|
245
|
+
expect(argv[argv.indexOf('--model') + 1]).toBe('opus');
|
|
246
|
+
expect(argv).toContain('--session-id');
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test('chat spawn: effort inherit / absent adds no --effort token', async () => {
|
|
250
|
+
expect(await chatArgvForPersona('effort: inherit')).not.toContain('--effort');
|
|
251
|
+
expect(await chatArgvForPersona(null)).not.toContain('--effort');
|
|
252
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { test, expect, vi } from 'vitest';
|
|
2
|
+
const { createIntradayRefresh } = require('../lib/intradayRefresh.cjs');
|
|
3
|
+
|
|
4
|
+
const mk = (over = {}) => {
|
|
5
|
+
const logs = { writeLine: vi.fn() };
|
|
6
|
+
const releaseLock = vi.fn();
|
|
7
|
+
const deps = { tryAcquireLock: vi.fn(() => true), releaseLock, lockPath: '/x.lock', refresh: vi.fn(async () => {}), logs, ...over };
|
|
8
|
+
return { deps, run: createIntradayRefresh(deps), logs, releaseLock };
|
|
9
|
+
};
|
|
10
|
+
const flush = () => new Promise((r) => setTimeout(r, 0));
|
|
11
|
+
|
|
12
|
+
test('sync throw from refresh releases the lock and logs', async () => {
|
|
13
|
+
const { run, logs, releaseLock } = mk({ refresh: () => { throw new Error('boom'); } });
|
|
14
|
+
expect(() => run()).not.toThrow();
|
|
15
|
+
await flush();
|
|
16
|
+
expect(releaseLock).toHaveBeenCalledWith('/x.lock');
|
|
17
|
+
expect(logs.writeLine).toHaveBeenCalled();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('async rejection releases the lock', async () => {
|
|
21
|
+
const { run, releaseLock } = mk({ refresh: async () => { throw new Error('x'); } });
|
|
22
|
+
run(); await flush();
|
|
23
|
+
expect(releaseLock).toHaveBeenCalledTimes(1);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('lock-acquire throw is logged, not escaped, and no release', async () => {
|
|
27
|
+
const { run, logs, releaseLock, deps } = mk({ tryAcquireLock: () => { throw Object.assign(new Error('EACCES'), { code: 'EACCES' }); } });
|
|
28
|
+
expect(() => run()).not.toThrow();
|
|
29
|
+
expect(logs.writeLine).toHaveBeenCalledTimes(1);
|
|
30
|
+
expect(releaseLock).not.toHaveBeenCalled();
|
|
31
|
+
expect(deps.refresh).not.toHaveBeenCalled();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('contended lock skips refresh', () => {
|
|
35
|
+
const { run, deps, releaseLock } = mk({ tryAcquireLock: () => false });
|
|
36
|
+
run();
|
|
37
|
+
expect(deps.refresh).not.toHaveBeenCalled();
|
|
38
|
+
expect(releaseLock).not.toHaveBeenCalled();
|
|
39
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { test, expect, vi } from 'vitest';
|
|
2
|
+
const Module = require('node:module');
|
|
3
|
+
const origLoad = Module._load;
|
|
4
|
+
Module._load = function (req, ...rest) {
|
|
5
|
+
if (req === 'electron') return { shell: { openPath: async () => '' } };
|
|
6
|
+
return origLoad.call(this, req, ...rest);
|
|
7
|
+
};
|
|
8
|
+
const { spawnDetached } = require('../lib/openExternalApp.cjs');
|
|
9
|
+
Module._load = origLoad;
|
|
10
|
+
|
|
11
|
+
test('non-existent command resolves ok:false with no uncaught exception', async () => {
|
|
12
|
+
const onUncaught = vi.fn();
|
|
13
|
+
process.on('uncaughtException', onUncaught);
|
|
14
|
+
const r = await spawnDetached('sm-definitely-not-a-real-binary-xyz', ['/tmp']);
|
|
15
|
+
await new Promise((res) => setTimeout(res, 20));
|
|
16
|
+
process.off('uncaughtException', onUncaught);
|
|
17
|
+
expect(r.ok).toBe(false);
|
|
18
|
+
expect(r.error).toMatch(/failed to launch/);
|
|
19
|
+
expect(onUncaught).not.toHaveBeenCalled();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('real command resolves ok:true with opener name', async () => {
|
|
23
|
+
const r = await spawnDetached('true', []);
|
|
24
|
+
expect(r).toEqual({ ok: true, opener: 'true' });
|
|
25
|
+
});
|
|
@@ -85,3 +85,25 @@ test('appendError merges caller tags without duplicating auto-derived ones', ()
|
|
|
85
85
|
assert.equal(count, 1);
|
|
86
86
|
assert.ok(line.tags.includes('silent-probe'));
|
|
87
87
|
});
|
|
88
|
+
|
|
89
|
+
test('under vitest, a real project root is refused: no file written, no telemetry', () => {
|
|
90
|
+
const repoRoot = path.resolve(__dirname, '..', '..', '..');
|
|
91
|
+
const logsBefore = fs.existsSync(path.join(repoRoot, 'session-manager-operations', 'logs'))
|
|
92
|
+
? fs.readdirSync(path.join(repoRoot, 'session-manager-operations', 'logs')).map((f) => [f, fs.statSync(path.join(repoRoot, 'session-manager-operations', 'logs', f)).mtimeMs])
|
|
93
|
+
: null;
|
|
94
|
+
const telemetryPath = require.resolve('../lib/telemetryClient.cjs');
|
|
95
|
+
const saved = require.cache[telemetryPath];
|
|
96
|
+
let calls = 0;
|
|
97
|
+
require.cache[telemetryPath] = { id: telemetryPath, filename: telemetryPath, loaded: true, exports: { reportError: () => { calls++; }, logLine: () => { calls++; } } };
|
|
98
|
+
try {
|
|
99
|
+
opsErrorLog.appendError({ cwd: repoRoot, scope: 'chatRunner', tabId: 'T', message: 'fixture' });
|
|
100
|
+
opsErrorLog.appendError({ cwd: repoRoot, scope: 'chatRunner', level: 'warn', message: 'fixture' });
|
|
101
|
+
} finally {
|
|
102
|
+
if (saved) require.cache[telemetryPath] = saved; else delete require.cache[telemetryPath];
|
|
103
|
+
}
|
|
104
|
+
const logsAfter = fs.existsSync(path.join(repoRoot, 'session-manager-operations', 'logs'))
|
|
105
|
+
? fs.readdirSync(path.join(repoRoot, 'session-manager-operations', 'logs')).map((f) => [f, fs.statSync(path.join(repoRoot, 'session-manager-operations', 'logs', f)).mtimeMs])
|
|
106
|
+
: null;
|
|
107
|
+
assert.deepEqual(logsAfter, logsBefore);
|
|
108
|
+
assert.equal(calls, 0);
|
|
109
|
+
});
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* personaMerge.test.cjs — overlay-over-global persona MERGE (lib/personaMerge.cjs)
|
|
3
|
+
* and every reader that goes through it.
|
|
4
|
+
*
|
|
5
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/personaMerge.test.cjs
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
import { test, expect, afterEach } from 'vitest';
|
|
11
|
+
const fs = require('node:fs');
|
|
12
|
+
const fsp = require('node:fs/promises');
|
|
13
|
+
const os = require('node:os');
|
|
14
|
+
const path = require('node:path');
|
|
15
|
+
const { resolveMergedPersona } = require('../lib/personaMerge.cjs');
|
|
16
|
+
const { readOverlayAwarePersonaModel, resolvePrdPersonaForSpawn } = require('../lib/agentModelResolve.cjs');
|
|
17
|
+
const { resolveEpicEffort } = require('../lib/agentEffortResolve.cjs');
|
|
18
|
+
const { resolveEffectiveModelInfo } = require('../lib/effectiveModelInfo.cjs');
|
|
19
|
+
const { getPersonaBody, listPersonas } = require('../agentLibrary.cjs');
|
|
20
|
+
|
|
21
|
+
const tmpDirs = [];
|
|
22
|
+
afterEach(async () => {
|
|
23
|
+
while (tmpDirs.length) await fsp.rm(tmpDirs.pop(), { recursive: true, force: true });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
function mk(prefix) {
|
|
27
|
+
const d = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
28
|
+
tmpDirs.push(d);
|
|
29
|
+
return d;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const noop = (p) => p;
|
|
33
|
+
|
|
34
|
+
function persona(dir, name, fmLines, body) {
|
|
35
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
36
|
+
fs.writeFileSync(path.join(dir, `${name}.md`), ['---', ...fmLines, '---', body].join('\n'));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function setup({ global, overlay }) {
|
|
40
|
+
const cwd = mk('sm-pm-cwd-');
|
|
41
|
+
const globalDir = mk('sm-pm-global-');
|
|
42
|
+
if (global) persona(globalDir, 'dev-lead', global.fm, global.body);
|
|
43
|
+
if (overlay) persona(path.join(cwd, '.claude', 'agents'), 'dev-lead', overlay.fm, overlay.body);
|
|
44
|
+
return { cwd, globalDir, deps: { globalDir, validatePath: noop } };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const GLOBAL = { fm: ['name: dev-lead', 'description: d', 'model: sonnet', 'effort: low'], body: 'Global body.' };
|
|
48
|
+
|
|
49
|
+
test('frontmatter-only overlay overrides model', () => {
|
|
50
|
+
const { cwd, deps } = setup({ global: GLOBAL, overlay: { fm: ['model: claude-opus-4-6'], body: '' } });
|
|
51
|
+
expect(readOverlayAwarePersonaModel('dev-lead', { ...deps, cwd })).toBe('claude-opus-4-6');
|
|
52
|
+
const m = resolveMergedPersona(cwd, 'dev-lead', deps);
|
|
53
|
+
expect(m.provenance.model).toBe('overlay');
|
|
54
|
+
expect(m.provenance.description).toBe('global');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('frontmatter-only overlay overrides effort; source reports persona-overlay', () => {
|
|
58
|
+
const { cwd, deps } = setup({ global: GLOBAL, overlay: { fm: ['effort: high'], body: '' } });
|
|
59
|
+
expect(resolveEpicEffort({ cwd, agentType: 'dev-lead', deps })).toEqual({ effort: 'high', source: 'persona-overlay' });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('a key absent from the overlay falls back to the global (model stays global, effort from overlay)', () => {
|
|
63
|
+
const { cwd, deps } = setup({ global: GLOBAL, overlay: { fm: ['effort: high'], body: '' } });
|
|
64
|
+
expect(readOverlayAwarePersonaModel('dev-lead', { ...deps, cwd })).toBe('sonnet');
|
|
65
|
+
// and the reverse: effort absent from overlay -> global effort, sourced 'persona'
|
|
66
|
+
const s2 = setup({ global: GLOBAL, overlay: { fm: ['model: opus'], body: '' } });
|
|
67
|
+
expect(resolveEpicEffort({ cwd: s2.cwd, agentType: 'dev-lead', deps: s2.deps })).toEqual({ effort: 'low', source: 'persona' });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('an overlay with no body uses the global body', () => {
|
|
71
|
+
const { cwd, deps } = setup({ global: GLOBAL, overlay: { fm: ['model: opus'], body: '\n \n' } });
|
|
72
|
+
const m = resolveMergedPersona(cwd, 'dev-lead', deps);
|
|
73
|
+
expect(m.body.trim()).toBe('Global body.');
|
|
74
|
+
expect(m.bodySource).toBe('global');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('a full-body overlay replaces the body wholesale and keeps its raw text byte-identical', () => {
|
|
78
|
+
const { cwd, deps } = setup({ global: GLOBAL, overlay: { fm: ['name: dev-lead', 'model: haiku'], body: 'Overlay body.' } });
|
|
79
|
+
const m = resolveMergedPersona(cwd, 'dev-lead', deps);
|
|
80
|
+
expect(m.body.trim()).toBe('Overlay body.');
|
|
81
|
+
expect(m.bodySource).toBe('overlay');
|
|
82
|
+
expect(m.text).toBe(fs.readFileSync(path.join(cwd, '.claude', 'agents', 'dev-lead.md'), 'utf8'));
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("this repo's three full-body overlays resolve byte-identically (text + body + path)", async () => {
|
|
86
|
+
const repoAgents = path.resolve(__dirname, '..', '..', '..', '.claude', 'agents');
|
|
87
|
+
const globalDir = mk('sm-pm-global-');
|
|
88
|
+
for (const name of ['bilko-host-publisher', 'builder', 'project-home-builder']) {
|
|
89
|
+
persona(globalDir, name, [`name: ${name}`, 'description: g', 'model: opus'], 'global body');
|
|
90
|
+
const overlayFile = path.join(repoAgents, `${name}.md`);
|
|
91
|
+
const raw = fs.readFileSync(overlayFile, 'utf8');
|
|
92
|
+
const m = resolveMergedPersona(path.resolve(repoAgents, '..', '..'), name, { globalDir, validatePath: noop });
|
|
93
|
+
expect(m.text).toBe(raw);
|
|
94
|
+
expect(m.path).toBe(overlayFile);
|
|
95
|
+
expect(m.body).toBe(require('../lib/prdFrontmatter.cjs').splitFrontmatter(raw).body);
|
|
96
|
+
const viaLib = await getPersonaBody({ cwd: path.resolve(repoAgents, '..', '..'), name, globalDir, validatePath: noop });
|
|
97
|
+
expect(viaLib.text).toBe(raw);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('malformed overlay frontmatter degrades to the global persona, never throws, and is reported', () => {
|
|
102
|
+
const { cwd, globalDir } = setup({ global: GLOBAL });
|
|
103
|
+
fs.mkdirSync(path.join(cwd, '.claude', 'agents'), { recursive: true });
|
|
104
|
+
fs.writeFileSync(path.join(cwd, '.claude', 'agents', 'dev-lead.md'), '---\nmodel: claude-opus-4-6\nno closing fence');
|
|
105
|
+
const deps = { globalDir, validatePath: noop };
|
|
106
|
+
const m = resolveMergedPersona(cwd, 'dev-lead', deps);
|
|
107
|
+
expect(m.fm.model).toBe('sonnet');
|
|
108
|
+
expect(m.fromOverlay).toBe(false);
|
|
109
|
+
expect(m.overlayIssue).toMatch(/malformed/);
|
|
110
|
+
expect(readOverlayAwarePersonaModel('dev-lead', { ...deps, cwd })).toBe('sonnet');
|
|
111
|
+
const { todayFile } = require('../lib/opsErrorLog.cjs');
|
|
112
|
+
expect(fs.readFileSync(todayFile(cwd), 'utf8')).toMatch(/malformed frontmatter/);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('an overlay with no global counterpart works standalone', () => {
|
|
116
|
+
const { cwd, deps } = setup({ overlay: { fm: ['name: dev-lead', 'model: opus'], body: 'Solo body.' } });
|
|
117
|
+
const m = resolveMergedPersona(cwd, 'dev-lead', deps);
|
|
118
|
+
expect(m.body.trim()).toBe('Solo body.');
|
|
119
|
+
expect(m.globalPath).toBeNull();
|
|
120
|
+
expect(readOverlayAwarePersonaModel('dev-lead', { ...deps, cwd })).toBe('opus');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("overlay `model: inherit` defers to the settings default — it does NOT fall back to the global model", async () => {
|
|
124
|
+
const { cwd, deps } = setup({ global: GLOBAL, overlay: { fm: ['model: inherit'], body: '' } });
|
|
125
|
+
expect(readOverlayAwarePersonaModel('dev-lead', { ...deps, cwd })).toBe('inherit');
|
|
126
|
+
const r = await resolvePrdPersonaForSpawn({ cwd, agentType: 'dev-lead', fallbackModel: 'sonnet-fallback', deps: { getPersonaBody: (a) => getPersonaBody({ ...a, ...deps }) } });
|
|
127
|
+
expect(r.model).toBe('sonnet-fallback');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('resolvePrdPersonaForSpawn hands the executor the merged model AND the global body for a frontmatter-only overlay', async () => {
|
|
131
|
+
const { cwd, deps } = setup({ global: GLOBAL, overlay: { fm: ['model: claude-opus-4-6'], body: '' } });
|
|
132
|
+
const r = await resolvePrdPersonaForSpawn({ cwd, agentType: 'dev-lead', deps: { getPersonaBody: (a) => getPersonaBody({ ...a, ...deps }) } });
|
|
133
|
+
expect(r.model).toBe('claude-opus-4-6');
|
|
134
|
+
expect(r.systemPrompt).toBe('Global body.');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('a crafted agentType is rejected by every reader (PERSONA_NAME_RE traversal guard)', async () => {
|
|
138
|
+
const { cwd, deps } = setup({ global: GLOBAL });
|
|
139
|
+
const evil = '../../other-project/CLAUDE';
|
|
140
|
+
expect(resolveMergedPersona(cwd, evil, deps)).toBeNull();
|
|
141
|
+
expect(readOverlayAwarePersonaModel(evil, { ...deps, cwd })).toBeNull();
|
|
142
|
+
expect(await getPersonaBody({ cwd, name: evil, ...deps })).toBeNull();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('getPersonaBody / effectiveModelInfo / listPersonas report per-field provenance, not a whole-file boolean', async () => {
|
|
146
|
+
const { cwd, globalDir, deps } = setup({ global: GLOBAL, overlay: { fm: ['model: claude-opus-4-6'], body: '' } });
|
|
147
|
+
const body = await getPersonaBody({ cwd, name: 'dev-lead', ...deps });
|
|
148
|
+
expect(body.provenance.model).toBe('overlay');
|
|
149
|
+
expect(body.provenance.effort).toBe('global');
|
|
150
|
+
|
|
151
|
+
const info = await resolveEffectiveModelInfo({
|
|
152
|
+
cwd,
|
|
153
|
+
agentType: 'dev-lead',
|
|
154
|
+
deps: {
|
|
155
|
+
...deps,
|
|
156
|
+
activeSessions: { projectRootOf: (p) => p },
|
|
157
|
+
getPersonaBody: (a) => getPersonaBody({ ...a, ...deps }),
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
expect(info.modelAlias).toBe('claude-opus-4-6');
|
|
161
|
+
expect(info.modelSource).toBe('persona-overlay');
|
|
162
|
+
expect(info.personaProvenance.effort).toBe('global');
|
|
163
|
+
expect(info.personaEffortSource).toBe('persona');
|
|
164
|
+
|
|
165
|
+
const list = await listPersonas({ globalDir, validatePath: noop, loadSessions: async () => ({ tabs: [{ cwd }] }) });
|
|
166
|
+
const dev = list.find((p) => p.name === 'dev-lead');
|
|
167
|
+
expect(dev.overridingProjects).toEqual([path.basename(cwd)]);
|
|
168
|
+
expect(dev.overrideDetails[0]).toMatchObject({ fields: ['model'], bodyOverridden: false, issue: null });
|
|
169
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* prdCreatePlanId.test.cjs — createPrd() stamps a durable `planId` (wave identity):
|
|
3
|
+
* first-ever PRD mints, append inherits the terminal's, new-head mints, explicit
|
|
4
|
+
* multi-dep inherits the lowest-numbered dependency's (diverged ids logged once).
|
|
5
|
+
*
|
|
6
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/prdCreatePlanId.test.cjs
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
import { test, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
12
|
+
const fsp = require('node:fs/promises');
|
|
13
|
+
const os = require('node:os');
|
|
14
|
+
const path = require('node:path');
|
|
15
|
+
const config = require('../config.cjs');
|
|
16
|
+
const prdParser = require('../scheduler/prdParser.cjs');
|
|
17
|
+
const { parsePrdFile } = require('../lib/prdFrontmatter.cjs');
|
|
18
|
+
const { createPrd } = require('../lib/prdCreate.cjs');
|
|
19
|
+
const { resolveInheritedPlanId, isValidPlanId } = require('../lib/prdDisposition.cjs');
|
|
20
|
+
|
|
21
|
+
async function mkTmpPrdsDir() {
|
|
22
|
+
const root = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-planid-create-prd-'));
|
|
23
|
+
config.addAllowedRoot(root);
|
|
24
|
+
const prdsDir = path.join(root, '.claude', 'prds');
|
|
25
|
+
await fsp.mkdir(prdsDir, { recursive: true });
|
|
26
|
+
return prdsDir;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function makeFakeRemote(prdsDir, statusBySlug = {}) {
|
|
30
|
+
return {
|
|
31
|
+
async allocateParallelGroup() { return prdParser.allocateParallelGroup(prdsDir); },
|
|
32
|
+
async readPrd(slug) {
|
|
33
|
+
try { return { ok: true, text: await fsp.readFile(path.join(prdsDir, `${slug}.md`), 'utf8') }; }
|
|
34
|
+
catch (e) { return { ok: false, error: e?.message }; }
|
|
35
|
+
},
|
|
36
|
+
async writePrd(slug, body) {
|
|
37
|
+
await config.writeTextAtomic(path.join(prdsDir, `${slug}.md`), body, { writer: 'scheduler' });
|
|
38
|
+
return { ok: true };
|
|
39
|
+
},
|
|
40
|
+
async listPrds() {
|
|
41
|
+
const prds = [];
|
|
42
|
+
for (const f of await fsp.readdir(prdsDir)) {
|
|
43
|
+
if (!f.endsWith('.md')) continue;
|
|
44
|
+
const slug = f.slice(0, -3);
|
|
45
|
+
const { frontmatter } = parsePrdFile(await fsp.readFile(path.join(prdsDir, f), 'utf8'));
|
|
46
|
+
prds.push({
|
|
47
|
+
slug,
|
|
48
|
+
sourcePromptId: frontmatter.sourcePromptId ?? null,
|
|
49
|
+
dependsOn: frontmatter.dependsOn ?? null,
|
|
50
|
+
planId: frontmatter.planId ?? null,
|
|
51
|
+
status: statusBySlug[slug] ?? null,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return { prds, total: prds.length, limit: prds.length, offset: 0, hasMore: false };
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function seedPrd(prdsDir, slug, { planId, dependsOn } = {}) {
|
|
60
|
+
const lines = ['---', 'title: seed', 'sourcePromptId: epic-1'];
|
|
61
|
+
if (dependsOn) lines.push(`dependsOn: [${dependsOn.join(', ')}]`);
|
|
62
|
+
if (planId) lines.push(`planId: ${planId}`);
|
|
63
|
+
lines.push('---', '# Goal', '', 'seed', '');
|
|
64
|
+
await fsp.writeFile(path.join(prdsDir, `${slug}.md`), lines.join('\n'));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const body = (o = {}) => ({
|
|
68
|
+
title: 'Next wave', cwd: os.homedir(), estimateMinutes: 10, goal: 'g',
|
|
69
|
+
acceptanceCriteria: ['a'], implementationNotes: 'n', ...o,
|
|
70
|
+
});
|
|
71
|
+
const planIdOf = async (dir, result) =>
|
|
72
|
+
parsePrdFile(await fsp.readFile(path.join(dir, result.filename), 'utf8')).frontmatter.planId;
|
|
73
|
+
|
|
74
|
+
const originalJobSlug = process.env.SM_SCHEDULER_JOB_SLUG;
|
|
75
|
+
beforeEach(() => { delete process.env.SM_SCHEDULER_JOB_SLUG; });
|
|
76
|
+
afterEach(() => {
|
|
77
|
+
vi.restoreAllMocks();
|
|
78
|
+
if (originalJobSlug === undefined) delete process.env.SM_SCHEDULER_JOB_SLUG;
|
|
79
|
+
else process.env.SM_SCHEDULER_JOB_SLUG = originalJobSlug;
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('first-ever PRD in an Epic mints a valid planId', async () => {
|
|
83
|
+
const dir = await mkTmpPrdsDir();
|
|
84
|
+
const r = await createPrd(body({ sourcePromptId: 'epic-1' }), makeFakeRemote(dir));
|
|
85
|
+
expect(r.ok).toBe(true);
|
|
86
|
+
expect(isValidPlanId(await planIdOf(dir, r))).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('a caller-supplied planId is ignored (API-owned)', async () => {
|
|
90
|
+
const dir = await mkTmpPrdsDir();
|
|
91
|
+
const r = await createPrd(body({ planId: 'evil-id' }), makeFakeRemote(dir));
|
|
92
|
+
expect(await planIdOf(dir, r)).not.toBe('evil-id');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('append inherits the chain terminal\'s planId', async () => {
|
|
96
|
+
const dir = await mkTmpPrdsDir();
|
|
97
|
+
await seedPrd(dir, '1-tail', { planId: 'pl-abc' });
|
|
98
|
+
const r = await createPrd(body({ sourcePromptId: 'epic-1', disposition: 'append' }), makeFakeRemote(dir, { '1-tail': 'pending' }));
|
|
99
|
+
expect(r.ok).toBe(true);
|
|
100
|
+
expect(await planIdOf(dir, r)).toBe('pl-abc');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('new-head mints a fresh planId, distinct from the existing plan', async () => {
|
|
104
|
+
const dir = await mkTmpPrdsDir();
|
|
105
|
+
await seedPrd(dir, '1-tail', { planId: 'pl-abc' });
|
|
106
|
+
const r = await createPrd(body({ sourcePromptId: 'epic-1', disposition: 'new-head' }), makeFakeRemote(dir, { '1-tail': 'pending' }));
|
|
107
|
+
const id = await planIdOf(dir, r);
|
|
108
|
+
expect(isValidPlanId(id)).toBe(true);
|
|
109
|
+
expect(id).not.toBe('pl-abc');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('explicit multi-dep across two plans inherits the lowest-numbered dependency\'s and warns once', async () => {
|
|
113
|
+
const dir = await mkTmpPrdsDir();
|
|
114
|
+
await seedPrd(dir, '9-late', { planId: 'pl-late' });
|
|
115
|
+
await seedPrd(dir, '2-early', { planId: 'pl-early' });
|
|
116
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
117
|
+
const r = await createPrd(body({ dependsOn: ['9-late', '2-early'] }), makeFakeRemote(dir));
|
|
118
|
+
expect(r.ok).toBe(true);
|
|
119
|
+
expect(await planIdOf(dir, r)).toBe('pl-early');
|
|
120
|
+
expect(warn.mock.calls.filter((c) => String(c[0]).includes('spans plans')).length).toBe(1);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('deps that predate the stamp fall back to a freshly minted planId', async () => {
|
|
124
|
+
const dir = await mkTmpPrdsDir();
|
|
125
|
+
await seedPrd(dir, '1-legacy');
|
|
126
|
+
const r = await createPrd(body({ dependsOn: ['1-legacy'] }), makeFakeRemote(dir));
|
|
127
|
+
expect(isValidPlanId(await planIdOf(dir, r))).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('resolveInheritedPlanId ignores garbage planIds', () => {
|
|
131
|
+
expect(resolveInheritedPlanId(['1-a'], [{ slug: '1-a', planId: 'bad id,[x]' }]).planId).toBeNull();
|
|
132
|
+
});
|
|
@@ -144,3 +144,16 @@ test('a cwd outside every allowed root is rejected before anything is minted', a
|
|
|
144
144
|
const index = readActiveIndex(unregistered);
|
|
145
145
|
expect(Object.keys(index.sessions)).toHaveLength(0);
|
|
146
146
|
});
|
|
147
|
+
|
|
148
|
+
test('model/effort overrides persist on the minted Epic; without them the record gains no such keys', async () => {
|
|
149
|
+
const cwd = await mkCwd();
|
|
150
|
+
const payload = schemas.promptSessionsCreateEpic.parse({ cwd, goalText: 'Override run', agentType: 'architect', model: 'claude-opus-5', effort: 'max' });
|
|
151
|
+
const withOverride = await createEpicViaIpc(cwd, payload);
|
|
152
|
+
expect(withOverride.session).toMatchObject({ model: 'claude-opus-5', effort: 'max' });
|
|
153
|
+
expect(readActiveIndex(cwd).sessions[withOverride.epicId]).toEqual(withOverride.session);
|
|
154
|
+
expect(PromptSessionSchema.safeParse(withOverride.session).success).toBe(true);
|
|
155
|
+
|
|
156
|
+
const plain = await createEpicViaIpc(cwd, schemas.promptSessionsCreateEpic.parse({ cwd, goalText: 'Plain run', agentType: 'architect' }));
|
|
157
|
+
expect('model' in plain.session).toBe(false);
|
|
158
|
+
expect('effort' in plain.session).toBe(false);
|
|
159
|
+
});
|