@monotykamary/pi-supervisor 0.5.9
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/CHANGELOG.md +120 -0
- package/LICENSE +21 -0
- package/README.md +341 -0
- package/media/demo.mp4 +0 -0
- package/media/screenshot.png +0 -0
- package/package.json +87 -0
- package/src/compaction/brief.ts +841 -0
- package/src/compaction/build-sections.ts +340 -0
- package/src/compaction/causal-keys.ts +138 -0
- package/src/compaction/content.ts +68 -0
- package/src/compaction/extract/commits.ts +78 -0
- package/src/compaction/extract/goals.ts +79 -0
- package/src/compaction/extract/preferences.ts +52 -0
- package/src/compaction/extract/shared-symbols.ts +376 -0
- package/src/compaction/filter-noise.ts +47 -0
- package/src/compaction/format.ts +89 -0
- package/src/compaction/index.ts +38 -0
- package/src/compaction/normalize.ts +73 -0
- package/src/compaction/sanitize.ts +5 -0
- package/src/compaction/sections.ts +19 -0
- package/src/compaction/skill-collapse.ts +35 -0
- package/src/compaction/tool-args.ts +14 -0
- package/src/compaction/types.ts +26 -0
- package/src/core/analyzer.ts +58 -0
- package/src/core/index.ts +8 -0
- package/src/core/inference.ts +77 -0
- package/src/core/prompt-builder.ts +137 -0
- package/src/core/prompt-loader.ts +125 -0
- package/src/core/reframe.ts +27 -0
- package/src/fabric-provider.ts +115 -0
- package/src/global-config.ts +65 -0
- package/src/index.ts +514 -0
- package/src/session/client.ts +46 -0
- package/src/session/response-parser.ts +37 -0
- package/src/session/supervisor-session.ts +102 -0
- package/src/state/manager.ts +133 -0
- package/src/state/mid-run-signals.ts +103 -0
- package/src/state/patterns.ts +82 -0
- package/src/state/reframe.ts +27 -0
- package/src/subagent-detector.ts +94 -0
- package/src/types.ts +42 -0
- package/src/ui/animations.ts +95 -0
- package/src/ui/model-picker.ts +72 -0
- package/src/ui/model-settings-selector.ts +440 -0
- package/src/ui/model-sort.ts +101 -0
- package/src/ui/renderer.ts +314 -0
- package/src/ui/types.ts +48 -0
- package/tests/compaction.test.ts +507 -0
- package/tests/engine.test.ts +622 -0
- package/tests/ephemeral-supervision.test.ts +347 -0
- package/tests/fabric-provider.test.ts +55 -0
- package/tests/full-fidelity-snapshot.test.ts +250 -0
- package/tests/global-config.test.ts +74 -0
- package/tests/model-sort.test.ts +157 -0
- package/tests/parsing.test.ts +303 -0
- package/tests/state.test.ts +474 -0
- package/tests/status-widget.test.ts +539 -0
- package/tests/subagent-detector.test.ts +191 -0
- package/tests/supervise-command.test.ts +363 -0
- package/tests/supervise-model-command.test.ts +184 -0
- package/tsconfig.json +14 -0
- package/vitest.config.ts +15 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { loadGlobalModel, saveGlobalModel } from '../src/global-config.js';
|
|
6
|
+
|
|
7
|
+
describe('global-config', () => {
|
|
8
|
+
let tmp: string;
|
|
9
|
+
let cwdSpy: ReturnType<typeof vi.spyOn>;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
tmp = mkdtempSync(join(tmpdir(), 'pi-supervisor-cfg-'));
|
|
13
|
+
cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(tmp);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
cwdSpy.mockRestore();
|
|
18
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('loadGlobalModel returns null when no config exists', () => {
|
|
22
|
+
expect(loadGlobalModel()).toBeNull();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('saveGlobalModel returns the written config path', () => {
|
|
26
|
+
const path = saveGlobalModel(tmp, { provider: 'openai', modelId: 'gpt-4o' });
|
|
27
|
+
expect(path).toBe(join(tmp, '.pi', 'supervisor-config.json'));
|
|
28
|
+
expect(existsSync(path)).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('loadGlobalModel reads back what saveGlobalModel wrote', () => {
|
|
32
|
+
saveGlobalModel(tmp, { provider: 'anthropic', modelId: 'claude-sonnet-4' });
|
|
33
|
+
expect(loadGlobalModel()).toEqual({ provider: 'anthropic', modelId: 'claude-sonnet-4' });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('saveGlobalModel creates the .pi directory when missing', () => {
|
|
37
|
+
expect(existsSync(join(tmp, '.pi'))).toBe(false);
|
|
38
|
+
saveGlobalModel(tmp, { provider: 'openai', modelId: 'gpt-4.1' });
|
|
39
|
+
expect(existsSync(join(tmp, '.pi'))).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('saveGlobalModel preserves other keys already present in the config', () => {
|
|
43
|
+
const configPath = join(tmp, '.pi', 'supervisor-config.json');
|
|
44
|
+
mkdirSync(join(tmp, '.pi'), { recursive: true });
|
|
45
|
+
writeFileSync(configPath, JSON.stringify({ otherKey: 'keep' }, null, 2));
|
|
46
|
+
|
|
47
|
+
saveGlobalModel(tmp, { provider: 'openai', modelId: 'gpt-4o' });
|
|
48
|
+
|
|
49
|
+
const parsed = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
50
|
+
expect(parsed.otherKey).toBe('keep');
|
|
51
|
+
expect(parsed.model).toEqual({ provider: 'openai', modelId: 'gpt-4o' });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('saveGlobalModel overwrites a previously saved model', () => {
|
|
55
|
+
saveGlobalModel(tmp, { provider: 'openai', modelId: 'gpt-4o' });
|
|
56
|
+
saveGlobalModel(tmp, { provider: 'anthropic', modelId: 'claude-haiku' });
|
|
57
|
+
expect(loadGlobalModel()).toEqual({ provider: 'anthropic', modelId: 'claude-haiku' });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('loadGlobalModel returns null for an invalid JSON config', () => {
|
|
61
|
+
mkdirSync(join(tmp, '.pi'), { recursive: true });
|
|
62
|
+
writeFileSync(join(tmp, '.pi', 'supervisor-config.json'), '{ not json');
|
|
63
|
+
expect(loadGlobalModel()).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('loadGlobalModel returns null when the model field is incomplete', () => {
|
|
67
|
+
mkdirSync(join(tmp, '.pi'), { recursive: true });
|
|
68
|
+
writeFileSync(
|
|
69
|
+
join(tmp, '.pi', 'supervisor-config.json'),
|
|
70
|
+
JSON.stringify({ model: { provider: 'openai' } })
|
|
71
|
+
);
|
|
72
|
+
expect(loadGlobalModel()).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// Mock node:fs so readModelSortLastUsed can be tested in isolation.
|
|
4
|
+
vi.mock('node:fs', () => ({
|
|
5
|
+
existsSync: vi.fn(),
|
|
6
|
+
readFileSync: vi.fn(),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
10
|
+
import {
|
|
11
|
+
buildModelKey,
|
|
12
|
+
hasUsageData,
|
|
13
|
+
readModelSortLastUsed,
|
|
14
|
+
sortByLastUsed,
|
|
15
|
+
} from '../src/ui/model-sort.js';
|
|
16
|
+
|
|
17
|
+
const mockedExists = vi.mocked(existsSync);
|
|
18
|
+
const mockedRead = vi.mocked(readFileSync);
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
vi.clearAllMocks();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('buildModelKey', () => {
|
|
25
|
+
it('builds a key from provider and model id', () => {
|
|
26
|
+
expect(buildModelKey('anthropic', 'claude-sonnet-4')).toBe('anthropic/claude-sonnet-4');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('preserves slashes in model ids (e.g. openrouter proxy ids)', () => {
|
|
30
|
+
expect(buildModelKey('openrouter', 'anthropic/claude-sonnet-4')).toBe(
|
|
31
|
+
'openrouter/anthropic/claude-sonnet-4'
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('hasUsageData', () => {
|
|
37
|
+
it('returns false for null', () => {
|
|
38
|
+
expect(hasUsageData(null)).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('returns false for an empty map', () => {
|
|
42
|
+
expect(hasUsageData({})).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('returns true for a non-empty map', () => {
|
|
46
|
+
expect(hasUsageData({ 'openai/gpt-4o': 1 })).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('readModelSortLastUsed', () => {
|
|
51
|
+
it('returns null when the config file does not exist', () => {
|
|
52
|
+
mockedExists.mockReturnValue(false);
|
|
53
|
+
expect(readModelSortLastUsed()).toBeNull();
|
|
54
|
+
expect(mockedRead).not.toHaveBeenCalled();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('returns null when the config is not valid JSON', () => {
|
|
58
|
+
mockedExists.mockReturnValue(true);
|
|
59
|
+
mockedRead.mockReturnValue('not json');
|
|
60
|
+
expect(readModelSortLastUsed()).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('returns null when lastUsed is missing', () => {
|
|
64
|
+
mockedExists.mockReturnValue(true);
|
|
65
|
+
mockedRead.mockReturnValue(JSON.stringify({}));
|
|
66
|
+
expect(readModelSortLastUsed()).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('returns null when lastUsed is not an object', () => {
|
|
70
|
+
mockedExists.mockReturnValue(true);
|
|
71
|
+
mockedRead.mockReturnValue(JSON.stringify({ lastUsed: 'nope' }));
|
|
72
|
+
expect(readModelSortLastUsed()).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('returns the lastUsed map when present', () => {
|
|
76
|
+
const lastUsed = { 'openai/gpt-4o': 100, 'anthropic/claude-sonnet-4': 200 };
|
|
77
|
+
mockedExists.mockReturnValue(true);
|
|
78
|
+
mockedRead.mockReturnValue(JSON.stringify({ lastUsed }));
|
|
79
|
+
expect(readModelSortLastUsed()).toEqual(lastUsed);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('sortByLastUsed', () => {
|
|
84
|
+
const models = [
|
|
85
|
+
{ provider: 'anthropic', id: 'claude-opus-4' },
|
|
86
|
+
{ provider: 'anthropic', id: 'claude-sonnet-4' },
|
|
87
|
+
{ provider: 'openai', id: 'gpt-4o' },
|
|
88
|
+
{ provider: 'google', id: 'gemini-2.5-pro' },
|
|
89
|
+
{ provider: 'openai', id: 'gpt-4.1' },
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
it('sorts by last-used descending when all have timestamps', () => {
|
|
93
|
+
const lastUsed = {
|
|
94
|
+
'google/gemini-2.5-pro': 300,
|
|
95
|
+
'openai/gpt-4.1': 500,
|
|
96
|
+
'openai/gpt-4o': 100,
|
|
97
|
+
'anthropic/claude-sonnet-4': 400,
|
|
98
|
+
'anthropic/claude-opus-4': 200,
|
|
99
|
+
};
|
|
100
|
+
const sorted = sortByLastUsed(models, lastUsed, null);
|
|
101
|
+
expect(sorted.map((m) => `${m.provider}/${m.id}`)).toEqual([
|
|
102
|
+
'openai/gpt-4.1',
|
|
103
|
+
'anthropic/claude-sonnet-4',
|
|
104
|
+
'google/gemini-2.5-pro',
|
|
105
|
+
'anthropic/claude-opus-4',
|
|
106
|
+
'openai/gpt-4o',
|
|
107
|
+
]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('puts the current model first, ahead of more-recent models', () => {
|
|
111
|
+
const lastUsed = {
|
|
112
|
+
'openai/gpt-4.1': 500,
|
|
113
|
+
'openai/gpt-4o': 100,
|
|
114
|
+
};
|
|
115
|
+
const sorted = sortByLastUsed(models, lastUsed, 'google/gemini-2.5-pro');
|
|
116
|
+
expect(sorted[0]).toEqual({ provider: 'google', id: 'gemini-2.5-pro' });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('falls back to provider/id alphabetical for equal or missing timestamps', () => {
|
|
120
|
+
const lastUsed: Record<string, number> = {};
|
|
121
|
+
const sorted = sortByLastUsed(models, lastUsed, null);
|
|
122
|
+
expect(sorted.map((m) => `${m.provider}/${m.id}`)).toEqual([
|
|
123
|
+
'anthropic/claude-opus-4',
|
|
124
|
+
'anthropic/claude-sonnet-4',
|
|
125
|
+
'google/gemini-2.5-pro',
|
|
126
|
+
'openai/gpt-4.1',
|
|
127
|
+
'openai/gpt-4o',
|
|
128
|
+
]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('sorts unused models last, alphabetically within the unused group', () => {
|
|
132
|
+
const lastUsed = {
|
|
133
|
+
'openai/gpt-4o': 500,
|
|
134
|
+
};
|
|
135
|
+
const sorted = sortByLastUsed(models, lastUsed, null);
|
|
136
|
+
expect(sorted.map((m) => `${m.provider}/${m.id}`)).toEqual([
|
|
137
|
+
'openai/gpt-4o',
|
|
138
|
+
'anthropic/claude-opus-4',
|
|
139
|
+
'anthropic/claude-sonnet-4',
|
|
140
|
+
'google/gemini-2.5-pro',
|
|
141
|
+
'openai/gpt-4.1',
|
|
142
|
+
]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('does not mutate the input array', () => {
|
|
146
|
+
const lastUsed = { 'openai/gpt-4o': 100 };
|
|
147
|
+
const input = models.map((m) => ({ ...m }));
|
|
148
|
+
sortByLastUsed(models, lastUsed, null);
|
|
149
|
+
expect(models).toEqual(input);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('returns the same length as the input', () => {
|
|
153
|
+
const lastUsed = { 'openai/gpt-4o': 100 };
|
|
154
|
+
const sorted = sortByLastUsed(models, lastUsed, null);
|
|
155
|
+
expect(sorted).toHaveLength(models.length);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { parseDecision } from '../src/session/response-parser.js';
|
|
3
|
+
import { extractThinking } from '../src/index.js';
|
|
4
|
+
|
|
5
|
+
describe('parseDecision', () => {
|
|
6
|
+
it('parses valid continue response', () => {
|
|
7
|
+
const text = JSON.stringify({
|
|
8
|
+
action: 'continue',
|
|
9
|
+
reasoning: 'Making good progress',
|
|
10
|
+
confidence: 0.9,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const result = parseDecision(text);
|
|
14
|
+
expect(result.action).toBe('continue');
|
|
15
|
+
expect(result.reasoning).toBe('Making good progress');
|
|
16
|
+
expect(result.confidence).toBe(0.9);
|
|
17
|
+
expect(result.message).toBeUndefined();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('parses valid steer response', () => {
|
|
21
|
+
const text = JSON.stringify({
|
|
22
|
+
action: 'steer',
|
|
23
|
+
message: 'Focus on the tests first',
|
|
24
|
+
reasoning: 'Agent is skipping tests',
|
|
25
|
+
confidence: 0.95,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const result = parseDecision(text);
|
|
29
|
+
expect(result.action).toBe('steer');
|
|
30
|
+
expect(result.message).toBe('Focus on the tests first');
|
|
31
|
+
expect(result.reasoning).toBe('Agent is skipping tests');
|
|
32
|
+
expect(result.confidence).toBe(0.95);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('parses valid done response', () => {
|
|
36
|
+
const text = JSON.stringify({
|
|
37
|
+
action: 'done',
|
|
38
|
+
reasoning: 'Goal achieved',
|
|
39
|
+
confidence: 0.99,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const result = parseDecision(text);
|
|
43
|
+
expect(result.action).toBe('done');
|
|
44
|
+
expect(result.reasoning).toBe('Goal achieved');
|
|
45
|
+
expect(result.confidence).toBe(0.99);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('extracts JSON from markdown code block', () => {
|
|
49
|
+
const json = JSON.stringify({
|
|
50
|
+
action: 'steer',
|
|
51
|
+
message: 'Fix the error',
|
|
52
|
+
reasoning: 'Test failed',
|
|
53
|
+
confidence: 0.85,
|
|
54
|
+
});
|
|
55
|
+
const text = '```json\n' + json + '\n```';
|
|
56
|
+
|
|
57
|
+
const result = parseDecision(text);
|
|
58
|
+
expect(result.action).toBe('steer');
|
|
59
|
+
expect(result.message).toBe('Fix the error');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('extracts JSON from plain code block', () => {
|
|
63
|
+
const json = JSON.stringify({
|
|
64
|
+
action: 'continue',
|
|
65
|
+
reasoning: 'On track',
|
|
66
|
+
confidence: 0.8,
|
|
67
|
+
});
|
|
68
|
+
const text = '```\n' + json + '\n```';
|
|
69
|
+
|
|
70
|
+
const result = parseDecision(text);
|
|
71
|
+
expect(result.action).toBe('continue');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('extracts JSON from curly braces when no code block', () => {
|
|
75
|
+
const text =
|
|
76
|
+
'Some text before { "action": "steer", "message": "Help", "reasoning": "Test", "confidence": 0.7 } some after';
|
|
77
|
+
|
|
78
|
+
const result = parseDecision(text);
|
|
79
|
+
expect(result.action).toBe('steer');
|
|
80
|
+
expect(result.message).toBe('Help');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('returns continue on invalid JSON', () => {
|
|
84
|
+
const text = 'not valid json at all';
|
|
85
|
+
|
|
86
|
+
const result = parseDecision(text);
|
|
87
|
+
expect(result.action).toBe('continue');
|
|
88
|
+
expect(result.reasoning).toBe('Failed to parse supervisor JSON decision');
|
|
89
|
+
expect(result.confidence).toBe(0);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('returns continue on invalid action', () => {
|
|
93
|
+
const text = JSON.stringify({
|
|
94
|
+
action: 'invalid',
|
|
95
|
+
reasoning: 'Something',
|
|
96
|
+
confidence: 0.5,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const result = parseDecision(text);
|
|
100
|
+
expect(result.action).toBe('continue');
|
|
101
|
+
expect(result.reasoning).toBe('Invalid action in supervisor response');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('handles missing fields with defaults', () => {
|
|
105
|
+
const text = JSON.stringify({
|
|
106
|
+
action: 'continue',
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const result = parseDecision(text);
|
|
110
|
+
expect(result.action).toBe('continue');
|
|
111
|
+
expect(result.reasoning).toBe('');
|
|
112
|
+
expect(result.confidence).toBe(0.5);
|
|
113
|
+
expect(result.message).toBeUndefined();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('trims message whitespace', () => {
|
|
117
|
+
const text = JSON.stringify({
|
|
118
|
+
action: 'steer',
|
|
119
|
+
message: ' Message with whitespace ',
|
|
120
|
+
reasoning: 'Test',
|
|
121
|
+
confidence: 0.8,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const result = parseDecision(text);
|
|
125
|
+
expect(result.message).toBe('Message with whitespace');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('handles escaped quotes in reasoning', () => {
|
|
129
|
+
const text = JSON.stringify({
|
|
130
|
+
action: 'steer',
|
|
131
|
+
message: 'Say "hello"',
|
|
132
|
+
reasoning: 'The agent said "test"',
|
|
133
|
+
confidence: 0.9,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const result = parseDecision(text);
|
|
137
|
+
expect(result.message).toBe('Say "hello"');
|
|
138
|
+
expect(result.reasoning).toBe('The agent said "test"');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('parses ASI from steer response', () => {
|
|
142
|
+
const text = JSON.stringify({
|
|
143
|
+
action: 'steer',
|
|
144
|
+
message: 'Focus on tests',
|
|
145
|
+
reasoning: 'Agent drifting',
|
|
146
|
+
confidence: 0.9,
|
|
147
|
+
asi: {
|
|
148
|
+
why_stuck: 'refactoring without tests',
|
|
149
|
+
strategy_used: 'directive',
|
|
150
|
+
pattern_detected: 'test_skipping',
|
|
151
|
+
confidence_source: 'no test files added',
|
|
152
|
+
would_escalate_sooner: false,
|
|
153
|
+
custom_key: 'custom_value',
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const result = parseDecision(text);
|
|
158
|
+
expect(result.asi).toBeDefined();
|
|
159
|
+
expect(result.asi!.why_stuck).toBe('refactoring without tests');
|
|
160
|
+
expect(result.asi!.strategy_used).toBe('directive');
|
|
161
|
+
expect(result.asi!.pattern_detected).toBe('test_skipping');
|
|
162
|
+
expect(result.asi!.would_escalate_sooner).toBe(false);
|
|
163
|
+
expect(result.asi!.custom_key).toBe('custom_value');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('handles missing ASI gracefully', () => {
|
|
167
|
+
const text = JSON.stringify({
|
|
168
|
+
action: 'steer',
|
|
169
|
+
message: 'Focus',
|
|
170
|
+
reasoning: 'Test',
|
|
171
|
+
confidence: 0.8,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const result = parseDecision(text);
|
|
175
|
+
expect(result.asi).toBeUndefined();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('handles ASI with only partial fields', () => {
|
|
179
|
+
const text = JSON.stringify({
|
|
180
|
+
action: 'done',
|
|
181
|
+
reasoning: 'Complete',
|
|
182
|
+
confidence: 0.99,
|
|
183
|
+
asi: {
|
|
184
|
+
why_stuck: 'already done',
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const result = parseDecision(text);
|
|
189
|
+
expect(result.asi).toBeDefined();
|
|
190
|
+
expect(result.asi!.why_stuck).toBe('already done');
|
|
191
|
+
expect(result.asi!.strategy_used).toBeUndefined();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('parses free-form ASI with arbitrary keys for cheating detection', () => {
|
|
195
|
+
const text = JSON.stringify({
|
|
196
|
+
action: 'steer',
|
|
197
|
+
message: 'Verify your claim',
|
|
198
|
+
reasoning: 'Suspicious unverified claim',
|
|
199
|
+
confidence: 0.9,
|
|
200
|
+
asi: {
|
|
201
|
+
suspicious_claim_detected: true,
|
|
202
|
+
claim_type: 'unverified_test_success',
|
|
203
|
+
evidence: 'agent_said_tests_pass_but_exit_code_1',
|
|
204
|
+
previous_contradiction_turn: 3,
|
|
205
|
+
watch_for: 'fake_test_results',
|
|
206
|
+
custom_observation: 'agent_removed_assertions_in_same_turn',
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const result = parseDecision(text);
|
|
211
|
+
expect(result.asi).toBeDefined();
|
|
212
|
+
expect(result.asi!.suspicious_claim_detected).toBe(true);
|
|
213
|
+
expect(result.asi!.claim_type).toBe('unverified_test_success');
|
|
214
|
+
expect(result.asi!.evidence).toBe('agent_said_tests_pass_but_exit_code_1');
|
|
215
|
+
expect(result.asi!.previous_contradiction_turn).toBe(3);
|
|
216
|
+
expect(result.asi!.watch_for).toBe('fake_test_results');
|
|
217
|
+
expect(result.asi!.custom_observation).toBe('agent_removed_assertions_in_same_turn');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('parses ASI with cheating-related keywords', () => {
|
|
221
|
+
const text = JSON.stringify({
|
|
222
|
+
action: 'steer',
|
|
223
|
+
message: 'Do not manipulate tests',
|
|
224
|
+
reasoning: 'Test manipulation detected',
|
|
225
|
+
confidence: 0.95,
|
|
226
|
+
asi: {
|
|
227
|
+
pattern: 'test_manipulation',
|
|
228
|
+
indicator: 'fake',
|
|
229
|
+
observation: 'agent_gaming_the_metrics',
|
|
230
|
+
status: 'unverified_claim',
|
|
231
|
+
severity: 'high',
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const result = parseDecision(text);
|
|
236
|
+
expect(result.asi).toBeDefined();
|
|
237
|
+
expect(result.asi!.pattern).toBe('test_manipulation');
|
|
238
|
+
expect(result.asi!.indicator).toBe('fake');
|
|
239
|
+
expect(result.asi!.observation).toBe('agent_gaming_the_metrics');
|
|
240
|
+
expect(result.asi!.status).toBe('unverified_claim');
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe('extractThinking', () => {
|
|
245
|
+
it('returns empty string when no reasoning key', () => {
|
|
246
|
+
const text = '{ "action": "continue" }';
|
|
247
|
+
expect(extractThinking(text)).toBe('');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('extracts reasoning from complete JSON', () => {
|
|
251
|
+
const text = JSON.stringify({
|
|
252
|
+
action: 'steer',
|
|
253
|
+
message: 'Focus',
|
|
254
|
+
reasoning: 'The agent is drifting from the goal',
|
|
255
|
+
confidence: 0.9,
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
expect(extractThinking(text)).toBe('The agent is drifting from the goal');
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('extracts reasoning from streaming partial JSON (no closing quote)', () => {
|
|
262
|
+
const text = '{ "action": "steer", "reasoning": "The agent is working on';
|
|
263
|
+
|
|
264
|
+
expect(extractThinking(text)).toBe('The agent is working on');
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('extracts reasoning with spaces around colon', () => {
|
|
268
|
+
const text = '{ "reasoning" : "Test reasoning" }';
|
|
269
|
+
|
|
270
|
+
expect(extractThinking(text)).toBe('Test reasoning');
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('returns empty string for malformed reasoning key', () => {
|
|
274
|
+
const text = '{ "reasoning": 123 }'; // Not a string
|
|
275
|
+
|
|
276
|
+
expect(extractThinking(text)).toBe('');
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('handles escaped newlines in reasoning', () => {
|
|
280
|
+
const text = JSON.stringify({
|
|
281
|
+
reasoning: 'Line 1\nLine 2',
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
expect(extractThinking(text)).toBe('Line 1 Line 2');
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('handles escaped quotes in reasoning', () => {
|
|
288
|
+
const text = '{ "reasoning": "The agent said \\"test\\" here" }';
|
|
289
|
+
|
|
290
|
+
expect(extractThinking(text)).toBe('The agent said "test" here');
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('extracts reasoning that appears later in the JSON', () => {
|
|
294
|
+
const text =
|
|
295
|
+
'{ "action": "steer", "confidence": 0.9, "reasoning": "Late reasoning", "message": "Hi" }';
|
|
296
|
+
|
|
297
|
+
expect(extractThinking(text)).toBe('Late reasoning');
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('returns empty string for empty input', () => {
|
|
301
|
+
expect(extractThinking('')).toBe('');
|
|
302
|
+
});
|
|
303
|
+
});
|