@adhdev/daemon-core 0.7.45 → 0.8.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/dist/cli-adapters/provider-cli-adapter.d.ts +34 -0
- package/dist/cli-adapters/pty-transport.d.ts +1 -0
- package/dist/cli-adapters/session-host-transport.d.ts +1 -0
- package/dist/commands/cli-manager.d.ts +11 -2
- package/dist/config/chat-history.d.ts +32 -2
- package/dist/config/config.d.ts +5 -1
- package/dist/config/recent-activity.d.ts +3 -1
- package/dist/config/saved-sessions.d.ts +22 -0
- package/dist/daemon/dev-auto-implement.d.ts +18 -2
- package/dist/daemon/dev-cli-debug.d.ts +82 -0
- package/dist/daemon/dev-server.d.ts +7 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +6122 -4038
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +6114 -4032
- package/dist/index.mjs.map +1 -1
- package/dist/providers/cli-provider-instance.d.ts +29 -1
- package/dist/providers/contracts.d.ts +11 -0
- package/dist/providers/provider-instance.d.ts +1 -0
- package/dist/shared-types.d.ts +2 -0
- package/node_modules/@adhdev/session-host-core/dist/index.d.mts +12 -1
- package/node_modules/@adhdev/session-host-core/dist/index.d.ts +12 -1
- package/node_modules/@adhdev/session-host-core/dist/index.js +9 -0
- package/node_modules/@adhdev/session-host-core/dist/index.js.map +1 -1
- package/node_modules/@adhdev/session-host-core/dist/index.mjs +9 -0
- package/node_modules/@adhdev/session-host-core/dist/index.mjs.map +1 -1
- package/node_modules/@adhdev/session-host-core/package.json +1 -1
- package/package.json +1 -1
- package/src/boot/daemon-lifecycle.ts +19 -15
- package/src/cli-adapters/provider-cli-adapter.ts +424 -7
- package/src/cli-adapters/pty-transport.ts +1 -0
- package/src/cli-adapters/session-host-transport.ts +32 -1
- package/src/commands/chat-commands.ts +36 -8
- package/src/commands/cli-manager.ts +259 -22
- package/src/commands/router.ts +52 -1
- package/src/config/chat-history.ts +197 -10
- package/src/config/config.d.ts +4 -0
- package/src/config/config.ts +8 -2
- package/src/config/recent-activity.ts +13 -2
- package/src/config/saved-sessions.ts +73 -0
- package/src/daemon/dev-auto-implement.ts +394 -43
- package/src/daemon/dev-cli-debug.ts +839 -0
- package/src/daemon/dev-server.ts +51 -5
- package/src/index.ts +2 -0
- package/src/providers/cli-provider-instance.ts +283 -4
- package/src/providers/contracts.ts +11 -0
- package/src/providers/provider-instance.d.ts +1 -0
- package/src/providers/provider-instance.ts +1 -0
- package/src/providers/provider-loader.ts +39 -0
- package/src/session-host/runtime-support.ts +1 -0
- package/src/shared-types.d.ts +2 -0
- package/src/shared-types.ts +2 -0
- package/src/status/builders.ts +1 -0
- package/src/status/snapshot.ts +1 -0
|
@@ -5,11 +5,242 @@
|
|
|
5
5
|
* All functions take a DevServerContext as their first argument.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import * as fs from 'fs';
|
|
9
|
+
import * as path from 'path';
|
|
8
10
|
import type * as http from 'http';
|
|
9
11
|
import type { DevServerContext } from './dev-server-types.js';
|
|
10
12
|
|
|
11
13
|
// ─── Helpers ──────────────────────────────────────
|
|
12
14
|
|
|
15
|
+
type CliExerciseRequest = {
|
|
16
|
+
type?: string;
|
|
17
|
+
text?: string;
|
|
18
|
+
instanceId?: string;
|
|
19
|
+
workingDir?: string;
|
|
20
|
+
args?: string[];
|
|
21
|
+
autoLaunch?: boolean;
|
|
22
|
+
freshSession?: boolean;
|
|
23
|
+
autoResolveApprovals?: boolean;
|
|
24
|
+
approvalButtonIndex?: number;
|
|
25
|
+
timeoutMs?: number;
|
|
26
|
+
readyTimeoutMs?: number;
|
|
27
|
+
idleSettledMs?: number;
|
|
28
|
+
traceLimit?: number;
|
|
29
|
+
stopWhenDone?: boolean;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type CliFixtureAssertions = {
|
|
33
|
+
mustContainAny?: string[];
|
|
34
|
+
mustNotContainAny?: string[];
|
|
35
|
+
mustMatchAny?: string[];
|
|
36
|
+
mustNotMatchAny?: string[];
|
|
37
|
+
lastAssistantMustContainAny?: string[];
|
|
38
|
+
lastAssistantMustNotContainAny?: string[];
|
|
39
|
+
lastAssistantMustMatchAny?: string[];
|
|
40
|
+
lastAssistantMustNotMatchAny?: string[];
|
|
41
|
+
statusesSeen?: string[];
|
|
42
|
+
requireNotTimedOut?: boolean;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
type CliExerciseFixture = {
|
|
46
|
+
version: 1;
|
|
47
|
+
kind: 'cli-exercise-fixture';
|
|
48
|
+
name: string;
|
|
49
|
+
type: string;
|
|
50
|
+
createdAt: string;
|
|
51
|
+
providerDir: string | null;
|
|
52
|
+
providerResolution: Record<string, any> | null;
|
|
53
|
+
request: CliExerciseRequest;
|
|
54
|
+
result: Record<string, any>;
|
|
55
|
+
assertions: CliFixtureAssertions;
|
|
56
|
+
notes?: string;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function slugifyFixtureName(value: string): string {
|
|
60
|
+
const normalized = String(value || '')
|
|
61
|
+
.trim()
|
|
62
|
+
.toLowerCase()
|
|
63
|
+
.replace(/[^a-z0-9._-]+/g, '-')
|
|
64
|
+
.replace(/^-+|-+$/g, '');
|
|
65
|
+
return normalized || `fixture-${Date.now()}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function getCliFixtureDir(ctx: DevServerContext, type: string): string {
|
|
69
|
+
const providerDir = ctx.providerLoader.findProviderDir(type);
|
|
70
|
+
if (!providerDir) {
|
|
71
|
+
throw new Error(`Provider directory not found for '${type}'`);
|
|
72
|
+
}
|
|
73
|
+
return path.join(providerDir, 'fixtures');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function readCliFixture(ctx: DevServerContext, type: string, name: string): CliExerciseFixture {
|
|
77
|
+
const fixtureDir = getCliFixtureDir(ctx, type);
|
|
78
|
+
const filePath = path.join(fixtureDir, `${name}.json`);
|
|
79
|
+
if (!fs.existsSync(filePath)) {
|
|
80
|
+
throw new Error(`Fixture not found: ${filePath}`);
|
|
81
|
+
}
|
|
82
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function getExerciseTranscriptText(result: any): string {
|
|
86
|
+
const parts: string[] = [];
|
|
87
|
+
const debugMessages = Array.isArray(result?.debug?.messages) ? result.debug.messages : [];
|
|
88
|
+
const traceMessages = Array.isArray(result?.trace?.messages) ? result.trace.messages : [];
|
|
89
|
+
|
|
90
|
+
for (const message of [...debugMessages, ...traceMessages]) {
|
|
91
|
+
if (!message || typeof message.content !== 'string') continue;
|
|
92
|
+
parts.push(message.content);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (typeof result?.debug?.partialResponse === 'string') parts.push(result.debug.partialResponse);
|
|
96
|
+
if (typeof result?.trace?.responseBuffer === 'string') parts.push(result.trace.responseBuffer);
|
|
97
|
+
|
|
98
|
+
return parts.join('\n');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function getExerciseLastAssistant(result: any): string {
|
|
102
|
+
const debugMessages = Array.isArray(result?.debug?.messages) ? result.debug.messages : [];
|
|
103
|
+
const traceMessages = Array.isArray(result?.trace?.messages) ? result.trace.messages : [];
|
|
104
|
+
for (const messages of [debugMessages, traceMessages]) {
|
|
105
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
106
|
+
const message = messages[i];
|
|
107
|
+
if (message?.role === 'assistant' && typeof message.content === 'string' && message.content.trim()) {
|
|
108
|
+
return message.content;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return '';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function getExerciseMessageCount(result: any): number {
|
|
116
|
+
const debugMessages = Array.isArray(result?.debug?.messages) ? result.debug.messages : [];
|
|
117
|
+
const traceMessages = Array.isArray(result?.trace?.messages) ? result.trace.messages : [];
|
|
118
|
+
return Math.max(debugMessages.length, traceMessages.length);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function compileFixtureRegex(source: string): RegExp | null {
|
|
122
|
+
const value = String(source || '').trim();
|
|
123
|
+
if (!value) return null;
|
|
124
|
+
const delimited = value.match(/^\/([\s\S]+)\/([dgimsuvy]*)$/);
|
|
125
|
+
try {
|
|
126
|
+
if (delimited) {
|
|
127
|
+
return new RegExp(delimited[1], delimited[2]);
|
|
128
|
+
}
|
|
129
|
+
return new RegExp(value, 'm');
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function statusesContainSequence(actual: string[], expected: string[]): boolean {
|
|
136
|
+
if (!expected.length) return true;
|
|
137
|
+
let index = 0;
|
|
138
|
+
for (const status of actual) {
|
|
139
|
+
if (status === expected[index]) index += 1;
|
|
140
|
+
if (index >= expected.length) return true;
|
|
141
|
+
}
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function validateCliFixtureResult(result: any, assertions: CliFixtureAssertions): string[] {
|
|
146
|
+
const failures: string[] = [];
|
|
147
|
+
const transcriptText = getExerciseTranscriptText(result);
|
|
148
|
+
const lastAssistant = getExerciseLastAssistant(result);
|
|
149
|
+
const mustContainAny = assertions.mustContainAny || [];
|
|
150
|
+
const mustNotContainAny = assertions.mustNotContainAny || [];
|
|
151
|
+
const mustMatchAny = assertions.mustMatchAny || [];
|
|
152
|
+
const mustNotMatchAny = assertions.mustNotMatchAny || [];
|
|
153
|
+
const lastAssistantMustContainAny = assertions.lastAssistantMustContainAny || [];
|
|
154
|
+
const lastAssistantMustNotContainAny = assertions.lastAssistantMustNotContainAny || [];
|
|
155
|
+
const lastAssistantMustMatchAny = assertions.lastAssistantMustMatchAny || [];
|
|
156
|
+
const lastAssistantMustNotMatchAny = assertions.lastAssistantMustNotMatchAny || [];
|
|
157
|
+
const statusesSeen = Array.isArray(result?.statusesSeen) ? result.statusesSeen.map((value: any) => String(value)) : [];
|
|
158
|
+
|
|
159
|
+
if (assertions.requireNotTimedOut !== false && result?.timedOut) {
|
|
160
|
+
failures.push('Exercise timed out');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const missingRequired = mustContainAny.filter((value) => !transcriptText.includes(value));
|
|
164
|
+
if (missingRequired.length > 0) {
|
|
165
|
+
failures.push(`Missing required substrings: ${missingRequired.join(', ')}`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const presentBanned = mustNotContainAny.filter((value) => transcriptText.includes(value));
|
|
169
|
+
if (presentBanned.length > 0) {
|
|
170
|
+
failures.push(`Found banned substrings: ${presentBanned.join(', ')}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const missingRegex = mustMatchAny.filter((value) => {
|
|
174
|
+
const regex = compileFixtureRegex(value);
|
|
175
|
+
return !regex || !regex.test(transcriptText);
|
|
176
|
+
});
|
|
177
|
+
if (missingRegex.length > 0) {
|
|
178
|
+
failures.push(`Missing required regex matches: ${missingRegex.join(', ')}`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const presentBannedRegex = mustNotMatchAny.filter((value) => {
|
|
182
|
+
const regex = compileFixtureRegex(value);
|
|
183
|
+
return !!regex && regex.test(transcriptText);
|
|
184
|
+
});
|
|
185
|
+
if (presentBannedRegex.length > 0) {
|
|
186
|
+
failures.push(`Found banned regex matches: ${presentBannedRegex.join(', ')}`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const missingLastAssistant = lastAssistantMustContainAny.filter((value) => !lastAssistant.includes(value));
|
|
190
|
+
if (missingLastAssistant.length > 0) {
|
|
191
|
+
failures.push(`Missing required lastAssistant substrings: ${missingLastAssistant.join(', ')}`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const presentBannedLastAssistant = lastAssistantMustNotContainAny.filter((value) => lastAssistant.includes(value));
|
|
195
|
+
if (presentBannedLastAssistant.length > 0) {
|
|
196
|
+
failures.push(`Found banned lastAssistant substrings: ${presentBannedLastAssistant.join(', ')}`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const missingLastAssistantRegex = lastAssistantMustMatchAny.filter((value) => {
|
|
200
|
+
const regex = compileFixtureRegex(value);
|
|
201
|
+
return !regex || !regex.test(lastAssistant);
|
|
202
|
+
});
|
|
203
|
+
if (missingLastAssistantRegex.length > 0) {
|
|
204
|
+
failures.push(`Missing required lastAssistant regex matches: ${missingLastAssistantRegex.join(', ')}`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const presentBannedLastAssistantRegex = lastAssistantMustNotMatchAny.filter((value) => {
|
|
208
|
+
const regex = compileFixtureRegex(value);
|
|
209
|
+
return !!regex && regex.test(lastAssistant);
|
|
210
|
+
});
|
|
211
|
+
if (presentBannedLastAssistantRegex.length > 0) {
|
|
212
|
+
failures.push(`Found banned lastAssistant regex matches: ${presentBannedLastAssistantRegex.join(', ')}`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (assertions.statusesSeen?.length && !statusesContainSequence(statusesSeen, assertions.statusesSeen)) {
|
|
216
|
+
failures.push(`Expected statuses sequence not observed: ${assertions.statusesSeen.join(' -> ')}`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (result && typeof result === 'object') {
|
|
220
|
+
result.lastAssistant = lastAssistant;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return failures;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function getCliProviderResolutionMeta(ctx: DevServerContext, type: string, adapter?: any): Record<string, any> | null {
|
|
227
|
+
const adapterMeta = typeof adapter?.getProviderResolutionMeta === 'function'
|
|
228
|
+
? adapter.getProviderResolutionMeta()
|
|
229
|
+
: (adapter?.getDebugState?.()?.providerResolution || null);
|
|
230
|
+
const resolvedProvider = ctx.providerLoader.resolve(type);
|
|
231
|
+
if (!adapterMeta && !resolvedProvider) return null;
|
|
232
|
+
return {
|
|
233
|
+
type,
|
|
234
|
+
providerDir: adapterMeta?.providerDir || resolvedProvider?._resolvedProviderDir || ctx.providerLoader.findProviderDir(type),
|
|
235
|
+
scriptDir: adapterMeta?.scriptDir || resolvedProvider?._resolvedScriptDir || null,
|
|
236
|
+
scriptsPath: adapterMeta?.scriptsPath || resolvedProvider?._resolvedScriptsPath || null,
|
|
237
|
+
scriptsSource: adapterMeta?.scriptsSource || resolvedProvider?._resolvedScriptsSource || null,
|
|
238
|
+
resolvedVersion: adapterMeta?.resolvedVersion || resolvedProvider?._resolvedVersion || null,
|
|
239
|
+
resolvedOs: adapterMeta?.resolvedOs || resolvedProvider?._resolvedOs || null,
|
|
240
|
+
versionWarning: adapterMeta?.versionWarning || resolvedProvider?._versionWarning || null,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
13
244
|
function findCliTarget(ctx: DevServerContext, type?: string, instanceId?: string): any | null {
|
|
14
245
|
if (!ctx.instanceManager) return null;
|
|
15
246
|
const cliStates = ctx.instanceManager
|
|
@@ -21,6 +252,409 @@ function findCliTarget(ctx: DevServerContext, type?: string, instanceId?: string
|
|
|
21
252
|
return matches[matches.length - 1] || null;
|
|
22
253
|
}
|
|
23
254
|
|
|
255
|
+
function getCliTargetBundle(ctx: DevServerContext, type?: string, instanceId?: string): {
|
|
256
|
+
target: any;
|
|
257
|
+
instance: any;
|
|
258
|
+
adapter: any;
|
|
259
|
+
} | null {
|
|
260
|
+
if (!ctx.instanceManager) return null;
|
|
261
|
+
const target = findCliTarget(ctx, type, instanceId);
|
|
262
|
+
if (!target) return null;
|
|
263
|
+
const instance = ctx.instanceManager.getInstance(target.instanceId) as any;
|
|
264
|
+
if (!instance) return null;
|
|
265
|
+
const adapter = instance.getAdapter?.() || instance.adapter;
|
|
266
|
+
if (!adapter) return null;
|
|
267
|
+
return { target, instance, adapter };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function sleep(ms: number): Promise<void> {
|
|
271
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function waitForCliReady(
|
|
275
|
+
ctx: DevServerContext,
|
|
276
|
+
type: string,
|
|
277
|
+
instanceId: string,
|
|
278
|
+
timeoutMs: number,
|
|
279
|
+
): Promise<{ target: any; instance: any; adapter: any } | null> {
|
|
280
|
+
const startedAt = Date.now();
|
|
281
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
282
|
+
const bundle = getCliTargetBundle(ctx, type, instanceId);
|
|
283
|
+
if (bundle) {
|
|
284
|
+
const debug = typeof bundle.adapter.getDebugState === 'function'
|
|
285
|
+
? bundle.adapter.getDebugState()
|
|
286
|
+
: null;
|
|
287
|
+
const startupParseGate = !!debug?.startupParseGate;
|
|
288
|
+
const adapterReady = !!debug?.ready;
|
|
289
|
+
const visibleStatusReady = bundle.target.status === 'generating' || bundle.target.status === 'waiting_approval';
|
|
290
|
+
const idleReady = bundle.target.status === 'idle' && !startupParseGate;
|
|
291
|
+
if (adapterReady || visibleStatusReady || idleReady) {
|
|
292
|
+
return bundle;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
await sleep(100);
|
|
296
|
+
}
|
|
297
|
+
return getCliTargetBundle(ctx, type, instanceId);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export async function runCliExerciseInternal(ctx: DevServerContext, body: CliExerciseRequest): Promise<Record<string, any>> {
|
|
301
|
+
if (!ctx.cliManager) {
|
|
302
|
+
throw new Error('CliManager not available');
|
|
303
|
+
}
|
|
304
|
+
if (!ctx.instanceManager) {
|
|
305
|
+
throw new Error('InstanceManager not available');
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const {
|
|
309
|
+
type,
|
|
310
|
+
text,
|
|
311
|
+
instanceId: requestedInstanceId,
|
|
312
|
+
workingDir,
|
|
313
|
+
args,
|
|
314
|
+
autoLaunch = true,
|
|
315
|
+
freshSession = true,
|
|
316
|
+
autoResolveApprovals = true,
|
|
317
|
+
approvalButtonIndex = 0,
|
|
318
|
+
timeoutMs = 45_000,
|
|
319
|
+
readyTimeoutMs = 15_000,
|
|
320
|
+
idleSettledMs = 1_200,
|
|
321
|
+
traceLimit = 160,
|
|
322
|
+
stopWhenDone = false,
|
|
323
|
+
} = body || {};
|
|
324
|
+
|
|
325
|
+
if (!type) {
|
|
326
|
+
throw new Error('type required (e.g. claude-cli, codex-cli)');
|
|
327
|
+
}
|
|
328
|
+
if (!text || typeof text !== 'string') {
|
|
329
|
+
throw new Error('text required (prompt to send to the CLI)');
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
let resolvedInstanceId = requestedInstanceId as string | undefined;
|
|
333
|
+
|
|
334
|
+
if (freshSession) {
|
|
335
|
+
const staleTargets = ctx.instanceManager
|
|
336
|
+
.collectAllStates()
|
|
337
|
+
.filter((state) => (state.category === 'cli' || state.category === 'acp') && state.type === type)
|
|
338
|
+
.map((state) => state.instanceId);
|
|
339
|
+
for (const staleId of staleTargets) {
|
|
340
|
+
ctx.instanceManager.removeInstance(staleId);
|
|
341
|
+
}
|
|
342
|
+
resolvedInstanceId = undefined;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
let bundle = getCliTargetBundle(ctx, type, resolvedInstanceId);
|
|
346
|
+
if (!bundle && autoLaunch) {
|
|
347
|
+
const launchArgs = [type, workingDir || process.cwd(), Array.isArray(args) ? args : []] as const;
|
|
348
|
+
let launched: { runtimeSessionId: string; providerSessionId?: string } | null = null;
|
|
349
|
+
let lastLaunchError: Error | null = null;
|
|
350
|
+
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
|
351
|
+
try {
|
|
352
|
+
launched = await ctx.cliManager.startSession(...launchArgs);
|
|
353
|
+
lastLaunchError = null;
|
|
354
|
+
break;
|
|
355
|
+
} catch (error: any) {
|
|
356
|
+
lastLaunchError = error instanceof Error ? error : new Error(String(error?.message || error));
|
|
357
|
+
const message = String(lastLaunchError.message || '');
|
|
358
|
+
const retryable = /ECONNREFUSED|session-host|Session host/i.test(message);
|
|
359
|
+
if (!retryable || attempt === 2) break;
|
|
360
|
+
await sleep(1_000);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
if (!launched) {
|
|
364
|
+
throw lastLaunchError || new Error(`Failed to start ${type}`);
|
|
365
|
+
}
|
|
366
|
+
resolvedInstanceId = launched.runtimeSessionId;
|
|
367
|
+
bundle = await waitForCliReady(ctx, type, resolvedInstanceId, Math.max(1_000, readyTimeoutMs));
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (!bundle) {
|
|
371
|
+
throw new Error(`No running instance found for: ${resolvedInstanceId || type}`);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const initialDebug = typeof bundle.adapter.getDebugState === 'function' ? bundle.adapter.getDebugState() : null;
|
|
375
|
+
const initialTrace = typeof bundle.adapter.getTraceState === 'function' ? bundle.adapter.getTraceState(traceLimit) : null;
|
|
376
|
+
const providerResolution = getCliProviderResolutionMeta(ctx, bundle.target.type, bundle.adapter);
|
|
377
|
+
const preTraceCount = Number(initialTrace?.entryCount || 0);
|
|
378
|
+
const startAt = Date.now();
|
|
379
|
+
const statusesSeen: string[] = [];
|
|
380
|
+
const approvalsResolved: { at: number; buttonIndex: number; label: string | null }[] = [];
|
|
381
|
+
let lastStatus = '';
|
|
382
|
+
let lastModalKey = '';
|
|
383
|
+
let idleSince = 0;
|
|
384
|
+
let sawBusy = false;
|
|
385
|
+
|
|
386
|
+
ctx.instanceManager.sendEvent(bundle.target.instanceId, 'send_message', { text });
|
|
387
|
+
|
|
388
|
+
while (Date.now() - startAt < Math.max(1_000, timeoutMs)) {
|
|
389
|
+
await sleep(150);
|
|
390
|
+
bundle = getCliTargetBundle(ctx, type, bundle.target.instanceId);
|
|
391
|
+
if (!bundle) {
|
|
392
|
+
throw new Error('CLI instance disappeared during exercise');
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const debug = typeof bundle.adapter.getDebugState === 'function' ? bundle.adapter.getDebugState() : null;
|
|
396
|
+
const trace = typeof bundle.adapter.getTraceState === 'function' ? bundle.adapter.getTraceState(traceLimit) : null;
|
|
397
|
+
const status = String(debug?.status || bundle.target.status || 'unknown');
|
|
398
|
+
const traceEntries = Array.isArray(trace?.entries) ? trace.entries : [];
|
|
399
|
+
const sawSendMessage = traceEntries.some((entry: any) => entry?.type === 'send_message');
|
|
400
|
+
const sawSubmitWrite = traceEntries.some((entry: any) => entry?.type === 'submit_write');
|
|
401
|
+
const hasTurnStarted = sawSendMessage || sawSubmitWrite || !!debug?.currentTurnScope;
|
|
402
|
+
|
|
403
|
+
if (status !== lastStatus) {
|
|
404
|
+
statusesSeen.push(status);
|
|
405
|
+
lastStatus = status;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (status === 'generating' || status === 'waiting_approval') {
|
|
409
|
+
sawBusy = true;
|
|
410
|
+
idleSince = 0;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const modal = debug?.activeModal || trace?.activeModal || null;
|
|
414
|
+
if (autoResolveApprovals && status === 'waiting_approval' && modal && Array.isArray(modal.buttons) && modal.buttons.length > 0) {
|
|
415
|
+
const clampedIndex = Math.max(0, Math.min(Number(approvalButtonIndex) || 0, modal.buttons.length - 1));
|
|
416
|
+
const modalKey = JSON.stringify({
|
|
417
|
+
message: modal.message || '',
|
|
418
|
+
buttons: modal.buttons,
|
|
419
|
+
index: clampedIndex,
|
|
420
|
+
});
|
|
421
|
+
if (modalKey !== lastModalKey && typeof bundle.adapter.resolveModal === 'function') {
|
|
422
|
+
lastModalKey = modalKey;
|
|
423
|
+
approvalsResolved.push({
|
|
424
|
+
at: Date.now(),
|
|
425
|
+
buttonIndex: clampedIndex,
|
|
426
|
+
label: modal.buttons[clampedIndex] || null,
|
|
427
|
+
});
|
|
428
|
+
bundle.adapter.resolveModal(clampedIndex);
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const traceCount = Number(trace?.entryCount || 0);
|
|
434
|
+
const hasProgress = hasTurnStarted && (traceCount > preTraceCount || statusesSeen.length > 1 || approvalsResolved.length > 0);
|
|
435
|
+
if (status === 'idle' && hasProgress && sawBusy) {
|
|
436
|
+
if (!idleSince) idleSince = Date.now();
|
|
437
|
+
if (Date.now() - idleSince >= Math.max(200, idleSettledMs)) {
|
|
438
|
+
const payload: Record<string, any> = {
|
|
439
|
+
exercised: true,
|
|
440
|
+
instanceId: bundle.target.instanceId,
|
|
441
|
+
providerState: {
|
|
442
|
+
type: bundle.target.type,
|
|
443
|
+
name: bundle.target.name,
|
|
444
|
+
status: bundle.target.status,
|
|
445
|
+
mode: 'mode' in bundle.target ? bundle.target.mode : undefined,
|
|
446
|
+
},
|
|
447
|
+
providerResolution,
|
|
448
|
+
initialDebug,
|
|
449
|
+
initialTrace,
|
|
450
|
+
debug,
|
|
451
|
+
trace,
|
|
452
|
+
statusesSeen,
|
|
453
|
+
approvalsResolved,
|
|
454
|
+
elapsedMs: Date.now() - startAt,
|
|
455
|
+
timedOut: false,
|
|
456
|
+
};
|
|
457
|
+
payload.lastAssistant = getExerciseLastAssistant(payload);
|
|
458
|
+
payload.messageCount = getExerciseMessageCount(payload);
|
|
459
|
+
if (stopWhenDone) {
|
|
460
|
+
ctx.instanceManager.removeInstance(bundle.target.instanceId);
|
|
461
|
+
}
|
|
462
|
+
return payload;
|
|
463
|
+
}
|
|
464
|
+
} else if (status === 'idle' && hasProgress) {
|
|
465
|
+
if (!idleSince) idleSince = Date.now();
|
|
466
|
+
if (Date.now() - idleSince >= Math.max(500, idleSettledMs) && Date.now() - startAt >= 750) {
|
|
467
|
+
const payload: Record<string, any> = {
|
|
468
|
+
exercised: true,
|
|
469
|
+
instanceId: bundle.target.instanceId,
|
|
470
|
+
providerState: {
|
|
471
|
+
type: bundle.target.type,
|
|
472
|
+
name: bundle.target.name,
|
|
473
|
+
status: bundle.target.status,
|
|
474
|
+
mode: 'mode' in bundle.target ? bundle.target.mode : undefined,
|
|
475
|
+
},
|
|
476
|
+
providerResolution,
|
|
477
|
+
initialDebug,
|
|
478
|
+
initialTrace,
|
|
479
|
+
debug,
|
|
480
|
+
trace,
|
|
481
|
+
statusesSeen,
|
|
482
|
+
approvalsResolved,
|
|
483
|
+
elapsedMs: Date.now() - startAt,
|
|
484
|
+
timedOut: false,
|
|
485
|
+
};
|
|
486
|
+
payload.lastAssistant = getExerciseLastAssistant(payload);
|
|
487
|
+
payload.messageCount = getExerciseMessageCount(payload);
|
|
488
|
+
if (stopWhenDone) {
|
|
489
|
+
ctx.instanceManager.removeInstance(bundle.target.instanceId);
|
|
490
|
+
}
|
|
491
|
+
return payload;
|
|
492
|
+
}
|
|
493
|
+
} else {
|
|
494
|
+
idleSince = 0;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const finalBundle = getCliTargetBundle(ctx, type, bundle.target.instanceId) || bundle;
|
|
499
|
+
const finalDebug = typeof finalBundle.adapter.getDebugState === 'function' ? finalBundle.adapter.getDebugState() : null;
|
|
500
|
+
const finalTrace = typeof finalBundle.adapter.getTraceState === 'function' ? finalBundle.adapter.getTraceState(traceLimit) : null;
|
|
501
|
+
if (stopWhenDone) {
|
|
502
|
+
ctx.instanceManager.removeInstance(finalBundle.target.instanceId);
|
|
503
|
+
}
|
|
504
|
+
const payload: Record<string, any> = {
|
|
505
|
+
exercised: true,
|
|
506
|
+
instanceId: finalBundle.target.instanceId,
|
|
507
|
+
providerState: {
|
|
508
|
+
type: finalBundle.target.type,
|
|
509
|
+
name: finalBundle.target.name,
|
|
510
|
+
status: finalBundle.target.status,
|
|
511
|
+
mode: 'mode' in finalBundle.target ? finalBundle.target.mode : undefined,
|
|
512
|
+
},
|
|
513
|
+
providerResolution: getCliProviderResolutionMeta(ctx, finalBundle.target.type, finalBundle.adapter),
|
|
514
|
+
initialDebug,
|
|
515
|
+
initialTrace,
|
|
516
|
+
debug: finalDebug,
|
|
517
|
+
trace: finalTrace,
|
|
518
|
+
statusesSeen,
|
|
519
|
+
approvalsResolved,
|
|
520
|
+
elapsedMs: Date.now() - startAt,
|
|
521
|
+
timedOut: true,
|
|
522
|
+
};
|
|
523
|
+
payload.lastAssistant = getExerciseLastAssistant(payload);
|
|
524
|
+
payload.messageCount = getExerciseMessageCount(payload);
|
|
525
|
+
return payload;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
export async function runCliAutoImplVerification(
|
|
529
|
+
ctx: DevServerContext,
|
|
530
|
+
type: string,
|
|
531
|
+
verification?: {
|
|
532
|
+
request?: Record<string, any>;
|
|
533
|
+
mustContainAny?: string[];
|
|
534
|
+
mustNotContainAny?: string[];
|
|
535
|
+
mustMatchAny?: string[];
|
|
536
|
+
mustNotMatchAny?: string[];
|
|
537
|
+
lastAssistantMustContainAny?: string[];
|
|
538
|
+
lastAssistantMustNotContainAny?: string[];
|
|
539
|
+
lastAssistantMustMatchAny?: string[];
|
|
540
|
+
lastAssistantMustNotMatchAny?: string[];
|
|
541
|
+
fixtureName?: string;
|
|
542
|
+
fixtureNames?: string[];
|
|
543
|
+
},
|
|
544
|
+
): Promise<{
|
|
545
|
+
mode: 'fixture_replay' | 'fixture_replay_suite' | 'exercise';
|
|
546
|
+
pass: boolean;
|
|
547
|
+
failures: string[];
|
|
548
|
+
result: Record<string, any>;
|
|
549
|
+
assertions: CliFixtureAssertions;
|
|
550
|
+
fixture?: CliExerciseFixture;
|
|
551
|
+
results?: Array<{
|
|
552
|
+
fixtureName: string;
|
|
553
|
+
pass: boolean;
|
|
554
|
+
failures: string[];
|
|
555
|
+
result: Record<string, any>;
|
|
556
|
+
assertions: CliFixtureAssertions;
|
|
557
|
+
fixture: CliExerciseFixture;
|
|
558
|
+
}>;
|
|
559
|
+
}> {
|
|
560
|
+
const assertions: CliFixtureAssertions = {
|
|
561
|
+
mustContainAny: verification?.mustContainAny || [],
|
|
562
|
+
mustNotContainAny: verification?.mustNotContainAny || [],
|
|
563
|
+
mustMatchAny: verification?.mustMatchAny || [],
|
|
564
|
+
mustNotMatchAny: verification?.mustNotMatchAny || [],
|
|
565
|
+
lastAssistantMustContainAny: verification?.lastAssistantMustContainAny || [],
|
|
566
|
+
lastAssistantMustNotContainAny: verification?.lastAssistantMustNotContainAny || [],
|
|
567
|
+
lastAssistantMustMatchAny: verification?.lastAssistantMustMatchAny || [],
|
|
568
|
+
lastAssistantMustNotMatchAny: verification?.lastAssistantMustNotMatchAny || [],
|
|
569
|
+
requireNotTimedOut: true,
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
const rawFixtureNames = Array.isArray(verification?.fixtureNames)
|
|
573
|
+
? verification!.fixtureNames.map((value) => String(value || '').trim()).filter(Boolean)
|
|
574
|
+
: [];
|
|
575
|
+
if (rawFixtureNames.length > 0) {
|
|
576
|
+
const results: Array<{
|
|
577
|
+
fixtureName: string;
|
|
578
|
+
pass: boolean;
|
|
579
|
+
failures: string[];
|
|
580
|
+
result: Record<string, any>;
|
|
581
|
+
assertions: CliFixtureAssertions;
|
|
582
|
+
fixture: CliExerciseFixture;
|
|
583
|
+
}> = [];
|
|
584
|
+
for (const rawFixtureName of rawFixtureNames) {
|
|
585
|
+
const name = slugifyFixtureName(rawFixtureName);
|
|
586
|
+
const fixture = readCliFixture(ctx, type, name);
|
|
587
|
+
const mergedAssertions: CliFixtureAssertions = {
|
|
588
|
+
...fixture.assertions,
|
|
589
|
+
...assertions,
|
|
590
|
+
};
|
|
591
|
+
const result = await runCliExerciseInternal(ctx, {
|
|
592
|
+
...fixture.request,
|
|
593
|
+
type,
|
|
594
|
+
});
|
|
595
|
+
const failures = validateCliFixtureResult(result, mergedAssertions);
|
|
596
|
+
results.push({
|
|
597
|
+
fixtureName: name,
|
|
598
|
+
pass: failures.length === 0,
|
|
599
|
+
failures,
|
|
600
|
+
result,
|
|
601
|
+
assertions: mergedAssertions,
|
|
602
|
+
fixture,
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
const firstFailure = results.find((item) => !item.pass) || results[results.length - 1];
|
|
606
|
+
return {
|
|
607
|
+
mode: 'fixture_replay_suite',
|
|
608
|
+
pass: results.every((item) => item.pass),
|
|
609
|
+
failures: results.flatMap((item) => item.failures.map((failure) => `${item.fixtureName}: ${failure}`)),
|
|
610
|
+
result: firstFailure.result,
|
|
611
|
+
assertions: firstFailure.assertions,
|
|
612
|
+
fixture: firstFailure.fixture,
|
|
613
|
+
results,
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const rawFixtureName = String(verification?.fixtureName || '').trim();
|
|
618
|
+
if (rawFixtureName) {
|
|
619
|
+
const name = slugifyFixtureName(rawFixtureName);
|
|
620
|
+
try {
|
|
621
|
+
const fixture = readCliFixture(ctx, type, name);
|
|
622
|
+
const mergedAssertions: CliFixtureAssertions = {
|
|
623
|
+
...fixture.assertions,
|
|
624
|
+
...assertions,
|
|
625
|
+
};
|
|
626
|
+
const result = await runCliExerciseInternal(ctx, {
|
|
627
|
+
...fixture.request,
|
|
628
|
+
type,
|
|
629
|
+
});
|
|
630
|
+
const failures = validateCliFixtureResult(result, mergedAssertions);
|
|
631
|
+
return {
|
|
632
|
+
mode: 'fixture_replay',
|
|
633
|
+
pass: failures.length === 0,
|
|
634
|
+
failures,
|
|
635
|
+
result,
|
|
636
|
+
assertions: mergedAssertions,
|
|
637
|
+
fixture,
|
|
638
|
+
};
|
|
639
|
+
} catch {
|
|
640
|
+
// Fall through to direct exercise verification if the named fixture is absent.
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const result = await runCliExerciseInternal(ctx, {
|
|
645
|
+
...(verification?.request || {}),
|
|
646
|
+
type,
|
|
647
|
+
});
|
|
648
|
+
const failures = validateCliFixtureResult(result, assertions);
|
|
649
|
+
return {
|
|
650
|
+
mode: 'exercise',
|
|
651
|
+
pass: failures.length === 0,
|
|
652
|
+
failures,
|
|
653
|
+
result,
|
|
654
|
+
assertions,
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
|
|
24
658
|
// ─── Handlers ─────────────────────────────────────
|
|
25
659
|
|
|
26
660
|
/** GET /api/cli/status — list all running CLI/ACP instances with state */
|
|
@@ -185,6 +819,7 @@ export async function handleCliDebug(ctx: DevServerContext, type: string, _req:
|
|
|
185
819
|
status: target.status,
|
|
186
820
|
mode: 'mode' in target ? target.mode : undefined,
|
|
187
821
|
},
|
|
822
|
+
providerResolution: getCliProviderResolutionMeta(ctx, target.type, adapter),
|
|
188
823
|
debug: debugState,
|
|
189
824
|
});
|
|
190
825
|
} else {
|
|
@@ -192,6 +827,7 @@ export async function handleCliDebug(ctx: DevServerContext, type: string, _req:
|
|
|
192
827
|
ctx.json(res, 200, {
|
|
193
828
|
instanceId: target.instanceId,
|
|
194
829
|
providerState: target,
|
|
830
|
+
providerResolution: getCliProviderResolutionMeta(ctx, target.type, adapter),
|
|
195
831
|
debug: null,
|
|
196
832
|
message: 'No debug state available (adapter.getDebugState not found)',
|
|
197
833
|
});
|
|
@@ -201,6 +837,209 @@ export async function handleCliDebug(ctx: DevServerContext, type: string, _req:
|
|
|
201
837
|
}
|
|
202
838
|
}
|
|
203
839
|
|
|
840
|
+
/** GET /api/cli/trace/:type — recent CLI trace timeline plus current debug snapshot */
|
|
841
|
+
export async function handleCliTrace(ctx: DevServerContext, type: string, req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
|
842
|
+
if (!ctx.instanceManager) {
|
|
843
|
+
ctx.json(res, 503, { error: 'InstanceManager not available' });
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
const target = findCliTarget(ctx, type);
|
|
848
|
+
if (!target) {
|
|
849
|
+
const allStates = ctx.instanceManager.collectAllStates();
|
|
850
|
+
ctx.json(res, 404, {
|
|
851
|
+
error: `No running instance for: ${type}`,
|
|
852
|
+
available: allStates.filter(s => s.category === 'cli' || s.category === 'acp').map(s => s.type),
|
|
853
|
+
});
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
const instance = ctx.instanceManager.getInstance(target.instanceId) as any;
|
|
858
|
+
if (!instance) {
|
|
859
|
+
ctx.json(res, 404, { error: `Instance not found: ${target.instanceId}` });
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
try {
|
|
864
|
+
const adapter = instance.getAdapter?.() || instance.adapter;
|
|
865
|
+
const url = new URL(req.url || '/', 'http://127.0.0.1');
|
|
866
|
+
const limit = parseInt(url.searchParams.get('limit') || '120', 10);
|
|
867
|
+
if (adapter && typeof adapter.getTraceState === 'function') {
|
|
868
|
+
const trace = adapter.getTraceState(limit);
|
|
869
|
+
const debug = typeof adapter.getDebugState === 'function' ? adapter.getDebugState() : null;
|
|
870
|
+
ctx.json(res, 200, {
|
|
871
|
+
instanceId: target.instanceId,
|
|
872
|
+
providerState: {
|
|
873
|
+
type: target.type,
|
|
874
|
+
name: target.name,
|
|
875
|
+
status: target.status,
|
|
876
|
+
mode: 'mode' in target ? target.mode : undefined,
|
|
877
|
+
},
|
|
878
|
+
providerResolution: getCliProviderResolutionMeta(ctx, target.type, adapter),
|
|
879
|
+
debug,
|
|
880
|
+
trace,
|
|
881
|
+
});
|
|
882
|
+
} else {
|
|
883
|
+
ctx.json(res, 200, {
|
|
884
|
+
instanceId: target.instanceId,
|
|
885
|
+
providerState: target,
|
|
886
|
+
providerResolution: getCliProviderResolutionMeta(ctx, target.type, adapter),
|
|
887
|
+
debug: typeof adapter?.getDebugState === 'function' ? adapter.getDebugState() : null,
|
|
888
|
+
trace: null,
|
|
889
|
+
message: 'No trace state available (adapter.getTraceState not found)',
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
} catch (e: any) {
|
|
893
|
+
ctx.json(res, 500, { error: `Trace state failed: ${e.message}` });
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/** POST /api/cli/exercise — autonomously run a CLI repro and wait for final settled trace */
|
|
898
|
+
export async function handleCliExercise(ctx: DevServerContext, req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
|
899
|
+
try {
|
|
900
|
+
const body = await ctx.readBody(req);
|
|
901
|
+
const result = await runCliExerciseInternal(ctx, body || {});
|
|
902
|
+
ctx.json(res, 200, result);
|
|
903
|
+
} catch (e: any) {
|
|
904
|
+
ctx.json(res, 500, { error: `Exercise failed: ${e.message}` });
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/** POST /api/cli/fixture/capture — run exact exercise once and persist it as a reusable fixture */
|
|
909
|
+
export async function handleCliFixtureCapture(ctx: DevServerContext, req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
|
910
|
+
try {
|
|
911
|
+
const body = await ctx.readBody(req);
|
|
912
|
+
const type = String(body?.type || '');
|
|
913
|
+
const request = (body?.request || {}) as CliExerciseRequest;
|
|
914
|
+
if (!type) {
|
|
915
|
+
ctx.json(res, 400, { error: 'type required' });
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
if (!request?.text) {
|
|
919
|
+
ctx.json(res, 400, { error: 'request.text required' });
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
const fixtureDir = getCliFixtureDir(ctx, type);
|
|
924
|
+
fs.mkdirSync(fixtureDir, { recursive: true });
|
|
925
|
+
const name = slugifyFixtureName(String(body?.name || `${type}-${Date.now()}`));
|
|
926
|
+
const result = await runCliExerciseInternal(ctx, { ...request, type });
|
|
927
|
+
const fixture: CliExerciseFixture = {
|
|
928
|
+
version: 1,
|
|
929
|
+
kind: 'cli-exercise-fixture',
|
|
930
|
+
name,
|
|
931
|
+
type,
|
|
932
|
+
createdAt: new Date().toISOString(),
|
|
933
|
+
providerDir: ctx.providerLoader.findProviderDir(type),
|
|
934
|
+
providerResolution: result?.providerResolution || null,
|
|
935
|
+
request: { ...request, type },
|
|
936
|
+
result,
|
|
937
|
+
assertions: {
|
|
938
|
+
mustContainAny: Array.isArray(body?.assertions?.mustContainAny) ? body.assertions.mustContainAny : [],
|
|
939
|
+
mustNotContainAny: Array.isArray(body?.assertions?.mustNotContainAny) ? body.assertions.mustNotContainAny : [],
|
|
940
|
+
mustMatchAny: Array.isArray(body?.assertions?.mustMatchAny) ? body.assertions.mustMatchAny : [],
|
|
941
|
+
mustNotMatchAny: Array.isArray(body?.assertions?.mustNotMatchAny) ? body.assertions.mustNotMatchAny : [],
|
|
942
|
+
lastAssistantMustContainAny: Array.isArray(body?.assertions?.lastAssistantMustContainAny) ? body.assertions.lastAssistantMustContainAny : [],
|
|
943
|
+
lastAssistantMustNotContainAny: Array.isArray(body?.assertions?.lastAssistantMustNotContainAny) ? body.assertions.lastAssistantMustNotContainAny : [],
|
|
944
|
+
lastAssistantMustMatchAny: Array.isArray(body?.assertions?.lastAssistantMustMatchAny) ? body.assertions.lastAssistantMustMatchAny : [],
|
|
945
|
+
lastAssistantMustNotMatchAny: Array.isArray(body?.assertions?.lastAssistantMustNotMatchAny) ? body.assertions.lastAssistantMustNotMatchAny : [],
|
|
946
|
+
statusesSeen: Array.isArray(body?.assertions?.statusesSeen) ? body.assertions.statusesSeen : undefined,
|
|
947
|
+
requireNotTimedOut: body?.assertions?.requireNotTimedOut !== false,
|
|
948
|
+
},
|
|
949
|
+
notes: typeof body?.notes === 'string' ? body.notes : undefined,
|
|
950
|
+
};
|
|
951
|
+
const filePath = path.join(fixtureDir, `${name}.json`);
|
|
952
|
+
fs.writeFileSync(filePath, JSON.stringify(fixture, null, 2));
|
|
953
|
+
ctx.json(res, 200, {
|
|
954
|
+
saved: true,
|
|
955
|
+
name,
|
|
956
|
+
path: filePath,
|
|
957
|
+
fixture,
|
|
958
|
+
verification: {
|
|
959
|
+
pass: validateCliFixtureResult(result, fixture.assertions).length === 0,
|
|
960
|
+
failures: validateCliFixtureResult(result, fixture.assertions),
|
|
961
|
+
},
|
|
962
|
+
});
|
|
963
|
+
} catch (e: any) {
|
|
964
|
+
ctx.json(res, 500, { error: `Fixture capture failed: ${e.message}` });
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/** GET /api/cli/fixtures/:type — list saved exercise fixtures for a provider */
|
|
969
|
+
export async function handleCliFixtureList(ctx: DevServerContext, type: string, _req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
|
970
|
+
try {
|
|
971
|
+
const fixtureDir = getCliFixtureDir(ctx, type);
|
|
972
|
+
if (!fs.existsSync(fixtureDir)) {
|
|
973
|
+
ctx.json(res, 200, { fixtures: [], count: 0 });
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
const fixtures = fs.readdirSync(fixtureDir)
|
|
977
|
+
.filter((file) => file.endsWith('.json'))
|
|
978
|
+
.sort((a, b) => b.localeCompare(a, undefined, { numeric: true, sensitivity: 'base' }))
|
|
979
|
+
.map((file) => {
|
|
980
|
+
const fullPath = path.join(fixtureDir, file);
|
|
981
|
+
try {
|
|
982
|
+
const raw = JSON.parse(fs.readFileSync(fullPath, 'utf-8')) as CliExerciseFixture;
|
|
983
|
+
return {
|
|
984
|
+
name: raw.name || file.replace(/\.json$/i, ''),
|
|
985
|
+
path: fullPath,
|
|
986
|
+
createdAt: raw.createdAt || null,
|
|
987
|
+
notes: raw.notes || null,
|
|
988
|
+
requestText: raw.request?.text || '',
|
|
989
|
+
assertions: raw.assertions || {},
|
|
990
|
+
};
|
|
991
|
+
} catch {
|
|
992
|
+
return {
|
|
993
|
+
name: file.replace(/\.json$/i, ''),
|
|
994
|
+
path: fullPath,
|
|
995
|
+
createdAt: null,
|
|
996
|
+
notes: 'Unreadable fixture',
|
|
997
|
+
requestText: '',
|
|
998
|
+
assertions: {},
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
});
|
|
1002
|
+
ctx.json(res, 200, { fixtures, count: fixtures.length });
|
|
1003
|
+
} catch (e: any) {
|
|
1004
|
+
ctx.json(res, 500, { error: `Fixture list failed: ${e.message}` });
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
/** POST /api/cli/fixture/replay — rerun a saved exact exercise and validate against saved assertions */
|
|
1009
|
+
export async function handleCliFixtureReplay(ctx: DevServerContext, req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
|
1010
|
+
try {
|
|
1011
|
+
const body = await ctx.readBody(req);
|
|
1012
|
+
const type = String(body?.type || '');
|
|
1013
|
+
const rawName = String(body?.name || '').trim();
|
|
1014
|
+
if (!type || !rawName) {
|
|
1015
|
+
ctx.json(res, 400, { error: 'type and name required' });
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
const name = slugifyFixtureName(rawName);
|
|
1019
|
+
|
|
1020
|
+
const fixture = readCliFixture(ctx, type, name);
|
|
1021
|
+
const result = await runCliExerciseInternal(ctx, {
|
|
1022
|
+
...fixture.request,
|
|
1023
|
+
type,
|
|
1024
|
+
});
|
|
1025
|
+
const assertions: CliFixtureAssertions = {
|
|
1026
|
+
...fixture.assertions,
|
|
1027
|
+
...(body?.assertions || {}),
|
|
1028
|
+
};
|
|
1029
|
+
const failures = validateCliFixtureResult(result, assertions);
|
|
1030
|
+
ctx.json(res, 200, {
|
|
1031
|
+
replayed: true,
|
|
1032
|
+
pass: failures.length === 0,
|
|
1033
|
+
failures,
|
|
1034
|
+
fixture,
|
|
1035
|
+
result,
|
|
1036
|
+
assertions,
|
|
1037
|
+
});
|
|
1038
|
+
} catch (e: any) {
|
|
1039
|
+
ctx.json(res, 500, { error: `Fixture replay failed: ${e.message}` });
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
|
|
204
1043
|
/** POST /api/cli/resolve — resolve an approval modal { type, buttonIndex } */
|
|
205
1044
|
export async function handleCliResolve(ctx: DevServerContext, req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
|
206
1045
|
const body = await ctx.readBody(req);
|