@principles/pd-cli 1.145.1 → 1.146.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/README.md +11 -2
- package/dist/commands/build-trajectory-evidence.d.ts +25 -6
- package/dist/commands/build-trajectory-evidence.d.ts.map +1 -1
- package/dist/commands/build-trajectory-evidence.js +174 -114
- package/dist/commands/build-trajectory-evidence.js.map +1 -1
- package/dist/commands/diagnose.d.ts.map +1 -1
- package/dist/commands/diagnose.js +48 -27
- package/dist/commands/diagnose.js.map +1 -1
- package/dist/commands/pain-record.d.ts.map +1 -1
- package/dist/commands/pain-record.js +212 -17
- package/dist/commands/pain-record.js.map +1 -1
- package/dist/commands/pain-retry.d.ts.map +1 -1
- package/dist/commands/pain-retry.js +43 -26
- package/dist/commands/pain-retry.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/commands/build-trajectory-evidence.ts +211 -122
- package/src/commands/diagnose.ts +59 -36
- package/src/commands/pain-record.ts +224 -16
- package/src/commands/pain-retry.ts +56 -36
- package/src/index.ts +1 -1
- package/tests/bdd/cli-contract.steps.ts +3 -0
- package/tests/commands/build-trajectory-evidence.test.ts +226 -161
- package/tests/commands/diagnose.test.ts +82 -0
- package/tests/commands/pain-record-async.test.ts +36 -30
- package/tests/commands/pain-record-session-parser.test.ts +127 -0
- package/tests/commands/pain-record.test.ts +205 -37
- package/tests/commands/pain-retry.test.ts +72 -1
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PRI-642 Scope A — real-Commander integration for `pd pain record --session`.
|
|
3
|
+
*
|
|
4
|
+
* Spawns the BUILT pd CLI (dist/index.js) against a real temp workspace with a
|
|
5
|
+
* real trajectory.db, exercising the actual commander registration and the
|
|
6
|
+
* real typed acquisition path — not the in-process handler with mocks
|
|
7
|
+
* (cli-7-test-wiring; SPEC §13 "Real Commander parser/registration tests for
|
|
8
|
+
* --session and --json").
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
11
|
+
import { promisify } from 'node:util';
|
|
12
|
+
import Database from 'better-sqlite3';
|
|
13
|
+
import * as fs from 'node:fs';
|
|
14
|
+
import * as path from 'node:path';
|
|
15
|
+
import * as os from 'node:os';
|
|
16
|
+
|
|
17
|
+
let tmpDir: string;
|
|
18
|
+
|
|
19
|
+
function createWorkspace(): void {
|
|
20
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-pain-record-parser-'));
|
|
21
|
+
const stateDir = path.join(tmpDir, '.state');
|
|
22
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
23
|
+
|
|
24
|
+
const db = new Database(path.join(stateDir, 'trajectory.db'));
|
|
25
|
+
db.exec("CREATE TABLE IF NOT EXISTS sessions (session_id TEXT PRIMARY KEY, started_at TEXT, updated_at TEXT)");
|
|
26
|
+
db.exec("CREATE TABLE IF NOT EXISTS assistant_turns (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, sanitized_text TEXT, stop_reason TEXT, created_at TEXT NOT NULL)");
|
|
27
|
+
db.exec("CREATE TABLE IF NOT EXISTS user_turns (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, raw_excerpt TEXT, correction_detected INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL)");
|
|
28
|
+
db.exec("CREATE TABLE IF NOT EXISTS tool_calls (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, tool_name TEXT NOT NULL, outcome TEXT NOT NULL, error_type TEXT, exit_code INTEGER, params_json TEXT NOT NULL DEFAULT '{}', result_preview TEXT, created_at TEXT NOT NULL)");
|
|
29
|
+
db.prepare('INSERT INTO sessions (session_id, started_at, updated_at) VALUES (?, ?, ?)')
|
|
30
|
+
.run('real-session-1', '2026-01-01T09:00:00Z', '2026-01-01T09:00:00Z');
|
|
31
|
+
db.prepare('INSERT INTO user_turns (session_id, raw_excerpt, correction_detected, created_at) VALUES (?, ?, ?, ?)')
|
|
32
|
+
.run('real-session-1', 'Please fix the output format', 1, '2026-01-01T09:59:00Z');
|
|
33
|
+
db.close();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface CliRunResult {
|
|
37
|
+
status: number | null;
|
|
38
|
+
stdout: string;
|
|
39
|
+
stderr: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Resolves and boundary-validates the built pd CLI entry before spawning it. */
|
|
43
|
+
async function resolveBuiltCliEntry(): Promise<string> {
|
|
44
|
+
const packageRoot = path.resolve(process.cwd());
|
|
45
|
+
const entry = path.resolve(packageRoot, 'dist', 'index.js');
|
|
46
|
+
if (!entry.startsWith(`${packageRoot}${path.sep}`) || !fs.statSync(entry).isFile()) {
|
|
47
|
+
throw new Error(`built pd CLI entry not found or outside the package: ${entry}`);
|
|
48
|
+
}
|
|
49
|
+
return entry;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function runBuiltCli(literalArgv: readonly string[]): Promise<CliRunResult> {
|
|
53
|
+
const { execFile } = await import('node:child_process');
|
|
54
|
+
const execFileAsync = promisify(execFile);
|
|
55
|
+
const entry = await resolveBuiltCliEntry();
|
|
56
|
+
try {
|
|
57
|
+
const { stdout } = await execFileAsync(process.execPath, [entry, ...literalArgv], {
|
|
58
|
+
cwd: tmpDir,
|
|
59
|
+
timeout: 30000,
|
|
60
|
+
encoding: 'utf8',
|
|
61
|
+
env: { ...process.env, PD_WORKSPACE_DIR: tmpDir },
|
|
62
|
+
});
|
|
63
|
+
return { status: 0, stdout, stderr: '' };
|
|
64
|
+
} catch (err) {
|
|
65
|
+
const e = err as { code?: number; stdout?: string; stderr?: string };
|
|
66
|
+
return { status: e.code ?? -1, stdout: e.stdout ?? '', stderr: e.stderr ?? '' };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe('pd pain record --session (real Commander + real trajectory.db)', () => {
|
|
71
|
+
beforeEach(() => {
|
|
72
|
+
createWorkspace();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
afterEach(() => {
|
|
76
|
+
try {
|
|
77
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
78
|
+
} catch {
|
|
79
|
+
// ignore cleanup errors
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('--help registers and documents --session', async () => {
|
|
84
|
+
const result = await runBuiltCli(['pain', 'record', '--help']);
|
|
85
|
+
expect(result.status).toBe(0);
|
|
86
|
+
expect(result.stdout).toContain('--session');
|
|
87
|
+
}, 15_000);
|
|
88
|
+
|
|
89
|
+
it('fails with a single JSON object and reason session_not_found for a nonexistent session (SPEC 12.1.4)', async () => {
|
|
90
|
+
const result = await runBuiltCli([
|
|
91
|
+
'pain', 'record',
|
|
92
|
+
'--reason', 'parser test pain',
|
|
93
|
+
'--session', 'no-such-session',
|
|
94
|
+
'--workspace', tmpDir,
|
|
95
|
+
'--json',
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
// Failed validation exits non-zero.
|
|
99
|
+
expect(result.status).not.toBe(0);
|
|
100
|
+
// cli-1: stdout is exactly one JSON object.
|
|
101
|
+
const trimmed = result.stdout.trim();
|
|
102
|
+
expect(trimmed.length).toBeGreaterThan(0);
|
|
103
|
+
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
|
|
104
|
+
expect(parsed.status).toBe('failed');
|
|
105
|
+
expect(parsed.reason).toBe('session_not_found');
|
|
106
|
+
expect(typeof parsed.nextAction).toBe('string');
|
|
107
|
+
}, 15_000);
|
|
108
|
+
|
|
109
|
+
it('accepts --session for a session that exists in the trajectory (validation passes, submission proceeds)', async () => {
|
|
110
|
+
const result = await runBuiltCli([
|
|
111
|
+
'pain', 'record',
|
|
112
|
+
'--reason', 'parser test pain with real session',
|
|
113
|
+
'--session', 'real-session-1',
|
|
114
|
+
'--workspace', tmpDir,
|
|
115
|
+
'--json',
|
|
116
|
+
]);
|
|
117
|
+
|
|
118
|
+
// The session exists, so session validation must NOT reject it. The run
|
|
119
|
+
// may still fail later (no LLM runtime configured in this temp
|
|
120
|
+
// workspace) — the assertion is only about the validation stage.
|
|
121
|
+
const trimmed = result.stdout.trim();
|
|
122
|
+
expect(trimmed.length).toBeGreaterThan(0);
|
|
123
|
+
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
|
|
124
|
+
expect(parsed.reason).not.toBe('session_not_found');
|
|
125
|
+
expect(parsed.reason).not.toBe('empty_trajectory');
|
|
126
|
+
}, 15_000);
|
|
127
|
+
});
|
|
@@ -21,21 +21,31 @@ vi.mock('fs', () => ({
|
|
|
21
21
|
}));
|
|
22
22
|
|
|
23
23
|
vi.mock('../../src/commands/build-trajectory-evidence.js', () => ({
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
acquireTrajectoryEvidenceFromDb: vi.fn().mockReturnValue({
|
|
25
|
+
status: 'available',
|
|
26
|
+
entries: [
|
|
27
|
+
{ sourceRef: 'owner_message:2026-01-01T10:00:00Z', note: 'Owner correction' },
|
|
28
|
+
{ sourceRef: 'agent_turn:2026-01-01T10:01:00Z', note: 'assistant evidence' },
|
|
29
|
+
],
|
|
30
|
+
}),
|
|
27
31
|
}));
|
|
28
32
|
|
|
29
|
-
vi.mock('@principles/core/runtime-v2', () =>
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
33
|
+
vi.mock('@principles/core/runtime-v2', async (importOriginal) => {
|
|
34
|
+
// PRI-642 review blocker 1: the CLI now evaluates ingress semantics via
|
|
35
|
+
// the REAL core evaluatePainIngress (the shared authority) — only the
|
|
36
|
+
// service/IO classes are mocked.
|
|
37
|
+
const actual = await importOriginal<typeof import('@principles/core/runtime-v2')>();
|
|
38
|
+
return {
|
|
39
|
+
...actual,
|
|
40
|
+
PainToPrincipleService: vi.fn().mockImplementation(function() {
|
|
41
|
+
return {
|
|
42
|
+
recordPain: vi.fn(async (input: PainToPrincipleInput) => {
|
|
43
|
+
lastRecordPainInput = input;
|
|
44
|
+
return mockRecordPainResult;
|
|
45
|
+
}),
|
|
46
|
+
};
|
|
47
|
+
}),
|
|
48
|
+
PrincipleTreeLedgerAdapter: vi.fn().mockImplementation(function() { return {}; }),
|
|
39
49
|
computeEffectivePdConfig: vi.fn().mockReturnValue({
|
|
40
50
|
runtimeKind: 'pi-ai',
|
|
41
51
|
provider: 'test-provider',
|
|
@@ -58,7 +68,8 @@ vi.mock('@principles/core/runtime-v2', () => ({
|
|
|
58
68
|
isBuiltinPiAiProvider: vi.fn().mockReturnValue(true),
|
|
59
69
|
resolveOutputLanguage: vi.fn().mockReturnValue({ outputLanguage: 'zh-CN' }),
|
|
60
70
|
isFeatureEnabled: vi.fn().mockReturnValue(false),
|
|
61
|
-
}
|
|
71
|
+
};
|
|
72
|
+
});
|
|
62
73
|
|
|
63
74
|
vi.mock('../../src/services/pd-config-loader.js', () => ({
|
|
64
75
|
loadPdConfig: vi.fn().mockReturnValue({
|
|
@@ -77,8 +88,9 @@ vi.mock('../../src/services/pd-config-loader.js', () => ({
|
|
|
77
88
|
}));
|
|
78
89
|
|
|
79
90
|
import { handlePainRecord } from '../../src/commands/pain-record.js';
|
|
80
|
-
import { isBuiltinPiAiProvider } from '@principles/core/runtime-v2';
|
|
81
|
-
import type {
|
|
91
|
+
import { isBuiltinPiAiProvider, type PainToPrincipleOutput, type PainToPrincipleInput } from '@principles/core/runtime-v2';
|
|
92
|
+
import type { FailureCategory } from '@principles/core/runtime-v2';
|
|
93
|
+
import { acquireTrajectoryEvidenceFromDb } from '../../src/commands/build-trajectory-evidence.js';
|
|
82
94
|
|
|
83
95
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
84
96
|
|
|
@@ -351,51 +363,207 @@ describe('pd pain record', () => {
|
|
|
351
363
|
expect(lastRecordPainInput!.source).toBe('ci');
|
|
352
364
|
expect(lastRecordPainInput!.reason).toBe('test pain');
|
|
353
365
|
expect(lastRecordPainInput!.score).toBe(90);
|
|
354
|
-
|
|
366
|
+
// PRI-642: no --session means an explicit unbound Owner report — the
|
|
367
|
+
// 'cli' sentinel session must NOT be fabricated (SPEC §7.4).
|
|
368
|
+
expect(lastRecordPainInput!.sessionId).toBeUndefined();
|
|
355
369
|
expect(lastRecordPainInput!.agentId).toBe('pd-cli');
|
|
356
|
-
//
|
|
357
|
-
expect(lastRecordPainInput!.evidence).
|
|
358
|
-
expect(lastRecordPainInput!.
|
|
370
|
+
// …and no placeholder evidence entry may be submitted.
|
|
371
|
+
expect(lastRecordPainInput!.evidence).toEqual([]);
|
|
372
|
+
expect(lastRecordPainInput!.recordObservability).toBe(false);
|
|
373
|
+
expect(lastRecordPainInput!.provenance).toBe('owner_reported_no_host_trace');
|
|
359
374
|
});
|
|
360
375
|
|
|
361
|
-
// ── PRI-341: evidence passthrough and --session flag
|
|
362
|
-
|
|
363
|
-
// 用例 C: recordPain receives non-empty evidence field when session provided
|
|
364
|
-
it('C: passes evidence to recordPain when --session is provided', async () => {
|
|
365
|
-
// Mock buildTrajectoryEvidenceFromDb to return evidence
|
|
366
|
-
const mockEvidence = [
|
|
367
|
-
{ sourceRef: 'agent_turn:2026-01-01T10:00:00Z', note: 'assistant text evidence' },
|
|
368
|
-
];
|
|
369
|
-
vi.doMock('../../src/commands/build-trajectory-evidence.js', () => ({
|
|
370
|
-
buildTrajectoryEvidenceFromDb: vi.fn().mockReturnValue(mockEvidence),
|
|
371
|
-
}));
|
|
376
|
+
// ── PRI-341 → PRI-642: evidence passthrough and --session flag ─────────────
|
|
372
377
|
|
|
378
|
+
// 用例 C: recordPain receives bound provenance + validated evidence when a real session is provided
|
|
379
|
+
it('C: passes bound provenance and evidence to recordPain when --session resolves available', async () => {
|
|
373
380
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
374
381
|
const exitSpy = mockProcessExit();
|
|
375
382
|
|
|
376
383
|
await handlePainRecord({ reason: 'test pain', session: 'sess-123', json: true });
|
|
377
384
|
|
|
378
385
|
expect(lastRecordPainInput).toBeTruthy();
|
|
386
|
+
expect(acquireTrajectoryEvidenceFromDb).toHaveBeenCalled();
|
|
379
387
|
expect(lastRecordPainInput!.evidence).toBeTruthy();
|
|
380
388
|
expect(lastRecordPainInput!.evidence!.length).toBeGreaterThan(0);
|
|
389
|
+
expect(lastRecordPainInput!.evidence![0].sourceRef).not.toBe('owner_reported:cli');
|
|
381
390
|
expect(lastRecordPainInput!.sessionId).toBe('sess-123');
|
|
391
|
+
// SPEC §7.3: provenance must be derived from the validated result —
|
|
392
|
+
// a real session is host-context-bound, never owner_reported_no_host_trace.
|
|
393
|
+
expect(lastRecordPainInput!.provenance).toBe('host_context_bound');
|
|
394
|
+
// PRI-640/SPEC §8.3: the bound path attributes the OpenClaw trajectory.
|
|
395
|
+
expect(lastRecordPainInput!.hostKind).toBe('openclaw');
|
|
396
|
+
expect(lastRecordPainInput!.recordObservability).toBe(true);
|
|
382
397
|
|
|
383
398
|
logSpy.mockRestore();
|
|
384
399
|
exitSpy.mockRestore();
|
|
385
400
|
});
|
|
386
401
|
|
|
387
|
-
// 用例 C2: without session,
|
|
388
|
-
|
|
402
|
+
// 用例 C2 (PRI-642 rewrite): without session, no sentinel session, no
|
|
403
|
+
// placeholder evidence — an honest unbound Owner report (SPEC §7.4).
|
|
404
|
+
it('C2: submits an honest unbound report when no --session provided', async () => {
|
|
389
405
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
390
406
|
const exitSpy = mockProcessExit();
|
|
391
407
|
|
|
392
408
|
await handlePainRecord({ reason: 'test pain', json: true });
|
|
393
409
|
|
|
394
410
|
expect(lastRecordPainInput).toBeTruthy();
|
|
395
|
-
expect(lastRecordPainInput!.
|
|
396
|
-
expect(lastRecordPainInput!.evidence
|
|
397
|
-
|
|
398
|
-
expect(lastRecordPainInput!.
|
|
411
|
+
expect(lastRecordPainInput!.sessionId).toBeUndefined();
|
|
412
|
+
expect(lastRecordPainInput!.evidence).toEqual([]);
|
|
413
|
+
expect(lastRecordPainInput!.recordObservability).toBe(false);
|
|
414
|
+
expect(lastRecordPainInput!.provenance).toBe('owner_reported_no_host_trace');
|
|
415
|
+
|
|
416
|
+
const jsonOutput = JSON.parse(logSpy.mock.calls[0][0]);
|
|
417
|
+
// SPEC §7.4: disclose context_unbound and recommend --session.
|
|
418
|
+
const warnings = JSON.stringify(jsonOutput);
|
|
419
|
+
expect(warnings).toMatch(/context_unbound/);
|
|
420
|
+
expect(warnings).toMatch(/--session/);
|
|
421
|
+
|
|
422
|
+
logSpy.mockRestore();
|
|
423
|
+
exitSpy.mockRestore();
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
// ── PRI-642 Scope A: explicit-session validation (SPEC §7.3, §12.1.3–12.1.4) ─
|
|
427
|
+
|
|
428
|
+
it('fails with session_not_found before any task/candidate mutation when --session does not exist', async () => {
|
|
429
|
+
vi.mocked(acquireTrajectoryEvidenceFromDb).mockReturnValueOnce({
|
|
430
|
+
status: 'unavailable',
|
|
431
|
+
reasonCode: 'session_not_found',
|
|
432
|
+
detail: 'session not present in trajectory.db sessions table',
|
|
433
|
+
} as any);
|
|
434
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
435
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
436
|
+
const exitSpy = mockProcessExit();
|
|
437
|
+
|
|
438
|
+
await handlePainRecord({ reason: 'test pain', session: 'missing-session', json: true });
|
|
439
|
+
|
|
440
|
+
// cli-1: exactly one JSON object on stdout
|
|
441
|
+
expect(logSpy).toHaveBeenCalledTimes(1);
|
|
442
|
+
const jsonOutput = JSON.parse(logSpy.mock.calls[0][0]);
|
|
443
|
+
expect(jsonOutput.status).toBe('failed');
|
|
444
|
+
expect(jsonOutput.reason).toBe('session_not_found');
|
|
445
|
+
expect(jsonOutput.nextAction).toBeTruthy();
|
|
446
|
+
// cli-2/cli-5: execution stopped — no service mutation, exit 1
|
|
447
|
+
expect(lastRecordPainInput).toBeNull();
|
|
448
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
449
|
+
|
|
450
|
+
logSpy.mockRestore();
|
|
451
|
+
errorSpy.mockRestore();
|
|
452
|
+
exitSpy.mockRestore();
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it('fails before any mutation on session_not_found even when process.exit is stubbed to a no-op', async () => {
|
|
456
|
+
vi.mocked(acquireTrajectoryEvidenceFromDb).mockReturnValueOnce({
|
|
457
|
+
status: 'unavailable',
|
|
458
|
+
reasonCode: 'session_not_found',
|
|
459
|
+
detail: 'session not present',
|
|
460
|
+
} as any);
|
|
461
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
462
|
+
const exitSpy = mockProcessExit();
|
|
463
|
+
|
|
464
|
+
await handlePainRecord({ reason: 'test pain', session: 'missing-session', json: true });
|
|
465
|
+
|
|
466
|
+
// cli-2: with process.exit stubbed, control flow must still stop —
|
|
467
|
+
// recordPain was never invoked and stdout carries exactly one object.
|
|
468
|
+
expect(lastRecordPainInput).toBeNull();
|
|
469
|
+
expect(logSpy).toHaveBeenCalledTimes(1);
|
|
470
|
+
logSpy.mockRestore();
|
|
471
|
+
exitSpy.mockRestore();
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it('fails before mutation on empty_trajectory (CLI never claims host_context_bound without a verified session)', async () => {
|
|
475
|
+
// Per Evidence Over Assumption: the CLI does not own session identity
|
|
476
|
+
// the way the OpenClaw host command context does. Unverified sessions
|
|
477
|
+
// (any acquisition that does not yield 'available') must refuse before
|
|
478
|
+
// any LLM/task/candidate mutation.
|
|
479
|
+
vi.mocked(acquireTrajectoryEvidenceFromDb).mockReturnValueOnce({
|
|
480
|
+
status: 'unavailable',
|
|
481
|
+
reasonCode: 'empty_trajectory',
|
|
482
|
+
detail: 'session exists but no usable evidence',
|
|
483
|
+
} as any);
|
|
484
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
485
|
+
const exitSpy = mockProcessExit();
|
|
486
|
+
|
|
487
|
+
await handlePainRecord({ reason: 'test pain', session: 'quiet-session', json: true });
|
|
488
|
+
|
|
489
|
+
expect(lastRecordPainInput).toBeNull();
|
|
490
|
+
const jsonOutput = JSON.parse(logSpy.mock.calls[0][0]);
|
|
491
|
+
expect(jsonOutput.status).toBe('failed');
|
|
492
|
+
expect(jsonOutput.reason).toBe('empty_trajectory');
|
|
493
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
494
|
+
|
|
495
|
+
logSpy.mockRestore();
|
|
496
|
+
exitSpy.mockRestore();
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it('fails before mutation on evidence_read_failed (no LLM/no task when the CLI cannot verify binding)', async () => {
|
|
500
|
+
vi.mocked(acquireTrajectoryEvidenceFromDb).mockReturnValueOnce({
|
|
501
|
+
status: 'unavailable',
|
|
502
|
+
reasonCode: 'evidence_read_failed',
|
|
503
|
+
detail: 'trajectory.db unreadable',
|
|
504
|
+
} as any);
|
|
505
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
506
|
+
const exitSpy = mockProcessExit();
|
|
507
|
+
|
|
508
|
+
await handlePainRecord({ reason: 'test pain', session: 'sess-x', json: true });
|
|
509
|
+
|
|
510
|
+
expect(lastRecordPainInput).toBeNull();
|
|
511
|
+
const jsonOutput = JSON.parse(logSpy.mock.calls[0][0]);
|
|
512
|
+
expect(jsonOutput.status).toBe('failed');
|
|
513
|
+
expect(jsonOutput.reason).toBe('evidence_read_failed');
|
|
514
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
515
|
+
|
|
516
|
+
logSpy.mockRestore();
|
|
517
|
+
exitSpy.mockRestore();
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it('fails before mutation on trajectory_unavailable (CLI cannot fabricate a session)', async () => {
|
|
521
|
+
vi.mocked(acquireTrajectoryEvidenceFromDb).mockReturnValueOnce({
|
|
522
|
+
status: 'unavailable',
|
|
523
|
+
reasonCode: 'trajectory_unavailable',
|
|
524
|
+
detail: 'no trajectory.db at workspace .state',
|
|
525
|
+
} as any);
|
|
526
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
527
|
+
const exitSpy = mockProcessExit();
|
|
528
|
+
|
|
529
|
+
await handlePainRecord({ reason: 'test pain', session: 'sess-x', json: true });
|
|
530
|
+
|
|
531
|
+
expect(lastRecordPainInput).toBeNull();
|
|
532
|
+
const jsonOutput = JSON.parse(logSpy.mock.calls[0][0]);
|
|
533
|
+
expect(jsonOutput.status).toBe('failed');
|
|
534
|
+
expect(jsonOutput.reason).toBe('trajectory_unavailable');
|
|
535
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
536
|
+
|
|
537
|
+
logSpy.mockRestore();
|
|
538
|
+
exitSpy.mockRestore();
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
// ── PRI-642 Scope A (SPEC §12.1.6): generated-but-unadmitted candidates must
|
|
542
|
+
// not be reported as completed internalization. ─────────────────────────────
|
|
543
|
+
|
|
544
|
+
it('warns that no candidate was admitted/internalized when all candidates are gated', async () => {
|
|
545
|
+
mockRecordPainResult = {
|
|
546
|
+
...SUCCEEDED_RESULT,
|
|
547
|
+
candidateIds: ['c1', 'c2', 'c3', 'c4'],
|
|
548
|
+
ledgerEntryIds: [],
|
|
549
|
+
admissionResults: [
|
|
550
|
+
{ candidateId: 'c1', recommendationKind: 'prompt', admission: { decision: 'needs_evidence', reason: 'confidence_below_threshold:0.45<0.50', nextAction: 'add evidence', evidenceStatus: 'host_context_bound' } },
|
|
551
|
+
{ candidateId: 'c2', recommendationKind: 'prompt', admission: { decision: 'deferred', reason: 'recommendation_kind_defer_not_actionable', nextAction: 'none', evidenceStatus: 'host_context_bound' } },
|
|
552
|
+
{ candidateId: 'c3', recommendationKind: 'prompt', admission: { decision: 'needs_evidence', reason: 'input_evidence_empty', nextAction: 'add evidence', evidenceStatus: 'host_context_bound' } },
|
|
553
|
+
{ candidateId: 'c4', recommendationKind: 'prompt', admission: { decision: 'needs_evidence', reason: 'confidence_below_threshold:0.45<0.50', nextAction: 'add evidence', evidenceStatus: 'host_context_bound' } },
|
|
554
|
+
],
|
|
555
|
+
} as PainToPrincipleOutput;
|
|
556
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
557
|
+
const exitSpy = mockProcessExit();
|
|
558
|
+
|
|
559
|
+
await handlePainRecord({ reason: 'test pain', json: true });
|
|
560
|
+
|
|
561
|
+
const allOutput = logSpy.mock.calls.map(c => c.join(' ')).join(' ');
|
|
562
|
+
// Explicit admission summary; must not read as completed internalization.
|
|
563
|
+
expect(allOutput).toMatch(/0 (?:of|\/) 4|admitted.*0|0.*admitted/i);
|
|
564
|
+
const jsonOutput = JSON.parse(logSpy.mock.calls.at(-1)![0] ?? '{}');
|
|
565
|
+
const jsonStr = JSON.stringify(jsonOutput);
|
|
566
|
+
expect(jsonStr).toMatch(/no candidate was admitted|admitted:\s*0/i);
|
|
399
567
|
|
|
400
568
|
logSpy.mockRestore();
|
|
401
569
|
exitSpy.mockRestore();
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* - Wrong taskKind: rejected with reason + nextAction
|
|
14
14
|
* - Missing pi-ai config: rejected with reason + nextAction
|
|
15
15
|
*/
|
|
16
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
16
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
17
17
|
import { Command } from 'commander';
|
|
18
18
|
|
|
19
19
|
// ── Mocks ──────────────────────────────────────────────────────────────────────
|
|
@@ -144,6 +144,8 @@ vi.mock('@principles/core/runtime-v2', () => {
|
|
|
144
144
|
OpenClawCliRuntimeAdapter: vi.fn().mockImplementation(function () { return {}; }),
|
|
145
145
|
PiAiRuntimeAdapter: vi.fn().mockImplementation(function () { return {}; }),
|
|
146
146
|
SPLIT_PIPELINE_TOTAL_TIMEOUT_MS: 300000,
|
|
147
|
+
// PRI-638: capability gate — available by default; disabled cases override this.
|
|
148
|
+
resolveDiagnosticianCapability: vi.fn((): { available: boolean; reason?: string; message?: string; nextAction?: string } => ({ available: true })),
|
|
147
149
|
PDRuntimeError: class PDRuntimeError extends Error {
|
|
148
150
|
constructor(public category: string, message: string) {
|
|
149
151
|
super(message);
|
|
@@ -1048,3 +1050,72 @@ describe('BUG-1 (PRI-442): pain retry — effectiveConfig wiring to split-pipeli
|
|
|
1048
1050
|
exitSpy.mockRestore();
|
|
1049
1051
|
});
|
|
1050
1052
|
});
|
|
1053
|
+
|
|
1054
|
+
// ── PRI-638: unified capability-disabled semantics ───────────────────────────
|
|
1055
|
+
//
|
|
1056
|
+
// On main, an Owner-disabled Diagnostician surfaced from `pd pain retry` as
|
|
1057
|
+
// `missing_runtime` ("no .pd/config.yaml runtime binding found") — telling the
|
|
1058
|
+
// Owner their config was broken when they had deliberately switched the agent
|
|
1059
|
+
// off. The capability gate now runs BEFORE runtime resolution and reads the
|
|
1060
|
+
// same canonical authority the runtime factory uses.
|
|
1061
|
+
|
|
1062
|
+
describe('PRI-638: pd pain retry when Diagnostician capability is disabled', () => {
|
|
1063
|
+
beforeEach(async () => {
|
|
1064
|
+
vi.clearAllMocks();
|
|
1065
|
+
const runtimeV2 = await import('@principles/core/runtime-v2');
|
|
1066
|
+
vi.mocked(runtimeV2.resolveDiagnosticianCapability).mockReturnValue({
|
|
1067
|
+
available: false,
|
|
1068
|
+
reason: 'capability_disabled',
|
|
1069
|
+
message: "Agent 'diagnostician' is disabled",
|
|
1070
|
+
nextAction: "Enable agent 'diagnostician' in .pd/config.yaml internalAgents.agents.diagnostician.enabled",
|
|
1071
|
+
});
|
|
1072
|
+
});
|
|
1073
|
+
|
|
1074
|
+
afterEach(async () => {
|
|
1075
|
+
const runtimeV2 = await import('@principles/core/runtime-v2');
|
|
1076
|
+
vi.mocked(runtimeV2.resolveDiagnosticianCapability).mockReset();
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
it('RETRY-638-01: --json refuses with capability_disabled, not missing_runtime', async () => {
|
|
1080
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
1081
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
|
|
1082
|
+
|
|
1083
|
+
await handlePainRetry({
|
|
1084
|
+
painId: 'pain-638',
|
|
1085
|
+
workspace: '/tmp/fake-workspace',
|
|
1086
|
+
json: true,
|
|
1087
|
+
});
|
|
1088
|
+
|
|
1089
|
+
const jsonCall = logSpy.mock.calls.find((call) => String(call[0]).trim().startsWith('{'));
|
|
1090
|
+
expect(jsonCall).toBeDefined();
|
|
1091
|
+
const parsed = JSON.parse(String(jsonCall?.[0]));
|
|
1092
|
+
expect(parsed.reason).toBe('capability_disabled');
|
|
1093
|
+
expect(parsed.reason).not.toBe('missing_runtime');
|
|
1094
|
+
expect(parsed.nextAction).toContain('internalAgents.agents.diagnostician.enabled');
|
|
1095
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
1096
|
+
|
|
1097
|
+
logSpy.mockRestore();
|
|
1098
|
+
exitSpy.mockRestore();
|
|
1099
|
+
});
|
|
1100
|
+
|
|
1101
|
+
it('RETRY-638-02: capability gate fires before the runtime adapter is built', async () => {
|
|
1102
|
+
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
1103
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
|
|
1104
|
+
|
|
1105
|
+
await handlePainRetry({
|
|
1106
|
+
painId: 'pain-638',
|
|
1107
|
+
workspace: '/tmp/fake-workspace',
|
|
1108
|
+
runtime: 'test-double',
|
|
1109
|
+
json: false,
|
|
1110
|
+
});
|
|
1111
|
+
|
|
1112
|
+
const out = errSpy.mock.calls.map((c) => String(c[0])).join('\n');
|
|
1113
|
+
expect(out).toContain('capability_disabled');
|
|
1114
|
+
const runtimeV2 = await import('@principles/core/runtime-v2');
|
|
1115
|
+
expect(runtimeV2.TestDoubleRuntimeAdapter).not.toHaveBeenCalled();
|
|
1116
|
+
expect(runtimeV2.SplitDiagnosticianRunner).not.toHaveBeenCalled();
|
|
1117
|
+
|
|
1118
|
+
errSpy.mockRestore();
|
|
1119
|
+
exitSpy.mockRestore();
|
|
1120
|
+
});
|
|
1121
|
+
});
|