mixdog 0.9.50 → 0.9.51

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.
Files changed (34) hide show
  1. package/package.json +1 -1
  2. package/scripts/hook-bus-test.mjs +23 -0
  3. package/scripts/internal-tools-normalization-test.mjs +10 -0
  4. package/scripts/openai-oauth-ws-1006-retry-test.mjs +67 -1
  5. package/scripts/provider-toolcall-test.mjs +200 -2
  6. package/scripts/session-bench-cache-break-test.mjs +102 -0
  7. package/scripts/session-bench.mjs +101 -42
  8. package/scripts/shell-failure-diagnostics-test.mjs +73 -4
  9. package/scripts/smoke-loop-failure-summary-test.mjs +38 -0
  10. package/scripts/smoke-loop-failure-summary.mjs +16 -0
  11. package/scripts/smoke-loop.mjs +2 -7
  12. package/scripts/tool-failures.mjs +15 -1
  13. package/scripts/tui-transcript-perf-test.mjs +38 -0
  14. package/scripts/web-fetch-routing-test.mjs +158 -0
  15. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +12 -0
  16. package/src/runtime/agent/orchestrator/internal-tools.mjs +2 -0
  17. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +14 -4
  18. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +74 -1
  19. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +75 -3
  20. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +13 -0
  21. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +38 -0
  22. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +15 -0
  23. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +5 -1
  24. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +4 -0
  25. package/src/runtime/search/index.mjs +41 -0
  26. package/src/runtime/search/lib/http-fetch.mjs +154 -0
  27. package/src/runtime/search/tool-defs.mjs +23 -0
  28. package/src/session-runtime/runtime-core.mjs +37 -15
  29. package/src/standalone/hook-bus/handlers.mjs +4 -0
  30. package/src/standalone/hook-bus/rules.mjs +7 -1
  31. package/src/standalone/hook-bus.mjs +10 -2
  32. package/src/tui/App.jsx +17 -11
  33. package/src/tui/app/live-spinner-visibility.mjs +20 -0
  34. package/src/tui/dist/index.mjs +28 -3
@@ -210,10 +210,27 @@ function cacheBreakExplanation(reason) {
210
210
  return r ? '\uC6D0\uC778 \uBBF8\uBD84\uB958' : '\uC6D0\uC778 \uAE30\uB85D \uC5C6\uC74C';
211
211
  }
212
212
 
213
+ function cacheBreakTransitionTag(row) {
214
+ const transition = field(row, 'intentional_transition');
215
+ return typeof transition === 'string' && transition ? transition : null;
216
+ }
217
+
218
+ function cacheBreakIntentionalTransition(row) {
219
+ const transition = cacheBreakTransitionTag(row);
220
+ const reason = field(row, 'reason') || field(row, 'chain_delta_reason') || null;
221
+ if (transition === 'automatic_compaction' && reason === 'input_prefix_mismatch') return transition;
222
+ if (transition === 'transcript_rebuild' && reason === 'input_prefix_mismatch') return transition;
223
+ if (transition === 'explorer_hard_cap_final_tool_choice_none'
224
+ && reason === 'request_properties_changed'
225
+ && field(row, 'request_tool_choice') === 'none') return transition;
226
+ return null;
227
+ }
228
+
213
229
  function classifyCacheBreakPhase(row, usageRows, transportRows) {
214
230
  const reason = field(row, 'reason') || field(row, 'chain_delta_reason') || field(row, 'payload')?.reason || null;
215
231
  const sid = sessionId(row);
216
- if (isCompactSessionId(sid)) return 'intentional_compact';
232
+ const intentionalTransition = cacheBreakIntentionalTransition(row);
233
+ if (intentionalTransition) return `intentional_${intentionalTransition}`;
217
234
  const ts = Number(row.ts || 0);
218
235
  const priorUsage = usageRows.filter((r) => sessionId(r) === sid && Number(r.ts || 0) < ts)
219
236
  .sort((a, b) => Number(b.ts || 0) - Number(a.ts || 0));
@@ -397,6 +414,44 @@ function buildCacheDiagnostics(rows) {
397
414
  model: field(r, 'model'),
398
415
  ts: r.ts,
399
416
  }));
417
+ const cacheBreaks = breaks.map((r) => {
418
+ const reason = field(r, 'reason') || field(r, 'chain_delta_reason') || field(r, 'payload')?.reason || null;
419
+ const relatedUsage = nearestRowAround(
420
+ usage,
421
+ r.ts,
422
+ 5_000,
423
+ (u) => sessionId(u) === sessionId(r) && num(u, 'iteration') === num(r, 'iteration'),
424
+ ) || nearestRowBefore(
425
+ usage.filter((u) => sessionId(u) === sessionId(r)),
426
+ r.ts,
427
+ 30_000,
428
+ );
429
+ const promptTokens = relatedUsage ? cacheDenom(relatedUsage) : 0;
430
+ const cachedTokens = relatedUsage ? (num(relatedUsage, 'cached_tokens') || 0) : 0;
431
+ const transitionTag = cacheBreakTransitionTag(r);
432
+ const transition = cacheBreakIntentionalTransition(r);
433
+ return {
434
+ session_id: sessionId(r),
435
+ iteration: num(r, 'iteration'),
436
+ reason,
437
+ phase: classifyCacheBreakPhase(r, usage, transport),
438
+ intentional_transition: transitionTag,
439
+ actionable: !transition,
440
+ explanation: cacheBreakExplanation(reason),
441
+ ws_mode: field(r, 'ws_mode'),
442
+ cache_key_hash: field(r, 'cache_key_hash'),
443
+ request_tool_choice: field(r, 'request_tool_choice'),
444
+ request_has_previous_response_id: field(r, 'request_has_previous_response_id'),
445
+ body_input_items: field(r, 'body_input_items'),
446
+ frame_input_items: field(r, 'frame_input_items'),
447
+ prompt_tokens: promptTokens,
448
+ cached_tokens: cachedTokens,
449
+ cache_ratio: promptTokens > 0 ? cachedTokens / promptTokens : null,
450
+ output_tokens: relatedUsage ? (num(relatedUsage, 'output_tokens') || 0) : 0,
451
+ ts: r.ts,
452
+ };
453
+ });
454
+ const actionableCacheBreaks = cacheBreaks.filter((b) => b.actionable);
400
455
  return {
401
456
  usage_cache_ratio: cacheRatioFromUsage(usage),
402
457
  cached_tokens: sum(usage.map((r) => num(r, 'cached_tokens'))),
@@ -407,38 +462,9 @@ function buildCacheDiagnostics(rows) {
407
462
  reused_connection: transport.filter((r) => field(r, 'reused_connection') === true).length,
408
463
  previous_response_id: transport.filter((r) => field(r, 'request_has_previous_response_id') === true).length,
409
464
  cache_key_hashes: [...keyCounts.entries()].map(([key, count]) => ({ key, count })).sort((a, b) => b.count - a.count),
410
- cache_breaks: breaks.map((r) => {
411
- const reason = field(r, 'reason') || field(r, 'chain_delta_reason') || field(r, 'payload')?.reason || null;
412
- const relatedUsage = nearestRowAround(
413
- usage,
414
- r.ts,
415
- 5_000,
416
- (u) => sessionId(u) === sessionId(r) && num(u, 'iteration') === num(r, 'iteration'),
417
- ) || nearestRowBefore(
418
- usage.filter((u) => sessionId(u) === sessionId(r)),
419
- r.ts,
420
- 30_000,
421
- );
422
- const promptTokens = relatedUsage ? cacheDenom(relatedUsage) : 0;
423
- const cachedTokens = relatedUsage ? (num(relatedUsage, 'cached_tokens') || 0) : 0;
424
- return {
425
- session_id: sessionId(r),
426
- iteration: num(r, 'iteration'),
427
- reason,
428
- phase: classifyCacheBreakPhase(r, usage, transport),
429
- explanation: cacheBreakExplanation(reason),
430
- ws_mode: field(r, 'ws_mode'),
431
- cache_key_hash: field(r, 'cache_key_hash'),
432
- request_has_previous_response_id: field(r, 'request_has_previous_response_id'),
433
- body_input_items: field(r, 'body_input_items'),
434
- frame_input_items: field(r, 'frame_input_items'),
435
- prompt_tokens: promptTokens,
436
- cached_tokens: cachedTokens,
437
- cache_ratio: promptTokens > 0 ? cachedTokens / promptTokens : null,
438
- output_tokens: relatedUsage ? (num(relatedUsage, 'output_tokens') || 0) : 0,
439
- ts: r.ts,
440
- };
441
- }),
465
+ cache_breaks: cacheBreaks,
466
+ actionable_cache_breaks: actionableCacheBreaks,
467
+ intentional_cache_breaks: cacheBreaks.filter((b) => !b.actionable),
442
468
  actual_cache_misses: misses.map((r) => ({
443
469
  session_id: sessionId(r),
444
470
  iteration: num(r, 'iteration'),
@@ -1146,6 +1172,18 @@ function buildCompactDiagnostics(rows, selectedIds) {
1146
1172
  };
1147
1173
  }
1148
1174
 
1175
+ function selectCacheBreakIssues(rows, limit = 10) {
1176
+ const ordered = [...rows].sort((a, b) => (
1177
+ Number(a.ts || 0) - Number(b.ts || 0)
1178
+ || String(a.session_id || '').localeCompare(String(b.session_id || ''))
1179
+ || Number(a.iteration || 0) - Number(b.iteration || 0)
1180
+ ));
1181
+ const root = ordered.filter((row) => !isCompactSessionId(row.session_id));
1182
+ const compact = ordered.filter((row) => isCompactSessionId(row.session_id));
1183
+ if (!root.length || !compact.length || limit < 2) return ordered.slice(0, limit);
1184
+ return [...root.slice(0, limit - 1), ...compact.slice(0, 1)];
1185
+ }
1186
+
1149
1187
  function buildIssues(routeGroups, cache, tools) {
1150
1188
  const issues = [];
1151
1189
  for (const stall of (tools.readonly_stalls || []).slice(0, 10)) {
@@ -1181,7 +1219,7 @@ function buildIssues(routeGroups, cache, tools) {
1181
1219
  issues.push({ severity: 'high', type: 'cache', message: `${g.agent || shortId(g.session_id)} low cache ratio ${fmtPct(g.cache_ratio * 100)}`, session_id: g.session_id });
1182
1220
  }
1183
1221
  }
1184
- for (const b of cache.cache_breaks.slice(0, 10)) {
1222
+ for (const b of selectCacheBreakIssues(cache.actionable_cache_breaks, 10)) {
1185
1223
  issues.push({ severity: b.reason === 'no_anchor' ? 'medium' : 'high', type: 'cache_break', message: `cache_break ${b.reason || 'unknown'} at it=${b.iteration ?? '-'}`, session_id: b.session_id });
1186
1224
  }
1187
1225
  for (const m of (cache.actual_cache_misses || []).slice(0, 10)) {
@@ -1253,9 +1291,9 @@ function buildWhySlowRankings(report) {
1253
1291
  const summary = countBy(report.tools.failures, (f) => f.category || f.tool || 'unknown').slice(0, 3).map(([k, v]) => `${k}×${v}`).join(', ');
1254
1292
  ranks.push({ score: 50 + report.tools.failures.length, type: 'tool_failures', message: `${report.tools.failures.length} failed tool call(s): ${summary}` });
1255
1293
  }
1256
- if (report.cache.cache_breaks.length) {
1257
- const summary = countBy(report.cache.cache_breaks, (b) => `${b.reason || 'unknown'}/${b.phase || 'unknown'}`).slice(0, 3).map(([k, v]) => `${k}×${v}`).join(', ');
1258
- ranks.push({ score: 40 + report.cache.cache_breaks.length, type: 'cache_breaks', message: `${report.cache.cache_breaks.length} cache break(s): ${summary}` });
1294
+ if (report.cache.actionable_cache_breaks.length) {
1295
+ const summary = countBy(report.cache.actionable_cache_breaks, (b) => `${b.reason || 'unknown'}/${b.phase || 'unknown'}`).slice(0, 3).map(([k, v]) => `${k}×${v}`).join(', ');
1296
+ ranks.push({ score: 40 + report.cache.actionable_cache_breaks.length, type: 'cache_breaks', message: `${report.cache.actionable_cache_breaks.length} actionable cache break(s): ${summary}` });
1259
1297
  }
1260
1298
  if (report.cache.actual_cache_misses?.length) {
1261
1299
  const top = [...report.cache.actual_cache_misses].sort((a, b) => (b.uncached_tokens || 0) - (a.uncached_tokens || 0))[0];
@@ -1311,11 +1349,24 @@ function buildReport(rows, selectedIds, failureRows = []) {
1311
1349
  const selected = rows.filter((r) => selectedIds.includes(sessionId(r)));
1312
1350
  const selectedFailures = failureRows.filter((r) => selectedIds.includes(sessionId(r)));
1313
1351
  const routeGroups = buildRouteGroups(selected);
1314
- const cache = buildCacheDiagnostics(selected);
1352
+ const selectedCache = buildCacheDiagnostics(selected);
1315
1353
  const tools = buildToolDiagnostics(selected, selectedFailures);
1316
1354
  const stages = buildTurnDiagnostics(selected, routeGroups);
1317
1355
  const tokens = buildTokenDiagnostics(stages.turns);
1318
1356
  const compact = buildCompactDiagnostics(rows, selectedIds);
1357
+ // Compact workers are normally sibling sessions rather than direct children,
1358
+ // so their genuine mismatches are absent from `selected`. Promote only those
1359
+ // actionable rows into the root report; intentional compact transitions stay
1360
+ // isolated in compact diagnostics and selected compact children never duplicate.
1361
+ const selectedSessionIds = new Set(selectedIds);
1362
+ const compactActionableBreaks = compact.cache_breaks.filter(
1363
+ (row) => row.actionable && !selectedSessionIds.has(row.session_id),
1364
+ );
1365
+ const cache = {
1366
+ ...selectedCache,
1367
+ cache_breaks: [...selectedCache.cache_breaks, ...compactActionableBreaks],
1368
+ actionable_cache_breaks: [...selectedCache.actionable_cache_breaks, ...compactActionableBreaks],
1369
+ };
1319
1370
  const issues = buildIssues(routeGroups, cache, tools);
1320
1371
  const tsValues = selected.map((r) => Number(r.ts || 0)).filter((n) => n > 0);
1321
1372
  const report = {
@@ -1417,9 +1468,17 @@ function renderText(report) {
1417
1468
  }
1418
1469
  }
1419
1470
  }
1420
- if (report.compact.cache_breaks.length) {
1471
+ const intentionalCompactBreaks = report.compact.cache_breaks.filter((b) => !b.actionable);
1472
+ const actionableCompactBreaks = report.compact.cache_breaks.filter((b) => b.actionable);
1473
+ if (intentionalCompactBreaks.length) {
1421
1474
  lines.push('compact cache resets (intentional):');
1422
- for (const b of report.compact.cache_breaks.slice(0, 5)) {
1475
+ for (const b of intentionalCompactBreaks.slice(0, 5)) {
1476
+ lines.push(`- ${shortId(b.session_id)} it=${b.iteration ?? '-'} phase=${b.phase || '-'} reason=${b.reason || '-'} prompt=${fmtTok(b.prompt_tokens)} out=${fmtTok(b.output_tokens)}`);
1477
+ }
1478
+ }
1479
+ if (actionableCompactBreaks.length) {
1480
+ lines.push('compact cache breaks (actionable):');
1481
+ for (const b of actionableCompactBreaks.slice(0, 5)) {
1423
1482
  lines.push(`- ${shortId(b.session_id)} it=${b.iteration ?? '-'} phase=${b.phase || '-'} reason=${b.reason || '-'} prompt=${fmtTok(b.prompt_tokens)} out=${fmtTok(b.output_tokens)}`);
1424
1483
  }
1425
1484
  }
@@ -1458,9 +1517,9 @@ function renderText(report) {
1458
1517
  lines.push(`ws: delta=${report.cache.ws_delta}, full=${report.cache.ws_full}, previous_response_id=${report.cache.previous_response_id}, reused=${report.cache.reused_connection}/${report.cache.transport_count}`);
1459
1518
  if (report.cache.cache_key_hashes.length) lines.push(`cache keys: ${report.cache.cache_key_hashes.slice(0, 5).map((k) => `${k.key}×${k.count}`).join(', ')}`);
1460
1519
  if (report.cache.cache_breaks.length) {
1461
- lines.push('cache breaks:');
1520
+ lines.push(`cache breaks (raw=${report.cache.cache_breaks.length}, actionable=${report.cache.actionable_cache_breaks.length}):`);
1462
1521
  for (const b of report.cache.cache_breaks.slice(0, 10)) {
1463
- lines.push(`- ${shortId(b.session_id)} it=${b.iteration ?? '-'} phase=${b.phase || '-'} reason=${b.reason || '-'} (${b.explanation || '-'}) ws=${b.ws_mode || '-'} prev=${b.request_has_previous_response_id} cache=${fmtPct((b.cache_ratio ?? 0) * 100)} prompt=${fmtTok(b.prompt_tokens)} out=${fmtTok(b.output_tokens)} body/frame=${b.body_input_items ?? '-'}/${b.frame_input_items ?? '-'}`);
1522
+ lines.push(`- ${shortId(b.session_id)} it=${b.iteration ?? '-'} ${b.actionable ? 'actionable' : `intentional:${b.intentional_transition}`} phase=${b.phase || '-'} reason=${b.reason || '-'} (${b.explanation || '-'}) ws=${b.ws_mode || '-'} tool_choice=${b.request_tool_choice ?? '-'} prev=${b.request_has_previous_response_id} cache=${fmtPct((b.cache_ratio ?? 0) * 100)} prompt=${fmtTok(b.prompt_tokens)} out=${fmtTok(b.output_tokens)} body/frame=${b.body_input_items ?? '-'}/${b.frame_input_items ?? '-'}`);
1464
1523
  }
1465
1524
  }
1466
1525
  if (report.cache.actual_cache_misses?.length) {
@@ -29,6 +29,14 @@ test('shell trace classification uses only the leading status marker', () => {
29
29
  'Error: [shell-run-failed] [signal: SIGKILL cause: output-limit]',
30
30
  'shell',
31
31
  ), 'runtime/failure');
32
+ assert.equal(classifyToolFailure(
33
+ 'Session "sess_cancelled" closed: aborted during call',
34
+ 'shell',
35
+ ), 'expected-cancellation');
36
+ assert.equal(classifyToolFailure(
37
+ 'call aborted',
38
+ 'read',
39
+ ), 'timeout/abort');
32
40
  assert.equal(classifyToolFailure(
33
41
  '⚠️ destructive command warning\nError: [shell-run-failed] [signal: SIGKILL]',
34
42
  'shell',
@@ -177,7 +185,7 @@ test('cancellation racing with auto-background adoption is returned as cancelled
177
185
  assert.equal(result.killCause, 'cancellation');
178
186
  });
179
187
 
180
- test('tool-failures separates actionable totals while retaining command-exit rows', () => {
188
+ test('tool-failures excludes session cancellations but retains real abort failures', () => {
181
189
  const dir = mkdtempSync(join(tmpdir(), 'mixdog-tool-failures-test-'));
182
190
  try {
183
191
  const history = join(dir, 'history');
@@ -185,8 +193,17 @@ test('tool-failures separates actionable totals while retaining command-exit row
185
193
  const rows = [
186
194
  { ts: 1, tool_name: 'shell', category: 'process/signal', error_first_line: 'SIGKILL' },
187
195
  { ts: 2, tool_name: 'shell', category: 'runtime/failure', error_first_line: 'capture guard' },
196
+ { ts: 3, tool_name: 'shell', category: 'timeout/abort', error_first_line: 'Session "sess_cancelled" closed: aborted during call' },
197
+ {
198
+ ts: 4,
199
+ tool_name: 'shell',
200
+ category: 'timeout/abort',
201
+ error_first_line: '⚠️ destructive command warning',
202
+ error_preview: '⚠️ destructive command warning\nSession "sess_warning" closed: aborted during call',
203
+ },
204
+ { ts: 5, tool_name: 'shell', category: 'timeout/abort', error_first_line: 'request timed out' },
188
205
  ...Array.from({ length: 45 }, (_, index) => ({
189
- ts: index + 3,
206
+ ts: index + 6,
190
207
  tool_name: 'shell',
191
208
  category: 'command-exit',
192
209
  error_first_line: `exit ${index}`,
@@ -196,16 +213,68 @@ test('tool-failures separates actionable totals while retaining command-exit row
196
213
  const script = resolve('scripts/tool-failures.mjs');
197
214
  const text = spawnSync(process.execPath, [script, '--data-dir', dir, '--limit', '2'], { encoding: 'utf8' });
198
215
  assert.equal(text.status, 0, text.stderr);
199
- assert.match(text.stdout, /actionable failures: 2\/2 shown/);
216
+ assert.match(text.stdout, /actionable failures: 2\/3 shown/);
200
217
  assert.match(text.stdout, /command exits: 2\/45 shown \(retained\)/);
218
+ assert.doesNotMatch(text.stdout, /aborted during call/);
201
219
  assert.equal((text.stdout.match(/^- /gm) || []).length, 4);
202
220
  const json = spawnSync(process.execPath, [script, '--data-dir', dir, '--limit', '2', '--json'], { encoding: 'utf8' });
203
221
  assert.equal(json.status, 0, json.stderr);
204
222
  const report = JSON.parse(json.stdout);
205
- assert.deepEqual(report.actionable_failures, { shown: 2, matched: 2 });
223
+ assert.deepEqual(report.actionable_failures, { shown: 2, matched: 3 });
206
224
  assert.deepEqual(report.command_exits, { shown: 2, matched: 45 });
207
225
  assert.equal(report.rows.length, 4);
208
226
  } finally {
209
227
  rmSync(dir, { recursive: true, force: true });
210
228
  }
211
229
  });
230
+
231
+ test('session cancellations remain traceable without entering tool-failures.jsonl', () => {
232
+ const dir = mkdtempSync(join(tmpdir(), 'mixdog-session-cancellation-test-'));
233
+ try {
234
+ const tracePath = join(dir, 'agent-trace.jsonl');
235
+ const failurePath = join(dir, 'tool-failures.jsonl');
236
+ const child = spawnSync(process.execPath, ['--input-type=module', '-e', `
237
+ import { existsSync, readFileSync } from 'node:fs';
238
+ import { traceAgentTool } from './src/runtime/agent/orchestrator/agent-trace-format.mjs';
239
+ import { drainAgentTrace } from './src/runtime/agent/orchestrator/agent-trace-io.mjs';
240
+ traceAgentTool({
241
+ sessionId: 'sess_cancelled',
242
+ iteration: 1,
243
+ toolName: 'read',
244
+ toolKind: 'function',
245
+ toolMs: 1,
246
+ toolArgs: { path: 'ignored' },
247
+ agent: 'worker',
248
+ model: 'test',
249
+ cwd: process.cwd(),
250
+ resultKind: 'error',
251
+ resultText: 'Session "sess_cancelled" closed: aborted during call',
252
+ });
253
+ await drainAgentTrace();
254
+ await new Promise((resolve) => setTimeout(resolve, 300));
255
+ const trace = JSON.parse(readFileSync(process.env.MIXDOG_AGENT_TRACE_PATH, 'utf8').trim());
256
+ process.stdout.write(JSON.stringify({
257
+ failureLogExists: existsSync(process.env.MIXDOG_TOOL_FAILURE_LOG_PATH),
258
+ category: trace.result_error_category,
259
+ }));
260
+ `], {
261
+ cwd: process.cwd(),
262
+ encoding: 'utf8',
263
+ env: {
264
+ ...process.env,
265
+ MIXDOG_AGENT_TRACE_PATH: tracePath,
266
+ MIXDOG_TOOL_FAILURE_LOG_PATH: failurePath,
267
+ MIXDOG_AGENT_TRACE_DISABLE: '',
268
+ MIXDOG_AGENT_TRACE_LOCAL_DISABLE: '',
269
+ MIXDOG_RUNTIME_ROOT: join(dir, 'no-service'),
270
+ },
271
+ });
272
+ assert.equal(child.status, 0, child.stderr);
273
+ assert.deepEqual(JSON.parse(child.stdout), {
274
+ failureLogExists: false,
275
+ category: 'expected-cancellation',
276
+ });
277
+ } finally {
278
+ rmSync(dir, { recursive: true, force: true });
279
+ }
280
+ });
@@ -0,0 +1,38 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { actionableFailureCount } from './smoke-loop-failure-summary.mjs';
4
+
5
+ test('returns a numeric actionable failure count', () => {
6
+ assert.equal(actionableFailureCount('{"actionable_failures":{"matched":0}}'), 0);
7
+ assert.equal(actionableFailureCount('{"actionable_failures":{"matched":1}}'), 1);
8
+ });
9
+
10
+ test('rejects malformed tool-failures JSON', () => {
11
+ assert.throws(
12
+ () => actionableFailureCount('{'),
13
+ /tool failures returned malformed JSON/,
14
+ );
15
+ });
16
+
17
+ test('rejects a missing or malformed actionable failure count', () => {
18
+ assert.throws(
19
+ () => actionableFailureCount('{}'),
20
+ /tool failures returned malformed result schema/,
21
+ );
22
+ assert.throws(
23
+ () => actionableFailureCount('{"actionable_failures":{"matched":"0"}}'),
24
+ /tool failures returned malformed result schema/,
25
+ );
26
+ assert.throws(
27
+ () => actionableFailureCount('{"actionable_failures":{"matched":-1}}'),
28
+ /tool failures returned malformed result schema/,
29
+ );
30
+ assert.throws(
31
+ () => actionableFailureCount('{"actionable_failures":{"matched":0.5}}'),
32
+ /tool failures returned malformed result schema/,
33
+ );
34
+ assert.throws(
35
+ () => actionableFailureCount('{"actionable_failures":{"matched":1e999}}'),
36
+ /tool failures returned malformed result schema/,
37
+ );
38
+ });
@@ -0,0 +1,16 @@
1
+ export function actionableFailureCount(stdout) {
2
+ let summary;
3
+ try {
4
+ summary = JSON.parse(stdout);
5
+ } catch {
6
+ throw new Error(`tool failures returned malformed JSON:\n${stdout}`);
7
+ }
8
+
9
+ const matched = summary?.actionable_failures?.matched;
10
+ if (!Number.isInteger(matched) || matched < 0) {
11
+ throw new Error(
12
+ `tool failures returned malformed result schema: expected actionable_failures.matched to be a non-negative integer:\n${stdout}`,
13
+ );
14
+ }
15
+ return matched;
16
+ }
@@ -3,6 +3,7 @@ import { appendFileSync, mkdirSync } from 'node:fs';
3
3
  import { spawnSync } from 'node:child_process';
4
4
  import { dirname, isAbsolute, resolve } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
+ import { actionableFailureCount } from './smoke-loop-failure-summary.mjs';
6
7
 
7
8
  const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
8
9
  const DEFAULT_DURATION_MS = 5 * 60 * 60 * 1000;
@@ -162,13 +163,7 @@ while (Date.now() < deadline && iteration < maxIterations) {
162
163
  const smoke = runSmokeAll(iteration);
163
164
  smokeTimes.push(smoke.ms);
164
165
  const failure = runNode(['scripts/tool-failures.mjs', '--since', since, '--limit', '1', '--json'], `failures iteration ${iteration}`, 60_000);
165
- let failureSummary;
166
- try {
167
- failureSummary = JSON.parse(failure.stdout);
168
- } catch {
169
- throw new Error(`tool failures returned malformed JSON:\n${failure.stdout}`);
170
- }
171
- if (failureSummary?.actionable_failures?.matched !== 0) {
166
+ if (actionableFailureCount(failure.stdout) > 0) {
172
167
  throw new Error(`tool failures appeared after loop start:\n${failure.stdout}`);
173
168
  }
174
169
  const currentRss = rssMb();
@@ -95,7 +95,21 @@ const rows = files.flatMap(readRows)
95
95
  .filter((row) => !categoryFilter || rowCategory(row) === categoryFilter)
96
96
  .sort((a, b) => Number(a.ts || 0) - Number(b.ts || 0));
97
97
  const isCommandExit = (row) => rowCategory(row) === 'command-exit';
98
- const actionableRows = rows.filter((row) => !isCommandExit(row));
98
+ const rowLeadingErrorLine = (row) => [
99
+ row.error_first_line,
100
+ row.error_preview,
101
+ row.result,
102
+ row.error,
103
+ row.message,
104
+ ].filter(Boolean).join('\n').split(/\r?\n/)
105
+ .map((line) => line.trim())
106
+ .find((line) => line && !line.startsWith('⚠️ '))
107
+ ?.replace(/^Error:\s*/i, '') || '';
108
+ const isExpectedCancellation = (row) => {
109
+ if (rowCategory(row) === 'expected-cancellation') return true;
110
+ return /^Session\s+"[^"]+"\s+closed:\s*(?:aborted|closed)\s+during call\b/i.test(rowLeadingErrorLine(row));
111
+ };
112
+ const actionableRows = rows.filter((row) => !isCommandExit(row) && !isExpectedCancellation(row));
99
113
  const commandExitRows = rows.filter(isCommandExit);
100
114
  // Limit each partition independently so a burst of ordinary command exits
101
115
  // cannot crowd runtime/actionable failures out of the displayed report.
@@ -5,6 +5,11 @@ import { createEngineItemMutators, replaceEngineItemsState } from '../src/tui/en
5
5
  import { createEngineApiA } from '../src/tui/engine/session-api.mjs';
6
6
  import { createRunTurn } from '../src/tui/engine/turn.mjs';
7
7
  import { buildTranscriptRowIndexIncremental } from '../src/tui/app/transcript-window.mjs';
8
+ import {
9
+ isCompletedTranscriptTail,
10
+ isCompletedTranscriptTailAppendedThisCommit,
11
+ isLiveSpinnerMetaVisible,
12
+ } from '../src/tui/app/live-spinner-visibility.mjs';
8
13
 
9
14
  const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
10
15
 
@@ -91,6 +96,7 @@ function makeTurnHarness(ask, stateOverrides = {}, bagOverrides = {}) {
91
96
 
92
97
  test('successful mid-turn compact trims history but preserves live turn references', async () => {
93
98
  let preservedTail = false;
99
+ let compactKeepsSpinnerVisible = false;
94
100
  let contextSyncs = 0;
95
101
  const harness = makeTurnHarness(async (_text, options) => {
96
102
  options.onTextDelta('before compact\n');
@@ -98,6 +104,8 @@ test('successful mid-turn compact trims history but preserves live turn referenc
98
104
  options.onCompactEvent({ status: 'compacted', trigger: 'reactive' });
99
105
  assert.equal(contextSyncs, 1, 'compact event must refresh context before returning');
100
106
  preservedTail = harness.getState().streamingTail?.text === 'before compact\n';
107
+ compactKeepsSpinnerVisible = harness.getState().spinner?.active === true
108
+ && harness.getState().items.at(-1)?.kind === 'statusdone';
101
109
  options.onCompactEvent({ status: 'compacted', trigger: 'reactive' });
102
110
  assert.equal(contextSyncs, 2, 'each compact event refreshes context immediately');
103
111
  options.onTextDelta('after compact\n');
@@ -113,6 +121,7 @@ test('successful mid-turn compact trims history but preserves live turn referenc
113
121
 
114
122
  assert.equal(await harness.runTurn('go', { submittedIds: ['current-user'] }), 'done');
115
123
  assert.equal(preservedTail, true);
124
+ assert.equal(compactKeepsSpinnerVisible, true, 'a compact status leaves the active turn spinner in place');
116
125
  assert.equal(harness.getState().items.some((item) => item.id === 'old'), false);
117
126
  assert.equal(harness.getState().items.some((item) => item.id === 'current-user'), true);
118
127
  assert.equal(
@@ -125,6 +134,35 @@ test('successful mid-turn compact trims history but preserves live turn referenc
125
134
  );
126
135
  });
127
136
 
137
+ test('live spinner remains visible across compact status and follows turn teardown', () => {
138
+ const activeTurnSpinner = { active: true };
139
+ const visible = (liveSpinner, liveSpinnerIsCommand, kind) => isLiveSpinnerMetaVisible({
140
+ inputBoxHidden: false,
141
+ slashPaletteOpen: false,
142
+ liveSpinner,
143
+ liveSpinnerIsCommand,
144
+ latestTranscriptItem: kind ? { kind } : null,
145
+ });
146
+
147
+ assert.equal(visible(activeTurnSpinner, false, 'statusdone'), true);
148
+ assert.equal(visible(activeTurnSpinner, false, 'turndone'), false);
149
+ assert.equal(visible(null, false, 'turndone'), false);
150
+ assert.equal(visible({ active: true }, true, 'turndone'), true);
151
+ });
152
+
153
+ test('completed transcript tail masking requires a new done tail', () => {
154
+ assert.equal(isCompletedTranscriptTail({ id: 'turn', kind: 'turndone' }), true);
155
+ assert.equal(isCompletedTranscriptTail({ id: 'status', kind: 'statusdone' }), true);
156
+ assert.equal(isCompletedTranscriptTail({ id: 'assistant', kind: 'assistant' }), false);
157
+ assert.equal(isCompletedTranscriptTail({ id: 'user', kind: 'user' }), false);
158
+ assert.equal(isCompletedTranscriptTail(null), false);
159
+ assert.equal(isCompletedTranscriptTailAppendedThisCommit({ id: 'turn', kind: 'turndone' }, 'turn'), false);
160
+ assert.equal(isCompletedTranscriptTailAppendedThisCommit({ id: 'status', kind: 'statusdone' }, 'status'), false);
161
+ assert.equal(isCompletedTranscriptTailAppendedThisCommit({ id: 'next', kind: 'turndone' }, 'previous'), true);
162
+ assert.equal(isCompletedTranscriptTailAppendedThisCommit({ id: 'next-status', kind: 'statusdone' }, 'previous'), true);
163
+ assert.equal(isCompletedTranscriptTailAppendedThisCommit({ id: 'assistant', kind: 'assistant' }, 'previous'), false);
164
+ });
165
+
128
166
  test('failed mid-turn compact leaves prior transcript items untouched', async () => {
129
167
  const harness = makeTurnHarness(async (_text, options) => {
130
168
  options.onCompactEvent({ status: 'failed' });
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env node
2
+ import test from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import http from 'node:http';
5
+ import {
6
+ isLoopbackHttpUrl,
7
+ preDispatchDenyForSession,
8
+ } from '../src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs';
9
+ import { executeTool } from '../src/runtime/agent/orchestrator/session/loop/tool-exec.mjs';
10
+ import {
11
+ getInternalTools,
12
+ setInternalToolsProvider,
13
+ } from '../src/runtime/agent/orchestrator/internal-tools.mjs';
14
+ import { previewSessionTools } from '../src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs';
15
+ import { TOOL_DEFS as SEARCH_TOOL_DEFS } from '../src/runtime/search/tool-defs.mjs';
16
+ import { dispatchSearchRuntimeTool } from '../src/session-runtime/runtime-core.mjs';
17
+ import {
18
+ fetchLoopbackText,
19
+ fetchPublicImage,
20
+ } from '../src/runtime/search/lib/http-fetch.mjs';
21
+
22
+ test('pre-dispatch keeps hook-facing web_fetch name unchanged', () => {
23
+ const local = { name: 'web_fetch', arguments: { url: 'http://127.0.0.1:4321/status' } };
24
+ const image = { name: 'web_fetch', arguments: { url: 'https://cdn.example.com/a.png?x=1' } };
25
+ const document = { name: 'web_fetch', arguments: { url: 'https://example.com/docs' } };
26
+ assert.equal(preDispatchDenyForSession({}, local), null);
27
+ assert.equal(preDispatchDenyForSession({}, image), null);
28
+ assert.equal(preDispatchDenyForSession({}, document), null);
29
+ assert.equal(local.name, 'web_fetch');
30
+ assert.equal(image.name, 'web_fetch');
31
+ assert.equal(document.name, 'web_fetch');
32
+ assert.equal(isLoopbackHttpUrl('http://localhost:80/'), true);
33
+ assert.equal(isLoopbackHttpUrl('http://192.168.1.2/'), false);
34
+ });
35
+
36
+ test('hidden routed tools remain dispatchable but absent from every model schema', () => {
37
+ setInternalToolsProvider({
38
+ tools: SEARCH_TOOL_DEFS,
39
+ executor: async () => '',
40
+ });
41
+ const registered = getInternalTools().map((tool) => tool.name);
42
+ assert.equal(registered.includes('local_fetch'), true);
43
+ assert.equal(registered.includes('image_fetch'), true);
44
+ for (const spec of ['full', 'mcp', 'readonly', ['tools:mcp']]) {
45
+ const visible = previewSessionTools(spec, []).map((tool) => tool.name);
46
+ assert.equal(visible.includes('local_fetch'), false, `local_fetch leaked for ${JSON.stringify(spec)}`);
47
+ assert.equal(visible.includes('image_fetch'), false, `image_fetch leaked for ${JSON.stringify(spec)}`);
48
+ assert.equal(visible.includes('web_fetch'), true, `web_fetch missing for ${JSON.stringify(spec)}`);
49
+ }
50
+ });
51
+
52
+ test('hook sees public name before routed dispatch and can change routing inputs', async () => {
53
+ const observed = [];
54
+ setInternalToolsProvider({
55
+ tools: SEARCH_TOOL_DEFS,
56
+ executor: async (name, args) => {
57
+ observed.push({ stage: 'dispatch', name, args });
58
+ return { content: [{ type: 'text', text: name }] };
59
+ },
60
+ });
61
+ const result = await executeTool(
62
+ 'web_fetch',
63
+ { url: 'https://example.com/document' },
64
+ process.cwd(),
65
+ 'routing-order-test',
66
+ {
67
+ beforeToolHook: async ({ name, args }) => {
68
+ observed.push({ stage: 'hook', name, args });
69
+ return { action: 'modify', args: { url: 'http://127.0.0.1:4321/status' } };
70
+ },
71
+ },
72
+ { toolCallId: 'routing-order-call' },
73
+ );
74
+ assert.equal(result, 'local_fetch');
75
+ assert.deepEqual(observed.map(({ stage, name }) => ({ stage, name })), [
76
+ { stage: 'hook', name: 'web_fetch' },
77
+ { stage: 'dispatch', name: 'local_fetch' },
78
+ ]);
79
+ });
80
+
81
+ test('cancellation signal propagates across executeTool and runtime search dispatch', async () => {
82
+ const received = [];
83
+ setInternalToolsProvider({
84
+ tools: SEARCH_TOOL_DEFS,
85
+ executor: (name, args, callerCtx) => dispatchSearchRuntimeTool(name, args, callerCtx, {
86
+ getSearchModule: async () => ({
87
+ handleToolCall: async (_name, _args, options) => {
88
+ received.push({ name: _name, signal: options.signal });
89
+ await new Promise((resolve, reject) => {
90
+ if (options.signal.aborted) reject(options.signal.reason);
91
+ else options.signal.addEventListener('abort', () => reject(options.signal.reason), { once: true });
92
+ });
93
+ },
94
+ }),
95
+ getCurrentCwd: () => process.cwd(),
96
+ getSession: () => null,
97
+ notifyFnForSession: () => null,
98
+ runNativeWebSearch: async () => null,
99
+ }),
100
+ });
101
+ for (const [index, testCase] of [
102
+ { url: 'https://example.com/document', expectedName: 'web_fetch' },
103
+ { url: 'http://127.0.0.1:4321/status', expectedName: 'local_fetch' },
104
+ ].entries()) {
105
+ const controller = new AbortController();
106
+ const running = executeTool(
107
+ 'web_fetch',
108
+ { url: testCase.url },
109
+ process.cwd(),
110
+ `cancel-test-${index}`,
111
+ {},
112
+ { toolCallId: `cancel-call-${index}`, signal: controller.signal },
113
+ );
114
+ controller.abort(new Error(`cancelled-by-test-${index}`));
115
+ await assert.rejects(running, new RegExp(`cancelled-by-test-${index}`));
116
+ assert.equal(received[index].name, testCase.expectedName);
117
+ assert.equal(received[index].signal, controller.signal);
118
+ }
119
+ });
120
+
121
+ test('local_fetch reads loopback and rejects redirect escape', async (t) => {
122
+ const server = http.createServer((req, res) => {
123
+ if (req.url === '/escape') {
124
+ res.writeHead(302, { location: 'http://169.254.169.254/latest/meta-data/' });
125
+ res.end();
126
+ return;
127
+ }
128
+ res.writeHead(200, { 'content-type': 'text/plain' });
129
+ res.end('local-ok');
130
+ });
131
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
132
+ t.after(() => server.close());
133
+ const { port } = server.address();
134
+ assert.equal(await fetchLoopbackText(`http://127.0.0.1:${port}/ok`), 'local-ok');
135
+ await assert.rejects(fetchLoopbackText(`http://127.0.0.1:${port}/escape`), /non-loopback/);
136
+ await assert.rejects(fetchLoopbackText('http://10.0.0.1/'), /non-loopback/);
137
+ });
138
+
139
+ test('public image fetch is bounded, media-shaped, and blocks private redirect targets', async () => {
140
+ const png = Buffer.from('89504e470d0a1a0a', 'hex');
141
+ const okFetch = async () => new Response(png, {
142
+ status: 200,
143
+ headers: { 'content-type': 'image/png', 'content-length': String(png.length) },
144
+ });
145
+ const image = await fetchPublicImage('https://images.example.com/a.png', { fetchImpl: okFetch });
146
+ assert.deepEqual(image, { mimeType: 'image/png', data: png.toString('base64'), bytes: png.length });
147
+
148
+ let calls = 0;
149
+ const redirectFetch = async () => {
150
+ calls++;
151
+ return new Response(null, { status: 302, headers: { location: 'http://169.254.169.254/latest/meta-data/' } });
152
+ };
153
+ await assert.rejects(
154
+ fetchPublicImage('https://images.example.com/a.png', { fetchImpl: redirectFetch }),
155
+ /private address/,
156
+ );
157
+ assert.equal(calls, 1);
158
+ });