mixdog 0.9.49 → 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 (39) hide show
  1. package/README.md +6 -6
  2. package/package.json +7 -7
  3. package/scripts/embedding-worker-exit-test.mjs +76 -0
  4. package/scripts/hook-bus-test.mjs +23 -0
  5. package/scripts/internal-tools-normalization-test.mjs +10 -0
  6. package/scripts/openai-oauth-ws-1006-retry-test.mjs +67 -1
  7. package/scripts/provider-toolcall-test.mjs +200 -2
  8. package/scripts/session-bench-cache-break-test.mjs +102 -0
  9. package/scripts/session-bench.mjs +101 -42
  10. package/scripts/shell-failure-diagnostics-test.mjs +73 -4
  11. package/scripts/smoke-loop-failure-summary-test.mjs +38 -0
  12. package/scripts/smoke-loop-failure-summary.mjs +16 -0
  13. package/scripts/smoke-loop.mjs +4 -3
  14. package/scripts/smoke.mjs +5 -106
  15. package/scripts/tool-failures.mjs +15 -1
  16. package/scripts/tui-transcript-perf-test.mjs +38 -0
  17. package/scripts/verify-release-assets-test.mjs +15 -381
  18. package/scripts/web-fetch-routing-test.mjs +158 -0
  19. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +12 -0
  20. package/src/runtime/agent/orchestrator/internal-tools.mjs +2 -0
  21. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +14 -4
  22. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +74 -1
  23. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +75 -3
  24. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +13 -0
  25. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +38 -0
  26. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +15 -0
  27. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +5 -1
  28. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +4 -0
  29. package/src/runtime/memory/lib/embedding-provider.mjs +3 -2
  30. package/src/runtime/search/index.mjs +41 -0
  31. package/src/runtime/search/lib/http-fetch.mjs +154 -0
  32. package/src/runtime/search/tool-defs.mjs +23 -0
  33. package/src/session-runtime/runtime-core.mjs +37 -15
  34. package/src/standalone/hook-bus/handlers.mjs +4 -0
  35. package/src/standalone/hook-bus/rules.mjs +7 -1
  36. package/src/standalone/hook-bus.mjs +10 -2
  37. package/src/tui/App.jsx +17 -11
  38. package/src/tui/app/live-spinner-visibility.mjs +20 -0
  39. 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;
@@ -58,7 +59,7 @@ function runNode(args, label, timeoutMs = 180_000) {
58
59
  function runSmokeAll(iteration) {
59
60
  let totalMs = 0;
60
61
  const steps = [];
61
- for (const script of ['scripts/boot-smoke.mjs', 'scripts/tool-smoke.mjs']) {
62
+ for (const script of ['scripts/smoke.mjs', 'scripts/boot-smoke.mjs']) {
62
63
  const result = runNode([script], `${script} iteration ${iteration}`);
63
64
  totalMs += result.ms;
64
65
  steps.push({
@@ -161,8 +162,8 @@ while (Date.now() < deadline && iteration < maxIterations) {
161
162
  const iterStartedAt = Date.now();
162
163
  const smoke = runSmokeAll(iteration);
163
164
  smokeTimes.push(smoke.ms);
164
- const failure = runNode(['scripts/tool-failures.mjs', '--since', since, '--limit', '1'], `failures iteration ${iteration}`, 60_000);
165
- if (!/tool failures:\s+0\/0 shown/.test(failure.stdout)) {
165
+ const failure = runNode(['scripts/tool-failures.mjs', '--since', since, '--limit', '1', '--json'], `failures iteration ${iteration}`, 60_000);
166
+ if (actionableFailureCount(failure.stdout) > 0) {
166
167
  throw new Error(`tool failures appeared after loop start:\n${failure.stdout}`);
167
168
  }
168
169
  const currentRss = rssMb();
package/scripts/smoke.mjs CHANGED
@@ -19,120 +19,19 @@ function runNode(args, label, options = {}) {
19
19
  return child;
20
20
  }
21
21
 
22
- const child = runNode(['src/cli.mjs', '--help'], 'help smoke');
23
- const isolatedStatuslineEnv = {
24
- MIXDOG_DATA_DIR: resolve(root, '.mixdog-smoke-data-empty'),
25
- MIXDOG_HOME: resolve(root, '.mixdog-smoke-home-empty'),
26
- MIXDOG_CONFIG_DIR: resolve(root, '.mixdog-smoke-config-empty'),
27
- };
28
-
29
- if (!child.stdout.includes('standalone mixdog CLI/TUI coding agent')) {
30
- process.stderr.write(`unexpected help output:\n${child.stdout}`);
31
- process.exit(1);
32
- }
22
+ runNode(['src/cli.mjs', '--help'], 'help smoke');
33
23
 
34
24
  runNode(['--input-type=module', '-e', `
35
25
  const mod = await import('./src/tui/dist/index.mjs');
36
26
  if (typeof mod.runTui !== 'function') throw new Error('runTui export missing');
37
27
  `], 'tui bundle import smoke');
38
28
 
39
- runNode(['--input-type=module', '-e', `
40
- process.stdout.columns = 120;
41
- const { renderStatusline } = await import('./src/ui/statusline.mjs');
42
- const line = await renderStatusline({
43
- provider: 'openai',
44
- model: 'gpt-5.5',
45
- contextWindow: 1000000,
46
- stats: { currentContextTokens: 999 },
47
- });
48
- if (/▓/.test(line) || !line.includes('░') || !line.includes('0.1%')) throw new Error('sub-1% context bar should stay empty: ' + JSON.stringify(line));
49
- `], 'statusline sub-percent context smoke', { env: isolatedStatuslineEnv });
50
-
51
- runNode(['--input-type=module', '-e', `
52
- process.stdout.columns = 120;
53
- const { renderStatusline } = await import('./src/ui/statusline.mjs');
54
- const line = await renderStatusline({
55
- provider: 'openai',
56
- model: 'gpt-5.5',
57
- contextWindow: 950000,
58
- rawContextWindow: 1000000,
59
- stats: { currentContextTokens: 900000 },
60
- });
61
- if (!line.includes('94%') || line.includes('100%')) throw new Error('statusline context% should use effective compact capacity, not the 90% auto-compact trigger: ' + JSON.stringify(line));
62
- `], 'statusline compact-capacity context smoke', { env: isolatedStatuslineEnv });
63
-
64
- runNode(['--input-type=module', '-e', `
65
- process.stdout.columns = 120;
66
- const { renderStatusline } = await import('./src/ui/statusline.mjs');
67
- const line = await renderStatusline({
68
- provider: 'openai',
69
- model: 'gpt-5.5',
70
- contextWindow: 950000,
71
- stats: { currentContextTokens: 350000, currentContextSource: 'estimated' },
72
- });
73
- if (line.includes('37%') || !line.includes('0%')) throw new Error('statusline must not show local estimated context as session usage: ' + JSON.stringify(line));
74
- `], 'statusline estimated-context isolation smoke', { env: isolatedStatuslineEnv });
75
-
76
- runNode(['--input-type=module', '-e', `
77
- process.stdout.columns = 120;
78
- const { renderStatusline } = await import('./src/ui/statusline.mjs');
79
- const line = await renderStatusline({
80
- provider: 'openai',
81
- model: 'gpt-5.5',
82
- contextWindow: 950000,
83
- stats: { currentContextTokens: 360000, currentContextSource: 'post_compact_estimate' },
84
- });
85
- if (!line.includes('37%')) throw new Error('statusline must show post-compact estimated context after stale API usage: ' + JSON.stringify(line));
86
- `], 'statusline post-compact context smoke', { env: isolatedStatuslineEnv });
87
-
88
- runNode(['--input-type=module', '-e', `
89
- process.stdout.columns = 120;
90
- const { renderStatusline } = await import('./src/ui/statusline.mjs');
91
- const line = await renderStatusline({
92
- provider: 'openai',
93
- model: 'gpt-5.5',
94
- contextWindow: 950000,
95
- stats: { currentContextTokens: 0 },
96
- agentJobs: [{ task_id: 'task_statusline_smoke', status: 'running', tag: 'bench-agent', startedAt: new Date().toISOString() }],
97
- });
98
- if (!line.includes('Running 1 Agent') || line.includes('bench-agent')) throw new Error('statusline must render live agent count without task tags: ' + JSON.stringify(line));
99
- `], 'statusline live agent task smoke', { env: isolatedStatuslineEnv });
100
-
101
- runNode(['--input-type=module', '-e', `
102
- const { mkdtempSync, mkdirSync, writeFileSync } = await import('node:fs');
103
- const { tmpdir } = await import('node:os');
104
- const { join } = await import('node:path');
105
- const root = mkdtempSync(join(tmpdir(), 'mixdog-status-smoke-'));
106
- mkdirSync(join(root, 'runtime'), { recursive: true });
107
- writeFileSync(join(root, 'runtime', 'active-instance.json'), JSON.stringify({
108
- gateway_port: 3468,
109
- gateway_server_pid: process.pid,
110
- gateway_provider: 'openai',
111
- gateway_model: 'gpt-5.5',
112
- gateway_context_window: 950000,
113
- gateway_raw_context_window: 1000000,
114
- gateway_auto_compact_token_limit: 950000,
115
- gateway_context_used_pct: 37,
116
- gateway_cc_session_id: 'other-session',
117
- gateway_updated_at: Date.now()
118
- }));
119
- process.env.MIXDOG_RUNTIME_ROOT = join(root, 'runtime');
120
- const { loadGatewayStatus } = await import('./src/vendor/statusline/bin/statusline-route.mjs');
121
- const status = loadGatewayStatus({ sessionId: 'fresh-session', clientHostPid: process.pid, activeContextTokens: 0 });
122
- if (status?.contextUsedPct === 37) throw new Error('statusline leaked active-instance context from another session');
123
- `], 'statusline session metrics isolation smoke', { env: isolatedStatuslineEnv });
124
-
125
- const boot = spawnSync(process.execPath, ['src/cli.mjs', '--help'], {
126
- cwd: root,
127
- env: { ...process.env, MIXDOG_BOOT_PROFILE: '1' },
128
- encoding: 'utf8',
129
- stdio: ['ignore', 'pipe', 'pipe'],
130
- timeout: 10000,
29
+ const boot = runNode(['src/cli.mjs', '--help'], 'boot profile smoke', {
30
+ env: { MIXDOG_BOOT_PROFILE: '1' },
131
31
  });
132
-
133
- if (boot.status !== 0 || !boot.stderr.includes('[mixdog-boot]') || !boot.stderr.includes('app:run:start')) {
32
+ if (!boot.stderr.includes('[mixdog-boot]')) {
134
33
  process.stderr.write(boot.stderr || boot.stdout || 'boot profile smoke failed\n');
135
- process.exit(boot.status || 1);
34
+ process.exit(1);
136
35
  }
137
36
 
138
37
  process.stdout.write('smoke passed ✓\n');
@@ -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' });