@iservu-inc/adf-cli 0.17.1 → 0.18.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/.project/chats/current/SESSION-STATUS.md +29 -27
- package/.project/docs/ROADMAP.md +74 -64
- package/CHANGELOG.md +78 -0
- package/CLAUDE.md +1 -1
- package/README.md +63 -27
- package/bin/adf.js +54 -0
- package/lib/analysis/dynamic-pipeline.js +26 -0
- package/lib/analysis/knowledge-graph.js +66 -0
- package/lib/commands/deploy.js +35 -0
- package/lib/commands/harness.js +345 -0
- package/lib/commands/init.js +135 -10
- package/lib/frameworks/interviewer.js +130 -0
- package/lib/frameworks/progress-tracker.js +30 -1
- package/lib/frameworks/session-manager.js +76 -0
- package/lib/harness/context-window-manager.js +255 -0
- package/lib/harness/event-logger.js +115 -0
- package/lib/harness/feature-manifest.js +175 -0
- package/lib/harness/headless-adapter.js +184 -0
- package/lib/harness/milestone-tracker.js +183 -0
- package/lib/harness/protocol.js +503 -0
- package/lib/harness/provider-bridge.js +226 -0
- package/lib/harness/run-manager.js +267 -0
- package/lib/templates/scripts/analyze-docs.js +12 -1
- package/lib/utils/context-extractor.js +48 -0
- package/lib/utils/framework-detector.js +10 -1
- package/lib/utils/project-detector.js +5 -1
- package/lib/utils/tool-detector.js +167 -0
- package/lib/utils/tool-feature-registry.js +82 -13
- package/lib/utils/tool-recommender.js +325 -0
- package/package.json +1 -1
- package/tests/context-extractor.test.js +45 -0
- package/tests/framework-detector.test.js +28 -0
- package/tests/harness-backward-compat.test.js +251 -0
- package/tests/harness-context-window.test.js +310 -0
- package/tests/harness-event-logger.test.js +148 -0
- package/tests/harness-feature-manifest.test.js +124 -0
- package/tests/harness-headless-adapter.test.js +196 -0
- package/tests/harness-integration.test.js +207 -0
- package/tests/harness-milestone-tracker.test.js +158 -0
- package/tests/harness-protocol.test.js +341 -0
- package/tests/harness-provider-bridge.test.js +180 -0
- package/tests/harness-provider-switch.test.js +204 -0
- package/tests/harness-run-manager.test.js +131 -0
- package/tests/tool-detector.test.js +152 -0
- package/tests/tool-recommender.test.js +218 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
const fs = require('fs-extra');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const MilestoneTracker = require('../lib/harness/milestone-tracker');
|
|
4
|
+
|
|
5
|
+
describe('MilestoneTracker', () => {
|
|
6
|
+
const tempDir = path.join(__dirname, 'temp-milestone-test');
|
|
7
|
+
let tracker;
|
|
8
|
+
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
await fs.ensureDir(tempDir);
|
|
11
|
+
tracker = new MilestoneTracker(tempDir);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(async () => {
|
|
15
|
+
await fs.remove(tempDir);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const sampleBlocks = [
|
|
19
|
+
{
|
|
20
|
+
title: 'Project Overview',
|
|
21
|
+
number: 1,
|
|
22
|
+
questions: [{ id: 'q1' }, { id: 'q2' }]
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
title: 'Technical Architecture',
|
|
26
|
+
number: 2,
|
|
27
|
+
questions: [{ id: 'q3' }, { id: 'q4' }, { id: 'q5' }]
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
title: 'Deployment',
|
|
31
|
+
number: 3,
|
|
32
|
+
questions: [{ id: 'q6' }]
|
|
33
|
+
}
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
test('should generate milestones from blocks', async () => {
|
|
37
|
+
const milestones = await tracker.generateFromBlocks(sampleBlocks);
|
|
38
|
+
expect(milestones.length).toBe(3);
|
|
39
|
+
expect(milestones[0].title).toBe('Project Overview');
|
|
40
|
+
expect(milestones[0].questionsInScope).toEqual(['q1', 'q2']);
|
|
41
|
+
expect(milestones[1].questionsInScope).toEqual(['q3', 'q4', 'q5']);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('should track question completion', async () => {
|
|
45
|
+
await tracker.generateFromBlocks(sampleBlocks);
|
|
46
|
+
|
|
47
|
+
const ms = await tracker.completeQuestion('q1');
|
|
48
|
+
expect(ms.questionsCompleted).toContain('q1');
|
|
49
|
+
expect(ms.percentage).toBe(50);
|
|
50
|
+
expect(ms.status).toBe('in_progress');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('should complete milestone when all questions done', async () => {
|
|
54
|
+
await tracker.generateFromBlocks(sampleBlocks);
|
|
55
|
+
|
|
56
|
+
await tracker.completeQuestion('q1');
|
|
57
|
+
const ms = await tracker.completeQuestion('q2');
|
|
58
|
+
expect(ms.status).toBe('completed');
|
|
59
|
+
expect(ms.percentage).toBe(100);
|
|
60
|
+
expect(ms.completedAt).toBeDefined();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('should not duplicate completed questions', async () => {
|
|
64
|
+
await tracker.generateFromBlocks(sampleBlocks);
|
|
65
|
+
|
|
66
|
+
await tracker.completeQuestion('q1');
|
|
67
|
+
await tracker.completeQuestion('q1');
|
|
68
|
+
const ms = tracker.getAll()[0];
|
|
69
|
+
expect(ms.questionsCompleted.length).toBe(1);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('should skip milestone', async () => {
|
|
73
|
+
await tracker.generateFromBlocks(sampleBlocks);
|
|
74
|
+
|
|
75
|
+
const ms = await tracker.skipMilestone(1);
|
|
76
|
+
expect(ms.status).toBe('skipped');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('should throw on invalid skip index', async () => {
|
|
80
|
+
await tracker.generateFromBlocks(sampleBlocks);
|
|
81
|
+
await expect(tracker.skipMilestone(10)).rejects.toThrow('Invalid milestone index');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('should get current milestone', async () => {
|
|
85
|
+
await tracker.generateFromBlocks(sampleBlocks);
|
|
86
|
+
|
|
87
|
+
const current = tracker.getCurrentMilestone();
|
|
88
|
+
expect(current.title).toBe('Project Overview');
|
|
89
|
+
|
|
90
|
+
// Complete first milestone
|
|
91
|
+
await tracker.completeQuestion('q1');
|
|
92
|
+
await tracker.completeQuestion('q2');
|
|
93
|
+
|
|
94
|
+
const next = tracker.getCurrentMilestone();
|
|
95
|
+
expect(next.title).toBe('Technical Architecture');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('should return null when all milestones complete', async () => {
|
|
99
|
+
await tracker.generateFromBlocks([
|
|
100
|
+
{ title: 'Only Block', number: 1, questions: [{ id: 'q1' }] }
|
|
101
|
+
]);
|
|
102
|
+
|
|
103
|
+
await tracker.completeQuestion('q1');
|
|
104
|
+
expect(tracker.getCurrentMilestone()).toBeNull();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('should calculate overall progress', async () => {
|
|
108
|
+
await tracker.generateFromBlocks(sampleBlocks);
|
|
109
|
+
|
|
110
|
+
let progress = tracker.getProgress();
|
|
111
|
+
expect(progress.total).toBe(3);
|
|
112
|
+
expect(progress.completed).toBe(0);
|
|
113
|
+
expect(progress.pending).toBe(3);
|
|
114
|
+
expect(progress.percentage).toBe(0);
|
|
115
|
+
|
|
116
|
+
// Complete first milestone
|
|
117
|
+
await tracker.completeQuestion('q1');
|
|
118
|
+
await tracker.completeQuestion('q2');
|
|
119
|
+
|
|
120
|
+
progress = tracker.getProgress();
|
|
121
|
+
expect(progress.completed).toBe(1);
|
|
122
|
+
expect(progress.percentage).toBe(33);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('should provide handoff info', async () => {
|
|
126
|
+
await tracker.generateFromBlocks(sampleBlocks);
|
|
127
|
+
await tracker.completeQuestion('q1');
|
|
128
|
+
|
|
129
|
+
const info = tracker.getHandoffInfo();
|
|
130
|
+
expect(info.title).toBe('Project Overview');
|
|
131
|
+
expect(info.percentage).toBe(50);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('should save and load milestones', async () => {
|
|
135
|
+
await tracker.generateFromBlocks(sampleBlocks);
|
|
136
|
+
await tracker.completeQuestion('q1');
|
|
137
|
+
|
|
138
|
+
// Create new tracker and load
|
|
139
|
+
const tracker2 = new MilestoneTracker(tempDir);
|
|
140
|
+
const loaded = await tracker2.load();
|
|
141
|
+
expect(loaded).toBe(true);
|
|
142
|
+
|
|
143
|
+
const milestones = tracker2.getAll();
|
|
144
|
+
expect(milestones.length).toBe(3);
|
|
145
|
+
expect(milestones[0].questionsCompleted).toContain('q1');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('should return false when no milestones to load', async () => {
|
|
149
|
+
const loaded = await tracker.load();
|
|
150
|
+
expect(loaded).toBe(false);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('should return null for unknown question', async () => {
|
|
154
|
+
await tracker.generateFromBlocks(sampleBlocks);
|
|
155
|
+
const result = await tracker.completeQuestion('q_unknown');
|
|
156
|
+
expect(result).toBeNull();
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
const {
|
|
2
|
+
HarnessRun,
|
|
3
|
+
ContextWindow,
|
|
4
|
+
HandoffPackage,
|
|
5
|
+
Milestone,
|
|
6
|
+
Event,
|
|
7
|
+
FeatureManifest,
|
|
8
|
+
ProviderCapability,
|
|
9
|
+
PROVIDER_CAPABILITIES,
|
|
10
|
+
VALID_RUN_STATUSES,
|
|
11
|
+
VALID_EVENT_TYPES
|
|
12
|
+
} = require('../lib/harness/protocol');
|
|
13
|
+
|
|
14
|
+
describe('Harness Protocol', () => {
|
|
15
|
+
|
|
16
|
+
describe('HarnessRun', () => {
|
|
17
|
+
test('should create with defaults', () => {
|
|
18
|
+
const run = new HarnessRun();
|
|
19
|
+
expect(run.id).toMatch(/^run_/);
|
|
20
|
+
expect(run.version).toBe('1.0.0');
|
|
21
|
+
expect(run.status).toBe('initializing');
|
|
22
|
+
expect(run.mode).toBe('interactive');
|
|
23
|
+
expect(run.workflow).toBe('balanced');
|
|
24
|
+
expect(run.spec.goals).toEqual([]);
|
|
25
|
+
expect(run.contextWindows).toEqual([]);
|
|
26
|
+
expect(run.tokenBudget.consumed).toBe(0);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('should create from explicit data', () => {
|
|
30
|
+
const run = new HarnessRun({
|
|
31
|
+
id: 'run_test-123',
|
|
32
|
+
status: 'running',
|
|
33
|
+
mode: 'headless',
|
|
34
|
+
workflow: 'rapid',
|
|
35
|
+
spec: { goals: ['Build MVP'], nonGoals: [], constraints: [], doneWhen: ['Tests pass'] },
|
|
36
|
+
sessionId: 'session-1'
|
|
37
|
+
});
|
|
38
|
+
expect(run.id).toBe('run_test-123');
|
|
39
|
+
expect(run.status).toBe('running');
|
|
40
|
+
expect(run.mode).toBe('headless');
|
|
41
|
+
expect(run.workflow).toBe('rapid');
|
|
42
|
+
expect(run.spec.goals).toEqual(['Build MVP']);
|
|
43
|
+
expect(run.sessionId).toBe('session-1');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('should validate correctly', () => {
|
|
47
|
+
const validRun = new HarnessRun();
|
|
48
|
+
expect(validRun.validate().valid).toBe(true);
|
|
49
|
+
|
|
50
|
+
const invalidRun = new HarnessRun({ id: 'bad', status: 'invalid', mode: 'wrong', workflow: 'nope' });
|
|
51
|
+
const result = invalidRun.validate();
|
|
52
|
+
expect(result.valid).toBe(false);
|
|
53
|
+
expect(result.errors.length).toBeGreaterThanOrEqual(3);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('should serialize/deserialize', () => {
|
|
57
|
+
const run = new HarnessRun({ workflow: 'comprehensive', sessionId: 'sess_1' });
|
|
58
|
+
const json = run.toJSON();
|
|
59
|
+
const restored = HarnessRun.fromJSON(json);
|
|
60
|
+
expect(restored.workflow).toBe('comprehensive');
|
|
61
|
+
expect(restored.sessionId).toBe('sess_1');
|
|
62
|
+
expect(restored.id).toBe(run.id);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('ContextWindow', () => {
|
|
67
|
+
test('should create with defaults', () => {
|
|
68
|
+
const cw = new ContextWindow();
|
|
69
|
+
expect(cw.id).toMatch(/^cw_/);
|
|
70
|
+
expect(cw.sequence).toBe(1);
|
|
71
|
+
expect(cw.status).toBe('active');
|
|
72
|
+
expect(cw.questionsAnswered).toEqual([]);
|
|
73
|
+
expect(cw.tokensConsumed).toBe(0);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('should validate runId requirement', () => {
|
|
77
|
+
const cw = new ContextWindow();
|
|
78
|
+
const result = cw.validate();
|
|
79
|
+
expect(result.valid).toBe(false);
|
|
80
|
+
expect(result.errors).toContain('runId is required');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('should validate with runId', () => {
|
|
84
|
+
const cw = new ContextWindow({ runId: 'run_abc' });
|
|
85
|
+
expect(cw.validate().valid).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('should serialize/deserialize', () => {
|
|
89
|
+
const cw = new ContextWindow({ runId: 'run_x', sequence: 3, tokensConsumed: 5000 });
|
|
90
|
+
const restored = ContextWindow.fromJSON(cw.toJSON());
|
|
91
|
+
expect(restored.runId).toBe('run_x');
|
|
92
|
+
expect(restored.sequence).toBe(3);
|
|
93
|
+
expect(restored.tokensConsumed).toBe(5000);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('HandoffPackage', () => {
|
|
98
|
+
test('should create with defaults', () => {
|
|
99
|
+
const hp = new HandoffPackage();
|
|
100
|
+
expect(hp.progressSummary).toBe('');
|
|
101
|
+
expect(hp.completedWork).toEqual([]);
|
|
102
|
+
expect(hp.nextSteps).toEqual([]);
|
|
103
|
+
expect(hp.knowledgeGraph.totalItems).toBe(0);
|
|
104
|
+
expect(hp.position.currentBlock).toBe(0);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('should create with full data', () => {
|
|
108
|
+
const hp = new HandoffPackage({
|
|
109
|
+
progressSummary: 'Completed 5 of 10 blocks',
|
|
110
|
+
completedWork: ['Block 1', 'Block 2'],
|
|
111
|
+
nextSteps: ['Block 6'],
|
|
112
|
+
currentMilestone: { index: 2, title: 'Core Features', percentage: 50 },
|
|
113
|
+
criticalAnswers: [{ questionId: 'q1', summary: 'React app', quality: 85 }],
|
|
114
|
+
position: { currentBlock: 5, totalBlocks: 10, answeredIds: ['q1'], remainingIds: ['q6'] }
|
|
115
|
+
});
|
|
116
|
+
expect(hp.completedWork.length).toBe(2);
|
|
117
|
+
expect(hp.criticalAnswers[0].quality).toBe(85);
|
|
118
|
+
expect(hp.position.currentBlock).toBe(5);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('should validate', () => {
|
|
122
|
+
const valid = new HandoffPackage();
|
|
123
|
+
expect(valid.validate().valid).toBe(true);
|
|
124
|
+
|
|
125
|
+
const invalid = new HandoffPackage({ progressSummary: 123 });
|
|
126
|
+
expect(invalid.validate().valid).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('should serialize/deserialize', () => {
|
|
130
|
+
const hp = new HandoffPackage({ progressSummary: 'test', sourceWindowId: 'cw_1' });
|
|
131
|
+
const restored = HandoffPackage.fromJSON(hp.toJSON());
|
|
132
|
+
expect(restored.progressSummary).toBe('test');
|
|
133
|
+
expect(restored.sourceWindowId).toBe('cw_1');
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe('Milestone', () => {
|
|
138
|
+
test('should create with defaults', () => {
|
|
139
|
+
const ms = new Milestone();
|
|
140
|
+
expect(ms.id).toMatch(/^ms_/);
|
|
141
|
+
expect(ms.status).toBe('pending');
|
|
142
|
+
expect(ms.percentage).toBe(0);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('should validate title required', () => {
|
|
146
|
+
const ms = new Milestone();
|
|
147
|
+
const result = ms.validate();
|
|
148
|
+
expect(result.valid).toBe(false);
|
|
149
|
+
expect(result.errors).toContain('title is required');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('should validate with title', () => {
|
|
153
|
+
const ms = new Milestone({ title: 'Setup' });
|
|
154
|
+
expect(ms.validate().valid).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('should serialize/deserialize', () => {
|
|
158
|
+
const ms = new Milestone({ title: 'Architecture', index: 2, blockNumber: 3 });
|
|
159
|
+
const restored = Milestone.fromJSON(ms.toJSON());
|
|
160
|
+
expect(restored.title).toBe('Architecture');
|
|
161
|
+
expect(restored.index).toBe(2);
|
|
162
|
+
expect(restored.blockNumber).toBe(3);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe('Event', () => {
|
|
167
|
+
test('should create with defaults', () => {
|
|
168
|
+
const event = new Event();
|
|
169
|
+
expect(event.type).toBe('checkpoint');
|
|
170
|
+
expect(event.timestamp).toBeDefined();
|
|
171
|
+
expect(event.data).toEqual({});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('should validate type', () => {
|
|
175
|
+
const invalid = new Event({ type: 'invalid_type', runId: 'run_1' });
|
|
176
|
+
expect(invalid.validate().valid).toBe(false);
|
|
177
|
+
|
|
178
|
+
const valid = new Event({ type: 'answer', runId: 'run_1' });
|
|
179
|
+
expect(valid.validate().valid).toBe(true);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('should serialize to JSONL and back', () => {
|
|
183
|
+
const event = new Event({
|
|
184
|
+
type: 'answer',
|
|
185
|
+
runId: 'run_1',
|
|
186
|
+
contextWindowId: 'cw_1',
|
|
187
|
+
data: { questionId: 'q1', quality: 90 }
|
|
188
|
+
});
|
|
189
|
+
const line = event.toJSONL();
|
|
190
|
+
expect(typeof line).toBe('string');
|
|
191
|
+
expect(line).not.toContain('\n');
|
|
192
|
+
|
|
193
|
+
const restored = Event.fromJSONL(line);
|
|
194
|
+
expect(restored.type).toBe('answer');
|
|
195
|
+
expect(restored.data.quality).toBe(90);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('should cover all valid event types', () => {
|
|
199
|
+
expect(VALID_EVENT_TYPES).toContain('answer');
|
|
200
|
+
expect(VALID_EVENT_TYPES).toContain('handoff');
|
|
201
|
+
expect(VALID_EVENT_TYPES).toContain('window_open');
|
|
202
|
+
expect(VALID_EVENT_TYPES).toContain('window_close');
|
|
203
|
+
expect(VALID_EVENT_TYPES.length).toBe(12);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe('FeatureManifest', () => {
|
|
208
|
+
test('should create with defaults', () => {
|
|
209
|
+
const fm = new FeatureManifest({ runId: 'run_1' });
|
|
210
|
+
expect(fm.features).toEqual([]);
|
|
211
|
+
expect(fm.runId).toBe('run_1');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('should create with features', () => {
|
|
215
|
+
const fm = new FeatureManifest({
|
|
216
|
+
runId: 'run_1',
|
|
217
|
+
features: [
|
|
218
|
+
{ title: 'Auth', description: 'User authentication' },
|
|
219
|
+
{ title: 'Dashboard', description: 'Admin dashboard' }
|
|
220
|
+
]
|
|
221
|
+
});
|
|
222
|
+
expect(fm.features.length).toBe(2);
|
|
223
|
+
expect(fm.features[0].id).toMatch(/^feat_/);
|
|
224
|
+
expect(fm.features[0].passes).toBe(false);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('should mark passes (only allowed mutation)', () => {
|
|
228
|
+
const fm = new FeatureManifest({
|
|
229
|
+
runId: 'run_1',
|
|
230
|
+
features: [{ id: 'feat_1', title: 'Auth' }]
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
fm.markPasses('feat_1');
|
|
234
|
+
expect(fm.features[0].passes).toBe(true);
|
|
235
|
+
expect(fm.features[0].status).toBe('passes');
|
|
236
|
+
expect(fm.features[0].passedAt).toBeDefined();
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('should throw on unknown feature', () => {
|
|
240
|
+
const fm = new FeatureManifest({ runId: 'run_1', features: [] });
|
|
241
|
+
expect(() => fm.markPasses('feat_nonexistent')).toThrow('Feature not found');
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('should calculate progress', () => {
|
|
245
|
+
const fm = new FeatureManifest({
|
|
246
|
+
runId: 'run_1',
|
|
247
|
+
features: [
|
|
248
|
+
{ id: 'feat_1', title: 'A', passes: true, status: 'passes' },
|
|
249
|
+
{ id: 'feat_2', title: 'B', passes: false },
|
|
250
|
+
{ id: 'feat_3', title: 'C', passes: true, status: 'passes' }
|
|
251
|
+
]
|
|
252
|
+
});
|
|
253
|
+
const progress = fm.getProgress();
|
|
254
|
+
expect(progress.total).toBe(3);
|
|
255
|
+
expect(progress.passed).toBe(2);
|
|
256
|
+
expect(progress.remaining).toBe(1);
|
|
257
|
+
expect(progress.percentage).toBe(67);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('should validate', () => {
|
|
261
|
+
const valid = new FeatureManifest({ runId: 'run_1', features: [{ title: 'X' }] });
|
|
262
|
+
expect(valid.validate().valid).toBe(true);
|
|
263
|
+
|
|
264
|
+
const invalid = new FeatureManifest({ features: [{ title: '' }] });
|
|
265
|
+
const result = invalid.validate();
|
|
266
|
+
expect(result.valid).toBe(false);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
describe('ProviderCapability', () => {
|
|
271
|
+
test('should create with defaults', () => {
|
|
272
|
+
const pc = new ProviderCapability();
|
|
273
|
+
expect(pc.contextWindow).toBe(0);
|
|
274
|
+
expect(pc.backgroundExec).toBe(false);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('should get effective context window', () => {
|
|
278
|
+
const pc = new ProviderCapability({
|
|
279
|
+
providerId: 'anthropic',
|
|
280
|
+
modelId: 'claude-opus-4-6',
|
|
281
|
+
contextWindow: 200000,
|
|
282
|
+
contextWindowBeta: 1000000
|
|
283
|
+
});
|
|
284
|
+
expect(pc.getEffectiveContextWindow(false)).toBe(200000);
|
|
285
|
+
expect(pc.getEffectiveContextWindow(true)).toBe(1000000);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test('should fall back when no beta', () => {
|
|
289
|
+
const pc = new ProviderCapability({
|
|
290
|
+
providerId: 'openai',
|
|
291
|
+
modelId: 'gpt-5-mini',
|
|
292
|
+
contextWindow: 128000
|
|
293
|
+
});
|
|
294
|
+
expect(pc.getEffectiveContextWindow(true)).toBe(128000);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test('should validate', () => {
|
|
298
|
+
const valid = new ProviderCapability({
|
|
299
|
+
providerId: 'anthropic',
|
|
300
|
+
modelId: 'claude-opus-4-6',
|
|
301
|
+
contextWindow: 200000
|
|
302
|
+
});
|
|
303
|
+
expect(valid.validate().valid).toBe(true);
|
|
304
|
+
|
|
305
|
+
const invalid = new ProviderCapability();
|
|
306
|
+
expect(invalid.validate().valid).toBe(false);
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
describe('PROVIDER_CAPABILITIES Registry', () => {
|
|
311
|
+
test('should have all three providers', () => {
|
|
312
|
+
expect(Object.keys(PROVIDER_CAPABILITIES)).toEqual(['anthropic', 'openai', 'google']);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test('should have correct Anthropic models', () => {
|
|
316
|
+
const anthropic = PROVIDER_CAPABILITIES.anthropic;
|
|
317
|
+
expect(anthropic['claude-opus-4-6'].contextWindow).toBe(200000);
|
|
318
|
+
expect(anthropic['claude-opus-4-6'].contextWindowBeta).toBe(1000000);
|
|
319
|
+
expect(anthropic['claude-opus-4-6'].maxOutput).toBe(128000);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test('should have correct OpenAI models', () => {
|
|
323
|
+
const openai = PROVIDER_CAPABILITIES.openai;
|
|
324
|
+
expect(openai['gpt-5.2'].contextWindow).toBe(400000);
|
|
325
|
+
expect(openai['gpt-5.2'].compactEndpoint).toBe('/responses/compact');
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test('should have correct Google models', () => {
|
|
329
|
+
const google = PROVIDER_CAPABILITIES.google;
|
|
330
|
+
expect(google['gemini-3.1-pro-preview'].contextWindow).toBe(1000000);
|
|
331
|
+
expect(google['gemini-3.1-pro-preview'].interactionsAPI).toBe(true);
|
|
332
|
+
expect(google['gemini-3.1-pro-preview'].thoughtSignatures).toBe(true);
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
describe('Run status constants', () => {
|
|
337
|
+
test('should have all valid statuses', () => {
|
|
338
|
+
expect(VALID_RUN_STATUSES).toEqual(['initializing', 'running', 'paused', 'completed', 'failed']);
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
});
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
const ProviderBridge = require('../lib/harness/provider-bridge');
|
|
2
|
+
|
|
3
|
+
describe('ProviderBridge', () => {
|
|
4
|
+
const mockAiClient = {
|
|
5
|
+
sendMessage: jest.fn().mockResolvedValue({
|
|
6
|
+
text: 'Mock response text for testing purposes.',
|
|
7
|
+
usage: { inputTokens: 100, outputTokens: 50 }
|
|
8
|
+
})
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
jest.clearAllMocks();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('should create with known provider capability', () => {
|
|
16
|
+
const bridge = new ProviderBridge(mockAiClient, 'anthropic', 'claude-opus-4-6');
|
|
17
|
+
const cap = bridge.getCapability();
|
|
18
|
+
expect(cap.contextWindow).toBe(200000);
|
|
19
|
+
expect(cap.contextWindowBeta).toBe(1000000);
|
|
20
|
+
expect(cap.maxOutput).toBe(128000);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('should create with OpenAI capability', () => {
|
|
24
|
+
const bridge = new ProviderBridge(mockAiClient, 'openai', 'gpt-5.2');
|
|
25
|
+
const cap = bridge.getCapability();
|
|
26
|
+
expect(cap.contextWindow).toBe(400000);
|
|
27
|
+
expect(cap.compaction).toBe(true);
|
|
28
|
+
expect(cap.compactEndpoint).toBe('/responses/compact');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('should create with Google capability', () => {
|
|
32
|
+
const bridge = new ProviderBridge(mockAiClient, 'google', 'gemini-3.1-pro-preview');
|
|
33
|
+
const cap = bridge.getCapability();
|
|
34
|
+
expect(cap.contextWindow).toBe(1000000);
|
|
35
|
+
expect(cap.interactionsAPI).toBe(true);
|
|
36
|
+
expect(cap.thoughtSignatures).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('should fallback for unknown model', () => {
|
|
40
|
+
const bridge = new ProviderBridge(mockAiClient, 'unknown', 'some-model');
|
|
41
|
+
const cap = bridge.getCapability();
|
|
42
|
+
expect(cap.contextWindow).toBe(128000);
|
|
43
|
+
expect(cap.maxOutput).toBe(8192);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('should resolve OpenRouter models', () => {
|
|
47
|
+
const bridge = new ProviderBridge(mockAiClient, 'openrouter', 'anthropic/claude-opus-4-6');
|
|
48
|
+
const cap = bridge.getCapability();
|
|
49
|
+
expect(cap.contextWindow).toBe(200000);
|
|
50
|
+
expect(cap.providerId).toBe('openrouter');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('should send message with instrumentation', async () => {
|
|
54
|
+
const bridge = new ProviderBridge(mockAiClient, 'anthropic', 'claude-opus-4-6');
|
|
55
|
+
const result = await bridge.sendMessage('Hello');
|
|
56
|
+
|
|
57
|
+
expect(mockAiClient.sendMessage).toHaveBeenCalledTimes(1);
|
|
58
|
+
expect(result.text).toBe('Mock response text for testing purposes.');
|
|
59
|
+
expect(result.instrumentation).toBeDefined();
|
|
60
|
+
expect(result.instrumentation.duration).toBeGreaterThanOrEqual(0);
|
|
61
|
+
expect(result.instrumentation.inputTokens).toBe(100);
|
|
62
|
+
expect(result.instrumentation.outputTokens).toBe(50);
|
|
63
|
+
expect(result.instrumentation.providerId).toBe('anthropic');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('should estimate tokens when not provided', async () => {
|
|
67
|
+
mockAiClient.sendMessage.mockResolvedValueOnce({
|
|
68
|
+
text: 'Short reply'
|
|
69
|
+
// No usage data
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const bridge = new ProviderBridge(mockAiClient, 'anthropic', 'claude-opus-4-6');
|
|
73
|
+
const result = await bridge.sendMessage('Test prompt');
|
|
74
|
+
|
|
75
|
+
expect(result.instrumentation.inputTokens).toBeGreaterThan(0);
|
|
76
|
+
expect(result.instrumentation.outputTokens).toBeGreaterThan(0);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('should track cumulative token usage', async () => {
|
|
80
|
+
const bridge = new ProviderBridge(mockAiClient, 'anthropic', 'claude-opus-4-6');
|
|
81
|
+
|
|
82
|
+
await bridge.sendMessage('First');
|
|
83
|
+
await bridge.sendMessage('Second');
|
|
84
|
+
|
|
85
|
+
const stats = bridge.getTokenStats();
|
|
86
|
+
expect(stats.inputTokens).toBe(200);
|
|
87
|
+
expect(stats.outputTokens).toBe(100);
|
|
88
|
+
expect(stats.totalCalls).toBe(2);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('should calculate context window size', () => {
|
|
92
|
+
const bridge = new ProviderBridge(mockAiClient, 'anthropic', 'claude-opus-4-6');
|
|
93
|
+
expect(bridge.getContextWindowSize(false)).toBe(200000);
|
|
94
|
+
expect(bridge.getContextWindowSize(true)).toBe(1000000);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('should calculate remaining budget', async () => {
|
|
98
|
+
const bridge = new ProviderBridge(mockAiClient, 'anthropic', 'claude-opus-4-6');
|
|
99
|
+
await bridge.sendMessage('Test');
|
|
100
|
+
|
|
101
|
+
const remaining = bridge.getRemainingBudget(false);
|
|
102
|
+
expect(remaining).toBe(200000 - 150); // 200K - 100 input - 50 output
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('should report feature support', () => {
|
|
106
|
+
const anthropic = new ProviderBridge(mockAiClient, 'anthropic', 'claude-opus-4-6');
|
|
107
|
+
expect(anthropic.supportsBackgroundExec()).toBe(false);
|
|
108
|
+
expect(anthropic.supportsCompaction()).toBe(true);
|
|
109
|
+
expect(anthropic.supportsInteractionsAPI()).toBe(false);
|
|
110
|
+
|
|
111
|
+
const google = new ProviderBridge(mockAiClient, 'google', 'gemini-3.1-pro-preview');
|
|
112
|
+
expect(google.supportsBackgroundExec()).toBe(true);
|
|
113
|
+
expect(google.supportsInteractionsAPI()).toBe(true);
|
|
114
|
+
expect(google.supportsThoughtSignatures()).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('should get provider info for handoff', () => {
|
|
118
|
+
const bridge = new ProviderBridge(mockAiClient, 'openai', 'gpt-5.2');
|
|
119
|
+
const info = bridge.getProviderInfo();
|
|
120
|
+
expect(info.id).toBe('openai');
|
|
121
|
+
expect(info.model).toBe('gpt-5.2');
|
|
122
|
+
expect(info.contextWindowSize).toBe(400000);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('should enhance options for Anthropic beta', async () => {
|
|
126
|
+
const bridge = new ProviderBridge(mockAiClient, 'anthropic', 'claude-opus-4-6');
|
|
127
|
+
await bridge.sendMessage('Test', { useBetaContext: true });
|
|
128
|
+
|
|
129
|
+
const callArgs = mockAiClient.sendMessage.mock.calls[0][1];
|
|
130
|
+
expect(callArgs.betas).toContain('context-1m-2025-08-07');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('should set default maxTokens', async () => {
|
|
134
|
+
const bridge = new ProviderBridge(mockAiClient, 'anthropic', 'claude-opus-4-6');
|
|
135
|
+
await bridge.sendMessage('Test');
|
|
136
|
+
|
|
137
|
+
const callArgs = mockAiClient.sendMessage.mock.calls[0][1];
|
|
138
|
+
expect(callArgs.maxTokens).toBe(4096);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe('static methods', () => {
|
|
142
|
+
test('should lookup capability', () => {
|
|
143
|
+
const cap = ProviderBridge.lookupCapability('anthropic', 'claude-opus-4-6');
|
|
144
|
+
expect(cap).not.toBeNull();
|
|
145
|
+
expect(cap.contextWindow).toBe(200000);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('should return null for unknown model', () => {
|
|
149
|
+
const cap = ProviderBridge.lookupCapability('unknown', 'model');
|
|
150
|
+
expect(cap).toBeNull();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('should get models for provider', () => {
|
|
154
|
+
const models = ProviderBridge.getModelsForProvider('openai');
|
|
155
|
+
expect(models).toContain('gpt-5.2');
|
|
156
|
+
expect(models).toContain('gpt-5.2-pro');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('should return empty for unknown provider', () => {
|
|
160
|
+
const models = ProviderBridge.getModelsForProvider('unknown');
|
|
161
|
+
expect(models).toEqual([]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('should get all providers', () => {
|
|
165
|
+
const providers = ProviderBridge.getProviders();
|
|
166
|
+
expect(providers).toContain('anthropic');
|
|
167
|
+
expect(providers).toContain('openai');
|
|
168
|
+
expect(providers).toContain('google');
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe('token estimation', () => {
|
|
173
|
+
test('should estimate tokens from text', () => {
|
|
174
|
+
const bridge = new ProviderBridge(mockAiClient, 'anthropic', 'claude-opus-4-6');
|
|
175
|
+
expect(bridge.estimateTokens('Hello world')).toBeGreaterThan(0);
|
|
176
|
+
expect(bridge.estimateTokens('')).toBe(0);
|
|
177
|
+
expect(bridge.estimateTokens(null)).toBe(0);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
});
|