@principles/pd-cli 1.101.0 → 1.102.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/commands/diagnose.js +27 -27
- package/dist/commands/diagnose.js.map +1 -1
- package/dist/commands/pain-retry.d.ts.map +1 -1
- package/dist/commands/pain-retry.js +22 -27
- package/dist/commands/pain-retry.js.map +1 -1
- package/dist/commands/runtime-internalization-run-once.d.ts.map +1 -1
- package/dist/commands/runtime-internalization-run-once.js +11 -9
- package/dist/commands/runtime-internalization-run-once.js.map +1 -1
- package/dist/commands/runtime.d.ts +1 -1
- package/dist/commands/runtime.d.ts.map +1 -1
- package/dist/commands/runtime.js +92 -25
- package/dist/commands/runtime.js.map +1 -1
- package/dist/services/resolve-runtime-from-pd-config.d.ts +59 -0
- package/dist/services/resolve-runtime-from-pd-config.d.ts.map +1 -0
- package/dist/services/resolve-runtime-from-pd-config.js +96 -0
- package/dist/services/resolve-runtime-from-pd-config.js.map +1 -0
- package/package.json +1 -1
- package/src/commands/diagnose.ts +26 -26
- package/src/commands/pain-retry.ts +21 -25
- package/src/commands/runtime-internalization-run-once.ts +10 -9
- package/src/commands/runtime.ts +96 -24
- package/src/services/resolve-runtime-from-pd-config.ts +142 -0
- package/tests/commands/console-launcher-edge-cases.test.ts +14 -47
- package/tests/commands/diagnose.test.ts +91 -39
- package/tests/commands/pain-retry.test.ts +130 -15
- package/tests/commands/pri-393-runtime-config-unification.test.ts +284 -0
- package/tests/commands/runtime-internalization-run-once.test.ts +59 -53
- package/tests/commands/runtime.test.ts +124 -1
|
@@ -55,7 +55,7 @@ const { MockPrincipleTreeLedgerAdapter } = vi.hoisted(() => {
|
|
|
55
55
|
return { MockPrincipleTreeLedgerAdapter };
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
-
const { mockRun, mockResolveRuntimeConfig } = vi.hoisted(() => {
|
|
58
|
+
const { mockRun, mockResolveRuntimeConfig, mockResolveRuntimeFromPdConfig } = vi.hoisted(() => {
|
|
59
59
|
const mockRun = vi.fn().mockResolvedValue({
|
|
60
60
|
status: 'succeeded',
|
|
61
61
|
taskId: 'diagnosis_test-pain-1',
|
|
@@ -70,7 +70,20 @@ const { mockRun, mockResolveRuntimeConfig } = vi.hoisted(() => {
|
|
|
70
70
|
timeoutMs: 300000,
|
|
71
71
|
agentId: 'main',
|
|
72
72
|
});
|
|
73
|
-
|
|
73
|
+
const mockResolveRuntimeFromPdConfig = vi.fn().mockReturnValue({
|
|
74
|
+
result: {
|
|
75
|
+
runtimeKind: 'pi-ai',
|
|
76
|
+
provider: 'test-provider',
|
|
77
|
+
model: 'test-model',
|
|
78
|
+
apiKeyEnv: 'TEST_KEY',
|
|
79
|
+
timeoutMs: 300000,
|
|
80
|
+
agentId: 'main',
|
|
81
|
+
},
|
|
82
|
+
legacyWarnings: [],
|
|
83
|
+
configSource: '.pd/config.yaml',
|
|
84
|
+
configLoadResult: { ok: true, effective: {}, defaults: {}, legacyFilesDetected: [] },
|
|
85
|
+
});
|
|
86
|
+
return { mockRun, mockResolveRuntimeConfig, mockResolveRuntimeFromPdConfig };
|
|
74
87
|
});
|
|
75
88
|
|
|
76
89
|
vi.mock('../../src/resolve-workspace.js', () => ({
|
|
@@ -128,6 +141,10 @@ vi.mock('../../src/config-reader.js', () => ({
|
|
|
128
141
|
readOutputLanguageFromWorkspace: vi.fn().mockReturnValue({ outputLanguage: 'zh-CN' }),
|
|
129
142
|
}));
|
|
130
143
|
|
|
144
|
+
vi.mock('../../src/services/resolve-runtime-from-pd-config.js', () => ({
|
|
145
|
+
resolveRuntimeFromPdConfig: mockResolveRuntimeFromPdConfig,
|
|
146
|
+
}));
|
|
147
|
+
|
|
131
148
|
import { handlePainRetry } from '../../src/commands/pain-retry.js';
|
|
132
149
|
|
|
133
150
|
// ── Test Data ──────────────────────────────────────────────────────────────────
|
|
@@ -201,6 +218,19 @@ describe('pd pain retry — validation and error paths', () => {
|
|
|
201
218
|
timeoutMs: 300000,
|
|
202
219
|
agentId: 'main',
|
|
203
220
|
});
|
|
221
|
+
mockResolveRuntimeFromPdConfig.mockReturnValue({
|
|
222
|
+
result: {
|
|
223
|
+
runtimeKind: 'pi-ai',
|
|
224
|
+
provider: 'test-provider',
|
|
225
|
+
model: 'test-model',
|
|
226
|
+
apiKeyEnv: 'TEST_KEY',
|
|
227
|
+
timeoutMs: 300000,
|
|
228
|
+
agentId: 'main',
|
|
229
|
+
},
|
|
230
|
+
legacyWarnings: [],
|
|
231
|
+
configSource: '.pd/config.yaml',
|
|
232
|
+
configLoadResult: { ok: true, effective: {}, defaults: {}, legacyFilesDetected: [] },
|
|
233
|
+
});
|
|
204
234
|
mockGetTask.mockResolvedValue(null);
|
|
205
235
|
mockGetCandidatesByTaskId.mockResolvedValue([]);
|
|
206
236
|
mockUpdateCandidateStatus.mockResolvedValue(undefined);
|
|
@@ -342,11 +372,16 @@ describe('pd pain retry — validation and error paths', () => {
|
|
|
342
372
|
|
|
343
373
|
it('RETRY-05a: missing --runtime and no config — refused with reason + nextAction (JSON)', async () => {
|
|
344
374
|
mockGetTask.mockResolvedValue(RETRY_WAIT_TASK);
|
|
345
|
-
//
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
375
|
+
// PRI-393: resolveRuntimeFromPdConfig returns error → no runtime resolved
|
|
376
|
+
mockResolveRuntimeFromPdConfig.mockReturnValueOnce({
|
|
377
|
+
result: {
|
|
378
|
+
reason: 'config_not_found',
|
|
379
|
+
message: 'No .pd/config.yaml found',
|
|
380
|
+
nextAction: 'Create .pd/config.yaml or pass --runtime',
|
|
381
|
+
},
|
|
382
|
+
legacyWarnings: [],
|
|
383
|
+
configSource: '.pd/config.yaml',
|
|
384
|
+
configLoadResult: { ok: false, effective: {}, defaults: {}, legacyFilesDetected: [] },
|
|
350
385
|
});
|
|
351
386
|
|
|
352
387
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
@@ -404,14 +439,19 @@ describe('pd pain retry — validation and error paths', () => {
|
|
|
404
439
|
|
|
405
440
|
it('RETRY-05c: blank provider/model/apiKeyEnv — refused with missing_required_config', async () => {
|
|
406
441
|
mockGetTask.mockResolvedValue(RETRY_WAIT_TASK);
|
|
407
|
-
//
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
442
|
+
// PRI-393: resolveRuntimeFromPdConfig returns config with blank strings
|
|
443
|
+
mockResolveRuntimeFromPdConfig.mockReturnValueOnce({
|
|
444
|
+
result: {
|
|
445
|
+
runtimeKind: 'pi-ai',
|
|
446
|
+
provider: '',
|
|
447
|
+
model: ' ',
|
|
448
|
+
apiKeyEnv: '',
|
|
449
|
+
timeoutMs: 300000,
|
|
450
|
+
agentId: 'main',
|
|
451
|
+
},
|
|
452
|
+
legacyWarnings: [],
|
|
453
|
+
configSource: '.pd/config.yaml',
|
|
454
|
+
configLoadResult: { ok: true, effective: {}, defaults: {}, legacyFilesDetected: [] },
|
|
415
455
|
});
|
|
416
456
|
|
|
417
457
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
@@ -440,6 +480,81 @@ describe('pd pain retry — validation and error paths', () => {
|
|
|
440
480
|
logSpy.mockRestore();
|
|
441
481
|
exitSpy.mockRestore();
|
|
442
482
|
});
|
|
483
|
+
|
|
484
|
+
it('DPB-09: openclaw-cli flag overrides file config mode (config=gateway, flag=local → runtimeMode=local)', async () => {
|
|
485
|
+
mockGetTask.mockResolvedValue(RETRY_WAIT_TASK);
|
|
486
|
+
mockResolveRuntimeFromPdConfig.mockReturnValueOnce({
|
|
487
|
+
result: {
|
|
488
|
+
runtimeKind: 'openclaw-cli',
|
|
489
|
+
openclawMode: 'gateway',
|
|
490
|
+
timeoutMs: 300000,
|
|
491
|
+
agentId: 'main',
|
|
492
|
+
},
|
|
493
|
+
legacyWarnings: [],
|
|
494
|
+
configSource: '.pd/config.yaml',
|
|
495
|
+
configLoadResult: { ok: true, effective: {}, defaults: {}, legacyFilesDetected: [] },
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
499
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
|
|
500
|
+
|
|
501
|
+
await handlePainRetry({
|
|
502
|
+
painId: 'test-pain-1',
|
|
503
|
+
workspace: '/tmp/fake-workspace',
|
|
504
|
+
runtime: 'openclaw-cli',
|
|
505
|
+
openclawLocal: true,
|
|
506
|
+
json: true,
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
// Flag override: config says gateway, flag says local → adapter gets local
|
|
510
|
+
const OpenClawCliMock = vi.mocked(
|
|
511
|
+
await import('@principles/core/runtime-v2').then(m => m.OpenClawCliRuntimeAdapter),
|
|
512
|
+
);
|
|
513
|
+
expect(OpenClawCliMock).toHaveBeenCalledWith(
|
|
514
|
+
expect.objectContaining({ runtimeMode: 'local' }),
|
|
515
|
+
);
|
|
516
|
+
expect(exitSpy).not.toHaveBeenCalledWith(1);
|
|
517
|
+
|
|
518
|
+
logSpy.mockRestore();
|
|
519
|
+
exitSpy.mockRestore();
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it('DPB-09: openclaw-cli flag overrides file config mode (config=local, flag=gateway → runtimeMode=gateway)', async () => {
|
|
523
|
+
mockGetTask.mockResolvedValue(RETRY_WAIT_TASK);
|
|
524
|
+
mockResolveRuntimeFromPdConfig.mockReturnValueOnce({
|
|
525
|
+
result: {
|
|
526
|
+
runtimeKind: 'openclaw-cli',
|
|
527
|
+
openclawMode: 'local',
|
|
528
|
+
timeoutMs: 300000,
|
|
529
|
+
agentId: 'main',
|
|
530
|
+
},
|
|
531
|
+
legacyWarnings: [],
|
|
532
|
+
configSource: '.pd/config.yaml',
|
|
533
|
+
configLoadResult: { ok: true, effective: {}, defaults: {}, legacyFilesDetected: [] },
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
537
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
|
|
538
|
+
|
|
539
|
+
await handlePainRetry({
|
|
540
|
+
painId: 'test-pain-1',
|
|
541
|
+
workspace: '/tmp/fake-workspace',
|
|
542
|
+
runtime: 'openclaw-cli',
|
|
543
|
+
openclawGateway: true,
|
|
544
|
+
json: true,
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
const OpenClawCliMock = vi.mocked(
|
|
548
|
+
await import('@principles/core/runtime-v2').then(m => m.OpenClawCliRuntimeAdapter),
|
|
549
|
+
);
|
|
550
|
+
expect(OpenClawCliMock).toHaveBeenCalledWith(
|
|
551
|
+
expect.objectContaining({ runtimeMode: 'gateway' }),
|
|
552
|
+
);
|
|
553
|
+
expect(exitSpy).not.toHaveBeenCalledWith(1);
|
|
554
|
+
|
|
555
|
+
logSpy.mockRestore();
|
|
556
|
+
exitSpy.mockRestore();
|
|
557
|
+
});
|
|
443
558
|
});
|
|
444
559
|
|
|
445
560
|
describe('pd pain retry — success paths', () => {
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PRI-393: Runtime config unification tests
|
|
3
|
+
*
|
|
4
|
+
* Validates that all MVP mainline execution paths (probe, run-once, diagnose,
|
|
5
|
+
* pain-retry) read from .pd/config.yaml, NOT from .state/workflows.yaml.
|
|
6
|
+
*
|
|
7
|
+
* ERR refs:
|
|
8
|
+
* - EP-02: production path wiring — tests exercise real production entry points
|
|
9
|
+
* - EP-03: fail loud — no silent fallback
|
|
10
|
+
* - EP-07: runtime state source alignment — doctor/probe/run-once agree
|
|
11
|
+
* - EP-09: test reality gap — production schema fixtures
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
15
|
+
import * as fs from 'node:fs';
|
|
16
|
+
import * as path from 'node:path';
|
|
17
|
+
import * as os from 'node:os';
|
|
18
|
+
import * as yaml from 'js-yaml';
|
|
19
|
+
import {
|
|
20
|
+
assertMainlineContract,
|
|
21
|
+
type MainlineSnapshot,
|
|
22
|
+
} from '@principles/core/runtime-v2';
|
|
23
|
+
|
|
24
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
function mkTmpDir(prefix = 'pri-393-'): string {
|
|
27
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function rmTmpDir(dir: string): void {
|
|
31
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function writeConfigYaml(workspaceDir: string, content: object): void {
|
|
35
|
+
const pdDir = path.join(workspaceDir, '.pd');
|
|
36
|
+
fs.mkdirSync(pdDir, { recursive: true });
|
|
37
|
+
fs.writeFileSync(
|
|
38
|
+
path.join(pdDir, 'config.yaml'),
|
|
39
|
+
yaml.dump(content, { lineWidth: -1 }),
|
|
40
|
+
'utf8',
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function writeLegacyWorkflowsYaml(workspaceDir: string, content: string): void {
|
|
45
|
+
const stateDir = path.join(workspaceDir, '.state');
|
|
46
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
47
|
+
fs.writeFileSync(path.join(stateDir, 'workflows.yaml'), content, 'utf8');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Minimal valid .pd/config.yaml with a pi-ai runtime profile for diagnostician. */
|
|
51
|
+
function makeValidConfigYaml(overrides?: { provider?: string; model?: string }): object {
|
|
52
|
+
return {
|
|
53
|
+
version: 1,
|
|
54
|
+
features: {
|
|
55
|
+
prompt: { enabled: true, category: 'core' },
|
|
56
|
+
correction_observer: { enabled: false, category: 'quiet' },
|
|
57
|
+
},
|
|
58
|
+
runtimeProfiles: {
|
|
59
|
+
lmstudio: {
|
|
60
|
+
type: 'pi-ai',
|
|
61
|
+
provider: overrides?.provider ?? 'lmstudio',
|
|
62
|
+
model: overrides?.model ?? 'local-model',
|
|
63
|
+
apiKeyEnv: 'LMSTUDIO_API_KEY',
|
|
64
|
+
baseUrl: 'http://localhost:1234/v1',
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
internalAgents: {
|
|
68
|
+
defaultRuntime: 'lmstudio',
|
|
69
|
+
agents: {
|
|
70
|
+
diagnostician: {
|
|
71
|
+
enabled: true,
|
|
72
|
+
runtimeProfile: 'lmstudio',
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Tests ──────────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
describe('PRI-393: runtime config unification', () => {
|
|
82
|
+
let tmpDir: string;
|
|
83
|
+
|
|
84
|
+
beforeEach(() => {
|
|
85
|
+
tmpDir = mkTmpDir();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
afterEach(() => {
|
|
89
|
+
rmTmpDir(tmpDir);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('Guard: legacy resolveRuntimeConfig not imported by production commands', () => {
|
|
93
|
+
it('production command source files do NOT import legacy resolveRuntimeConfig', () => {
|
|
94
|
+
// Read the source of each production command and verify the import
|
|
95
|
+
const commandFiles = [
|
|
96
|
+
'packages/pd-cli/src/commands/runtime.ts',
|
|
97
|
+
'packages/pd-cli/src/commands/runtime-internalization-run-once.ts',
|
|
98
|
+
'packages/pd-cli/src/commands/diagnose.ts',
|
|
99
|
+
'packages/pd-cli/src/commands/pain-retry.ts',
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
for (const file of commandFiles) {
|
|
103
|
+
const fullPath = path.resolve(file);
|
|
104
|
+
if (!fs.existsSync(fullPath)) continue;
|
|
105
|
+
const source = fs.readFileSync(fullPath, 'utf8');
|
|
106
|
+
|
|
107
|
+
// Check that resolveRuntimeConfig is NOT imported from @principles/core/runtime-v2
|
|
108
|
+
// (it may appear in comments or as resolveRuntimeConfigFromPdConfig)
|
|
109
|
+
// Match the full import block (handles multi-line imports)
|
|
110
|
+
const importPattern = /import\s*[\s\S]*?@principles\/core\/runtime-v2['";]/g;
|
|
111
|
+
const importBlocks: string[] = [];
|
|
112
|
+
let match;
|
|
113
|
+
while ((match = importPattern.exec(source)) !== null) {
|
|
114
|
+
importBlocks.push(match[0]);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
for (const block of importBlocks) {
|
|
118
|
+
// Allow resolveRuntimeConfigFromPdConfig but NOT bare resolveRuntimeConfig
|
|
119
|
+
if (block.includes('resolveRuntimeConfig') && !block.includes('resolveRuntimeConfigFromPdConfig')) {
|
|
120
|
+
// This is the legacy import — fail
|
|
121
|
+
expect.fail(
|
|
122
|
+
`${file} still imports legacy resolveRuntimeConfig from @principles/core/runtime-v2. ` +
|
|
123
|
+
`Use resolveRuntimeFromPdConfig() from services/resolve-runtime-from-pd-config.ts instead.`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe('Config source alignment via mainline contract', () => {
|
|
132
|
+
function makeBaseSnapshot(readiness: Partial<MainlineSnapshot['readiness']>): MainlineSnapshot {
|
|
133
|
+
return {
|
|
134
|
+
readiness: {
|
|
135
|
+
configDoctorProfile: null,
|
|
136
|
+
runtimeProbeProfile: null,
|
|
137
|
+
configSource: '.pd/config.yaml',
|
|
138
|
+
probeConfigSource: '.pd/config.yaml',
|
|
139
|
+
diagnosticianReady: true,
|
|
140
|
+
...readiness,
|
|
141
|
+
},
|
|
142
|
+
chain: {
|
|
143
|
+
painId: null,
|
|
144
|
+
diagnosisTask: null,
|
|
145
|
+
diagnosticianArtifact: null,
|
|
146
|
+
candidate: null,
|
|
147
|
+
dreamerTask: null,
|
|
148
|
+
dreamerContext: null,
|
|
149
|
+
successor: null,
|
|
150
|
+
principle: null,
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
it('violation: doctor and probe use different config sources (drift)', () => {
|
|
156
|
+
const snapshot = makeBaseSnapshot({
|
|
157
|
+
configDoctorProfile: 'pi-ai.lmstudio',
|
|
158
|
+
runtimeProbeProfile: 'pi-ai.sensenova-cn',
|
|
159
|
+
configSource: '.pd/config.yaml',
|
|
160
|
+
probeConfigSource: '.state/workflows.yaml',
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const verdict = assertMainlineContract(snapshot);
|
|
164
|
+
const alignmentStage = verdict.stages.find((s) => s.stage === 'config_source_alignment');
|
|
165
|
+
|
|
166
|
+
expect(alignmentStage).toBeDefined();
|
|
167
|
+
expect(alignmentStage!.status).toBe('violation');
|
|
168
|
+
expect(alignmentStage!.reason).toContain('drift');
|
|
169
|
+
expect(alignmentStage!.nextAction).toContain('.pd/config.yaml');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('violation: profiles match but probe reads from workflows.yaml (coincidental)', () => {
|
|
173
|
+
const snapshot = makeBaseSnapshot({
|
|
174
|
+
configDoctorProfile: 'pi-ai.lmstudio',
|
|
175
|
+
runtimeProbeProfile: 'pi-ai.lmstudio',
|
|
176
|
+
configSource: '.pd/config.yaml',
|
|
177
|
+
probeConfigSource: '.state/workflows.yaml',
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const verdict = assertMainlineContract(snapshot);
|
|
181
|
+
const alignmentStage = verdict.stages.find((s) => s.stage === 'config_source_alignment');
|
|
182
|
+
|
|
183
|
+
expect(alignmentStage).toBeDefined();
|
|
184
|
+
expect(alignmentStage!.status).toBe('violation');
|
|
185
|
+
expect(alignmentStage!.reason).toContain('coincidental');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('ok: doctor and probe agree on same profile from .pd/config.yaml', () => {
|
|
189
|
+
const snapshot = makeBaseSnapshot({
|
|
190
|
+
configDoctorProfile: 'pi-ai.lmstudio',
|
|
191
|
+
runtimeProbeProfile: 'pi-ai.lmstudio',
|
|
192
|
+
configSource: '.pd/config.yaml',
|
|
193
|
+
probeConfigSource: '.pd/config.yaml',
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const verdict = assertMainlineContract(snapshot);
|
|
197
|
+
const alignmentStage = verdict.stages.find((s) => s.stage === 'config_source_alignment');
|
|
198
|
+
|
|
199
|
+
expect(alignmentStage).toBeDefined();
|
|
200
|
+
expect(alignmentStage!.status).toBe('ok');
|
|
201
|
+
expect(alignmentStage!.reason).toContain('.pd/config.yaml');
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe('resolveRuntimeFromPdConfig reads .pd/config.yaml', () => {
|
|
206
|
+
it('resolves pi-ai config from .pd/config.yaml', async () => {
|
|
207
|
+
writeConfigYaml(tmpDir, makeValidConfigYaml());
|
|
208
|
+
|
|
209
|
+
// Dynamically import to avoid module resolution issues
|
|
210
|
+
const { resolveRuntimeFromPdConfig } = await import('../../src/services/resolve-runtime-from-pd-config.js');
|
|
211
|
+
const resolved = resolveRuntimeFromPdConfig(tmpDir, () => 'test-key');
|
|
212
|
+
|
|
213
|
+
expect(resolved.configSource).toBe('.pd/config.yaml');
|
|
214
|
+
expect(resolved.result).toBeDefined();
|
|
215
|
+
// Should not be an error when config is valid
|
|
216
|
+
const { isRuntimeConfigError: isErr } = await import('@principles/core/runtime-v2');
|
|
217
|
+
expect(isErr(resolved.result)).toBe(false);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('ignores conflicting .state/workflows.yaml when .pd/config.yaml exists', async () => {
|
|
221
|
+
// Write .pd/config.yaml with lmstudio
|
|
222
|
+
writeConfigYaml(tmpDir, makeValidConfigYaml({ provider: 'lmstudio', model: 'local-model' }));
|
|
223
|
+
|
|
224
|
+
// Write conflicting .state/workflows.yaml
|
|
225
|
+
writeLegacyWorkflowsYaml(tmpDir, `version: '1'
|
|
226
|
+
funnels:
|
|
227
|
+
- workflowId: pd-runtime-v2-diagnosis
|
|
228
|
+
stages: []
|
|
229
|
+
policy:
|
|
230
|
+
runtimeKind: pi-ai
|
|
231
|
+
provider: sensenova-cn
|
|
232
|
+
model: deepseek-v4-flash
|
|
233
|
+
apiKeyEnv: SENSENOVA_API_KEY
|
|
234
|
+
`);
|
|
235
|
+
|
|
236
|
+
const { resolveRuntimeFromPdConfig } = await import('../../src/services/resolve-runtime-from-pd-config.js');
|
|
237
|
+
const resolved = resolveRuntimeFromPdConfig(tmpDir, () => 'test-key');
|
|
238
|
+
|
|
239
|
+
// Should have legacy warning
|
|
240
|
+
expect(resolved.legacyWarnings.length).toBeGreaterThan(0);
|
|
241
|
+
expect(resolved.legacyWarnings[0]).toContain('workflows.yaml');
|
|
242
|
+
|
|
243
|
+
// Should resolve from .pd/config.yaml, NOT workflows.yaml
|
|
244
|
+
const { isRuntimeConfigError: isErr } = await import('@principles/core/runtime-v2');
|
|
245
|
+
if (!isErr(resolved.result)) {
|
|
246
|
+
expect(resolved.result.provider).not.toBe('sensenova-cn');
|
|
247
|
+
}
|
|
248
|
+
expect(resolved.configSource).toBe('.pd/config.yaml');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('fail loud when .pd/config.yaml is malformed', async () => {
|
|
252
|
+
const pdDir = path.join(tmpDir, '.pd');
|
|
253
|
+
fs.mkdirSync(pdDir, { recursive: true });
|
|
254
|
+
fs.writeFileSync(path.join(pdDir, 'config.yaml'), 'version: [unterminated', 'utf8');
|
|
255
|
+
|
|
256
|
+
const { resolveRuntimeFromPdConfig } = await import('../../src/services/resolve-runtime-from-pd-config.js');
|
|
257
|
+
const { isRuntimeConfigError: isErr } = await import('@principles/core/runtime-v2');
|
|
258
|
+
const resolved = resolveRuntimeFromPdConfig(tmpDir, () => 'test-key');
|
|
259
|
+
|
|
260
|
+
// Malformed config must produce a RuntimeConfigError — never fall back to defaults
|
|
261
|
+
expect(resolved.configLoadResult.ok).toBe(false);
|
|
262
|
+
expect(resolved.configSource).toBe('.pd/config.yaml');
|
|
263
|
+
expect(isErr(resolved.result)).toBe(true);
|
|
264
|
+
if (isErr(resolved.result)) {
|
|
265
|
+
expect(resolved.result.reason).toContain('config_malformed');
|
|
266
|
+
expect(resolved.result.nextAction).toBeTruthy();
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
describe('JSON output purity', () => {
|
|
272
|
+
it('resolveRuntimeFromPdConfig result serializes to valid JSON', async () => {
|
|
273
|
+
writeConfigYaml(tmpDir, makeValidConfigYaml());
|
|
274
|
+
|
|
275
|
+
const { resolveRuntimeFromPdConfig } = await import('../../src/services/resolve-runtime-from-pd-config.js');
|
|
276
|
+
const resolved = resolveRuntimeFromPdConfig(tmpDir, () => 'test-key');
|
|
277
|
+
|
|
278
|
+
const jsonStr = JSON.stringify(resolved.result);
|
|
279
|
+
const parsed = JSON.parse(jsonStr);
|
|
280
|
+
expect(parsed).toBeDefined();
|
|
281
|
+
expect(typeof parsed).toBe('object');
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
});
|
|
@@ -20,6 +20,27 @@ vi.mock('../../src/resolve-workspace.js', () => ({
|
|
|
20
20
|
resolveWorkspaceDir: vi.fn().mockReturnValue('/fake/workspace'),
|
|
21
21
|
}));
|
|
22
22
|
|
|
23
|
+
const { mockResolveRuntimeFromPdConfig } = vi.hoisted(() => {
|
|
24
|
+
const mockResolveRuntimeFromPdConfig = vi.fn().mockReturnValue({
|
|
25
|
+
result: {
|
|
26
|
+
runtimeKind: 'pi-ai',
|
|
27
|
+
provider: 'test-provider',
|
|
28
|
+
model: 'test-model',
|
|
29
|
+
apiKeyEnv: 'TEST_API_KEY',
|
|
30
|
+
timeoutMs: 300_000,
|
|
31
|
+
agentId: 'main',
|
|
32
|
+
},
|
|
33
|
+
legacyWarnings: [],
|
|
34
|
+
configSource: '.pd/config.yaml',
|
|
35
|
+
configLoadResult: { ok: true, effective: {}, defaults: {}, legacyFilesDetected: [] },
|
|
36
|
+
});
|
|
37
|
+
return { mockResolveRuntimeFromPdConfig };
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
vi.mock('../../src/services/resolve-runtime-from-pd-config.js', () => ({
|
|
41
|
+
resolveRuntimeFromPdConfig: mockResolveRuntimeFromPdConfig,
|
|
42
|
+
}));
|
|
43
|
+
|
|
23
44
|
vi.mock('@principles/core/runtime-v2', () => ({
|
|
24
45
|
RuntimeStateManager: vi.fn().mockImplementation(function () {
|
|
25
46
|
return {
|
|
@@ -120,8 +141,9 @@ vi.mock('@principles/core/runtime-v2', () => ({
|
|
|
120
141
|
model: 'test-model',
|
|
121
142
|
apiKeyEnv: 'TEST_API_KEY',
|
|
122
143
|
}),
|
|
123
|
-
isRuntimeConfigError: vi.fn().
|
|
144
|
+
isRuntimeConfigError: vi.fn().mockImplementation((result: unknown) => result != null && typeof result === 'object' && Object.hasOwn(result, 'reason') && !Object.hasOwn(result, 'runtimeKind')),
|
|
124
145
|
validateRuntimeConfig: vi.fn(),
|
|
146
|
+
resolveRuntimeConfigFromPdConfig: vi.fn().mockReturnValue({ runtimeKind: 'pi-ai', provider: 'test-provider', model: 'test-model', apiKeyEnv: 'TEST_API_KEY', timeoutMs: 300_000, agentId: 'main' }),
|
|
125
147
|
resolveOutputLanguage: vi.fn().mockReturnValue({ outputLanguage: 'zh-CN' }),
|
|
126
148
|
}));
|
|
127
149
|
|
|
@@ -381,12 +403,16 @@ describe('handleRuntimeInternalizationRunOnce', () => {
|
|
|
381
403
|
});
|
|
382
404
|
|
|
383
405
|
it('--runtime openclaw-cli resolves OpenClawCliRuntimeAdapter', async () => {
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
406
|
+
mockResolveRuntimeFromPdConfig.mockReturnValueOnce({
|
|
407
|
+
result: {
|
|
408
|
+
runtimeKind: 'openclaw-cli',
|
|
409
|
+
openclawMode: 'local',
|
|
410
|
+
timeoutMs: 300_000,
|
|
411
|
+
agentId: 'main',
|
|
412
|
+
},
|
|
413
|
+
legacyWarnings: [],
|
|
414
|
+
configSource: '.pd/config.yaml',
|
|
415
|
+
configLoadResult: { ok: true, effective: {}, defaults: {}, legacyFilesDetected: [] },
|
|
390
416
|
});
|
|
391
417
|
|
|
392
418
|
mockWakeOnce.mockResolvedValue({
|
|
@@ -412,31 +438,7 @@ describe('handleRuntimeInternalizationRunOnce', () => {
|
|
|
412
438
|
expect(OpenClawMock).toHaveBeenCalled();
|
|
413
439
|
});
|
|
414
440
|
|
|
415
|
-
it('--runtime config resolves adapter from
|
|
416
|
-
mockWakeOnce.mockResolvedValue({
|
|
417
|
-
decision: 'would_lease',
|
|
418
|
-
taskId: 'task-dreamer-008',
|
|
419
|
-
taskKind: 'dreamer',
|
|
420
|
-
});
|
|
421
|
-
|
|
422
|
-
mockRun.mockResolvedValue({
|
|
423
|
-
status: 'succeeded',
|
|
424
|
-
taskId: 'task-dreamer-008',
|
|
425
|
-
runId: 'run-008',
|
|
426
|
-
artifactId: 'pi-art-task-dreamer-008-run-008',
|
|
427
|
-
resultRef: 'dreamer://run-008',
|
|
428
|
-
attemptCount: 1,
|
|
429
|
-
});
|
|
430
|
-
|
|
431
|
-
await handleRuntimeInternalizationRunOnce({ workspace: WS, runtime: 'config', json: true });
|
|
432
|
-
|
|
433
|
-
const ResolveConfigMock = vi.mocked(
|
|
434
|
-
await import('@principles/core/runtime-v2').then(m => m.resolveRuntimeConfig),
|
|
435
|
-
);
|
|
436
|
-
expect(ResolveConfigMock).toHaveBeenCalled();
|
|
437
|
-
});
|
|
438
|
-
|
|
439
|
-
it('--runtime config reads from workspaceDir/.state (not .pd)', async () => {
|
|
441
|
+
it('--runtime config resolves adapter from .pd/config.yaml with workspace path (PRI-393)', async () => {
|
|
440
442
|
mockWakeOnce.mockResolvedValue({
|
|
441
443
|
decision: 'would_lease',
|
|
442
444
|
taskId: 'task-dreamer-009',
|
|
@@ -455,12 +457,10 @@ describe('handleRuntimeInternalizationRunOnce', () => {
|
|
|
455
457
|
const customWs = '/tmp/test-workspace';
|
|
456
458
|
await handleRuntimeInternalizationRunOnce({ workspace: customWs, runtime: 'config', json: true });
|
|
457
459
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
+
// PRI-393: verify resolveRuntimeFromPdConfig was called with workspace dir
|
|
461
|
+
expect(mockResolveRuntimeFromPdConfig).toHaveBeenCalledWith(
|
|
462
|
+
expect.stringContaining('test-workspace'),
|
|
460
463
|
);
|
|
461
|
-
const resolvedWorkspace = path.resolve(customWs);
|
|
462
|
-
const expectedStateDir = path.join(resolvedWorkspace, '.state');
|
|
463
|
-
expect(ResolveConfigMock).toHaveBeenCalledWith(expectedStateDir, { requestedRuntimeKind: 'config' });
|
|
464
464
|
});
|
|
465
465
|
|
|
466
466
|
it('--runner philosopher dispatches PhilosopherRunner', async () => {
|
|
@@ -810,7 +810,7 @@ describe('handleRuntimeInternalizationRunOnce', () => {
|
|
|
810
810
|
expect(output.timeoutSource).toBe('runner_poll');
|
|
811
811
|
});
|
|
812
812
|
|
|
813
|
-
it('--timeout-ms overrides
|
|
813
|
+
it('--timeout-ms overrides .pd/config.yaml timeoutMs for PiAiRuntimeAdapter (PRI-393)', async () => {
|
|
814
814
|
mockWakeOnce.mockResolvedValue({
|
|
815
815
|
decision: 'would_lease',
|
|
816
816
|
taskId: 'task-dreamer-ov',
|
|
@@ -1258,14 +1258,17 @@ describe('handleRuntimeInternalizationRunOnce', () => {
|
|
|
1258
1258
|
});
|
|
1259
1259
|
|
|
1260
1260
|
it('--runtime config with missing config outputs structured JSON error', async () => {
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1261
|
+
// PRI-393: mock resolveRuntimeFromPdConfig to return config error
|
|
1262
|
+
mockResolveRuntimeFromPdConfig.mockReturnValueOnce({
|
|
1263
|
+
result: {
|
|
1264
|
+
reason: 'explicit_config_missing',
|
|
1265
|
+
message: 'runtime=config requested but no .pd/config.yaml runtime binding found',
|
|
1266
|
+
nextAction: 'Add runtime binding to .pd/config.yaml',
|
|
1267
|
+
},
|
|
1268
|
+
legacyWarnings: [],
|
|
1269
|
+
configSource: '.pd/config.yaml',
|
|
1270
|
+
configLoadResult: { ok: false, effective: {}, defaults: {}, legacyFilesDetected: [] },
|
|
1267
1271
|
});
|
|
1268
|
-
vi.mocked(isRuntimeConfigError).mockReturnValue(true);
|
|
1269
1272
|
|
|
1270
1273
|
mockWakeOnce.mockResolvedValue({
|
|
1271
1274
|
decision: 'would_lease',
|
|
@@ -1283,14 +1286,17 @@ describe('handleRuntimeInternalizationRunOnce', () => {
|
|
|
1283
1286
|
});
|
|
1284
1287
|
|
|
1285
1288
|
it('--runtime config with missing config outputs text error', async () => {
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1289
|
+
// PRI-393: mock resolveRuntimeFromPdConfig to return config error
|
|
1290
|
+
mockResolveRuntimeFromPdConfig.mockReturnValueOnce({
|
|
1291
|
+
result: {
|
|
1292
|
+
reason: 'explicit_config_missing',
|
|
1293
|
+
message: 'runtime=config requested but no .pd/config.yaml runtime binding found',
|
|
1294
|
+
nextAction: 'Add runtime binding to .pd/config.yaml',
|
|
1295
|
+
},
|
|
1296
|
+
legacyWarnings: [],
|
|
1297
|
+
configSource: '.pd/config.yaml',
|
|
1298
|
+
configLoadResult: { ok: false, effective: {}, defaults: {}, legacyFilesDetected: [] },
|
|
1299
|
+
});
|
|
1294
1300
|
|
|
1295
1301
|
mockWakeOnce.mockResolvedValue({
|
|
1296
1302
|
decision: 'would_lease',
|