principles-disciple 1.32.0 → 1.34.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 (37) hide show
  1. package/openclaw.plugin.json +4 -4
  2. package/package.json +1 -1
  3. package/src/core/correction-cue-learner.ts +203 -0
  4. package/src/core/correction-types.ts +88 -0
  5. package/src/core/evolution-logger.ts +3 -3
  6. package/src/core/init.ts +67 -0
  7. package/src/service/correction-observer-types.ts +58 -0
  8. package/src/service/correction-observer-workflow-manager.ts +218 -0
  9. package/src/service/evolution-worker.ts +172 -146
  10. package/src/service/nocturnal-service.ts +4 -1
  11. package/src/service/subagent-workflow/index.ts +14 -0
  12. package/src/service/subagent-workflow/nocturnal-workflow-manager.ts +3 -1
  13. package/tests/service/evolution-worker.nocturnal.test.ts +14 -1
  14. package/tests/service/evolution-worker.timeout.test.ts +350 -0
  15. package/tests/commands/implementation-lifecycle.test.ts +0 -362
  16. package/tests/core/detection-funnel.test.ts +0 -63
  17. package/tests/core/evolution-e2e.test.ts +0 -58
  18. package/tests/core/evolution-engine-gate-integration.test.ts +0 -543
  19. package/tests/core/evolution-engine.test.ts +0 -562
  20. package/tests/core/evolution-reducer.test.ts +0 -180
  21. package/tests/core/evolution-user-stories.e2e.test.ts +0 -249
  22. package/tests/core/local-worker-routing.test.ts +0 -757
  23. package/tests/core/rule-host.test.ts +0 -389
  24. package/tests/core/trajectory-correction-pain.test.ts +0 -180
  25. package/tests/hooks/gate-edit-verification.test.ts +0 -435
  26. package/tests/hooks/llm.test.ts +0 -308
  27. package/tests/hooks/progressive-trust-gate.test.ts +0 -277
  28. package/tests/hooks/prompt.test.ts +0 -1473
  29. package/tests/index.integration.test.ts +0 -179
  30. package/tests/index.shadow-routing.integration.test.ts +0 -140
  31. package/tests/service/evolution-worker.test.ts +0 -462
  32. package/tests/service/nocturnal-service.test.ts +0 -577
  33. package/tests/service/nocturnal-workflow-manager.test.ts +0 -441
  34. package/tests/tools/critique-prompt.test.ts +0 -260
  35. package/tests/tools/deep-reflect.test.ts +0 -232
  36. package/tests/tools/model-index.test.ts +0 -246
  37. package/tests/ui/app.test.tsx +0 -114
@@ -1,757 +0,0 @@
1
- /**
2
- * Local Worker Routing Policy — Tests
3
- * ====================================
4
- *
5
- * Tests for task classification and routing decision logic.
6
- *
7
- * Test organization:
8
- * - Without deployment: classification-only tests (reader_eligible, editor_eligible, high_entropy, risk, ambiguous)
9
- * - With deployment enabled: full route_local decision
10
- * - With deployment disabled: stay_main with deployment_unavailable
11
- * - Helper functions: canRouteToProfile, isAnyLocalRoutingEnabled, listEnabledProfiles
12
- */
13
-
14
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
15
- import * as fs from 'fs';
16
- import * as path from 'path';
17
- import * as os from 'os';
18
- import {
19
- classifyTask,
20
- canRouteToProfile,
21
- isAnyLocalRoutingEnabled,
22
- listEnabledProfiles,
23
- type RoutingInput,
24
- type RoutingDecision,
25
- } from '../../src/core/local-worker-routing.js';
26
- import {
27
- registerTrainingRun,
28
- startTrainingRun,
29
- completeTrainingRun,
30
- registerCheckpoint,
31
- attachEvalSummary,
32
- markCheckpointDeployable,
33
- } from '../../src/core/model-training-registry.js';
34
- import {
35
- advancePromotion,
36
- DEFAULT_BASELINE_METRICS,
37
- } from '../../src/core/promotion-gate.js';
38
- import {
39
- bindCheckpointToWorkerProfile,
40
- enableRoutingForProfile,
41
- disableRoutingForProfile,
42
- } from '../../src/core/model-deployment-registry.js';
43
-
44
- // ---------------------------------------------------------------------------
45
- // Test Fixtures
46
- // ---------------------------------------------------------------------------
47
-
48
- function makeTmpDir(): string {
49
- return fs.mkdtempSync(path.join(os.tmpdir(), 'pd-routing-test-'));
50
- }
51
-
52
- function rmdir(dir: string): void {
53
- try {
54
- if (fs.existsSync(dir)) {
55
- fs.rmSync(dir, { recursive: true, force: true });
56
- }
57
- } catch {
58
- // Ignore
59
- }
60
- }
61
-
62
- /** Set up a fully deployable reader-family checkpoint and bind to local-reader */
63
- function setupReaderDeployment(tmpDir: string, routingEnabled = false): string {
64
- const run = registerTrainingRun(tmpDir, {
65
- targetModelFamily: 'claude-reader-latest',
66
- datasetFingerprint: 'sha256-rdr',
67
- exportId: 'export-rdr',
68
- sampleCount: 10,
69
- configFingerprint: 'cfg-v1',
70
- });
71
- const ck = registerCheckpoint(tmpDir, {
72
- trainRunId: run.trainRunId,
73
- targetModelFamily: 'claude-reader-latest',
74
- artifactPath: '/ck/reader.safetensors',
75
- });
76
- attachEvalSummary(tmpDir, ck.checkpointId, {
77
- evalId: 'eval-rdr',
78
- checkpointId: ck.checkpointId,
79
- targetModelFamily: 'claude-reader-latest',
80
- benchmarkId: 'bench',
81
- mode: 'reduced_prompt',
82
- baselineScore: 0.5,
83
- candidateScore: 0.65,
84
- delta: 0.15,
85
- verdict: 'pass',
86
- });
87
- startTrainingRun(tmpDir, run.trainRunId);
88
- completeTrainingRun(tmpDir, run.trainRunId);
89
- markCheckpointDeployable(tmpDir, ck.checkpointId, true);
90
- advancePromotion(tmpDir, {
91
- checkpointId: ck.checkpointId,
92
- targetProfile: 'local-reader',
93
- baselineMetrics: DEFAULT_BASELINE_METRICS,
94
- orchestratorReviewPassed: true,
95
- reviewNote: 'Test approval',
96
- });
97
- bindCheckpointToWorkerProfile(tmpDir, 'local-reader', ck.checkpointId, 'reader deployment');
98
- if (routingEnabled) {
99
- enableRoutingForProfile(tmpDir, 'local-reader');
100
- }
101
- return ck.checkpointId;
102
- }
103
-
104
- /** Set up a fully deployable editor-family checkpoint and bind to local-editor */
105
- function setupEditorDeployment(tmpDir: string, routingEnabled = false): string {
106
- const run = registerTrainingRun(tmpDir, {
107
- targetModelFamily: 'gpt-editor-v4',
108
- datasetFingerprint: 'sha256-edt',
109
- exportId: 'export-edt',
110
- sampleCount: 10,
111
- configFingerprint: 'cfg-v1',
112
- });
113
- const ck = registerCheckpoint(tmpDir, {
114
- trainRunId: run.trainRunId,
115
- targetModelFamily: 'gpt-editor-v4',
116
- artifactPath: '/ck/editor.safetensors',
117
- });
118
- attachEvalSummary(tmpDir, ck.checkpointId, {
119
- evalId: 'eval-edt',
120
- checkpointId: ck.checkpointId,
121
- targetModelFamily: 'gpt-editor-v4',
122
- benchmarkId: 'bench',
123
- mode: 'reduced_prompt',
124
- baselineScore: 0.5,
125
- candidateScore: 0.7,
126
- delta: 0.2,
127
- verdict: 'pass',
128
- });
129
- startTrainingRun(tmpDir, run.trainRunId);
130
- completeTrainingRun(tmpDir, run.trainRunId);
131
- markCheckpointDeployable(tmpDir, ck.checkpointId, true);
132
- advancePromotion(tmpDir, {
133
- checkpointId: ck.checkpointId,
134
- targetProfile: 'local-editor',
135
- baselineMetrics: DEFAULT_BASELINE_METRICS,
136
- orchestratorReviewPassed: true,
137
- reviewNote: 'Test approval',
138
- });
139
- bindCheckpointToWorkerProfile(tmpDir, 'local-editor', ck.checkpointId, 'editor deployment');
140
- if (routingEnabled) {
141
- enableRoutingForProfile(tmpDir, 'local-editor');
142
- }
143
- return ck.checkpointId;
144
- }
145
-
146
- describe('LocalWorkerRouting reader_eligible classification', () => {
147
- let tmpDir: string;
148
- beforeEach(() => { tmpDir = makeTmpDir(); });
149
- afterEach(() => { rmdir(tmpDir); });
150
-
151
- it('classifies "read_file" taskIntent as reader_eligible', () => {
152
- const decision = classifyTask({ taskIntent: 'read_file', taskDescription: 'Read the config file' }, tmpDir);
153
- expect(decision.classification).toBe('deployment_unavailable'); // eligible but no deployment
154
- expect(decision.decision).toBe('stay_main'); // No deployment
155
- expect(decision.deploymentCheck.performed).toBe(true);
156
- expect(decision.deploymentCheck.routingEnabled).toBe(false);
157
- });
158
-
159
- it('classifies "grep" taskIntent as reader_eligible', () => {
160
- const decision = classifyTask({ taskIntent: 'grep', taskDescription: 'Find all occurrences of foo in src/' }, tmpDir);
161
- expect(decision.classification).toBe('deployment_unavailable');
162
- });
163
-
164
- it('classifies "summarize" taskIntent as reader_eligible', () => {
165
- const decision = classifyTask({ taskIntent: 'summarize', taskDescription: 'Summarize the changelog' }, tmpDir);
166
- expect(decision.classification).toBe('deployment_unavailable');
167
- });
168
-
169
- it('classifies "inspect" keyword in description as reader_eligible', () => {
170
- const decision = classifyTask({ taskIntent: 'read', taskDescription: 'inspect the package.json for dependencies' }, tmpDir);
171
- expect(decision.classification).toBe('deployment_unavailable');
172
- });
173
-
174
- it('classifies with only taskIntent (no description) as reader_eligible', () => {
175
- const decision = classifyTask({ taskIntent: 'grep' }, tmpDir);
176
- expect(decision.classification).toBe('deployment_unavailable');
177
- });
178
- });
179
-
180
- // ---------------------------------------------------------------------------
181
- // Tests: Editor-Eligible Classification (no deployment)
182
- // ---------------------------------------------------------------------------
183
-
184
- describe('LocalWorkerRouting editor_eligible classification', () => {
185
- let tmpDir: string;
186
- beforeEach(() => { tmpDir = makeTmpDir(); });
187
- afterEach(() => { rmdir(tmpDir); });
188
-
189
- it('classifies "edit" taskIntent as editor_eligible', () => {
190
- const decision = classifyTask({ taskIntent: 'edit_file', taskDescription: 'Edit the config to add new key' }, tmpDir);
191
- expect(decision.classification).toBe('deployment_unavailable'); // eligible but no deployment
192
- expect(decision.decision).toBe('stay_main'); // No deployment
193
- });
194
-
195
- it('classifies "fix" taskIntent as editor_eligible', () => {
196
- const decision = classifyTask({ taskIntent: 'fix', taskDescription: 'Fix the typo in README.md' }, tmpDir);
197
- expect(decision.classification).toBe('deployment_unavailable');
198
- });
199
-
200
- it('classifies "replace" keyword in description as editor_eligible', () => {
201
- const decision = classifyTask({ taskIntent: 'replace', taskDescription: 'Replace all old API calls with new ones' }, tmpDir);
202
- expect(decision.classification).toBe('deployment_unavailable');
203
- });
204
-
205
- it('classifies "add" keyword as editor_eligible', () => {
206
- const decision = classifyTask({ taskIntent: 'add', taskDescription: 'add logging to the function' }, tmpDir);
207
- expect(decision.classification).toBe('deployment_unavailable');
208
- });
209
- });
210
-
211
- // ---------------------------------------------------------------------------
212
- // Tests: High-Entropy Rejection
213
- // ---------------------------------------------------------------------------
214
-
215
- describe('LocalWorkerRouting high_entropy_disallowed', () => {
216
- let tmpDir: string;
217
- beforeEach(() => { tmpDir = makeTmpDir(); });
218
- afterEach(() => { rmdir(tmpDir); });
219
-
220
- it('rejects "design" taskIntent as high_entropy', () => {
221
- const decision = classifyTask({ taskIntent: 'design_system', taskDescription: 'Design the new architecture' }, tmpDir);
222
- expect(decision.classification).toBe('high_entropy_disallowed');
223
- expect(decision.decision).toBe('stay_main');
224
- expect(decision.blockers.length).toBeGreaterThan(0);
225
- });
226
-
227
- it('rejects "plan" keyword as high_entropy', () => {
228
- const decision = classifyTask({ taskIntent: 'plan', taskDescription: 'Plan the refactoring approach' }, tmpDir);
229
- expect(decision.classification).toBe('high_entropy_disallowed');
230
- });
231
-
232
- it('rejects "architect" keyword as high_entropy', () => {
233
- const decision = classifyTask({ taskIntent: 'architect', taskDescription: 'Architect the microservices layout' }, tmpDir);
234
- expect(decision.classification).toBe('high_entropy_disallowed');
235
- });
236
-
237
- it('rejects "research" keyword as high_entropy', () => {
238
- const decision = classifyTask({ taskIntent: 'research', taskDescription: 'Research the best approach for this problem' }, tmpDir);
239
- expect(decision.classification).toBe('high_entropy_disallowed');
240
- });
241
-
242
- it('rejects "investigate" keyword as high_entropy', () => {
243
- const decision = classifyTask({ taskIntent: 'investigate', taskDescription: 'Investigate the memory leak' }, tmpDir);
244
- // Note: "investigate" is high entropy but "fix" is editor-eligible
245
- expect(decision.classification).toBe('high_entropy_disallowed');
246
- });
247
-
248
- it('rejects complexity hint "multi_step" as high_entropy', () => {
249
- const decision = classifyTask({
250
- taskIntent: 'fix',
251
- taskDescription: 'Fix the bug',
252
- complexityHints: ['multi_step', 'cross_file'],
253
- }, tmpDir);
254
- expect(decision.classification).toBe('high_entropy_disallowed');
255
- });
256
-
257
- it('rejects "ambiguous" complexity hint as high_entropy', () => {
258
- const decision = classifyTask({
259
- taskIntent: 'fix',
260
- taskDescription: 'Improve the code',
261
- complexityHints: ['ambiguous'],
262
- }, tmpDir);
263
- expect(decision.classification).toBe('high_entropy_disallowed');
264
- });
265
-
266
- it('high_entropy blocks even with editor-eligible keywords', () => {
267
- // "design" + "edit" → high_entropy wins
268
- const decision = classifyTask({
269
- taskIntent: 'edit',
270
- taskDescription: 'design and edit the new module',
271
- }, tmpDir);
272
- expect(decision.classification).toBe('high_entropy_disallowed');
273
- });
274
-
275
- it('rejects large-scale multi-file editing (4+ files) as high_entropy', () => {
276
- // Bounded scope: 1-3 files → editor_eligible
277
- // Too broad: 4+ files → high_entropy_disallowed (requires main agent coordination)
278
- const decision = classifyTask({
279
- taskIntent: 'edit',
280
- taskDescription: 'Fix the bug across multiple modules',
281
- requestedFiles: [
282
- 'src/auth/login.ts',
283
- 'src/auth/session.ts',
284
- 'src/auth/middleware.ts',
285
- 'src/auth/guards.ts',
286
- ],
287
- }, tmpDir);
288
- expect(decision.classification).toBe('high_entropy_disallowed');
289
- expect(decision.blockers[0]).toContain('large-scale multi-file edit');
290
- expect(decision.decision).toBe('stay_main');
291
- });
292
-
293
- it('allows bounded multi-file editing (1-3 files) as editor_eligible', () => {
294
- const decision = classifyTask({
295
- taskIntent: 'edit',
296
- taskDescription: 'Fix the bug in auth files',
297
- requestedFiles: [
298
- 'src/auth/login.ts',
299
- 'src/auth/session.ts',
300
- ],
301
- }, tmpDir);
302
- // Raw classification is editor_eligible; no deployment → final is deployment_unavailable
303
- expect(decision.classification).toBe('deployment_unavailable');
304
- expect(decision.decision).toBe('stay_main'); // no deployment
305
- });
306
- });
307
-
308
- // ---------------------------------------------------------------------------
309
- // Tests: Risk Disallowed
310
- // ---------------------------------------------------------------------------
311
-
312
- describe('LocalWorkerRouting risk_disallowed', () => {
313
- let tmpDir: string;
314
- beforeEach(() => { tmpDir = makeTmpDir(); });
315
- afterEach(() => { rmdir(tmpDir); });
316
-
317
- it('rejects bash tool as risk', () => {
318
- const decision = classifyTask({
319
- taskIntent: 'run',
320
- taskDescription: 'Execute a bash command',
321
- requestedTools: ['bash'],
322
- }, tmpDir);
323
- expect(decision.classification).toBe('risk_disallowed');
324
- expect(decision.decision).toBe('stay_main');
325
- expect(decision.blockers).toContain('risk tool requested (bash/exec/sudo/DROP/DELETE)');
326
- });
327
-
328
- it('rejects rm/destroy tools as risk', () => {
329
- const decision = classifyTask({
330
- taskIntent: 'process',
331
- requestedTools: ['rm', 'delete'],
332
- riskSignals: ['destructive'],
333
- }, tmpDir);
334
- expect(decision.classification).toBe('risk_disallowed');
335
- });
336
-
337
- it('rejects production file as risk', () => {
338
- const decision = classifyTask({
339
- taskIntent: 'edit',
340
- requestedFiles: ['production-config.yaml', '.env'],
341
- }, tmpDir);
342
- expect(decision.classification).toBe('risk_disallowed');
343
- expect(decision.blockers).toContain('risk file pattern detected (production/secrets/.git/node_modules)');
344
- });
345
-
346
- it('rejects .git/config as risk file', () => {
347
- const decision = classifyTask({
348
- taskIntent: 'edit',
349
- requestedFiles: ['.git/config'],
350
- }, tmpDir);
351
- expect(decision.classification).toBe('risk_disallowed');
352
- });
353
-
354
- it('rejects explicit riskSignals as risk', () => {
355
- const decision = classifyTask({
356
- taskIntent: 'edit',
357
- riskSignals: ['destructive', 'irreversible'],
358
- }, tmpDir);
359
- expect(decision.classification).toBe('risk_disallowed');
360
- expect(decision.blockers).toContain('risk tool requested (bash/exec/sudo/DROP/DELETE)');
361
- });
362
-
363
- it('risk blocks even reader-eligible tasks', () => {
364
- // bash + read → risk wins
365
- const decision = classifyTask({
366
- taskIntent: 'grep',
367
- taskDescription: 'Search for pattern in files',
368
- requestedTools: ['bash'],
369
- }, tmpDir);
370
- expect(decision.classification).toBe('risk_disallowed');
371
- });
372
-
373
- it('rejects node_modules as risk file', () => {
374
- const decision = classifyTask({
375
- taskIntent: 'edit',
376
- requestedFiles: ['node_modules/some/package.json'],
377
- }, tmpDir);
378
- expect(decision.classification).toBe('risk_disallowed');
379
- });
380
- });
381
-
382
- // ---------------------------------------------------------------------------
383
- // Tests: Ambiguous Scope
384
- // ---------------------------------------------------------------------------
385
-
386
- describe('LocalWorkerRouting ambiguous_scope', () => {
387
- let tmpDir: string;
388
- beforeEach(() => { tmpDir = makeTmpDir(); });
389
- afterEach(() => { rmdir(tmpDir); });
390
-
391
- it('rejects very short generic taskDescription as ambiguous', () => {
392
- const decision = classifyTask({ taskIntent: 'process', taskDescription: 'fix it' }, tmpDir);
393
- expect(decision.classification).toBe('ambiguous_scope');
394
- });
395
-
396
- it('rejects "todo" as ambiguous', () => {
397
- const decision = classifyTask({ taskIntent: 'todo', taskDescription: 'todo' }, tmpDir);
398
- expect(decision.classification).toBe('ambiguous_scope');
399
- });
400
-
401
- it('rejects "improve" as ambiguous', () => {
402
- const decision = classifyTask({ taskIntent: 'improve', taskDescription: 'improve' }, tmpDir);
403
- expect(decision.classification).toBe('ambiguous_scope');
404
- });
405
-
406
- it('rejects open-ended question words as ambiguous', () => {
407
- const decision = classifyTask({
408
- taskIntent: 'analyze',
409
- taskDescription: 'Should we refactor this or rewrite it?',
410
- }, tmpDir);
411
- expect(decision.classification).toBe('ambiguous_scope');
412
- expect(decision.blockers).toContain('open-ended question words detected');
413
- });
414
-
415
- it('rejects when no intent and no description', () => {
416
- const decision = classifyTask({}, tmpDir);
417
- expect(decision.classification).toBe('ambiguous_scope');
418
- });
419
-
420
- it('does NOT classify a detailed description as ambiguous', () => {
421
- const decision = classifyTask({
422
- taskIntent: 'fix',
423
- taskDescription: 'Fix the null pointer exception thrown when parsing the config file in parseConfig()',
424
- }, tmpDir);
425
- // Task is editor_eligible (fix keyword in intent and description)
426
- // No deployment exists, so final classification is deployment_unavailable
427
- expect(decision.classification).toBe('deployment_unavailable');
428
- expect(decision.decision).toBe('stay_main'); // no deployment exists
429
- });
430
-
431
- it('DEBUG: detailed fix description classification', () => {
432
- const decision = classifyTask({
433
- taskIntent: 'fix',
434
- taskDescription: 'Fix the null pointer exception thrown when parsing the config file in parseConfig()',
435
- }, tmpDir);
436
- // Same as above — editor_eligible but no deployment → deployment_unavailable
437
- expect(decision.classification).toBe('deployment_unavailable');
438
- });
439
- });
440
-
441
- // ---------------------------------------------------------------------------
442
- // Tests: Deployment Availability (no deployment at all)
443
- // ---------------------------------------------------------------------------
444
-
445
- describe('LocalWorkerRouting deployment_unavailable', () => {
446
- let tmpDir: string;
447
- beforeEach(() => { tmpDir = makeTmpDir(); });
448
- afterEach(() => { rmdir(tmpDir); });
449
-
450
- it('returns deployment_unavailable when no deployment exists', () => {
451
- const decision = classifyTask({ taskIntent: 'read_file', taskDescription: 'read the config' }, tmpDir);
452
- expect(decision.classification).toBe('deployment_unavailable');
453
- expect(decision.decision).toBe('stay_main');
454
- expect(decision.deploymentCheck.performed).toBe(true);
455
- expect(decision.deploymentCheck.routingEnabled).toBe(false);
456
- });
457
- });
458
-
459
- // ---------------------------------------------------------------------------
460
- // Tests: Routing with Enabled Deployment
461
- // ---------------------------------------------------------------------------
462
-
463
- describe('LocalWorkerRouting with enabled deployment', () => {
464
- let tmpDir: string;
465
- beforeEach(() => { tmpDir = makeTmpDir(); });
466
- afterEach(() => { rmdir(tmpDir); });
467
-
468
- it('reader task routes to local-reader when deployment is enabled', () => {
469
- setupReaderDeployment(tmpDir, true);
470
-
471
- const decision = classifyTask({ taskIntent: 'read_file', taskDescription: 'read the config' }, tmpDir);
472
-
473
- expect(decision.decision).toBe('route_local');
474
- expect(decision.targetProfile).toBe('local-reader');
475
- expect(decision.classification).toBe('reader_eligible');
476
- expect(decision.deploymentCheck.routingEnabled).toBe(true);
477
- expect(decision.blockers).toHaveLength(0);
478
- });
479
-
480
- it('editor task routes to local-editor when deployment is enabled', () => {
481
- setupEditorDeployment(tmpDir, true);
482
-
483
- const decision = classifyTask({ taskIntent: 'edit', taskDescription: 'edit the config' }, tmpDir);
484
-
485
- expect(decision.decision).toBe('route_local');
486
- expect(decision.targetProfile).toBe('local-editor');
487
- expect(decision.classification).toBe('editor_eligible');
488
- expect(decision.blockers).toHaveLength(0);
489
- });
490
-
491
- it('reader task still blocked as high_entropy even with enabled deployment', () => {
492
- setupReaderDeployment(tmpDir, true);
493
-
494
- const decision = classifyTask({
495
- taskIntent: 'design',
496
- taskDescription: 'Design the new system architecture',
497
- }, tmpDir);
498
-
499
- expect(decision.decision).toBe('stay_main');
500
- expect(decision.classification).toBe('high_entropy_disallowed');
501
- });
502
-
503
- it('reader task blocked as risk even with enabled deployment', () => {
504
- setupReaderDeployment(tmpDir, true);
505
-
506
- const decision = classifyTask({
507
- taskIntent: 'grep',
508
- requestedTools: ['bash'],
509
- }, tmpDir);
510
-
511
- expect(decision.decision).toBe('stay_main');
512
- expect(decision.classification).toBe('risk_disallowed');
513
- });
514
- });
515
-
516
- // ---------------------------------------------------------------------------
517
- // Tests: Routing with Disabled Deployment
518
- // ---------------------------------------------------------------------------
519
-
520
- describe('LocalWorkerRouting with disabled deployment (routing=false)', () => {
521
- let tmpDir: string;
522
- beforeEach(() => { tmpDir = makeTmpDir(); });
523
- afterEach(() => { rmdir(tmpDir); });
524
-
525
- it('reader-eligible task stays_main when routing is disabled', () => {
526
- setupReaderDeployment(tmpDir, false); // routingEnabled = false
527
-
528
- const decision = classifyTask({ taskIntent: 'read_file', taskDescription: 'read the config' }, tmpDir);
529
-
530
- expect(decision.decision).toBe('stay_main');
531
- expect(decision.classification).toBe('deployment_unavailable');
532
- expect(decision.deploymentCheck.routingEnabled).toBe(false);
533
- expect(decision.reason).toContain('routing is not enabled');
534
- });
535
-
536
- it('editor-eligible task stays_main when routing is disabled', () => {
537
- setupEditorDeployment(tmpDir, false);
538
-
539
- const decision = classifyTask({ taskIntent: 'edit', taskDescription: 'edit the file' }, tmpDir);
540
-
541
- expect(decision.decision).toBe('stay_main');
542
- expect(decision.classification).toBe('deployment_unavailable');
543
- });
544
-
545
- it('re-enabling routing allows route_local again', () => {
546
- setupReaderDeployment(tmpDir, false);
547
- enableRoutingForProfile(tmpDir, 'local-reader');
548
-
549
- const decision = classifyTask({ taskIntent: 'read_file' }, tmpDir);
550
- expect(decision.decision).toBe('route_local');
551
- expect(decision.targetProfile).toBe('local-reader');
552
- });
553
-
554
- it('stays_main when active checkpoint has been revoked (no longer deployable)', () => {
555
- // Set up deployment with routing enabled
556
- const ckId = setupReaderDeployment(tmpDir, true);
557
-
558
- // Verify it routes successfully first
559
- const before = classifyTask({ taskIntent: 'read_file' }, tmpDir);
560
- expect(before.decision).toBe('route_local');
561
-
562
- // Revoke the checkpoint — it no longer passes evaluation
563
- markCheckpointDeployable(tmpDir, ckId, false);
564
-
565
- // Routing must now be blocked — governance: revoked checkpoints must not be used
566
- const after = classifyTask({ taskIntent: 'read_file' }, tmpDir);
567
- expect(after.decision).toBe('stay_main');
568
- expect(after.classification).toBe('deployment_unavailable');
569
- expect(after.deploymentCheck.checkpointDeployable).toBe(false);
570
- expect(after.blockers.some((b: string) => b.includes('no longer deployable'))).toBe(true);
571
- });
572
- });
573
-
574
- // ---------------------------------------------------------------------------
575
- // Tests: canRouteToProfile helper
576
- // ---------------------------------------------------------------------------
577
-
578
- describe('LocalWorkerRouting canRouteToProfile', () => {
579
- let tmpDir: string;
580
- beforeEach(() => { tmpDir = makeTmpDir(); });
581
- afterEach(() => { rmdir(tmpDir); });
582
-
583
- it('returns true when profile has enabled deployment and task is eligible', () => {
584
- setupReaderDeployment(tmpDir, true);
585
-
586
- const result = canRouteToProfile({ taskIntent: 'read_file', taskDescription: 'read config' }, tmpDir, 'local-reader');
587
- expect(result).toBe(true);
588
- });
589
-
590
- it('returns false when no deployment exists', () => {
591
- const result = canRouteToProfile({ taskIntent: 'read_file' }, tmpDir, 'local-reader');
592
- expect(result).toBe(false);
593
- });
594
-
595
- it('returns false when task is high-entropy', () => {
596
- setupReaderDeployment(tmpDir, true);
597
-
598
- const result = canRouteToProfile({ taskIntent: 'design', taskDescription: 'Design the system' }, tmpDir, 'local-reader');
599
- expect(result).toBe(false);
600
- });
601
-
602
- it('returns false when routing is disabled', () => {
603
- setupReaderDeployment(tmpDir, false);
604
-
605
- const result = canRouteToProfile({ taskIntent: 'read_file' }, tmpDir, 'local-reader');
606
- expect(result).toBe(false);
607
- });
608
-
609
- it('returns false for editor profile on reader task', () => {
610
- setupEditorDeployment(tmpDir, true);
611
-
612
- const result = canRouteToProfile({ taskIntent: 'read_file', taskDescription: 'read config' }, tmpDir, 'local-editor');
613
- expect(result).toBe(false);
614
- });
615
- });
616
-
617
- // ---------------------------------------------------------------------------
618
- // Tests: isAnyLocalRoutingEnabled / listEnabledProfiles
619
- // ---------------------------------------------------------------------------
620
-
621
- describe('LocalWorkerRouting isAnyLocalRoutingEnabled / listEnabledProfiles', () => {
622
- let tmpDir: string;
623
- beforeEach(() => { tmpDir = makeTmpDir(); });
624
- afterEach(() => { rmdir(tmpDir); });
625
-
626
- it('returns false when no deployments exist', () => {
627
- expect(isAnyLocalRoutingEnabled(tmpDir)).toBe(false);
628
- expect(listEnabledProfiles(tmpDir)).toEqual([]);
629
- });
630
-
631
- it('returns false when deployments exist but routing is disabled', () => {
632
- setupReaderDeployment(tmpDir, false);
633
- setupEditorDeployment(tmpDir, false);
634
-
635
- expect(isAnyLocalRoutingEnabled(tmpDir)).toBe(false);
636
- expect(listEnabledProfiles(tmpDir)).toEqual([]);
637
- });
638
-
639
- it('returns true and lists profile when routing is enabled', () => {
640
- setupReaderDeployment(tmpDir, true);
641
-
642
- expect(isAnyLocalRoutingEnabled(tmpDir)).toBe(true);
643
- expect(listEnabledProfiles(tmpDir)).toEqual(['local-reader']);
644
- });
645
-
646
- it('lists multiple enabled profiles', () => {
647
- setupReaderDeployment(tmpDir, true);
648
- setupEditorDeployment(tmpDir, true);
649
-
650
- const enabled = listEnabledProfiles(tmpDir);
651
- expect(enabled).toContain('local-reader');
652
- expect(enabled).toContain('local-editor');
653
- expect(enabled).toHaveLength(2);
654
- });
655
-
656
- it('only lists profiles with routing enabled (not just bound)', () => {
657
- setupReaderDeployment(tmpDir, true); // enabled
658
- setupEditorDeployment(tmpDir, false); // bound but disabled
659
-
660
- const enabled = listEnabledProfiles(tmpDir);
661
- expect(enabled).toEqual(['local-reader']);
662
- });
663
- });
664
-
665
- // ---------------------------------------------------------------------------
666
- // Tests: targetProfile override
667
- // ---------------------------------------------------------------------------
668
-
669
- describe('LocalWorkerRouting targetProfile override', () => {
670
- let tmpDir: string;
671
- beforeEach(() => { tmpDir = makeTmpDir(); });
672
- afterEach(() => { rmdir(tmpDir); });
673
-
674
- it('uses targetProfile from input when specified', () => {
675
- setupEditorDeployment(tmpDir, true); // Only editor is enabled
676
-
677
- // Reader deployment doesn't exist but we explicitly target reader
678
- const decision = classifyTask({
679
- taskIntent: 'read_file',
680
- taskDescription: 'read the config',
681
- targetProfile: 'local-reader',
682
- }, tmpDir);
683
-
684
- // Reader deployment doesn't exist → deployment_unavailable
685
- expect(decision.decision).toBe('stay_main');
686
- expect(decision.classification).toBe('deployment_unavailable');
687
- });
688
-
689
- it('rejects editor task targeting local-reader (profile-task mismatch)', () => {
690
- // Both deployments enabled
691
- setupReaderDeployment(tmpDir, true);
692
- setupEditorDeployment(tmpDir, true);
693
-
694
- // Editor-eligible task but explicitly targeting local-reader
695
- const decision = classifyTask({
696
- taskIntent: 'edit',
697
- taskDescription: 'Edit the file',
698
- targetProfile: 'local-reader',
699
- }, tmpDir);
700
-
701
- // MUST reject: editor task cannot route to reader profile
702
- expect(decision.decision).toBe('stay_main');
703
- expect(decision.classification).toBe('profile_mismatch');
704
- expect(decision.blockers).toContainEqual(expect.stringContaining('profile mismatch'));
705
- });
706
-
707
- it('accepts editor task targeting local-editor (correct profile)', () => {
708
- setupEditorDeployment(tmpDir, true);
709
-
710
- const decision = classifyTask({
711
- taskIntent: 'edit',
712
- taskDescription: 'Edit the file',
713
- targetProfile: 'local-editor',
714
- }, tmpDir);
715
-
716
- expect(decision.decision).toBe('route_local');
717
- expect(decision.targetProfile).toBe('local-editor');
718
- });
719
- });
720
-
721
- // ---------------------------------------------------------------------------
722
- // Tests: Decision Explainability
723
- // ---------------------------------------------------------------------------
724
-
725
- describe('LocalWorkerRouting explainability', () => {
726
- let tmpDir: string;
727
- beforeEach(() => { tmpDir = makeTmpDir(); });
728
- afterEach(() => { rmdir(tmpDir); });
729
-
730
- it('always provides a reason string', () => {
731
- setupReaderDeployment(tmpDir, true);
732
-
733
- const decision = classifyTask({ taskIntent: 'read_file' }, tmpDir);
734
- expect(typeof decision.reason).toBe('string');
735
- expect(decision.reason.length).toBeGreaterThan(0);
736
- });
737
-
738
- it('blockers is empty when route_local', () => {
739
- setupReaderDeployment(tmpDir, true);
740
-
741
- const decision = classifyTask({ taskIntent: 'read_file' }, tmpDir);
742
- expect(decision.blockers).toEqual([]);
743
- });
744
-
745
- it('blockers is non-empty when stay_main', () => {
746
- const decision = classifyTask({ taskIntent: 'design', taskDescription: 'Design the system' }, tmpDir);
747
- expect(decision.blockers.length).toBeGreaterThan(0);
748
- });
749
-
750
- it('provides deployment check details', () => {
751
- const decision = classifyTask({ taskIntent: 'read_file' }, tmpDir);
752
- expect(decision.deploymentCheck).toBeDefined();
753
- expect('performed' in decision.deploymentCheck).toBe(true);
754
- expect('profileAvailable' in decision.deploymentCheck).toBe(true);
755
- expect('routingEnabled' in decision.deploymentCheck).toBe(true);
756
- });
757
- });