@surlymochan/evalid 0.5.19 → 0.5.21

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.
@@ -0,0 +1,440 @@
1
+ /**
2
+ * agent-stream — turns coding-agent CLI output into execution-log events.
3
+ *
4
+ * `agent_invoke` spawns codex/claude with line-delimited JSON (--json / --output-format
5
+ * stream-json). That output used to be buffered into a string and thrown away, which left a
6
+ * 5-to-30-minute black box in the middle of every heavy phase. Here the same bytes are split
7
+ * into lines, normalized to a small event vocabulary, and appended to the hash-chained
8
+ * execution log, which both the CLI and `xflow serve` already read.
9
+ *
10
+ * Vocabulary (deliberately tiny, and aligned with bench/swebench/harness/arms.py):
11
+ * agent_start | tool_start | tool_end | message_end | agent_end | raw
12
+ *
13
+ * Unknown line shapes degrade to `raw` instead of throwing: a CLI version bump must never be
14
+ * able to break a workflow run.
15
+ */
16
+ import { createHash } from 'node:crypto';
17
+ import { StringDecoder } from 'node:string_decoder';
18
+ import { appendExecutionLog } from './execution-log.js';
19
+
20
+ export const AGENT_EVENT_KINDS = Object.freeze(['agent_start', 'tool_start', 'tool_end', 'message_end', 'agent_end', 'raw']);
21
+ export const DEFAULT_MAX_LINE_BYTES = 1 << 20;
22
+ export const DEFAULT_FLUSH_INTERVAL_MS = 200;
23
+ const MAX_TEXT_CHARS = 2048;
24
+ const MAX_PREVIEW_CHARS = 160;
25
+ const MAX_QUEUE_LENGTH = 500;
26
+ // While a line is being dropped, only this much of it is kept: enough to see the newline that
27
+ // ends it, never enough to matter.
28
+ const DROP_TAIL_CHARS = 1024;
29
+
30
+ /**
31
+ * Split a byte stream into complete lines.
32
+ *
33
+ * `push` accepts Buffers or strings and never emits a partial line; `flush` emits the trailing
34
+ * line before the process goes away. A line larger than `maxLineBytes` is replaced by a single
35
+ * truncation marker so a runaway output cannot grow memory without bound.
36
+ */
37
+ export function createLineSplitter(onLine, options = {}) {
38
+ const maxLineBytes = Math.max(1024, Number(options.maxLineBytes) || DEFAULT_MAX_LINE_BYTES);
39
+ const decoder = new StringDecoder('utf8');
40
+ let buffer = '';
41
+ let dropping = false;
42
+
43
+ const emit = (line) => {
44
+ const value = line.endsWith('\r') ? line.slice(0, -1) : line;
45
+ try {
46
+ onLine(value);
47
+ } catch {
48
+ // A consumer bug must never break the agent pipe.
49
+ }
50
+ };
51
+
52
+ const drain = () => {
53
+ let newlineAt = buffer.indexOf('\n');
54
+ while (newlineAt !== -1) {
55
+ const line = buffer.slice(0, newlineAt);
56
+ buffer = buffer.slice(newlineAt + 1);
57
+ if (dropping) {
58
+ dropping = false;
59
+ } else if (line.length > maxLineBytes) {
60
+ emit(JSON.stringify({ type: 'xflow.line_truncated', bytes: line.length }));
61
+ } else {
62
+ emit(line);
63
+ }
64
+ newlineAt = buffer.indexOf('\n');
65
+ }
66
+ };
67
+
68
+ return {
69
+ push(chunk) {
70
+ if (chunk === undefined || chunk === null) return;
71
+ buffer += typeof chunk === 'string' ? chunk : decoder.write(chunk);
72
+ drain();
73
+ if (buffer.length > maxLineBytes) {
74
+ if (!dropping) {
75
+ emit(JSON.stringify({ type: 'xflow.line_truncated', bytes: buffer.length }));
76
+ dropping = true;
77
+ }
78
+ // The runaway line was already replaced by its marker, so only its tail is kept: without
79
+ // this a stream that never sends a newline grew the buffer without bound.
80
+ buffer = buffer.slice(-DROP_TAIL_CHARS);
81
+ }
82
+ },
83
+ flush() {
84
+ const tail = decoder.end();
85
+ if (tail) buffer += tail;
86
+ drain();
87
+ if (dropping) {
88
+ buffer = '';
89
+ dropping = false;
90
+ }
91
+ if (buffer.length) {
92
+ const line = buffer;
93
+ buffer = '';
94
+ emit(line);
95
+ }
96
+ },
97
+ get pending() {
98
+ return buffer.length;
99
+ },
100
+ };
101
+ }
102
+
103
+ /** First normalized event for a line, or null. Kept for one-line CLIs and simple callers. */
104
+ export function normalizeAgentEvent(raw, options = {}) {
105
+ const events = normalizeAgentEvents(raw, options);
106
+ return events.length ? events[0] : null;
107
+ }
108
+
109
+ /** Every normalized event carried by one CLI line (a Claude message can hold text and tool calls). */
110
+ export function normalizeAgentEvents(raw, options = {}) {
111
+ const adapter = options.adapter || 'unknown';
112
+ const seq = Number(options.seq) || 0;
113
+ const text = String(raw ?? '').trim();
114
+ if (!text) return [];
115
+
116
+ let payload;
117
+ try {
118
+ payload = JSON.parse(text);
119
+ } catch {
120
+ return [{ seq, event: 'raw', reason: 'invalid_json', text: truncateText(text) }];
121
+ }
122
+ if (!payload || typeof payload !== 'object') {
123
+ return [{ seq, event: 'raw', reason: 'non_object_payload', text: truncateText(text) }];
124
+ }
125
+ if (payload.type === 'xflow.line_truncated') {
126
+ return [{ seq, event: 'raw', reason: 'line_truncated', truncated: true, text: String(payload.bytes || 0) }];
127
+ }
128
+
129
+ const family = detectFamily(payload, adapter);
130
+ const mapped = (family === 'claude' ? mapClaudePayload : mapCodexPayload)(payload)
131
+ .filter((event) => event && event.event);
132
+ if (!mapped.length) return [];
133
+ return mapped.map((event) => ({ seq, ...event }));
134
+ }
135
+
136
+ function detectFamily(payload, adapter) {
137
+ if (adapter === 'claude_cli') return 'claude';
138
+ if (adapter === 'codex_cli') return 'codex';
139
+ const type = String(payload.type || '');
140
+ if (['system', 'assistant', 'result', 'user', 'stream_event'].includes(type)) return 'claude';
141
+ return 'codex';
142
+ }
143
+
144
+ function mapCodexPayload(payload) {
145
+ const type = String(payload.type || '');
146
+ switch (type) {
147
+ case 'thread.started':
148
+ return [{ event: 'agent_start', event_type: type }];
149
+ case 'item.started':
150
+ return mapCodexItem(payload.item, 'start');
151
+ case 'item.completed':
152
+ return mapCodexItem(payload.item, 'end');
153
+ case 'turn.completed':
154
+ return [{ event: 'agent_end', event_type: type, usage: readCodexUsage(payload.usage) }];
155
+ case 'turn.failed':
156
+ case 'error':
157
+ return [{ event: 'raw', event_type: type, level: 'error', text: truncateText(errorText(payload)) }];
158
+ default:
159
+ return [];
160
+ }
161
+ }
162
+
163
+ function mapCodexItem(item, phase) {
164
+ if (!item || typeof item !== 'object') return [];
165
+ const itemType = String(item.type || '');
166
+ if (itemType === 'agent_message') {
167
+ return phase === 'end' ? [{ event: 'message_end', event_type: itemType, text: truncateText(item.text) }] : [];
168
+ }
169
+ if (itemType === 'reasoning') {
170
+ // Model reasoning is not progress a human can act on, and it is the largest payload.
171
+ return [];
172
+ }
173
+ if (itemType === 'command_execution') {
174
+ if (phase === 'start') {
175
+ const command = Array.isArray(item.command) ? item.command.join(' ') : String(item.command || '');
176
+ return [toolStart('shell', command, itemType)];
177
+ }
178
+ return [toolEnd('shell', itemType, item.exit_code, item.status)];
179
+ }
180
+ if (itemType === 'mcp_tool_call') {
181
+ const tool = String(item.name || item.tool || 'mcp_tool');
182
+ if (phase === 'start') return [toolStart(tool, stringifyArgs(item.arguments ?? item.input ?? item.args), itemType)];
183
+ return [toolEnd(tool, itemType, item.exit_code, item.status)];
184
+ }
185
+ if (itemType === 'file_change') {
186
+ const paths = Array.isArray(item.changes)
187
+ ? item.changes.map((change) => change && change.path).filter(Boolean).join(', ')
188
+ : String(item.path || '');
189
+ if (phase === 'start') return [toolStart('file_change', paths, itemType)];
190
+ return [toolEnd('file_change', itemType, item.exit_code, item.status)];
191
+ }
192
+ if (itemType === 'web_search') {
193
+ return phase === 'start'
194
+ ? [toolStart('web_search', String(item.query || ''), itemType)]
195
+ : [toolEnd('web_search', itemType, null, item.status)];
196
+ }
197
+ return [];
198
+ }
199
+
200
+ function mapClaudePayload(payload) {
201
+ const type = String(payload.type || '');
202
+ if (type === 'system') {
203
+ return payload.subtype === 'init' ? [{ event: 'agent_start', event_type: 'system:init' }] : [];
204
+ }
205
+ if (type === 'assistant') {
206
+ const content = Array.isArray(payload.message?.content) ? payload.message.content : [];
207
+ const events = [];
208
+ let pendingText = [];
209
+ const flushText = () => {
210
+ if (!pendingText.length) return;
211
+ events.push({ event: 'message_end', event_type: 'assistant_text', text: truncateText(pendingText.join('\n')) });
212
+ pendingText = [];
213
+ };
214
+ for (const block of content) {
215
+ if (!block || typeof block !== 'object') continue;
216
+ if (block.type === 'text' && typeof block.text === 'string') {
217
+ pendingText.push(block.text);
218
+ } else if (block.type === 'tool_use') {
219
+ // Preserve the order the model produced: text first, then the tool call it announced.
220
+ flushText();
221
+ events.push(toolStart(String(block.name || 'tool'), stringifyArgs(block.input), 'tool_use'));
222
+ }
223
+ }
224
+ flushText();
225
+ return events;
226
+ }
227
+ if (type === 'user') {
228
+ // Tool results echo output the tool_start/tool_end pair already accounted for.
229
+ return [];
230
+ }
231
+ if (type === 'result') {
232
+ return [{
233
+ event: 'agent_end',
234
+ event_type: 'result:' + String(payload.subtype || 'success'),
235
+ usage: readClaudeUsage(payload.usage),
236
+ cost_usd: finiteOrNull(payload.total_cost_usd),
237
+ duration_ms: finiteOrNull(payload.duration_ms),
238
+ }];
239
+ }
240
+ if (type === 'stream_event') {
241
+ // Token-level deltas are out of scope; the finished message arrives as an assistant line.
242
+ return [];
243
+ }
244
+ if (type === 'error') {
245
+ return [{ event: 'raw', event_type: 'error', level: 'error', text: truncateText(errorText(payload)) }];
246
+ }
247
+ return [];
248
+ }
249
+
250
+ function toolStart(tool, argsText, eventType) {
251
+ const text = typeof argsText === 'string' ? argsText : '';
252
+ return {
253
+ event: 'tool_start',
254
+ event_type: eventType,
255
+ tool,
256
+ args_preview: text ? truncateText(text, MAX_PREVIEW_CHARS) : null,
257
+ args_digest: text ? digest(text) : null,
258
+ };
259
+ }
260
+
261
+ function toolEnd(tool, eventType, exitCode, status) {
262
+ return {
263
+ event: 'tool_end',
264
+ event_type: eventType,
265
+ tool,
266
+ exit_code: finiteOrNull(exitCode),
267
+ status: status ? String(status) : null,
268
+ };
269
+ }
270
+
271
+ function readCodexUsage(usage) {
272
+ if (!usage || typeof usage !== 'object') return null;
273
+ return compactUsage({
274
+ input: usage.input_tokens,
275
+ output: usage.output_tokens,
276
+ cache_read: usage.cached_input_tokens,
277
+ cache_write: usage.cache_creation_input_tokens,
278
+ });
279
+ }
280
+
281
+ function readClaudeUsage(usage) {
282
+ if (!usage || typeof usage !== 'object') return null;
283
+ return compactUsage({
284
+ input: usage.input_tokens,
285
+ output: usage.output_tokens,
286
+ cache_read: usage.cache_read_input_tokens,
287
+ cache_write: usage.cache_creation_input_tokens,
288
+ });
289
+ }
290
+
291
+ function compactUsage(raw) {
292
+ const usage = {};
293
+ for (const [key, value] of Object.entries(raw)) {
294
+ const number = Number(value);
295
+ if (Number.isFinite(number) && number >= 0) usage[key] = number;
296
+ }
297
+ return Object.keys(usage).length ? usage : null;
298
+ }
299
+
300
+ /**
301
+ * Collect normalized events from one agent process and append them to the execution log.
302
+ *
303
+ * Writes are batched on a short timer so a chatty CLI produces kilobytes, not megabytes, and a
304
+ * write failure is counted in `stats.write_errors` rather than surfaced to the agent run.
305
+ */
306
+ export function createAgentEventSink(options = {}) {
307
+ const projectRoot = options.projectRoot || process.cwd();
308
+ const changeId = options.changeId || null;
309
+ const missionId = options.missionId || null;
310
+ const phase = typeof options.phase === 'string' ? options.phase : (options.phase?.id || null);
311
+ const atom = options.atom || null;
312
+ const adapter = options.adapter || 'unknown';
313
+ const flushIntervalMs = Math.max(0, Number(options.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS));
314
+ const maxQueue = Math.max(1, Number(options.maxQueue) || MAX_QUEUE_LENGTH);
315
+
316
+ const queue = [];
317
+ const stats = { lines: 0, events: 0, written: 0, dropped: 0, truncated: 0, write_errors: 0 };
318
+ let seq = 0;
319
+ let timer = null;
320
+ let writing = Promise.resolve();
321
+ let closed = false;
322
+
323
+ const splitter = createLineSplitter((line) => {
324
+ if (closed || !line) return;
325
+ stats.lines += 1;
326
+ for (const normalized of normalizeAgentEvents(line, { adapter, seq })) {
327
+ seq += 1;
328
+ stats.events += 1;
329
+ if (normalized.truncated) stats.truncated += 1;
330
+ queue.push(buildRecord(normalized, seq));
331
+ if (queue.length > maxQueue) {
332
+ queue.shift();
333
+ stats.dropped += 1;
334
+ }
335
+ }
336
+ schedule();
337
+ });
338
+
339
+ function buildRecord(event, eventSeq) {
340
+ const record = {
341
+ kind: 'agent_event',
342
+ event: event.event,
343
+ phase,
344
+ atom,
345
+ adapter,
346
+ seq: eventSeq,
347
+ };
348
+ for (const key of [
349
+ 'event_type', 'tool', 'text', 'args_preview', 'args_digest', 'usage',
350
+ 'exit_code', 'status', 'level', 'cost_usd', 'duration_ms', 'reason', 'truncated',
351
+ ]) {
352
+ const value = event[key];
353
+ if (value === undefined || value === null) continue;
354
+ record[key] = value;
355
+ }
356
+ if (changeId) record.change_id = changeId;
357
+ if (missionId) record.mission_id = missionId;
358
+ return record;
359
+ }
360
+
361
+ function schedule() {
362
+ if (timer || !queue.length || closed) return;
363
+ timer = setTimeout(() => {
364
+ timer = null;
365
+ flush().catch(() => {});
366
+ }, flushIntervalMs);
367
+ if (typeof timer.unref === 'function') timer.unref();
368
+ }
369
+
370
+ function flush() {
371
+ if (timer) {
372
+ clearTimeout(timer);
373
+ timer = null;
374
+ }
375
+ const batch = queue.splice(0, queue.length);
376
+ if (!batch.length) return writing;
377
+ writing = writing.then(async () => {
378
+ for (const record of batch) {
379
+ try {
380
+ await appendExecutionLog(projectRoot, record);
381
+ stats.written += 1;
382
+ } catch {
383
+ stats.write_errors += 1;
384
+ }
385
+ }
386
+ });
387
+ return writing;
388
+ }
389
+
390
+ return {
391
+ push(chunk) {
392
+ if (closed) return;
393
+ splitter.push(chunk);
394
+ },
395
+ /** Write the trailing partial line, then every queued event. Safe to call twice. */
396
+ async close() {
397
+ splitter.flush();
398
+ closed = true;
399
+ await flush();
400
+ return stats;
401
+ },
402
+ flush,
403
+ stats,
404
+ get pending() {
405
+ return queue.length;
406
+ },
407
+ get closed() {
408
+ return closed;
409
+ },
410
+ };
411
+ }
412
+
413
+ function digest(value) {
414
+ return createHash('sha256').update(value).digest('hex').slice(0, 16);
415
+ }
416
+
417
+ function errorText(payload) {
418
+ return String(payload.message || payload.error?.message || payload.error || payload.type || 'agent error');
419
+ }
420
+
421
+ function finiteOrNull(value) {
422
+ const number = Number(value);
423
+ return Number.isFinite(number) ? number : null;
424
+ }
425
+
426
+ function stringifyArgs(value) {
427
+ if (value === undefined || value === null) return '';
428
+ if (typeof value === 'string') return value;
429
+ try {
430
+ return JSON.stringify(value);
431
+ } catch {
432
+ return '';
433
+ }
434
+ }
435
+
436
+ function truncateText(value, maxChars = MAX_TEXT_CHARS) {
437
+ const text = typeof value === 'string' ? value : String(value ?? '');
438
+ if (text.length <= maxChars) return text;
439
+ return `${text.slice(0, maxChars)}…`;
440
+ }
@@ -10,6 +10,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync, statS
10
10
  import { resolve, dirname } from 'node:path';
11
11
  import { spawn, spawnSync } from 'node:child_process';
12
12
  import { persistSyntheticPhaseRun, defaultVerdictForPhase } from '../../agent_team/common.js';
13
+ import { createAgentEventSink } from '../agent-stream.js';
13
14
  import { resolveChangePath, writeJsonFile, writeTextFile } from '../change-artifacts.js';
14
15
 
15
16
  const ROOT = resolve(import.meta.dirname, '..', '..', '..');
@@ -65,6 +66,12 @@ export default async function agentInvoke(args, context) {
65
66
  changeDir,
66
67
  expectedArtifact: artifactPath,
67
68
  model: agentCfg.model || process.env.XFLOW_CODEX_MODEL || DEFAULT_CODEX_MODEL,
69
+ projectRoot,
70
+ changeId,
71
+ phaseId: phase?.id || null,
72
+ atomId: taskPackage.atom_id,
73
+ missionId: context.runtime?.missionId || null,
74
+ adapterName: 'codex_cli',
68
75
  });
69
76
  return {
70
77
  ...result,
@@ -82,6 +89,12 @@ export default async function agentInvoke(args, context) {
82
89
  retryDelayMs,
83
90
  changeDir,
84
91
  expectedArtifact: artifactPath,
92
+ projectRoot,
93
+ changeId,
94
+ phaseId: phase?.id || null,
95
+ atomId: taskPackage.atom_id,
96
+ missionId: context.runtime?.missionId || null,
97
+ adapterName: 'claude_cli',
85
98
  });
86
99
  return {
87
100
  ...result,
@@ -258,6 +271,24 @@ function resolveArtifactPath(artifacts, changeId, projectRoot) {
258
271
  return resolve(projectRoot, 'specs', 'changes', changeId, 'artifact.json');
259
272
  }
260
273
 
274
+ // Progress telemetry is optional: a project root is only absent when an atom is invoked
275
+ // directly by a test harness, and in that case the run must still work without a log.
276
+ function createAgentStreamSink(opts) {
277
+ if (!opts.projectRoot) return null;
278
+ try {
279
+ return createAgentEventSink({
280
+ projectRoot: opts.projectRoot,
281
+ changeId: opts.changeId || null,
282
+ missionId: opts.missionId || null,
283
+ phase: opts.phaseId || null,
284
+ atom: opts.atomId || null,
285
+ adapter: opts.adapterName || 'unknown',
286
+ });
287
+ } catch {
288
+ return null;
289
+ }
290
+ }
291
+
261
292
  async function runProc(cmd, args, opts) {
262
293
  return new Promise((resolve, reject) => {
263
294
  const child = spawn(cmd, args, {
@@ -274,7 +305,16 @@ async function runProc(cmd, args, opts) {
274
305
  let timeoutHandle = null;
275
306
  let killHandle = null;
276
307
  let artifactPollHandle = null;
277
- child.stdout.on('data', (d) => { stdout += d.toString(); });
308
+ child.stdout.on('data', (d) => {
309
+ stdout += d.toString();
310
+ if (opts.onData) {
311
+ try {
312
+ opts.onData(d);
313
+ } catch {
314
+ // Telemetry must never be able to break an agent run.
315
+ }
316
+ }
317
+ });
278
318
  child.stderr.on('data', (d) => { stderr += d.toString(); });
279
319
  child.on('error', (error) => {
280
320
  if (timeoutHandle) clearTimeout(timeoutHandle);
@@ -356,41 +396,52 @@ async function runCodexExec(prompt, opts) {
356
396
  args.push('--model', opts.model);
357
397
  }
358
398
  args.push(prompt);
359
- let { code, signal, stdout, stderr, timedOut, forceKilled, earlyArtifactExit, artifact } = await runProc('codex', args, { cwd: opts.cwd, timeoutMs: opts.timeoutMs, killAfterMs: opts.killAfterMs, input: '', expectedArtifact: opts.expectedArtifact });
360
- const output = artifact || await resolveOutputJson(opts.expectedArtifact, 10_000);
361
- let timedOutRecovered = false;
362
- if (output && timedOut) {
363
- timedOutRecovered = true;
364
- code = 0;
365
- signal = null;
366
- timedOut = false;
367
- stderr += '\n[agent_invoke] valid artifact detected after timeout; accepting artifact output';
368
- }
369
- if (output && earlyArtifactExit) {
370
- code = 0;
371
- signal = null;
399
+ const sink = createAgentStreamSink(opts);
400
+ try {
401
+ let { code, signal, stdout, stderr, timedOut, forceKilled, earlyArtifactExit, artifact } = await runProc('codex', args, { cwd: opts.cwd, timeoutMs: opts.timeoutMs, killAfterMs: opts.killAfterMs, input: '', expectedArtifact: opts.expectedArtifact, onData: sink ? (chunk) => sink.push(chunk) : null });
402
+ const output = artifact || await resolveOutputJson(opts.expectedArtifact, 10_000);
403
+ let timedOutRecovered = false;
404
+ if (output && timedOut) {
405
+ timedOutRecovered = true;
406
+ code = 0;
407
+ signal = null;
408
+ timedOut = false;
409
+ stderr += '\n[agent_invoke] valid artifact detected after timeout; accepting artifact output';
410
+ }
411
+ if (output && earlyArtifactExit) {
412
+ code = 0;
413
+ signal = null;
414
+ }
415
+ return { code, signal, stdout, stderr, artifact: output, timedOut, forceKilled, timedOutRecovered, earlyArtifactExit };
416
+ } finally {
417
+ if (sink) await sink.close().catch(() => {});
372
418
  }
373
- return { code, signal, stdout, stderr, artifact: output, timedOut, forceKilled, timedOutRecovered, earlyArtifactExit };
374
419
  }
375
420
 
376
421
  async function runClaudeCli(prompt, opts) {
377
- let { code, signal, stdout, stderr, timedOut, forceKilled, earlyArtifactExit, artifact } = await runProc('claude', [
378
- '-p', '--dangerously-skip-permissions', '--no-session-persistence', prompt,
379
- ], { cwd: opts.cwd, timeoutMs: opts.timeoutMs, killAfterMs: opts.killAfterMs, input: '', expectedArtifact: opts.expectedArtifact });
380
- const output = artifact || await resolveOutputJson(opts.expectedArtifact, 10_000);
381
- let timedOutRecovered = false;
382
- if (output && timedOut) {
383
- timedOutRecovered = true;
384
- code = 0;
385
- signal = null;
386
- timedOut = false;
387
- stderr += '\n[agent_invoke] valid artifact detected after timeout; accepting artifact output';
388
- }
389
- if (output && earlyArtifactExit) {
390
- code = 0;
391
- signal = null;
422
+ const sink = createAgentStreamSink(opts);
423
+ try {
424
+ let { code, signal, stdout, stderr, timedOut, forceKilled, earlyArtifactExit, artifact } = await runProc('claude', [
425
+ '-p', '--dangerously-skip-permissions', '--no-session-persistence',
426
+ '--output-format', 'stream-json', '--verbose', prompt,
427
+ ], { cwd: opts.cwd, timeoutMs: opts.timeoutMs, killAfterMs: opts.killAfterMs, input: '', expectedArtifact: opts.expectedArtifact, onData: sink ? (chunk) => sink.push(chunk) : null });
428
+ const output = artifact || await resolveOutputJson(opts.expectedArtifact, 10_000);
429
+ let timedOutRecovered = false;
430
+ if (output && timedOut) {
431
+ timedOutRecovered = true;
432
+ code = 0;
433
+ signal = null;
434
+ timedOut = false;
435
+ stderr += '\n[agent_invoke] valid artifact detected after timeout; accepting artifact output';
436
+ }
437
+ if (output && earlyArtifactExit) {
438
+ code = 0;
439
+ signal = null;
440
+ }
441
+ return { code, signal, stdout, stderr, artifact: output, timedOut, forceKilled, timedOutRecovered, earlyArtifactExit };
442
+ } finally {
443
+ if (sink) await sink.close().catch(() => {});
392
444
  }
393
- return { code, signal, stdout, stderr, artifact: output, timedOut, forceKilled, timedOutRecovered, earlyArtifactExit };
394
445
  }
395
446
 
396
447
  async function runPencilCli(prompt, opts) {
package/src/core/audit.js CHANGED
@@ -46,6 +46,7 @@ export const TEST_COMMAND_PATTERNS = [
46
46
  /node\s+--test\b/,
47
47
  /pytest\b/,
48
48
  /python3?\s+-m\s+pytest\b/,
49
+ /python3?\s+-m\s+unittest\b/,
49
50
  /go\s+test\b/,
50
51
  /cargo\s+test\b/,
51
52
  /mvn\s+(?:-[^ ]+\s+)*test\b/,
@@ -54,7 +55,7 @@ export const TEST_COMMAND_PATTERNS = [
54
55
 
55
56
  const COMMAND_CHARS = String.raw`[\w@./:+\-='"]`;
56
57
  const COMMAND_TAIL = new RegExp(
57
- String.raw`(?:npm\s+test|npm\s+run\s+[\w:.-]+|pnpm\s+test|yarn\s+test|node\s+--test(?:\s+${COMMAND_CHARS}+)*|pytest(?:\s+${COMMAND_CHARS}+)*|python3?\s+-m\s+pytest(?:\s+${COMMAND_CHARS}+)*|go\s+test(?:\s+${COMMAND_CHARS}+)*|cargo\s+test(?:\s+${COMMAND_CHARS}+)*|mvn\s+(?:-[^\s]+\s+)*test|npx\s+[\w@./-]+(?:\s+${COMMAND_CHARS}+)*)`,
58
+ String.raw`(?:npm\s+test|npm\s+run\s+[\w:.-]+|pnpm\s+test|yarn\s+test|node\s+--test(?:\s+${COMMAND_CHARS}+)*|pytest(?:\s+${COMMAND_CHARS}+)*|python3?\s+-m\s+pytest(?:\s+${COMMAND_CHARS}+)*|python3?\s+-m\s+unittest(?:\s+${COMMAND_CHARS}+)*|go\s+test(?:\s+${COMMAND_CHARS}+)*|cargo\s+test(?:\s+${COMMAND_CHARS}+)*|mvn\s+(?:-[^\s]+\s+)*test|npx\s+[\w@./-]+(?:\s+${COMMAND_CHARS}+)*)`,
58
59
  );
59
60
 
60
61
  export function isTestCommand(text) {
@@ -317,6 +318,7 @@ export function parseDiffFiles(diffText) {
317
318
  /**
318
319
  * Parse pass/fail counts out of a test runner's output.
319
320
  * node --test ("# pass 18", "# fail 0"), pytest ("18 passed, 0 failed"),
321
+ * python -m unittest ("Ran 18 tests" + "OK" / "FAILED (failures=1, errors=0)"),
320
322
  * mocha ("18 passing"), npm-wrapped node --test (same "# pass" lines).
321
323
  */
322
324
  export function parseReplayCounts(output, command) {
@@ -333,6 +335,30 @@ export function parseReplayCounts(output, command) {
333
335
  return { passed: nodePass, failed: nodeFail, total: nodeFail !== null ? nodePass + nodeFail : null };
334
336
  }
335
337
  if (nodePasses.length > 1 || nodeFails.length > 1) return null;
338
+ // python -m unittest prints one summary per run: "Ran N tests in ...",
339
+ // then "OK" (optionally "OK (skipped=S)") or "FAILED (failures=F,
340
+ // errors=E)". Same count semantics as the pytest branch below:
341
+ // passed = ran - failed (- skipped on an OK run).
342
+ const ran = text.match(/Ran (\d+) tests? in /);
343
+ if (ran) {
344
+ const total = Number(ran[1]);
345
+ const ok = text.match(/\nOK(?: \(skipped=(\d+)\))?\s*$/);
346
+ if (ok) {
347
+ const skipped = ok[1] !== undefined ? Number(ok[1]) : 0;
348
+ return { passed: total - skipped, failed: 0, total };
349
+ }
350
+ let m = text.match(/FAILED \(failures=(\d+)(?:, errors=(\d+))?\)/);
351
+ if (m) {
352
+ const failed = Number(m[1]) + (m[2] !== undefined ? Number(m[2]) : 0);
353
+ return { passed: total - failed, failed, total };
354
+ }
355
+ m = text.match(/FAILED \(errors=(\d+)\)/);
356
+ if (m) {
357
+ const failed = Number(m[1]);
358
+ return { passed: total - failed, failed, total };
359
+ }
360
+ return null;
361
+ }
336
362
  let m = text.match(/(\d+)\s+passed?(?:,\s*(\d+)\s+failed?)?/);
337
363
  if (m) {
338
364
  return { passed: Number(m[1]), failed: m[2] !== undefined ? Number(m[2]) : null, total: null };