principles-disciple 1.52.0 → 1.54.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/.planning/phases/01-basic-visualization/01-GAP-CLOSURE-VERIFICATION.md +113 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/core/bootstrap-rules.ts +43 -4
- package/src/core/evolution-hook.ts +74 -0
- package/src/core/file-storage-adapter.ts +203 -0
- package/src/core/init.ts +29 -2
- package/src/core/nocturnal-trinity.ts +230 -0
- package/src/core/observability.ts +242 -0
- package/src/core/pain-lifecycle.ts +38 -0
- package/src/core/pain-signal-adapter.ts +42 -0
- package/src/core/pain-signal.ts +139 -0
- package/src/core/principle-injection.ts +208 -0
- package/src/core/principle-injector.ts +84 -0
- package/src/core/storage-adapter.ts +65 -0
- package/src/core/telemetry-event.ts +109 -0
- package/src/hooks/prompt.ts +18 -3
- package/src/service/evolution-worker.ts +59 -2
- package/tests/core/evolution-hook.test.ts +123 -0
- package/tests/core/file-storage-adapter.test.ts +285 -0
- package/tests/core/nocturnal-trinity.test.ts +236 -0
- package/tests/core/observability.test.ts +383 -0
- package/tests/core/pain-lifecycle.test.ts +37 -0
- package/tests/core/pain-signal-adapter.test.ts +116 -0
- package/tests/core/pain-signal.test.ts +190 -0
- package/tests/core/principle-injection.test.ts +223 -0
- package/tests/core/principle-injector.test.ts +90 -0
- package/tests/core/storage-conformance.test.ts +429 -0
- package/tests/core/telemetry-event.test.ts +119 -0
- package/tests/integration/pain-lifecycle-e2e.test.ts +74 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import type { PainSignalAdapter } from '../../src/core/pain-signal-adapter.js';
|
|
3
|
+
import type { PainSignal } from '../../src/core/pain-signal.js';
|
|
4
|
+
import { validatePainSignal } from '../../src/core/pain-signal.js';
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Mock Framework Event
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
/** Simulated framework-specific event for testing */
|
|
11
|
+
interface MockToolCallEvent {
|
|
12
|
+
toolName: string;
|
|
13
|
+
success: boolean;
|
|
14
|
+
errorMessage?: string;
|
|
15
|
+
sessionId: string;
|
|
16
|
+
agentId: string;
|
|
17
|
+
timestamp: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Test Adapter Implementation
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
/** Test adapter that translates MockToolCallEvent to PainSignal */
|
|
25
|
+
const mockAdapter: PainSignalAdapter<MockToolCallEvent> = {
|
|
26
|
+
capture(event: MockToolCallEvent): PainSignal | null {
|
|
27
|
+
// Per D-02: pure translation. Only failed tool calls produce signals.
|
|
28
|
+
if (event.success) return null;
|
|
29
|
+
|
|
30
|
+
// Return null for malformed events
|
|
31
|
+
if (!event.toolName || !event.errorMessage) return null;
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
source: 'tool_failure',
|
|
35
|
+
score: 75,
|
|
36
|
+
timestamp: event.timestamp,
|
|
37
|
+
reason: `Tool ${event.toolName} failed: ${event.errorMessage}`,
|
|
38
|
+
sessionId: event.sessionId,
|
|
39
|
+
agentId: event.agentId,
|
|
40
|
+
traceId: `test-${Date.now()}`,
|
|
41
|
+
triggerTextPreview: event.errorMessage.slice(0, 100),
|
|
42
|
+
domain: 'coding',
|
|
43
|
+
severity: 'high',
|
|
44
|
+
context: { toolName: event.toolName },
|
|
45
|
+
};
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Helpers
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
function mockToolFailure(overrides: Partial<MockToolCallEvent> = {}): MockToolCallEvent {
|
|
54
|
+
return {
|
|
55
|
+
toolName: 'edit_file',
|
|
56
|
+
success: false,
|
|
57
|
+
errorMessage: 'File not found: test.ts',
|
|
58
|
+
sessionId: 'session-001',
|
|
59
|
+
agentId: 'main',
|
|
60
|
+
timestamp: '2026-04-17T00:00:00.000Z',
|
|
61
|
+
...overrides,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Tests
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
describe('PainSignalAdapter', () => {
|
|
70
|
+
it('captures a failed tool call as PainSignal', () => {
|
|
71
|
+
const result = mockAdapter.capture(mockToolFailure());
|
|
72
|
+
expect(result).not.toBeNull();
|
|
73
|
+
expect(result!.source).toBe('tool_failure');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('returns null for successful tool calls', () => {
|
|
77
|
+
const result = mockAdapter.capture({ success: true } as MockToolCallEvent);
|
|
78
|
+
expect(result).toBeNull();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('returns null for malformed events missing toolName', () => {
|
|
82
|
+
const result = mockAdapter.capture({
|
|
83
|
+
success: false,
|
|
84
|
+
toolName: '',
|
|
85
|
+
errorMessage: 'err',
|
|
86
|
+
sessionId: 's-1',
|
|
87
|
+
agentId: 'a-1',
|
|
88
|
+
timestamp: '2026-04-17T00:00:00.000Z',
|
|
89
|
+
});
|
|
90
|
+
expect(result).toBeNull();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('returns null for malformed events missing errorMessage', () => {
|
|
94
|
+
const result = mockAdapter.capture({
|
|
95
|
+
success: false,
|
|
96
|
+
toolName: 'edit',
|
|
97
|
+
errorMessage: undefined,
|
|
98
|
+
sessionId: 's-1',
|
|
99
|
+
agentId: 'a-1',
|
|
100
|
+
timestamp: '2026-04-17T00:00:00.000Z',
|
|
101
|
+
});
|
|
102
|
+
expect(result).toBeNull();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('produces signals that pass validatePainSignal', () => {
|
|
106
|
+
const signal = mockAdapter.capture(mockToolFailure());
|
|
107
|
+
expect(signal).not.toBeNull();
|
|
108
|
+
const result = validatePainSignal(signal!);
|
|
109
|
+
expect(result.valid).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('satisfies the PainSignalAdapter interface type contract', () => {
|
|
113
|
+
const adapter: PainSignalAdapter<MockToolCallEvent> = mockAdapter;
|
|
114
|
+
expect(typeof adapter.capture).toBe('function');
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
PainSignalSchema,
|
|
4
|
+
validatePainSignal,
|
|
5
|
+
deriveSeverity,
|
|
6
|
+
type PainSignal,
|
|
7
|
+
} from '../../src/core/pain-signal.js';
|
|
8
|
+
import { Value } from '@sinclair/typebox/value';
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Helpers
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
/** Produces a valid minimal PainSignal object. */
|
|
15
|
+
function validSignal(overrides: Partial<PainSignal> = {}): PainSignal {
|
|
16
|
+
return {
|
|
17
|
+
source: 'tool_failure',
|
|
18
|
+
score: 75,
|
|
19
|
+
timestamp: '2026-04-17T00:00:00.000Z',
|
|
20
|
+
reason: 'Build failed with exit code 1',
|
|
21
|
+
sessionId: 'session-001',
|
|
22
|
+
agentId: 'main',
|
|
23
|
+
traceId: 'trace-abc',
|
|
24
|
+
triggerTextPreview: 'npm run build',
|
|
25
|
+
domain: 'coding',
|
|
26
|
+
severity: 'high',
|
|
27
|
+
context: {},
|
|
28
|
+
...overrides,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// PainSignalSchema
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
describe('PainSignalSchema', () => {
|
|
37
|
+
it('accepts a valid signal', () => {
|
|
38
|
+
const signal = validSignal();
|
|
39
|
+
expect(Value.Check(PainSignalSchema, signal)).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('rejects signal with missing required source', () => {
|
|
43
|
+
const signal = validSignal();
|
|
44
|
+
delete (signal as Record<string, unknown>).source;
|
|
45
|
+
expect(Value.Check(PainSignalSchema, signal)).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('rejects signal with missing required reason', () => {
|
|
49
|
+
const signal = validSignal();
|
|
50
|
+
delete (signal as Record<string, unknown>).reason;
|
|
51
|
+
expect(Value.Check(PainSignalSchema, signal)).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('rejects score below 0', () => {
|
|
55
|
+
const signal = validSignal({ score: -1 });
|
|
56
|
+
expect(Value.Check(PainSignalSchema, signal)).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('rejects score above 100', () => {
|
|
60
|
+
const signal = validSignal({ score: 101 });
|
|
61
|
+
expect(Value.Check(PainSignalSchema, signal)).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('rejects empty source string', () => {
|
|
65
|
+
const signal = validSignal({ source: '' });
|
|
66
|
+
expect(Value.Check(PainSignalSchema, signal)).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('accepts empty optional fields (sessionId, agentId, etc.)', () => {
|
|
70
|
+
const signal = validSignal({
|
|
71
|
+
sessionId: '',
|
|
72
|
+
agentId: '',
|
|
73
|
+
traceId: '',
|
|
74
|
+
triggerTextPreview: '',
|
|
75
|
+
});
|
|
76
|
+
expect(Value.Check(PainSignalSchema, signal)).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('accepts any string for domain', () => {
|
|
80
|
+
const signal = validSignal({ domain: 'writing' });
|
|
81
|
+
expect(Value.Check(PainSignalSchema, signal)).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('accepts context with mixed value types', () => {
|
|
85
|
+
const signal = validSignal({ context: { filePath: '/src/index.ts', lineCount: 42 } });
|
|
86
|
+
expect(Value.Check(PainSignalSchema, signal)).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// deriveSeverity
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
describe('deriveSeverity', () => {
|
|
95
|
+
it('returns "low" for scores 0-39', () => {
|
|
96
|
+
expect(deriveSeverity(0)).toBe('low');
|
|
97
|
+
expect(deriveSeverity(20)).toBe('low');
|
|
98
|
+
expect(deriveSeverity(39)).toBe('low');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('returns "medium" for scores 40-69', () => {
|
|
102
|
+
expect(deriveSeverity(40)).toBe('medium');
|
|
103
|
+
expect(deriveSeverity(55)).toBe('medium');
|
|
104
|
+
expect(deriveSeverity(69)).toBe('medium');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('returns "high" for scores 70-89', () => {
|
|
108
|
+
expect(deriveSeverity(70)).toBe('high');
|
|
109
|
+
expect(deriveSeverity(80)).toBe('high');
|
|
110
|
+
expect(deriveSeverity(89)).toBe('high');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('returns "critical" for scores 90-100', () => {
|
|
114
|
+
expect(deriveSeverity(90)).toBe('critical');
|
|
115
|
+
expect(deriveSeverity(95)).toBe('critical');
|
|
116
|
+
expect(deriveSeverity(100)).toBe('critical');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// validatePainSignal
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
describe('validatePainSignal', () => {
|
|
125
|
+
it('validates a correct signal and returns it typed', () => {
|
|
126
|
+
const input = validSignal();
|
|
127
|
+
const result = validatePainSignal(input);
|
|
128
|
+
expect(result.valid).toBe(true);
|
|
129
|
+
expect(result.errors).toEqual([]);
|
|
130
|
+
expect(result.signal).toEqual(input);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('fills default domain when missing', () => {
|
|
134
|
+
const input = validSignal();
|
|
135
|
+
delete (input as Record<string, unknown>).domain;
|
|
136
|
+
const result = validatePainSignal(input);
|
|
137
|
+
expect(result.valid).toBe(true);
|
|
138
|
+
expect(result.signal?.domain).toBe('coding');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('fills default severity from score when missing', () => {
|
|
142
|
+
const input = validSignal({ score: 45 });
|
|
143
|
+
delete (input as Record<string, unknown>).severity;
|
|
144
|
+
const result = validatePainSignal(input);
|
|
145
|
+
expect(result.valid).toBe(true);
|
|
146
|
+
expect(result.signal?.severity).toBe('medium');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('fills default context when missing', () => {
|
|
150
|
+
const input = validSignal();
|
|
151
|
+
delete (input as Record<string, unknown>).context;
|
|
152
|
+
const result = validatePainSignal(input);
|
|
153
|
+
expect(result.valid).toBe(true);
|
|
154
|
+
expect(result.signal?.context).toEqual({});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('rejects non-object input', () => {
|
|
158
|
+
const result = validatePainSignal('not an object');
|
|
159
|
+
expect(result.valid).toBe(false);
|
|
160
|
+
expect(result.errors).toContain('Input must be a non-null object');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('rejects null input', () => {
|
|
164
|
+
const result = validatePainSignal(null);
|
|
165
|
+
expect(result.valid).toBe(false);
|
|
166
|
+
expect(result.errors).toContain('Input must be a non-null object');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('rejects array input', () => {
|
|
170
|
+
const result = validatePainSignal([1, 2, 3]);
|
|
171
|
+
expect(result.valid).toBe(false);
|
|
172
|
+
expect(result.errors).toContain('Input must be a non-null object');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('reports errors for missing required fields', () => {
|
|
176
|
+
const result = validatePainSignal({});
|
|
177
|
+
expect(result.valid).toBe(false);
|
|
178
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('rejects invalid score type', () => {
|
|
182
|
+
const result = validatePainSignal({ ...validSignal(), score: 'high' });
|
|
183
|
+
expect(result.valid).toBe(false);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('rejects invalid severity value', () => {
|
|
187
|
+
const result = validatePainSignal({ ...validSignal(), severity: 'extreme' });
|
|
188
|
+
expect(result.valid).toBe(false);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
selectPrinciplesForInjection,
|
|
4
|
+
formatPrinciple,
|
|
5
|
+
DEFAULT_PRINCIPLE_BUDGET,
|
|
6
|
+
type InjectablePrinciple,
|
|
7
|
+
type PrincipleSelectionResult,
|
|
8
|
+
} from '../../src/core/principle-injection.js';
|
|
9
|
+
import type { PrinciplePriority } from '../../src/types/principle-tree-schema.js';
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Test Fixtures
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
function makePrinciple(overrides: Partial<{
|
|
16
|
+
id: string;
|
|
17
|
+
text: string;
|
|
18
|
+
priority: PrinciplePriority;
|
|
19
|
+
createdAt: string;
|
|
20
|
+
}> = {}): InjectablePrinciple {
|
|
21
|
+
return {
|
|
22
|
+
id: overrides.id ?? 'P_001',
|
|
23
|
+
text: overrides.text ?? 'Always verify file content before editing',
|
|
24
|
+
priority: overrides.priority ?? 'P1',
|
|
25
|
+
createdAt: overrides.createdAt ?? '2026-04-01T00:00:00.000Z',
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function makePrinciples(configs: Array<{ id: string; priority: PrinciplePriority; text?: string; createdAt?: string }>): InjectablePrinciple[] {
|
|
30
|
+
return configs.map(c => makePrinciple({
|
|
31
|
+
id: c.id,
|
|
32
|
+
priority: c.priority,
|
|
33
|
+
text: c.text ?? `Principle ${c.id}`,
|
|
34
|
+
createdAt: c.createdAt ?? '2026-04-01T00:00:00.000Z',
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Tests: formatPrinciple
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
describe('formatPrinciple', () => {
|
|
43
|
+
it('formats a principle as "- [ID] text"', () => {
|
|
44
|
+
const p = makePrinciple({ id: 'P_001', text: 'Always verify before editing' });
|
|
45
|
+
expect(formatPrinciple(p)).toBe('- [P_001] Always verify before editing');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('includes the full text even when long', () => {
|
|
49
|
+
const longText = 'A'.repeat(200);
|
|
50
|
+
const p = makePrinciple({ id: 'P_100', text: longText });
|
|
51
|
+
expect(formatPrinciple(p)).toBe(`- [P_100] ${longText}`);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Tests: selectPrinciplesForInjection — priority ordering
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
describe('selectPrinciplesForInjection — priority ordering', () => {
|
|
60
|
+
it('selects P0 principles before P1 and P2', () => {
|
|
61
|
+
const principles = makePrinciples([
|
|
62
|
+
{ id: 'P1', priority: 'P2' },
|
|
63
|
+
{ id: 'P2', priority: 'P0' },
|
|
64
|
+
{ id: 'P3', priority: 'P1' },
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
const result = selectPrinciplesForInjection(principles, 10000);
|
|
68
|
+
|
|
69
|
+
expect(result.selected[0].id).toBe('P2'); // P0 first
|
|
70
|
+
expect(result.selected[1].id).toBe('P3'); // P1 second
|
|
71
|
+
expect(result.selected[2].id).toBe('P1'); // P2 third
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('selects newer principles first within same priority', () => {
|
|
75
|
+
const principles = makePrinciples([
|
|
76
|
+
{ id: 'OLD', priority: 'P1', createdAt: '2026-03-01T00:00:00.000Z' },
|
|
77
|
+
{ id: 'NEW', priority: 'P1', createdAt: '2026-04-15T00:00:00.000Z' },
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
const result = selectPrinciplesForInjection(principles, 10000);
|
|
81
|
+
|
|
82
|
+
expect(result.selected[0].id).toBe('NEW'); // Newer first
|
|
83
|
+
expect(result.selected[1].id).toBe('OLD');
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Tests: selectPrinciplesForInjection — budget enforcement
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
describe('selectPrinciplesForInjection — budget enforcement', () => {
|
|
92
|
+
it('respects the character budget', () => {
|
|
93
|
+
const principles = makePrinciples(
|
|
94
|
+
Array.from({ length: 20 }, (_, i) => ({
|
|
95
|
+
id: `P_${String(i).padStart(3, '0')}`,
|
|
96
|
+
priority: 'P1' as PrinciplePriority,
|
|
97
|
+
text: `Principle with a reasonably long text description number ${i}`,
|
|
98
|
+
}))
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const budget = 500;
|
|
102
|
+
const result = selectPrinciplesForInjection(principles, budget);
|
|
103
|
+
|
|
104
|
+
expect(result.totalChars).toBeLessThanOrEqual(budget + 200); // Allow some slack for P0 force-include
|
|
105
|
+
expect(result.selected.length).toBeLessThan(20);
|
|
106
|
+
expect(result.wasTruncated).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('includes all principles when budget is large enough', () => {
|
|
110
|
+
const principles = makePrinciples([
|
|
111
|
+
{ id: 'P_001', priority: 'P0' },
|
|
112
|
+
{ id: 'P_002', priority: 'P1' },
|
|
113
|
+
{ id: 'P_003', priority: 'P2' },
|
|
114
|
+
]);
|
|
115
|
+
|
|
116
|
+
const result = selectPrinciplesForInjection(principles, 10000);
|
|
117
|
+
|
|
118
|
+
expect(result.selected).toHaveLength(3);
|
|
119
|
+
expect(result.wasTruncated).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('returns empty selection for empty principles array', () => {
|
|
123
|
+
const result = selectPrinciplesForInjection([], 10000);
|
|
124
|
+
|
|
125
|
+
expect(result.selected).toHaveLength(0);
|
|
126
|
+
expect(result.totalChars).toBe(0);
|
|
127
|
+
expect(result.hasP0).toBe(false);
|
|
128
|
+
expect(result.wasTruncated).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// Tests: selectPrinciplesForInjection — P0 guarantee
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
describe('selectPrinciplesForInjection — P0 guarantee', () => {
|
|
137
|
+
it('ensures at least one P0 principle is included even when over budget', () => {
|
|
138
|
+
// Fill budget with P1 principles, then have a P0 that would exceed budget
|
|
139
|
+
const principles: InjectablePrinciple[] = [];
|
|
140
|
+
|
|
141
|
+
// Add many P1 principles that fill the budget
|
|
142
|
+
for (let i = 0; i < 10; i++) {
|
|
143
|
+
principles.push(makePrinciple({
|
|
144
|
+
id: `P1_${i}`,
|
|
145
|
+
priority: 'P1',
|
|
146
|
+
text: `P1 principle with enough text to consume budget space ${i}`,
|
|
147
|
+
createdAt: '2026-04-01T00:00:00.000Z',
|
|
148
|
+
}));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Add a P0 principle
|
|
152
|
+
principles.push(makePrinciple({
|
|
153
|
+
id: 'P0_CRITICAL',
|
|
154
|
+
priority: 'P0',
|
|
155
|
+
text: 'Critical P0 principle that must always be included',
|
|
156
|
+
createdAt: '2026-04-01T00:00:00.000Z',
|
|
157
|
+
}));
|
|
158
|
+
|
|
159
|
+
const budget = 200; // Very small budget
|
|
160
|
+
const result = selectPrinciplesForInjection(principles, budget);
|
|
161
|
+
|
|
162
|
+
expect(result.hasP0).toBe(true);
|
|
163
|
+
expect(result.selected.some(p => p.priority === 'P0')).toBe(true);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('sets hasP0=true when P0 principles are naturally selected', () => {
|
|
167
|
+
const principles = makePrinciples([
|
|
168
|
+
{ id: 'P0_1', priority: 'P0' },
|
|
169
|
+
{ id: 'P1_1', priority: 'P1' },
|
|
170
|
+
]);
|
|
171
|
+
|
|
172
|
+
const result = selectPrinciplesForInjection(principles, 10000);
|
|
173
|
+
|
|
174
|
+
expect(result.hasP0).toBe(true);
|
|
175
|
+
expect(result.breakdown.p0).toBe(1);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('sets hasP0=false when no P0 principles exist', () => {
|
|
179
|
+
const principles = makePrinciples([
|
|
180
|
+
{ id: 'P1_1', priority: 'P1' },
|
|
181
|
+
{ id: 'P2_1', priority: 'P2' },
|
|
182
|
+
]);
|
|
183
|
+
|
|
184
|
+
const result = selectPrinciplesForInjection(principles, 10000);
|
|
185
|
+
|
|
186
|
+
expect(result.hasP0).toBe(false);
|
|
187
|
+
expect(result.breakdown.p0).toBe(0);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
// Tests: selectPrinciplesForInjection — breakdown
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
describe('selectPrinciplesForInjection — breakdown', () => {
|
|
196
|
+
it('counts principles by priority tier correctly', () => {
|
|
197
|
+
const principles = makePrinciples([
|
|
198
|
+
{ id: 'P0_1', priority: 'P0' },
|
|
199
|
+
{ id: 'P0_2', priority: 'P0' },
|
|
200
|
+
{ id: 'P1_1', priority: 'P1' },
|
|
201
|
+
{ id: 'P1_2', priority: 'P1' },
|
|
202
|
+
{ id: 'P1_3', priority: 'P1' },
|
|
203
|
+
{ id: 'P2_1', priority: 'P2' },
|
|
204
|
+
]);
|
|
205
|
+
|
|
206
|
+
const result = selectPrinciplesForInjection(principles, 10000);
|
|
207
|
+
|
|
208
|
+
expect(result.breakdown.p0).toBe(2);
|
|
209
|
+
expect(result.breakdown.p1).toBe(3);
|
|
210
|
+
expect(result.breakdown.p2).toBe(1);
|
|
211
|
+
expect(result.selected).toHaveLength(6);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
// Tests: DEFAULT_PRINCIPLE_BUDGET
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
describe('DEFAULT_PRINCIPLE_BUDGET', () => {
|
|
220
|
+
it('is set to 4000 characters', () => {
|
|
221
|
+
expect(DEFAULT_PRINCIPLE_BUDGET).toBe(4000);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import type { PrincipleInjector, InjectionContext } from '../../src/core/principle-injector.js';
|
|
3
|
+
import { DefaultPrincipleInjector } from '../../src/core/principle-injector.js';
|
|
4
|
+
import type { InjectablePrinciple } from '../../src/core/principle-injection.js';
|
|
5
|
+
import { selectPrinciplesForInjection, formatPrinciple } from '../../src/core/principle-injection.js';
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Helpers
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
function makePrinciple(overrides: Partial<InjectablePrinciple> = {}): InjectablePrinciple {
|
|
12
|
+
return {
|
|
13
|
+
id: overrides.id ?? 'P_001',
|
|
14
|
+
text: overrides.text ?? 'Always verify file content before editing',
|
|
15
|
+
priority: overrides.priority ?? 'P1',
|
|
16
|
+
createdAt: overrides.createdAt ?? '2026-04-01T00:00:00.000Z',
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Tests
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
describe('DefaultPrincipleInjector', () => {
|
|
25
|
+
const injector: PrincipleInjector = new DefaultPrincipleInjector();
|
|
26
|
+
|
|
27
|
+
it('getRelevantPrinciples delegates to selectPrinciplesForInjection', () => {
|
|
28
|
+
const principles = [
|
|
29
|
+
makePrinciple({ id: 'P0', priority: 'P0', text: 'Critical rule' }),
|
|
30
|
+
makePrinciple({ id: 'P1', priority: 'P1', text: 'Standard rule' }),
|
|
31
|
+
makePrinciple({ id: 'P2', priority: 'P2', text: 'Low priority rule' }),
|
|
32
|
+
makePrinciple({ id: 'P1b', priority: 'P1', text: 'Another standard rule' }),
|
|
33
|
+
makePrinciple({ id: 'P0b', priority: 'P0', text: 'Another critical rule' }),
|
|
34
|
+
];
|
|
35
|
+
const context: InjectionContext = { domain: 'coding', sessionId: 's-1', budgetChars: 4000 };
|
|
36
|
+
|
|
37
|
+
const result = injector.getRelevantPrinciples(principles, context);
|
|
38
|
+
const expected = selectPrinciplesForInjection(principles, 4000).selected;
|
|
39
|
+
|
|
40
|
+
expect(result).toEqual(expected);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('formatForInjection delegates to formatPrinciple', () => {
|
|
44
|
+
const principle = makePrinciple({ id: 'P_001', text: 'Test' });
|
|
45
|
+
|
|
46
|
+
const result = injector.formatForInjection(principle);
|
|
47
|
+
const expected = formatPrinciple(makePrinciple({ id: 'P_001', text: 'Test' }));
|
|
48
|
+
|
|
49
|
+
expect(result).toBe(expected);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('formatForInjection returns "- [ID] text" format', () => {
|
|
53
|
+
const result = injector.formatForInjection(
|
|
54
|
+
makePrinciple({ id: 'P_001', text: 'Verify before edit' }),
|
|
55
|
+
);
|
|
56
|
+
expect(result).toBe('- [P_001] Verify before edit');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('getRelevantPrinciples respects budget constraint', () => {
|
|
60
|
+
const principles = Array.from({ length: 20 }, (_, i) =>
|
|
61
|
+
makePrinciple({
|
|
62
|
+
id: `P_${i.toString().padStart(3, '0')}`,
|
|
63
|
+
text: 'A'.repeat(300), // 300 chars each
|
|
64
|
+
priority: i === 0 ? 'P0' : 'P2',
|
|
65
|
+
}),
|
|
66
|
+
);
|
|
67
|
+
const context: InjectionContext = { domain: 'coding', sessionId: 's-1', budgetChars: 500 };
|
|
68
|
+
|
|
69
|
+
const result = injector.getRelevantPrinciples(principles, context);
|
|
70
|
+
|
|
71
|
+
// Should include at least the forced P0, total chars may exceed budget slightly
|
|
72
|
+
expect(result.length).toBeGreaterThan(0);
|
|
73
|
+
expect(result.some(p => p.priority === 'P0')).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('getRelevantPrinciples returns empty array for empty input', () => {
|
|
77
|
+
const context: InjectionContext = { domain: 'coding', sessionId: 's-1', budgetChars: 4000 };
|
|
78
|
+
const result = injector.getRelevantPrinciples([], context);
|
|
79
|
+
expect(result).toEqual([]);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('InjectionContext', () => {
|
|
84
|
+
it('has domain, sessionId, and budgetChars fields', () => {
|
|
85
|
+
const ctx: InjectionContext = { domain: 'coding', sessionId: 's-1', budgetChars: 4000 };
|
|
86
|
+
expect(ctx.domain).toBe('coding');
|
|
87
|
+
expect(ctx.sessionId).toBe('s-1');
|
|
88
|
+
expect(ctx.budgetChars).toBe(4000);
|
|
89
|
+
});
|
|
90
|
+
});
|