principles-disciple 1.52.0 → 1.53.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/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/core/bootstrap-rules.ts +41 -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-signal-adapter.ts +42 -0
- package/src/core/pain-signal.ts +136 -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 +52 -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-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
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as os from 'os';
|
|
4
|
+
import * as path from 'path';
|
|
5
|
+
import { calculateBaselines } from '../../src/core/observability.js';
|
|
6
|
+
import type { ObservabilityBaselines } from '../../src/core/observability.js';
|
|
7
|
+
import { loadLedger, saveLedger } from '../../src/core/principle-tree-ledger.js';
|
|
8
|
+
import type { HybridLedgerStore } from '../../src/core/principle-tree-ledger.js';
|
|
9
|
+
import { safeRmDir } from '../test-utils.js';
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Helpers
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
function createTmpDir(): string {
|
|
16
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'pd-observability-test-'));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function createEmptyStore(): HybridLedgerStore {
|
|
20
|
+
return {
|
|
21
|
+
trainingStore: {},
|
|
22
|
+
tree: {
|
|
23
|
+
principles: {},
|
|
24
|
+
rules: {},
|
|
25
|
+
implementations: {},
|
|
26
|
+
metrics: {},
|
|
27
|
+
lastUpdated: new Date(0).toISOString(),
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function createTestPrinciple(id: string, status: string, priority: string) {
|
|
33
|
+
return {
|
|
34
|
+
id,
|
|
35
|
+
version: 1,
|
|
36
|
+
text: `Test principle ${id}`,
|
|
37
|
+
triggerPattern: 'test',
|
|
38
|
+
action: 'verify',
|
|
39
|
+
status,
|
|
40
|
+
priority,
|
|
41
|
+
scope: 'general',
|
|
42
|
+
evaluability: 'manual_only' as const,
|
|
43
|
+
valueScore: 0,
|
|
44
|
+
adherenceRate: 0,
|
|
45
|
+
painPreventedCount: 0,
|
|
46
|
+
derivedFromPainIds: [] as string[],
|
|
47
|
+
ruleIds: [] as string[],
|
|
48
|
+
conflictsWithPrincipleIds: [] as string[],
|
|
49
|
+
createdAt: '2026-04-17T00:00:00.000Z',
|
|
50
|
+
updatedAt: '2026-04-17T00:00:00.000Z',
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function seedStore(stateDir: string, store: HybridLedgerStore): void {
|
|
55
|
+
saveLedger(stateDir, store);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Tests
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
describe('calculateBaselines', () => {
|
|
63
|
+
let tmpDir: string;
|
|
64
|
+
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
tmpDir = createTmpDir();
|
|
67
|
+
seedStore(tmpDir, createEmptyStore());
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
afterEach(() => {
|
|
71
|
+
safeRmDir(tmpDir);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// -------------------------------------------------------------------------
|
|
75
|
+
// Empty state
|
|
76
|
+
// -------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
it('returns zeros for empty store', () => {
|
|
79
|
+
const baselines = calculateBaselines(tmpDir);
|
|
80
|
+
|
|
81
|
+
expect(baselines.principleStock).toBe(0);
|
|
82
|
+
expect(baselines.totalRules).toBe(0);
|
|
83
|
+
expect(baselines.totalImplementations).toBe(0);
|
|
84
|
+
expect(baselines.avgRulesPerPrinciple).toBe(0);
|
|
85
|
+
expect(baselines.avgImplementationsPerRule).toBe(0);
|
|
86
|
+
expect(baselines.totalPainEvents).toBe(0);
|
|
87
|
+
expect(baselines.associationRate).toBe(0);
|
|
88
|
+
expect(baselines.internalizedCount).toBe(0);
|
|
89
|
+
expect(baselines.internalizationRate).toBe(0);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('sets calculatedAt to current time', () => {
|
|
93
|
+
const before = new Date().toISOString();
|
|
94
|
+
const baselines = calculateBaselines(tmpDir);
|
|
95
|
+
const after = new Date().toISOString();
|
|
96
|
+
|
|
97
|
+
expect(baselines.calculatedAt >= before).toBe(true);
|
|
98
|
+
expect(baselines.calculatedAt <= after).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// -------------------------------------------------------------------------
|
|
102
|
+
// Principle Stock
|
|
103
|
+
// -------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
it('counts all principles in the ledger', () => {
|
|
106
|
+
const store = createEmptyStore();
|
|
107
|
+
store.tree.principles['P-001'] = createTestPrinciple('P-001', 'active', 'P1');
|
|
108
|
+
store.tree.principles['P-002'] = createTestPrinciple('P-002', 'candidate', 'P2');
|
|
109
|
+
store.tree.principles['P-003'] = createTestPrinciple('P-003', 'deprecated', 'P1');
|
|
110
|
+
seedStore(tmpDir, store);
|
|
111
|
+
|
|
112
|
+
const baselines = calculateBaselines(tmpDir);
|
|
113
|
+
expect(baselines.principleStock).toBe(3);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// -------------------------------------------------------------------------
|
|
117
|
+
// Structure
|
|
118
|
+
// -------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
it('calculates avgRulesPerPrinciple', () => {
|
|
121
|
+
const store = createEmptyStore();
|
|
122
|
+
store.tree.principles['P-001'] = {
|
|
123
|
+
...createTestPrinciple('P-001', 'active', 'P1'),
|
|
124
|
+
ruleIds: ['R-001', 'R-002'],
|
|
125
|
+
};
|
|
126
|
+
store.tree.principles['P-002'] = {
|
|
127
|
+
...createTestPrinciple('P-002', 'active', 'P1'),
|
|
128
|
+
ruleIds: ['R-003'],
|
|
129
|
+
};
|
|
130
|
+
store.tree.rules['R-001'] = { id: 'R-001', principleId: 'P-001', implementationIds: [] } as any;
|
|
131
|
+
store.tree.rules['R-002'] = { id: 'R-002', principleId: 'P-001', implementationIds: [] } as any;
|
|
132
|
+
store.tree.rules['R-003'] = { id: 'R-003', principleId: 'P-002', implementationIds: [] } as any;
|
|
133
|
+
seedStore(tmpDir, store);
|
|
134
|
+
|
|
135
|
+
const baselines = calculateBaselines(tmpDir);
|
|
136
|
+
expect(baselines.totalRules).toBe(3);
|
|
137
|
+
expect(baselines.avgRulesPerPrinciple).toBe(1.5); // 3 rules / 2 principles
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('calculates avgImplementationsPerRule', () => {
|
|
141
|
+
const store = createEmptyStore();
|
|
142
|
+
store.tree.principles['P-001'] = {
|
|
143
|
+
...createTestPrinciple('P-001', 'active', 'P1'),
|
|
144
|
+
ruleIds: ['R-001'],
|
|
145
|
+
};
|
|
146
|
+
store.tree.rules['R-001'] = {
|
|
147
|
+
id: 'R-001',
|
|
148
|
+
principleId: 'P-001',
|
|
149
|
+
implementationIds: ['I-001', 'I-002'],
|
|
150
|
+
} as any;
|
|
151
|
+
store.tree.implementations['I-001'] = { id: 'I-001', ruleId: 'R-001' } as any;
|
|
152
|
+
store.tree.implementations['I-002'] = { id: 'I-002', ruleId: 'R-001' } as any;
|
|
153
|
+
seedStore(tmpDir, store);
|
|
154
|
+
|
|
155
|
+
const baselines = calculateBaselines(tmpDir);
|
|
156
|
+
expect(baselines.totalImplementations).toBe(2);
|
|
157
|
+
expect(baselines.avgImplementationsPerRule).toBe(2); // 2 impls / 1 rule
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('returns 0 for structure metrics when no principles exist', () => {
|
|
161
|
+
seedStore(tmpDir, createEmptyStore());
|
|
162
|
+
const baselines = calculateBaselines(tmpDir);
|
|
163
|
+
|
|
164
|
+
expect(baselines.avgRulesPerPrinciple).toBe(0);
|
|
165
|
+
expect(baselines.avgImplementationsPerRule).toBe(0);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// -------------------------------------------------------------------------
|
|
169
|
+
// Association Rate
|
|
170
|
+
// -------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
it('returns 0 association rate when no pain events exist', () => {
|
|
173
|
+
const store = createEmptyStore();
|
|
174
|
+
store.tree.principles['P-001'] = createTestPrinciple('P-001', 'active', 'P1');
|
|
175
|
+
seedStore(tmpDir, store);
|
|
176
|
+
|
|
177
|
+
const baselines = calculateBaselines(tmpDir);
|
|
178
|
+
expect(baselines.totalPainEvents).toBe(0);
|
|
179
|
+
expect(baselines.associationRate).toBe(0);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('computes association rate as principles / pain events', () => {
|
|
183
|
+
const store = createEmptyStore();
|
|
184
|
+
store.tree.principles['P-001'] = createTestPrinciple('P-001', 'active', 'P1');
|
|
185
|
+
store.tree.principles['P-002'] = createTestPrinciple('P-002', 'active', 'P2');
|
|
186
|
+
seedStore(tmpDir, store);
|
|
187
|
+
|
|
188
|
+
// Create a trajectory DB with pain events
|
|
189
|
+
const dbPath = path.join(tmpDir, 'trajectory.db');
|
|
190
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
191
|
+
const Database = require('better-sqlite3');
|
|
192
|
+
const db = new Database(dbPath);
|
|
193
|
+
db.exec(`
|
|
194
|
+
CREATE TABLE pain_events (id TEXT PRIMARY KEY, created_at TEXT);
|
|
195
|
+
INSERT INTO pain_events VALUES ('pe-1', '2026-04-17T00:00:00Z');
|
|
196
|
+
INSERT INTO pain_events VALUES ('pe-2', '2026-04-17T00:01:00Z');
|
|
197
|
+
INSERT INTO pain_events VALUES ('pe-3', '2026-04-17T00:02:00Z');
|
|
198
|
+
INSERT INTO pain_events VALUES ('pe-4', '2026-04-17T00:03:00Z');
|
|
199
|
+
`);
|
|
200
|
+
db.close();
|
|
201
|
+
|
|
202
|
+
const baselines = calculateBaselines(tmpDir);
|
|
203
|
+
expect(baselines.totalPainEvents).toBe(4);
|
|
204
|
+
expect(baselines.associationRate).toBe(0.5); // 2 principles / 4 pain events
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// -------------------------------------------------------------------------
|
|
208
|
+
// Internalization Rate
|
|
209
|
+
// -------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
it('computes internalization rate from training store', () => {
|
|
212
|
+
const store = createEmptyStore();
|
|
213
|
+
store.tree.principles['P-001'] = createTestPrinciple('P-001', 'active', 'P1');
|
|
214
|
+
store.tree.principles['P-002'] = createTestPrinciple('P-002', 'active', 'P2');
|
|
215
|
+
store.tree.principles['P-003'] = createTestPrinciple('P-003', 'candidate', 'P2');
|
|
216
|
+
store.trainingStore['P-001'] = {
|
|
217
|
+
principleId: 'P-001',
|
|
218
|
+
evaluability: 'deterministic',
|
|
219
|
+
applicableOpportunityCount: 10,
|
|
220
|
+
observedViolationCount: 0,
|
|
221
|
+
complianceRate: 1.0,
|
|
222
|
+
violationTrend: 0,
|
|
223
|
+
generatedSampleCount: 5,
|
|
224
|
+
approvedSampleCount: 5,
|
|
225
|
+
includedTrainRunIds: ['run-1'],
|
|
226
|
+
deployedCheckpointIds: ['ckpt-1'],
|
|
227
|
+
internalizationStatus: 'internalized',
|
|
228
|
+
};
|
|
229
|
+
store.trainingStore['P-002'] = {
|
|
230
|
+
principleId: 'P-002',
|
|
231
|
+
evaluability: 'weak_heuristic',
|
|
232
|
+
applicableOpportunityCount: 3,
|
|
233
|
+
observedViolationCount: 1,
|
|
234
|
+
complianceRate: 0.67,
|
|
235
|
+
violationTrend: 0,
|
|
236
|
+
generatedSampleCount: 0,
|
|
237
|
+
approvedSampleCount: 0,
|
|
238
|
+
includedTrainRunIds: [],
|
|
239
|
+
deployedCheckpointIds: [],
|
|
240
|
+
internalizationStatus: 'in_training',
|
|
241
|
+
};
|
|
242
|
+
seedStore(tmpDir, store);
|
|
243
|
+
|
|
244
|
+
const baselines = calculateBaselines(tmpDir);
|
|
245
|
+
expect(baselines.internalizedCount).toBe(1);
|
|
246
|
+
// 1 internalized / 3 total principles = 0.333
|
|
247
|
+
expect(baselines.internalizationRate).toBeGreaterThan(0.33);
|
|
248
|
+
expect(baselines.internalizationRate).toBeLessThanOrEqual(0.334);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('returns 0 internalization rate when no principles are internalized', () => {
|
|
252
|
+
const store = createEmptyStore();
|
|
253
|
+
store.tree.principles['P-001'] = createTestPrinciple('P-001', 'active', 'P1');
|
|
254
|
+
store.trainingStore['P-001'] = {
|
|
255
|
+
principleId: 'P-001',
|
|
256
|
+
evaluability: 'manual_only',
|
|
257
|
+
applicableOpportunityCount: 0,
|
|
258
|
+
observedViolationCount: 0,
|
|
259
|
+
complianceRate: 0,
|
|
260
|
+
violationTrend: 0,
|
|
261
|
+
generatedSampleCount: 0,
|
|
262
|
+
approvedSampleCount: 0,
|
|
263
|
+
includedTrainRunIds: [],
|
|
264
|
+
deployedCheckpointIds: [],
|
|
265
|
+
internalizationStatus: 'prompt_only',
|
|
266
|
+
};
|
|
267
|
+
seedStore(tmpDir, store);
|
|
268
|
+
|
|
269
|
+
const baselines = calculateBaselines(tmpDir);
|
|
270
|
+
expect(baselines.internalizedCount).toBe(0);
|
|
271
|
+
expect(baselines.internalizationRate).toBe(0);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// -------------------------------------------------------------------------
|
|
275
|
+
// Distributions
|
|
276
|
+
// -------------------------------------------------------------------------
|
|
277
|
+
|
|
278
|
+
it('computes status distribution', () => {
|
|
279
|
+
const store = createEmptyStore();
|
|
280
|
+
store.tree.principles['P-001'] = createTestPrinciple('P-001', 'active', 'P1');
|
|
281
|
+
store.tree.principles['P-002'] = createTestPrinciple('P-002', 'active', 'P2');
|
|
282
|
+
store.tree.principles['P-003'] = createTestPrinciple('P-003', 'candidate', 'P1');
|
|
283
|
+
store.tree.principles['P-004'] = createTestPrinciple('P-004', 'deprecated', 'P2');
|
|
284
|
+
seedStore(tmpDir, store);
|
|
285
|
+
|
|
286
|
+
const baselines = calculateBaselines(tmpDir);
|
|
287
|
+
expect(baselines.statusDistribution.active).toBe(2);
|
|
288
|
+
expect(baselines.statusDistribution.candidate).toBe(1);
|
|
289
|
+
expect(baselines.statusDistribution.deprecated).toBe(1);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('computes priority distribution', () => {
|
|
293
|
+
const store = createEmptyStore();
|
|
294
|
+
store.tree.principles['P-001'] = createTestPrinciple('P-001', 'active', 'P0');
|
|
295
|
+
store.tree.principles['P-002'] = createTestPrinciple('P-002', 'active', 'P1');
|
|
296
|
+
store.tree.principles['P-003'] = createTestPrinciple('P-003', 'active', 'P1');
|
|
297
|
+
store.tree.principles['P-004'] = createTestPrinciple('P-004', 'active', 'P2');
|
|
298
|
+
seedStore(tmpDir, store);
|
|
299
|
+
|
|
300
|
+
const baselines = calculateBaselines(tmpDir);
|
|
301
|
+
expect(baselines.priorityDistribution.P0).toBe(1);
|
|
302
|
+
expect(baselines.priorityDistribution.P1).toBe(2);
|
|
303
|
+
expect(baselines.priorityDistribution.P2).toBe(1);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('computes internalization distribution', () => {
|
|
307
|
+
const store = createEmptyStore();
|
|
308
|
+
store.trainingStore['p-1'] = {
|
|
309
|
+
principleId: 'p-1',
|
|
310
|
+
evaluability: 'deterministic',
|
|
311
|
+
applicableOpportunityCount: 5,
|
|
312
|
+
observedViolationCount: 0,
|
|
313
|
+
complianceRate: 1.0,
|
|
314
|
+
violationTrend: 0,
|
|
315
|
+
generatedSampleCount: 3,
|
|
316
|
+
approvedSampleCount: 3,
|
|
317
|
+
includedTrainRunIds: [],
|
|
318
|
+
deployedCheckpointIds: [],
|
|
319
|
+
internalizationStatus: 'internalized',
|
|
320
|
+
};
|
|
321
|
+
store.trainingStore['p-2'] = {
|
|
322
|
+
principleId: 'p-2',
|
|
323
|
+
evaluability: 'manual_only',
|
|
324
|
+
applicableOpportunityCount: 0,
|
|
325
|
+
observedViolationCount: 0,
|
|
326
|
+
complianceRate: 0,
|
|
327
|
+
violationTrend: 0,
|
|
328
|
+
generatedSampleCount: 0,
|
|
329
|
+
approvedSampleCount: 0,
|
|
330
|
+
includedTrainRunIds: [],
|
|
331
|
+
deployedCheckpointIds: [],
|
|
332
|
+
internalizationStatus: 'prompt_only',
|
|
333
|
+
};
|
|
334
|
+
seedStore(tmpDir, store);
|
|
335
|
+
|
|
336
|
+
const baselines = calculateBaselines(tmpDir);
|
|
337
|
+
expect(baselines.internalizationDistribution.internalized).toBe(1);
|
|
338
|
+
expect(baselines.internalizationDistribution.prompt_only).toBe(1);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// -------------------------------------------------------------------------
|
|
342
|
+
// Persistence
|
|
343
|
+
// -------------------------------------------------------------------------
|
|
344
|
+
|
|
345
|
+
it('persists baselines to .state/baselines.json', () => {
|
|
346
|
+
calculateBaselines(tmpDir);
|
|
347
|
+
|
|
348
|
+
const baselinesPath = path.join(tmpDir, 'baselines.json');
|
|
349
|
+
expect(fs.existsSync(baselinesPath)).toBe(true);
|
|
350
|
+
|
|
351
|
+
const raw = JSON.parse(fs.readFileSync(baselinesPath, 'utf8')) as ObservabilityBaselines;
|
|
352
|
+
expect(raw.principleStock).toBe(0);
|
|
353
|
+
expect(raw.calculatedAt).toBeDefined();
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('overwrites previous baselines on recalculation', () => {
|
|
357
|
+
// First calculation with empty store
|
|
358
|
+
calculateBaselines(tmpDir);
|
|
359
|
+
|
|
360
|
+
// Add a principle
|
|
361
|
+
const store = createEmptyStore();
|
|
362
|
+
store.tree.principles['P-001'] = createTestPrinciple('P-001', 'active', 'P1');
|
|
363
|
+
seedStore(tmpDir, store);
|
|
364
|
+
|
|
365
|
+
// Second calculation
|
|
366
|
+
calculateBaselines(tmpDir);
|
|
367
|
+
|
|
368
|
+
const baselinesPath = path.join(tmpDir, 'baselines.json');
|
|
369
|
+
const raw = JSON.parse(fs.readFileSync(baselinesPath, 'utf8')) as ObservabilityBaselines;
|
|
370
|
+
expect(raw.principleStock).toBe(1);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
// -------------------------------------------------------------------------
|
|
374
|
+
// Edge cases
|
|
375
|
+
// -------------------------------------------------------------------------
|
|
376
|
+
|
|
377
|
+
it('handles missing state directory gracefully', () => {
|
|
378
|
+
const missingDir = path.join(tmpDir, 'nonexistent');
|
|
379
|
+
// loadLedger handles missing dirs by returning empty store
|
|
380
|
+
const baselines = calculateBaselines(missingDir);
|
|
381
|
+
expect(baselines.principleStock).toBe(0);
|
|
382
|
+
});
|
|
383
|
+
});
|
|
@@ -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('rejects empty optional fields (sessionId, agentId, traceId, triggerTextPreview)', () => {
|
|
70
|
+
const signal = validSignal({
|
|
71
|
+
sessionId: '',
|
|
72
|
+
agentId: '',
|
|
73
|
+
traceId: '',
|
|
74
|
+
triggerTextPreview: '',
|
|
75
|
+
});
|
|
76
|
+
expect(Value.Check(PainSignalSchema, signal)).toBe(false);
|
|
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
|
+
});
|