@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.
Files changed (28) hide show
  1. package/dist/commands/diagnose.js +27 -27
  2. package/dist/commands/diagnose.js.map +1 -1
  3. package/dist/commands/pain-retry.d.ts.map +1 -1
  4. package/dist/commands/pain-retry.js +22 -27
  5. package/dist/commands/pain-retry.js.map +1 -1
  6. package/dist/commands/runtime-internalization-run-once.d.ts.map +1 -1
  7. package/dist/commands/runtime-internalization-run-once.js +11 -9
  8. package/dist/commands/runtime-internalization-run-once.js.map +1 -1
  9. package/dist/commands/runtime.d.ts +1 -1
  10. package/dist/commands/runtime.d.ts.map +1 -1
  11. package/dist/commands/runtime.js +92 -25
  12. package/dist/commands/runtime.js.map +1 -1
  13. package/dist/services/resolve-runtime-from-pd-config.d.ts +59 -0
  14. package/dist/services/resolve-runtime-from-pd-config.d.ts.map +1 -0
  15. package/dist/services/resolve-runtime-from-pd-config.js +96 -0
  16. package/dist/services/resolve-runtime-from-pd-config.js.map +1 -0
  17. package/package.json +1 -1
  18. package/src/commands/diagnose.ts +26 -26
  19. package/src/commands/pain-retry.ts +21 -25
  20. package/src/commands/runtime-internalization-run-once.ts +10 -9
  21. package/src/commands/runtime.ts +96 -24
  22. package/src/services/resolve-runtime-from-pd-config.ts +142 -0
  23. package/tests/commands/console-launcher-edge-cases.test.ts +14 -47
  24. package/tests/commands/diagnose.test.ts +91 -39
  25. package/tests/commands/pain-retry.test.ts +130 -15
  26. package/tests/commands/pri-393-runtime-config-unification.test.ts +284 -0
  27. package/tests/commands/runtime-internalization-run-once.test.ts +59 -53
  28. 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
- return { mockRun, mockResolveRuntimeConfig };
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
- // Make resolveRuntimeConfig return an error so no runtime is resolved from config
346
- mockResolveRuntimeConfig.mockReturnValueOnce({
347
- reason: 'config_not_found',
348
- message: 'No workflows.yaml found',
349
- nextAction: 'Create workflows.yaml or pass --runtime',
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
- // Config returns blank strings for provider/model/apiKeyEnv
408
- mockResolveRuntimeConfig.mockReturnValueOnce({
409
- runtimeKind: 'pi-ai',
410
- provider: '',
411
- model: ' ',
412
- apiKeyEnv: '',
413
- timeoutMs: 300000,
414
- agentId: 'main',
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().mockReturnValue(false),
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
- const { resolveRuntimeConfig } = await import('@principles/core/runtime-v2');
385
- vi.mocked(resolveRuntimeConfig).mockReturnValue({
386
- runtimeKind: 'openclaw-cli',
387
- openclawMode: 'local',
388
- timeoutMs: 300_000,
389
- agentId: 'main',
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 workflows.yaml', async () => {
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
- const ResolveConfigMock = vi.mocked(
459
- await import('@principles/core/runtime-v2').then(m => m.resolveRuntimeConfig),
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 workflows.yaml timeoutMs for PiAiRuntimeAdapter', async () => {
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
- const { resolveRuntimeConfig, isRuntimeConfigError } = await import('@principles/core/runtime-v2');
1262
- vi.mocked(resolveRuntimeConfig).mockReturnValue({
1263
- ok: false,
1264
- reason: 'explicit_config_missing',
1265
- message: 'runtime=config requested but no workflows.yaml funnel policy found',
1266
- nextAction: 'Create a pd-runtime-v2-diagnosis funnel policy in workflows.yaml',
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
- const { resolveRuntimeConfig, isRuntimeConfigError } = await import('@principles/core/runtime-v2');
1287
- vi.mocked(resolveRuntimeConfig).mockReturnValue({
1288
- ok: false,
1289
- reason: 'explicit_config_missing',
1290
- message: 'runtime=config requested but no workflows.yaml funnel policy found',
1291
- nextAction: 'Create a pd-runtime-v2-diagnosis funnel policy in workflows.yaml',
1292
- });
1293
- vi.mocked(isRuntimeConfigError).mockReturnValue(true);
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',