principles-disciple 1.107.0 → 1.108.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 +2 -2
- package/src/core/init.ts +3 -1
- package/src/core/workspace-dir-validation.ts +3 -3
- package/tests/core-anti-growth.test.ts +0 -13
- package/tests/hooks/prompt-characterization.test.ts +1 -11
- package/tests/hooks/prompt-diet.test.ts +3 -11
- package/tests/hooks/prompt-size-guard.test.ts +0 -10
- package/tests/hooks/runtime-v2-prompt-activation.test.ts +0 -10
- package/tests/index.test.ts +1 -1
- package/tests/runtime-v2-discovery-guard.test.ts +1 -2
- package/vitest.config.ts +2 -3
- package/vitest.unit.config.ts +12 -0
- package/src/core/evolution-hook.ts +0 -74
- package/src/core/file-storage-adapter.ts +0 -203
- package/src/core/merge-gate-audit.ts +0 -314
- package/src/core/pain-context-extractor.ts +0 -306
- package/src/core/pain-lifecycle.ts +0 -38
- package/src/core/pain-signal-adapter.ts +0 -42
- package/src/core/pain-signal.ts +0 -22
- package/src/core/principle-injector.ts +0 -84
- package/src/core/principle-tree-migration.ts +0 -196
- package/src/core/storage-adapter.ts +0 -65
- package/src/core/telemetry-event.ts +0 -109
- package/src/core/training-program.ts +0 -632
- package/src/core/workspace-dir-service.ts +0 -119
- package/src/hooks/lifecycle-routing.ts +0 -125
- package/src/service/event-log-auditor.ts +0 -284
- package/src/service/evolution-queue-lock.ts +0 -47
- package/src/service/failure-classifier.ts +0 -79
- package/src/service/internalization-trigger-adapter.ts +0 -302
- package/src/service/monitoring-query-service.ts +0 -67
- package/src/service/subagent-workflow/index.ts +0 -17
- package/src/tools/critique-prompt.ts +0 -1
- package/src/tools/model-index.ts +0 -1
- package/src/types/event-payload.ts +0 -16
- package/src/utils/glob-match.ts +0 -50
- package/src/utils/nlp.ts +0 -25
- package/src/utils/plugin-logger.ts +0 -97
- package/src/utils/subagent-probe.ts +0 -81
- package/tests/core/evolution-hook.test.ts +0 -123
- package/tests/core/file-storage-adapter.test.ts +0 -285
- package/tests/core/merge-gate-audit.test.ts +0 -117
- package/tests/core/pain-context-extractor.test.ts +0 -279
- package/tests/core/pain-lifecycle.test.ts +0 -38
- package/tests/core/pain-signal-adapter.test.ts +0 -116
- package/tests/core/pain-signal.test.ts +0 -190
- package/tests/core/principle-injector.test.ts +0 -90
- package/tests/core/principle-tree-migration.test.ts +0 -77
- package/tests/core/storage-conformance.test.ts +0 -429
- package/tests/core/telemetry-event.test.ts +0 -119
- package/tests/core/training-program.test.ts +0 -472
- package/tests/core/workspace-dir-service.test.ts +0 -68
- package/tests/core/workspace-dir-validation.test.ts +0 -143
- package/tests/integration/internalization-trigger-guard.test.ts +0 -69
- package/tests/integration/pain-lifecycle-e2e.test.ts +0 -75
- package/tests/integration/tool-hooks-workspace-dir.e2e.test.ts +0 -209
- package/tests/service/failure-classifier.test.ts +0 -171
- package/tests/service/internalization-trigger-adapter.test.ts +0 -251
- package/tests/service/monitoring-query-service.test.ts +0 -67
- package/tests/utils/nlp.test.ts +0 -35
- package/tests/utils/plugin-logger.test.ts +0 -156
- package/tests/utils/subagent-probe.test.ts +0 -79
|
@@ -1,279 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import * as fs from 'fs';
|
|
3
|
-
import * as path from 'path';
|
|
4
|
-
import * as os from 'os';
|
|
5
|
-
|
|
6
|
-
const TEST_AGENTS_DIR = path.join(os.tmpdir(), 'pd-test-agents-' + Date.now());
|
|
7
|
-
|
|
8
|
-
// Set env before module load
|
|
9
|
-
process.env.PD_TEST_AGENTS_DIR = TEST_AGENTS_DIR;
|
|
10
|
-
|
|
11
|
-
describe('Pain Context Extractor', () => {
|
|
12
|
-
beforeEach(() => {
|
|
13
|
-
// Clean up test directory
|
|
14
|
-
if (fs.existsSync(TEST_AGENTS_DIR)) {
|
|
15
|
-
fs.rmSync(TEST_AGENTS_DIR, { recursive: true, force: true });
|
|
16
|
-
}
|
|
17
|
-
fs.mkdirSync(path.join(TEST_AGENTS_DIR, 'main', 'sessions'), { recursive: true });
|
|
18
|
-
fs.mkdirSync(path.join(TEST_AGENTS_DIR, 'builder', 'sessions'), { recursive: true });
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
afterEach(() => {
|
|
22
|
-
if (fs.existsSync(TEST_AGENTS_DIR)) {
|
|
23
|
-
fs.rmSync(TEST_AGENTS_DIR, { recursive: true, force: true });
|
|
24
|
-
}
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
function createSessionFile(
|
|
28
|
-
sessionId: string,
|
|
29
|
-
lines: string[],
|
|
30
|
-
agentId: string = 'main',
|
|
31
|
-
): string {
|
|
32
|
-
const dir = path.join(TEST_AGENTS_DIR, agentId, 'sessions');
|
|
33
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
34
|
-
const filePath = path.join(dir, `${sessionId}.jsonl`);
|
|
35
|
-
fs.writeFileSync(filePath, lines.join('\n'), 'utf8');
|
|
36
|
-
return filePath;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function makeMessage(
|
|
40
|
-
role: string,
|
|
41
|
-
textParts: string[] = [],
|
|
42
|
-
extra: Record<string, unknown> = {},
|
|
43
|
-
): string {
|
|
44
|
-
return JSON.stringify({
|
|
45
|
-
type: 'message',
|
|
46
|
-
message: {
|
|
47
|
-
role,
|
|
48
|
-
content: textParts.map((t) => ({ type: 'text', text: t })),
|
|
49
|
-
...extra,
|
|
50
|
-
},
|
|
51
|
-
});
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function makeToolCallMessage(
|
|
55
|
-
toolCalls: Array<{ id: string; name: string; arguments?: Record<string, unknown> }>,
|
|
56
|
-
): string {
|
|
57
|
-
return JSON.stringify({
|
|
58
|
-
type: 'message',
|
|
59
|
-
message: {
|
|
60
|
-
role: 'assistant',
|
|
61
|
-
content: toolCalls.map((tc) => ({
|
|
62
|
-
type: 'toolCall',
|
|
63
|
-
id: tc.id || 'tc1',
|
|
64
|
-
name: tc.name || 'read_file',
|
|
65
|
-
arguments: tc.arguments || {},
|
|
66
|
-
})),
|
|
67
|
-
},
|
|
68
|
-
});
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function makeToolResult(
|
|
72
|
-
toolName: string,
|
|
73
|
-
toolCallId: string,
|
|
74
|
-
textParts: string[] = [],
|
|
75
|
-
details: Record<string, unknown> = {},
|
|
76
|
-
): string {
|
|
77
|
-
return JSON.stringify({
|
|
78
|
-
type: 'message',
|
|
79
|
-
message: {
|
|
80
|
-
role: 'toolResult',
|
|
81
|
-
toolName,
|
|
82
|
-
toolCallId,
|
|
83
|
-
content: textParts.map((t) => ({ type: 'text', text: t })),
|
|
84
|
-
details,
|
|
85
|
-
},
|
|
86
|
-
});
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
describe('extractRecentConversation', () => {
|
|
90
|
-
it('returns empty string for non-existent session', async () => {
|
|
91
|
-
const { extractRecentConversation } = await import(
|
|
92
|
-
'../../src/core/pain-context-extractor.js'
|
|
93
|
-
);
|
|
94
|
-
const result = await extractRecentConversation('nonexistent', 'main');
|
|
95
|
-
expect(result).toBe('');
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it('returns empty string for short session ID', async () => {
|
|
99
|
-
const { extractRecentConversation } = await import(
|
|
100
|
-
'../../src/core/pain-context-extractor.js'
|
|
101
|
-
);
|
|
102
|
-
const result = await extractRecentConversation('ab', 'main');
|
|
103
|
-
expect(result).toBe('');
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it('rejects path traversal session IDs', async () => {
|
|
107
|
-
const { extractRecentConversation } = await import(
|
|
108
|
-
'../../src/core/pain-context-extractor.js'
|
|
109
|
-
);
|
|
110
|
-
const result1 = await extractRecentConversation('../../etc/passwd', 'main');
|
|
111
|
-
expect(result1).toBe('');
|
|
112
|
-
const result2 = await extractRecentConversation('sess/../../../etc/shadow', 'main');
|
|
113
|
-
expect(result2).toBe('');
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it('rejects path traversal agent IDs', async () => {
|
|
117
|
-
const { extractRecentConversation } = await import(
|
|
118
|
-
'../../src/core/pain-context-extractor.js'
|
|
119
|
-
);
|
|
120
|
-
const result = await extractRecentConversation('sess1', '../../etc');
|
|
121
|
-
expect(result).toBe('');
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
it('extracts user and assistant messages', async () => {
|
|
125
|
-
createSessionFile('sess1', [
|
|
126
|
-
makeMessage('user', ['Hello, please help me']),
|
|
127
|
-
makeMessage('assistant', ['Sure, I can help with that']),
|
|
128
|
-
makeMessage('user', ['Fix the bug in auth.ts']),
|
|
129
|
-
makeMessage('assistant', ['I found the issue and fixed it']),
|
|
130
|
-
], 'main');
|
|
131
|
-
|
|
132
|
-
const { extractRecentConversation } = await import(
|
|
133
|
-
'../../src/core/pain-context-extractor.js'
|
|
134
|
-
);
|
|
135
|
-
const result = await extractRecentConversation('sess1', 'main');
|
|
136
|
-
|
|
137
|
-
expect(result).toContain('[User]');
|
|
138
|
-
expect(result).toContain('[Assistant]');
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
it('skips system prompt injection patterns', async () => {
|
|
142
|
-
createSessionFile('sess3', [
|
|
143
|
-
makeMessage('user', ['<evolution_task><pain_score>50</pain_score>']),
|
|
144
|
-
makeMessage('user', ['Real user input: fix the login bug']),
|
|
145
|
-
], 'main');
|
|
146
|
-
|
|
147
|
-
const { extractRecentConversation } = await import(
|
|
148
|
-
'../../src/core/pain-context-extractor.js'
|
|
149
|
-
);
|
|
150
|
-
const result = await extractRecentConversation('sess3', 'main');
|
|
151
|
-
|
|
152
|
-
expect(result).not.toContain('<evolution_task>');
|
|
153
|
-
expect(result).toContain('Real user input');
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
it('handles empty file gracefully', async () => {
|
|
157
|
-
const emptyPath = path.join(TEST_AGENTS_DIR, 'main', 'sessions', 'sess-empty.jsonl');
|
|
158
|
-
fs.writeFileSync(emptyPath, '');
|
|
159
|
-
|
|
160
|
-
const { extractRecentConversation } = await import(
|
|
161
|
-
'../../src/core/pain-context-extractor.js'
|
|
162
|
-
);
|
|
163
|
-
const result = await extractRecentConversation('sess-empty', 'main');
|
|
164
|
-
expect(result).toBe('');
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
it('skips oversized lines (>100KB)', async () => {
|
|
168
|
-
const bigLine = makeMessage('user', ['x'.repeat(150_000)]);
|
|
169
|
-
createSessionFile('sess-big', [
|
|
170
|
-
bigLine,
|
|
171
|
-
makeMessage('user', ['Normal input']),
|
|
172
|
-
], 'main');
|
|
173
|
-
|
|
174
|
-
const { extractRecentConversation } = await import(
|
|
175
|
-
'../../src/core/pain-context-extractor.js'
|
|
176
|
-
);
|
|
177
|
-
const result = await extractRecentConversation('sess-big', 'main');
|
|
178
|
-
|
|
179
|
-
expect(result).toContain('Normal input');
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
it('uses custom agent ID', async () => {
|
|
183
|
-
createSessionFile('sess-builder', [
|
|
184
|
-
makeMessage('user', ['Builder task']),
|
|
185
|
-
], 'builder');
|
|
186
|
-
|
|
187
|
-
const { extractRecentConversation } = await import(
|
|
188
|
-
'../../src/core/pain-context-extractor.js'
|
|
189
|
-
);
|
|
190
|
-
const result = await extractRecentConversation('sess-builder', 'builder');
|
|
191
|
-
expect(result).toContain('Builder task');
|
|
192
|
-
});
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
describe('extractFailedToolContext', () => {
|
|
196
|
-
it('returns empty string for non-existent session', async () => {
|
|
197
|
-
const { extractFailedToolContext } = await import(
|
|
198
|
-
'../../src/core/pain-context-extractor.js'
|
|
199
|
-
);
|
|
200
|
-
const result = await extractFailedToolContext('nonexistent', 'main', 'read_file');
|
|
201
|
-
expect(result).toBe('');
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
it('returns empty string for missing toolName', async () => {
|
|
205
|
-
const { extractFailedToolContext } = await import(
|
|
206
|
-
'../../src/core/pain-context-extractor.js'
|
|
207
|
-
);
|
|
208
|
-
const result = await extractFailedToolContext('sess1', 'main', '');
|
|
209
|
-
expect(result).toBe('');
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
it('extracts failed tool call with correlation', async () => {
|
|
213
|
-
createSessionFile('sess-fail', [
|
|
214
|
-
makeMessage('user', ['system init']), // safeTail strips first line for small files
|
|
215
|
-
makeToolCallMessage([{
|
|
216
|
-
id: 'tc-fail',
|
|
217
|
-
name: 'read_file',
|
|
218
|
-
arguments: { file_path: '/etc/passwd' },
|
|
219
|
-
}]),
|
|
220
|
-
makeToolResult('read_file', 'tc-fail', ['Permission denied'], {
|
|
221
|
-
exitCode: 1,
|
|
222
|
-
isError: true,
|
|
223
|
-
}),
|
|
224
|
-
], 'main');
|
|
225
|
-
|
|
226
|
-
const { extractFailedToolContext } = await import(
|
|
227
|
-
'../../src/core/pain-context-extractor.js'
|
|
228
|
-
);
|
|
229
|
-
const result = await extractFailedToolContext('sess-fail', 'main', 'read_file');
|
|
230
|
-
|
|
231
|
-
expect(result).toContain('Tool Call: read_file');
|
|
232
|
-
expect(result).toContain('Exit Code: 1');
|
|
233
|
-
});
|
|
234
|
-
|
|
235
|
-
it('ignores successful tool results', async () => {
|
|
236
|
-
createSessionFile('sess-ok', [
|
|
237
|
-
makeMessage('user', ['system init']),
|
|
238
|
-
makeToolCallMessage([{ id: 'tc-ok', name: 'read_file' }]),
|
|
239
|
-
makeToolResult('read_file', 'tc-ok', ['File contents'], {
|
|
240
|
-
exitCode: 0,
|
|
241
|
-
isError: false,
|
|
242
|
-
}),
|
|
243
|
-
], 'main');
|
|
244
|
-
|
|
245
|
-
const { extractFailedToolContext } = await import(
|
|
246
|
-
'../../src/core/pain-context-extractor.js'
|
|
247
|
-
);
|
|
248
|
-
const result = await extractFailedToolContext('sess-ok', 'main', 'read_file');
|
|
249
|
-
|
|
250
|
-
expect(result).toBe('');
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
it('filters by file path when provided', async () => {
|
|
254
|
-
createSessionFile('sess-filter', [
|
|
255
|
-
makeMessage('user', ['system init']),
|
|
256
|
-
makeToolCallMessage([{
|
|
257
|
-
id: 'tc1',
|
|
258
|
-
name: 'edit',
|
|
259
|
-
arguments: { file_path: '/src/auth.ts' },
|
|
260
|
-
}]),
|
|
261
|
-
makeToolResult('edit', 'tc1', ['ENOENT'], { exitCode: 1, isError: true }),
|
|
262
|
-
], 'main');
|
|
263
|
-
|
|
264
|
-
const { extractFailedToolContext } = await import(
|
|
265
|
-
'../../src/core/pain-context-extractor.js'
|
|
266
|
-
);
|
|
267
|
-
// With matching file path, should return result
|
|
268
|
-
const result = await extractFailedToolContext(
|
|
269
|
-
'sess-filter',
|
|
270
|
-
'main',
|
|
271
|
-
'edit',
|
|
272
|
-
'/src/auth.ts',
|
|
273
|
-
);
|
|
274
|
-
|
|
275
|
-
expect(result).toContain('Tool Call: edit');
|
|
276
|
-
expect(result).toContain('Exit Code: 1');
|
|
277
|
-
});
|
|
278
|
-
});
|
|
279
|
-
});
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import * as fs from 'fs';
|
|
3
|
-
import * as path from 'path';
|
|
4
|
-
import * as os from 'os';
|
|
5
|
-
import { clearPainFlag, PAIN_FLAG_FILENAME } from '../../src/core/pain-lifecycle.js';
|
|
6
|
-
import { resolvePdPath } from '../../src/core/paths.js';
|
|
7
|
-
|
|
8
|
-
describe('PainLifecycle', () => {
|
|
9
|
-
const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pain-lifecycle-test-'));
|
|
10
|
-
const painFlagPath = resolvePdPath(workspaceDir, 'PAIN_FLAG');
|
|
11
|
-
|
|
12
|
-
beforeEach(() => {
|
|
13
|
-
const stateDir = path.dirname(painFlagPath);
|
|
14
|
-
if (!fs.existsSync(stateDir)) {
|
|
15
|
-
fs.mkdirSync(stateDir, { recursive: true });
|
|
16
|
-
}
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
afterEach(() => {
|
|
20
|
-
if (fs.existsSync(painFlagPath)) fs.unlinkSync(painFlagPath);
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it('should delete .pain_flag file when it exists', () => {
|
|
24
|
-
fs.writeFileSync(painFlagPath, 'source: test\nscore: 80\nreason: test\ntime: 2026-01-01\n', 'utf8');
|
|
25
|
-
expect(fs.existsSync(painFlagPath)).toBe(true);
|
|
26
|
-
clearPainFlag(workspaceDir);
|
|
27
|
-
expect(fs.existsSync(painFlagPath)).toBe(false);
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it('should not throw when .pain_flag does not exist', () => {
|
|
31
|
-
expect(fs.existsSync(painFlagPath)).toBe(false);
|
|
32
|
-
expect(() => clearPainFlag(workspaceDir)).not.toThrow();
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it('should export correct filename constant', () => {
|
|
36
|
-
expect(PAIN_FLAG_FILENAME).toBe('.pain_flag');
|
|
37
|
-
});
|
|
38
|
-
});
|
|
@@ -1,116 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,190 +0,0 @@
|
|
|
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
|
-
});
|