pi-watchdog-supervisor 0.1.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/.tmp/2026-07-08_pi_watchdog_extension_requirement.md +907 -0
- package/.tmp/2026-07-08_pi_watchdog_high_level_spec.md +192 -0
- package/.tmp/2026-07-08_pi_watchdog_task01_low_level_spec.md +234 -0
- package/.tmp/2026-07-08_pi_watchdog_task02_low_level_spec.md +158 -0
- package/.tmp/2026-07-08_pi_watchdog_task03_low_level_spec.md +175 -0
- package/.tmp/2026-07-08_pi_watchdog_task04_low_level_spec.md +151 -0
- package/.tmp/2026-07-08_pi_watchdog_task05_low_level_spec.md +116 -0
- package/.tmp/2026-07-08_pi_watchdog_task06_low_level_spec.md +69 -0
- package/.tmp/detect_approach.patch +253 -0
- package/README.md +176 -0
- package/examples/AGENTS.md +27 -0
- package/examples/watchdog-supervisor.json +12 -0
- package/package.json +39 -0
- package/prompts/watchdog-agent.md +36 -0
- package/skills/watchdog-supervisor/SKILL.md +35 -0
- package/src/checker.ts +54 -0
- package/src/collector.ts +223 -0
- package/src/commands.ts +159 -0
- package/src/config.ts +80 -0
- package/src/detector.ts +150 -0
- package/src/index.ts +94 -0
- package/src/integrations/gotgenes-subagents.ts +45 -0
- package/src/lmdebug.ts +128 -0
- package/src/normalize.ts +40 -0
- package/src/registry.ts +121 -0
- package/src/store.ts +137 -0
- package/src/tools.ts +289 -0
- package/src/types.ts +80 -0
- package/test/Screenshot_20260708_220337.png +0 -0
- package/test/checker.test.ts +118 -0
- package/test/collector.test.ts +201 -0
- package/test/config.test.ts +131 -0
- package/test/detector.test.ts +224 -0
- package/test/integration.test.ts +57 -0
- package/test/lmdebug.test.ts +114 -0
- package/test/normalize.test.ts +78 -0
- package/test/registry.test.ts +149 -0
- package/test/store.test.ts +176 -0
- package/test/tools.test.ts +315 -0
- package/tsconfig.json +13 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { tmpdir, homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_CONFIG,
|
|
7
|
+
getConfigPaths,
|
|
8
|
+
loadConfigFile,
|
|
9
|
+
loadEffectiveConfig,
|
|
10
|
+
mergeConfig,
|
|
11
|
+
} from '../src/config.ts';
|
|
12
|
+
|
|
13
|
+
const tempDirs: string[] = [];
|
|
14
|
+
|
|
15
|
+
const makeTempDir = () => {
|
|
16
|
+
const dir = mkdtempSync(join(tmpdir(), 'watchdog-test-'));
|
|
17
|
+
tempDirs.push(dir);
|
|
18
|
+
return dir;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
while (tempDirs.length > 0) {
|
|
23
|
+
rmSync(tempDirs.pop()!, { recursive: true, force: true });
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('DEFAULT_CONFIG', () => {
|
|
28
|
+
it('has the documented default values', () => {
|
|
29
|
+
expect(DEFAULT_CONFIG.enabled).toBe(true);
|
|
30
|
+
expect(DEFAULT_CONFIG.rescueMessage).toContain('might be stuck');
|
|
31
|
+
expect(DEFAULT_CONFIG.llmRepeatThreshold).toBe(3);
|
|
32
|
+
expect(DEFAULT_CONFIG.idleNoProgressSec).toBe(0);
|
|
33
|
+
expect(DEFAULT_CONFIG.cooldownSec).toBe(0);
|
|
34
|
+
expect(DEFAULT_CONFIG.maxPreviewLines).toBe(20);
|
|
35
|
+
expect(DEFAULT_CONFIG.maxEventsPerAgent).toBe(200);
|
|
36
|
+
expect(DEFAULT_CONFIG.alertMode).toBe('main_only');
|
|
37
|
+
expect(DEFAULT_CONFIG.steerDryRunDefault).toBeNull();
|
|
38
|
+
expect(DEFAULT_CONFIG.debug).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('getConfigPaths', () => {
|
|
43
|
+
it('returns global path under home and project path under project dir', () => {
|
|
44
|
+
const { globalPath, projectPath } = getConfigPaths('/proj');
|
|
45
|
+
expect(globalPath).toBe(
|
|
46
|
+
join(homedir(), '.pi', 'agent', 'watchdog-supervisor', 'config.json'),
|
|
47
|
+
);
|
|
48
|
+
expect(projectPath).toBe(join('/proj', '.pi', 'watchdog-supervisor.json'));
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('loadConfigFile', () => {
|
|
53
|
+
it('returns null config without warning when file does not exist', () => {
|
|
54
|
+
const { config, warning } = loadConfigFile(join(makeTempDir(), 'missing.json'));
|
|
55
|
+
expect(config).toBeNull();
|
|
56
|
+
expect(warning).toBeUndefined();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('returns parsed partial config for a valid file', () => {
|
|
60
|
+
const dir = makeTempDir();
|
|
61
|
+
const path = join(dir, 'config.json');
|
|
62
|
+
writeFileSync(path, JSON.stringify({ llmRepeatThreshold: 5 }));
|
|
63
|
+
const { config, warning } = loadConfigFile(path);
|
|
64
|
+
expect(config).toEqual({ llmRepeatThreshold: 5 });
|
|
65
|
+
expect(warning).toBeUndefined();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('returns null config with warning for invalid JSON', () => {
|
|
69
|
+
const dir = makeTempDir();
|
|
70
|
+
const path = join(dir, 'config.json');
|
|
71
|
+
writeFileSync(path, '{ not json');
|
|
72
|
+
const { config, warning } = loadConfigFile(path);
|
|
73
|
+
expect(config).toBeNull();
|
|
74
|
+
expect(warning).toContain(path);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('ignores unknown fields', () => {
|
|
78
|
+
const dir = makeTempDir();
|
|
79
|
+
const path = join(dir, 'config.json');
|
|
80
|
+
writeFileSync(path, JSON.stringify({ llmRepeatThreshold: 5, bogus: true }));
|
|
81
|
+
const { config } = loadConfigFile(path);
|
|
82
|
+
expect(config).toEqual({ llmRepeatThreshold: 5 });
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('mergeConfig', () => {
|
|
87
|
+
it('returns defaults when no parts are given', () => {
|
|
88
|
+
expect(mergeConfig()).toEqual(DEFAULT_CONFIG);
|
|
89
|
+
expect(mergeConfig(null, undefined)).toEqual(DEFAULT_CONFIG);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('overrides defaults with global values, keeping unspecified defaults', () => {
|
|
93
|
+
const merged = mergeConfig({ llmRepeatThreshold: 10 });
|
|
94
|
+
expect(merged.llmRepeatThreshold).toBe(10);
|
|
95
|
+
expect(merged.cooldownSec).toBe(DEFAULT_CONFIG.cooldownSec);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('lets later parts (project) override earlier parts (global)', () => {
|
|
99
|
+
const merged = mergeConfig(
|
|
100
|
+
{ llmRepeatThreshold: 10, cooldownSec: 120 },
|
|
101
|
+
{ llmRepeatThreshold: 7 },
|
|
102
|
+
);
|
|
103
|
+
expect(merged.llmRepeatThreshold).toBe(7);
|
|
104
|
+
expect(merged.cooldownSec).toBe(120);
|
|
105
|
+
expect(merged.idleNoProgressSec).toBe(DEFAULT_CONFIG.idleNoProgressSec);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('loadEffectiveConfig', () => {
|
|
110
|
+
it('merges project config over defaults and collects no warnings for valid files', () => {
|
|
111
|
+
const projectDir = makeTempDir();
|
|
112
|
+
mkdirSync(join(projectDir, '.pi'));
|
|
113
|
+
writeFileSync(
|
|
114
|
+
join(projectDir, '.pi', 'watchdog-supervisor.json'),
|
|
115
|
+
JSON.stringify({ llmRepeatThreshold: 5 }),
|
|
116
|
+
);
|
|
117
|
+
const { config, warnings } = loadEffectiveConfig(projectDir);
|
|
118
|
+
expect(config.llmRepeatThreshold).toBe(5);
|
|
119
|
+
expect(config.alertMode).toBe('main_only');
|
|
120
|
+
expect(warnings).toEqual([]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('falls back and reports warning when project config is invalid JSON', () => {
|
|
124
|
+
const projectDir = makeTempDir();
|
|
125
|
+
mkdirSync(join(projectDir, '.pi'));
|
|
126
|
+
writeFileSync(join(projectDir, '.pi', 'watchdog-supervisor.json'), '{ oops');
|
|
127
|
+
const { config, warnings } = loadEffectiveConfig(projectDir);
|
|
128
|
+
expect(config.llmRepeatThreshold).toBe(DEFAULT_CONFIG.llmRepeatThreshold);
|
|
129
|
+
expect(warnings).toHaveLength(1);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { detectStuck, shouldAlert } from '../src/detector.ts';
|
|
3
|
+
import { DEFAULT_CONFIG } from '../src/config.ts';
|
|
4
|
+
import type { WatchdogEvent, WatchdogEventType } from '../src/types.ts';
|
|
5
|
+
|
|
6
|
+
const NOW = 1_000_000_000;
|
|
7
|
+
|
|
8
|
+
let seq = 0;
|
|
9
|
+
const ev = (
|
|
10
|
+
type: WatchdogEventType,
|
|
11
|
+
at: number,
|
|
12
|
+
commandKey?: string,
|
|
13
|
+
outputHash?: string,
|
|
14
|
+
): WatchdogEvent => ({
|
|
15
|
+
id: `s1-${++seq}`,
|
|
16
|
+
targetId: 's1',
|
|
17
|
+
at,
|
|
18
|
+
type,
|
|
19
|
+
summary: `${type}: ${commandKey ?? ''}`,
|
|
20
|
+
commandKey,
|
|
21
|
+
outputHash,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// N consecutive llm events of one type with configurable body hashes
|
|
25
|
+
const llmRun = (
|
|
26
|
+
type: 'llm_input' | 'llm_output',
|
|
27
|
+
count: number,
|
|
28
|
+
hash: string | ((i: number) => string),
|
|
29
|
+
) =>
|
|
30
|
+
Array.from({ length: count }, (_, i) =>
|
|
31
|
+
ev(type, NOW - 10_000 + i * 1000, type, typeof hash === 'string' ? hash : hash(i)),
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
describe('detectStuck: repeated llm messages', () => {
|
|
35
|
+
it('flags repeated_llm_output for 3x identical normalized output body', () => {
|
|
36
|
+
const analysis = detectStuck(llmRun('llm_output', 3, 'body-1'), DEFAULT_CONFIG, NOW);
|
|
37
|
+
expect(analysis.likelyStuck).toBe(true);
|
|
38
|
+
expect(analysis.confidence).toBe('medium');
|
|
39
|
+
expect(analysis.evidence.map((item) => item.type)).toEqual(['repeated_llm_output']);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('flags repeated_llm_input for 3x identical input body', () => {
|
|
43
|
+
const analysis = detectStuck(llmRun('llm_input', 3, 'body-1'), DEFAULT_CONFIG, NOW);
|
|
44
|
+
expect(analysis.evidence.map((item) => item.type)).toEqual(['repeated_llm_input']);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('flags both types with high confidence when input and output both loop', () => {
|
|
48
|
+
const events = [0, 1, 2].flatMap((i) => [
|
|
49
|
+
ev('llm_input', NOW - 10_000 + i * 2000, 'llm_input', 'in-same'),
|
|
50
|
+
ev('llm_output', NOW - 9000 + i * 2000, 'llm_output', 'out-same'),
|
|
51
|
+
]);
|
|
52
|
+
const analysis = detectStuck(events, DEFAULT_CONFIG, NOW);
|
|
53
|
+
expect(analysis.likelyStuck).toBe(true);
|
|
54
|
+
expect(analysis.confidence).toBe('high');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('does not flag 2x identical body', () => {
|
|
58
|
+
const analysis = detectStuck(llmRun('llm_output', 2, 'body-1'), DEFAULT_CONFIG, NOW);
|
|
59
|
+
expect(analysis.likelyStuck).toBe(false);
|
|
60
|
+
expect(analysis.evidence).toEqual([]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('does not flag when bodies differ', () => {
|
|
64
|
+
const analysis = detectStuck(llmRun('llm_output', 5, (i) => `body-${i}`), DEFAULT_CONFIG, NOW);
|
|
65
|
+
expect(analysis.likelyStuck).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('requires the repeats to be consecutive', () => {
|
|
69
|
+
const events = [
|
|
70
|
+
...llmRun('llm_output', 2, 'body-1'),
|
|
71
|
+
ev('llm_output', NOW - 7000, 'llm_output', 'body-other'),
|
|
72
|
+
ev('llm_output', NOW - 6000, 'llm_output', 'body-1'),
|
|
73
|
+
];
|
|
74
|
+
const analysis = detectStuck(events, DEFAULT_CONFIG, NOW);
|
|
75
|
+
expect(analysis.likelyStuck).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('resets the counter via sinceAt: only events after the last alert count', () => {
|
|
79
|
+
const events = llmRun('llm_output', 5, 'body-1');
|
|
80
|
+
// alert happened after the 3rd event → only 2 remain countable
|
|
81
|
+
const sinceAt = events[2].at;
|
|
82
|
+
const analysis = detectStuck(events, DEFAULT_CONFIG, NOW, sinceAt);
|
|
83
|
+
expect(analysis.likelyStuck).toBe(false);
|
|
84
|
+
// three more repeats after the alert → triggers again
|
|
85
|
+
const more = [...events, ...llmRun('llm_output', 1, 'body-1').map((e) => ({ ...e, at: NOW - 1000 }))];
|
|
86
|
+
expect(detectStuck(more, DEFAULT_CONFIG, NOW, sinceAt).likelyStuck).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('counts the full buffer when sinceAt is omitted', () => {
|
|
90
|
+
const analysis = detectStuck(llmRun('llm_output', 3, 'body-1'), DEFAULT_CONFIG, NOW);
|
|
91
|
+
expect(analysis.likelyStuck).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('ignores interleaved tool events when counting the run', () => {
|
|
95
|
+
const events = [0, 1, 2].flatMap((i) => [
|
|
96
|
+
ev('llm_output', NOW - 10_000 + i * 2000, 'llm_output', 'out-same'),
|
|
97
|
+
ev('tool_result', NOW - 9500 + i * 2000, 'edit src/a.ts', `tool-${i}`),
|
|
98
|
+
ev('edit', NOW - 9200 + i * 2000, 'edit src/a.ts'),
|
|
99
|
+
]);
|
|
100
|
+
const analysis = detectStuck(events, DEFAULT_CONFIG, NOW);
|
|
101
|
+
expect(analysis.evidence.map((item) => item.type)).toEqual(['repeated_llm_output']);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('detectStuck: idle no progress', () => {
|
|
106
|
+
// R4 is disabled by default (idleNoProgressSec: 0); enable it for these tests
|
|
107
|
+
const IDLE_CONFIG = { ...DEFAULT_CONFIG, idleNoProgressSec: 300 };
|
|
108
|
+
const IDLE = IDLE_CONFIG.idleNoProgressSec * 1000;
|
|
109
|
+
|
|
110
|
+
const busyNoEditEvents = (toolEventCount: number) =>
|
|
111
|
+
Array.from({ length: toolEventCount }, (_, i) =>
|
|
112
|
+
ev(
|
|
113
|
+
i % 2 === 0 ? 'tool_call' : 'tool_result',
|
|
114
|
+
NOW - IDLE - 60_000 + i * ((IDLE + 30_000) / toolEventCount),
|
|
115
|
+
`cmd-${i}`,
|
|
116
|
+
`h-${i}`,
|
|
117
|
+
),
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
it('flags idle_no_progress when tools keep running without edits', () => {
|
|
121
|
+
const events = [...busyNoEditEvents(6), ev('tool_call', NOW - 30_000, 'cmd-last')];
|
|
122
|
+
const analysis = detectStuck(events, IDLE_CONFIG, NOW);
|
|
123
|
+
expect(analysis.evidence.map((item) => item.type)).toContain('idle_no_progress');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('does not flag with fewer than 5 tool events', () => {
|
|
127
|
+
const events = [
|
|
128
|
+
ev('tool_call', NOW - IDLE - 10_000, 'cmd-a'),
|
|
129
|
+
ev('tool_result', NOW - IDLE - 9000, 'cmd-a', 'ha'),
|
|
130
|
+
ev('tool_call', NOW - 30_000, 'cmd-b'),
|
|
131
|
+
];
|
|
132
|
+
const analysis = detectStuck(events, IDLE_CONFIG, NOW);
|
|
133
|
+
expect(analysis.evidence.map((item) => item.type)).not.toContain('idle_no_progress');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('does not flag when the agent has gone quiet', () => {
|
|
137
|
+
const events = busyNoEditEvents(6).map((event) => ({ ...event, at: event.at - 120_000 }));
|
|
138
|
+
const analysis = detectStuck(events, IDLE_CONFIG, NOW);
|
|
139
|
+
expect(analysis.evidence.map((item) => item.type)).not.toContain('idle_no_progress');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('does not flag when a recent edit shows progress', () => {
|
|
143
|
+
const events = [...busyNoEditEvents(6), ev('edit', NOW - 60_000, 'edit src/a.ts'), ev('tool_call', NOW - 30_000, 'cmd-last')];
|
|
144
|
+
const analysis = detectStuck(events, IDLE_CONFIG, NOW);
|
|
145
|
+
expect(analysis.evidence.map((item) => item.type)).not.toContain('idle_no_progress');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('is disabled when idleNoProgressSec is 0 (the default)', () => {
|
|
149
|
+
const events = [...busyNoEditEvents(6), ev('tool_call', NOW - 30_000, 'cmd-last')];
|
|
150
|
+
const analysis = detectStuck(events, DEFAULT_CONFIG, NOW);
|
|
151
|
+
expect(analysis.evidence.map((item) => item.type)).not.toContain('idle_no_progress');
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe('detectStuck: zero thresholds disable rules', () => {
|
|
156
|
+
it('never flags llm repetition when llmRepeatThreshold is 0', () => {
|
|
157
|
+
const config = { ...DEFAULT_CONFIG, llmRepeatThreshold: 0 };
|
|
158
|
+
const analysis = detectStuck(llmRun('llm_output', 5, 'body-1'), config, NOW);
|
|
159
|
+
expect(analysis.likelyStuck).toBe(false);
|
|
160
|
+
expect(analysis.evidence).toEqual([]);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe('detectStuck: evidenceKey and empty buffer', () => {
|
|
165
|
+
it('is stable for the same input and differs across evidence sets', () => {
|
|
166
|
+
const repeated = llmRun('llm_output', 3, 'body-1');
|
|
167
|
+
const first = detectStuck(repeated, DEFAULT_CONFIG, NOW);
|
|
168
|
+
const second = detectStuck(repeated, DEFAULT_CONFIG, NOW);
|
|
169
|
+
const other = detectStuck(llmRun('llm_input', 3, 'body-2'), DEFAULT_CONFIG, NOW);
|
|
170
|
+
expect(first.evidenceKey).toBe(second.evidenceKey);
|
|
171
|
+
expect(first.evidenceKey).not.toBe(other.evidenceKey);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('returns not stuck for an empty buffer', () => {
|
|
175
|
+
const analysis = detectStuck([], DEFAULT_CONFIG, NOW);
|
|
176
|
+
expect(analysis.likelyStuck).toBe(false);
|
|
177
|
+
expect(analysis.evidenceKey).toBe('');
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe('shouldAlert', () => {
|
|
182
|
+
const stuck = detectStuck(llmRun('llm_output', 3, 'body-1'), DEFAULT_CONFIG, NOW);
|
|
183
|
+
const calm = detectStuck([], DEFAULT_CONFIG, NOW);
|
|
184
|
+
const COOLDOWN = 60;
|
|
185
|
+
|
|
186
|
+
it('never alerts when not stuck', () => {
|
|
187
|
+
expect(shouldAlert(undefined, calm, NOW, COOLDOWN)).toBe(false);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('alerts when there is no previous alert', () => {
|
|
191
|
+
expect(shouldAlert(undefined, stuck, NOW, COOLDOWN)).toBe(true);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('suppresses the same evidence during cooldown', () => {
|
|
195
|
+
const lastAlert = { at: NOW - 30_000, evidenceKey: stuck.evidenceKey };
|
|
196
|
+
expect(shouldAlert(lastAlert, stuck, NOW, COOLDOWN)).toBe(false);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('alerts for new evidence even during cooldown', () => {
|
|
200
|
+
const lastAlert = { at: NOW - 30_000, evidenceKey: 'other-key' };
|
|
201
|
+
expect(shouldAlert(lastAlert, stuck, NOW, COOLDOWN)).toBe(true);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('alerts again after cooldown expires', () => {
|
|
205
|
+
const lastAlert = { at: NOW - COOLDOWN * 1000 - 1, evidenceKey: stuck.evidenceKey };
|
|
206
|
+
expect(shouldAlert(lastAlert, stuck, NOW, COOLDOWN)).toBe(true);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('treats cooldownSec -1 as infinite: same evidence alerts only once', () => {
|
|
210
|
+
const lastAlert = { at: NOW - 999_999_999, evidenceKey: stuck.evidenceKey };
|
|
211
|
+
expect(shouldAlert(lastAlert, stuck, NOW, -1)).toBe(false);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('still alerts for new evidence when cooldownSec is -1', () => {
|
|
215
|
+
const lastAlert = { at: NOW - 30_000, evidenceKey: 'other-key' };
|
|
216
|
+
expect(shouldAlert(lastAlert, stuck, NOW, -1)).toBe(true);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('treats cooldownSec 0 (the default) as no cooldown: same evidence alerts every time', () => {
|
|
220
|
+
const lastAlert = { at: NOW - 1, evidenceKey: stuck.evidenceKey };
|
|
221
|
+
expect(shouldAlert(lastAlert, stuck, NOW, 0)).toBe(true);
|
|
222
|
+
expect(shouldAlert(lastAlert, stuck, NOW, DEFAULT_CONFIG.cooldownSec)).toBe(true);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { connectSubagents } from '../src/integrations/gotgenes-subagents.ts';
|
|
3
|
+
|
|
4
|
+
const SERVICE_KEY = Symbol.for('@gotgenes/pi-subagents:service');
|
|
5
|
+
const globalRecord = globalThis as Record<symbol, unknown>;
|
|
6
|
+
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
delete globalRecord[SERVICE_KEY];
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe('connectSubagents', () => {
|
|
12
|
+
it('reports unavailable when the service is not published', async () => {
|
|
13
|
+
const integration = await connectSubagents();
|
|
14
|
+
expect(integration.available).toBe(false);
|
|
15
|
+
if (!integration.available) {
|
|
16
|
+
expect(integration.reason).toContain('not published');
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('exposes listAgents when the service is published', async () => {
|
|
21
|
+
const records = [
|
|
22
|
+
{
|
|
23
|
+
id: 'a1',
|
|
24
|
+
type: 'Explore',
|
|
25
|
+
description: 'explore stuff',
|
|
26
|
+
status: 'running',
|
|
27
|
+
toolUses: 2,
|
|
28
|
+
startedAt: 1000,
|
|
29
|
+
},
|
|
30
|
+
];
|
|
31
|
+
globalRecord[SERVICE_KEY] = { listAgents: () => records, steer: async () => true };
|
|
32
|
+
|
|
33
|
+
const integration = await connectSubagents();
|
|
34
|
+
expect(integration.available).toBe(true);
|
|
35
|
+
if (integration.available) {
|
|
36
|
+
expect(integration.listAgents()).toEqual(records);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('exposes steer when the service is published', async () => {
|
|
41
|
+
const steered: Array<[string, string]> = [];
|
|
42
|
+
globalRecord[SERVICE_KEY] = {
|
|
43
|
+
listAgents: () => [],
|
|
44
|
+
steer: async (id: string, message: string) => {
|
|
45
|
+
steered.push([id, message]);
|
|
46
|
+
return true;
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const integration = await connectSubagents();
|
|
51
|
+
expect(integration.available).toBe(true);
|
|
52
|
+
if (integration.available) {
|
|
53
|
+
await expect(integration.steer('a1', 'wake up')).resolves.toBe(true);
|
|
54
|
+
expect(steered).toEqual([['a1', 'wake up']]);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
describeAssistantMessage,
|
|
4
|
+
describeSentPayload,
|
|
5
|
+
registerLmDebugWidget,
|
|
6
|
+
} from '../src/lmdebug.ts';
|
|
7
|
+
|
|
8
|
+
const NOW = new Date('2026-07-08T14:15:23').getTime();
|
|
9
|
+
|
|
10
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- bivariant fake for overloads
|
|
11
|
+
type Handler = (event: any, ctx: any) => void;
|
|
12
|
+
|
|
13
|
+
const createFakePi = () => {
|
|
14
|
+
const handlers = new Map<string, Handler[]>();
|
|
15
|
+
const widgets: Array<{ key: string; content: string[] | undefined; options: unknown }> = [];
|
|
16
|
+
const ctx = {
|
|
17
|
+
hasUI: true,
|
|
18
|
+
ui: {
|
|
19
|
+
setWidget: (key: string, content: string[] | undefined, options?: unknown) => {
|
|
20
|
+
widgets.push({ key, content, options });
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
return {
|
|
25
|
+
pi: {
|
|
26
|
+
on: (event: string, handler: Handler) => {
|
|
27
|
+
handlers.set(event, [...(handlers.get(event) ?? []), handler]);
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
emit: (event: string, payload: unknown) => {
|
|
31
|
+
for (const handler of handlers.get(event) ?? []) {
|
|
32
|
+
handler(payload, ctx);
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
widgets,
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
describe('describeSentPayload', () => {
|
|
40
|
+
it('summarizes the last message of an OpenAI-style payload', () => {
|
|
41
|
+
const text = describeSentPayload({
|
|
42
|
+
messages: [
|
|
43
|
+
{ role: 'system', content: 'sys' },
|
|
44
|
+
{ role: 'user', content: 'fix the bug in index.tsx' },
|
|
45
|
+
],
|
|
46
|
+
});
|
|
47
|
+
expect(text).toBe('#2 user:\nfix the bug in index.tsx');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('handles content parts arrays and missing payloads', () => {
|
|
51
|
+
expect(
|
|
52
|
+
describeSentPayload({ messages: [{ role: 'tool', content: [{ type: 'text', text: 'ok' }] }] }),
|
|
53
|
+
).toBe('#1 tool:\nok');
|
|
54
|
+
expect(describeSentPayload(undefined)).toBe('no messages in payload');
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('describeAssistantMessage', () => {
|
|
59
|
+
it('includes text and toolCall blocks with newlines preserved', () => {
|
|
60
|
+
const text = describeAssistantMessage({
|
|
61
|
+
role: 'assistant',
|
|
62
|
+
content: [
|
|
63
|
+
{ type: 'text', text: 'I will edit\nthe file' },
|
|
64
|
+
{ type: 'toolCall', name: 'edit', arguments: {} },
|
|
65
|
+
],
|
|
66
|
+
});
|
|
67
|
+
expect(text).toBe('assistant:\nI will edit\nthe file\n[toolCall edit]');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('returns undefined for non-assistant messages', () => {
|
|
71
|
+
expect(describeAssistantMessage({ role: 'user', content: [] })).toBeUndefined();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('registerLmDebugWidget', () => {
|
|
76
|
+
it('registers nothing when debug is false (the default)', () => {
|
|
77
|
+
const { pi, emit, widgets } = createFakePi();
|
|
78
|
+
registerLmDebugWidget(pi, false, () => NOW);
|
|
79
|
+
emit('before_provider_request', {
|
|
80
|
+
payload: { messages: [{ role: 'user', content: 'hello' }] },
|
|
81
|
+
});
|
|
82
|
+
expect(widgets).toHaveLength(0);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('renders full multi-line messages with timestamps into a belowEditor widget', () => {
|
|
86
|
+
const { pi, emit, widgets } = createFakePi();
|
|
87
|
+
registerLmDebugWidget(pi, true, () => NOW);
|
|
88
|
+
emit('before_provider_request', {
|
|
89
|
+
payload: { messages: [{ role: 'user', content: 'hello\nsecond line' }] },
|
|
90
|
+
});
|
|
91
|
+
emit('message_end', {
|
|
92
|
+
message: { role: 'assistant', content: [{ type: 'text', text: 'hi there\nmore detail' }] },
|
|
93
|
+
});
|
|
94
|
+
expect(widgets).toHaveLength(2);
|
|
95
|
+
expect(widgets[1].key).toBe('watchdog-lm-debug');
|
|
96
|
+
expect(widgets[1].options).toEqual({ placement: 'belowEditor' });
|
|
97
|
+
expect(widgets[1].content).toEqual([
|
|
98
|
+
'▲ sent 14:15:23 #1 user:',
|
|
99
|
+
'hello',
|
|
100
|
+
'second line',
|
|
101
|
+
'',
|
|
102
|
+
'▼ recv 14:15:23 assistant:',
|
|
103
|
+
'hi there',
|
|
104
|
+
'more detail',
|
|
105
|
+
]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('ignores non-assistant message_end events', () => {
|
|
109
|
+
const { pi, emit, widgets } = createFakePi();
|
|
110
|
+
registerLmDebugWidget(pi, true, () => NOW);
|
|
111
|
+
emit('message_end', { message: { role: 'toolResult', content: [] } });
|
|
112
|
+
expect(widgets).toHaveLength(0);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { hashOutput, normalizeCommand, normalizeLlmBody } from '../src/normalize.ts';
|
|
3
|
+
|
|
4
|
+
describe('normalizeCommand', () => {
|
|
5
|
+
it('strips ANSI escape codes', () => {
|
|
6
|
+
expect(normalizeCommand('\u001b[31mrg\u001b[0m "X" src')).toBe('rg "X" src');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('trims and collapses repeated whitespace', () => {
|
|
10
|
+
expect(normalizeCommand(' rg "X"\t src ')).toBe('rg "X" src');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('returns empty string for whitespace-only input', () => {
|
|
14
|
+
expect(normalizeCommand(' \t ')).toBe('');
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('normalizeLlmBody', () => {
|
|
19
|
+
it('makes bodies differing only in timestamps identical', () => {
|
|
20
|
+
const a = normalizeLlmBody('done at 2026-07-08T14:15:23.018Z, retry 10:15:46 PM');
|
|
21
|
+
const b = normalizeLlmBody('done at 2026-07-08T14:16:41.777Z, retry 10:17:02 PM');
|
|
22
|
+
expect(a).toBe(b);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('makes bodies differing only in ids and sequence numbers identical', () => {
|
|
26
|
+
const a = normalizeLlmBody('call tc-17 session 019f4213-974b-742e-883c-12f525463fb2 hash a1b2c3d4e5f6');
|
|
27
|
+
const b = normalizeLlmBody('call tc-42 session 22222222-974b-742e-883c-125254632222 hash ffffffffffff');
|
|
28
|
+
expect(a).toBe(b);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('keeps genuinely different bodies different', () => {
|
|
32
|
+
expect(normalizeLlmBody('read file a.ts')).not.toBe(normalizeLlmBody('edit file a.ts'));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('collapses whitespace and strips ANSI', () => {
|
|
36
|
+
expect(normalizeLlmBody('\u001b[31mok\u001b[0m done\n\n now')).toBe('ok done now');
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('hashOutput', () => {
|
|
41
|
+
it('is stable for identical input', () => {
|
|
42
|
+
const a = hashOutput('same output', { previewLines: 5 });
|
|
43
|
+
const b = hashOutput('same output', { previewLines: 5 });
|
|
44
|
+
expect(a.hash).toBe(b.hash);
|
|
45
|
+
expect(a.hash).toMatch(/^[0-9a-f]{64}$/);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('differs for different input', () => {
|
|
49
|
+
expect(hashOutput('one', { previewLines: 5 }).hash).not.toBe(
|
|
50
|
+
hashOutput('two', { previewLines: 5 }).hash,
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('strips ANSI before hashing', () => {
|
|
55
|
+
expect(hashOutput('\u001b[32mok\u001b[0m', { previewLines: 5 }).hash).toBe(
|
|
56
|
+
hashOutput('ok', { previewLines: 5 }).hash,
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('truncates to maxBytes before hashing', () => {
|
|
61
|
+
const long = 'x'.repeat(100);
|
|
62
|
+
expect(hashOutput(long, { maxBytes: 10, previewLines: 5 }).hash).toBe(
|
|
63
|
+
hashOutput(long.slice(0, 10), { previewLines: 5 }).hash,
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('limits preview to previewLines lines', () => {
|
|
68
|
+
const output = ['l1', 'l2', 'l3', 'l4'].join('\n');
|
|
69
|
+
const { preview } = hashOutput(output, { previewLines: 2 });
|
|
70
|
+
expect(preview).toBe('l1\nl2');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('handles empty input', () => {
|
|
74
|
+
const { hash, preview } = hashOutput('', { previewLines: 5 });
|
|
75
|
+
expect(hash).toMatch(/^[0-9a-f]{64}$/);
|
|
76
|
+
expect(preview).toBe('');
|
|
77
|
+
});
|
|
78
|
+
});
|