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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.50",
3
+ "version": "0.9.51",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -60,6 +60,29 @@ if (command.includes('rm -rf')) {
60
60
  }
61
61
  });
62
62
 
63
+ test('standard PreToolUse hook can replace the tool name', async () => {
64
+ const root = tempRoot();
65
+ const hookScript = join(root, 'rename.mjs');
66
+ writeFileSync(hookScript, `console.log(JSON.stringify({ hookSpecificOutput: {
67
+ hookEventName: 'PreToolUse',
68
+ updatedToolName: 'local_fetch'
69
+ }}));\n`, 'utf8');
70
+ const hooksFile = join(root, 'hooks.json');
71
+ writeJson(hooksFile, { hooks: { PreToolUse: [{ matcher: 'web_fetch', hooks: [{ type: 'command', command: process.execPath, args: [hookScript] }] }] } });
72
+ const prev = process.env.MIXDOG_HOOKS_FILE;
73
+ process.env.MIXDOG_HOOKS_FILE = hooksFile;
74
+ try {
75
+ const decision = await createStandaloneHookBus({ dataDir: root }).beforeTool({
76
+ cwd: root, name: 'web_fetch', args: { url: 'http://localhost:3000/' },
77
+ });
78
+ assert.equal(decision.action, 'modify');
79
+ assert.equal(decision.name, 'local_fetch');
80
+ } finally {
81
+ if (prev == null) delete process.env.MIXDOG_HOOKS_FILE;
82
+ else process.env.MIXDOG_HOOKS_FILE = prev;
83
+ }
84
+ });
85
+
63
86
  test('UserPromptSubmit hook returns additional context', async () => {
64
87
  const root = tempRoot();
65
88
  const hookScript = join(root, 'prompt.mjs');
@@ -50,3 +50,13 @@ test('isError:false output stays normal', async () => {
50
50
  assert.equal(result, 'search complete');
51
51
  assert.equal(classifyResultKind(result), 'normal');
52
52
  });
53
+
54
+ test('structured image output survives internal tool normalization', async () => {
55
+ const input = {
56
+ content: [
57
+ { type: 'text', text: 'downloaded' },
58
+ { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'YWJj' } },
59
+ ],
60
+ };
61
+ assert.deepEqual(await normalizeToolResult(input), input);
62
+ });
@@ -1,9 +1,16 @@
1
1
  // Regression: OpenAI OAuth WS abnormal-close recovery before visible output.
2
2
  import test from 'node:test';
3
3
  import assert from 'node:assert/strict';
4
+ import { EventEmitter } from 'node:events';
4
5
  import { OpenAIOAuthProvider } from '../src/runtime/agent/orchestrator/providers/openai-oauth.mjs';
5
6
  import { _acquireWithRetry, sendViaWebSocket } from '../src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs';
6
- import { _sendFrame } from '../src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs';
7
+ import {
8
+ _clearWebSocketPoolForTest,
9
+ _seedWebSocketEntryForTest,
10
+ acquireWebSocket,
11
+ releaseWebSocket,
12
+ _sendFrame,
13
+ } from '../src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs';
7
14
 
8
15
  function close1006() {
9
16
  const err = new Error('WebSocket closed abnormally');
@@ -28,6 +35,65 @@ function wsArgs(overrides = {}) {
28
35
  };
29
36
  }
30
37
 
38
+ class PoolSocket extends EventEmitter {
39
+ constructor() {
40
+ super();
41
+ this.readyState = 1;
42
+ this._socket = { ref() {}, unref() {} };
43
+ }
44
+ close() { this.readyState = 3; }
45
+ ping() { queueMicrotask(() => this.emit('pong')); }
46
+ }
47
+
48
+ function poolEntry(responseId) {
49
+ return {
50
+ socket: new PoolSocket(),
51
+ busy: true,
52
+ closing: false,
53
+ ephemeral: false,
54
+ lastResponseId: responseId,
55
+ };
56
+ }
57
+
58
+ test('pool release/acquire preserves latest compatible chain and auth/cache boundaries', async (t) => {
59
+ t.after(() => _clearWebSocketPoolForTest());
60
+ const poolKey = 'reused-session-id';
61
+ const authA = { account_id: 'account-a', access_token: 'token-a' };
62
+ const authB = { account_id: 'account-b', access_token: 'token-b' };
63
+ const oldest = _seedWebSocketEntryForTest({
64
+ poolKey, auth: authA, cacheKey: 'cache-a', entry: poolEntry('resp-old'),
65
+ });
66
+ const latest = _seedWebSocketEntryForTest({
67
+ poolKey, auth: authA, cacheKey: 'cache-a', entry: poolEntry('resp-latest'),
68
+ });
69
+ const otherAccount = _seedWebSocketEntryForTest({
70
+ poolKey, auth: authB, cacheKey: 'cache-a', entry: poolEntry('resp-account-b'),
71
+ });
72
+ const otherCache = _seedWebSocketEntryForTest({
73
+ poolKey, auth: authA, cacheKey: 'cache-b', entry: poolEntry('resp-cache-b'),
74
+ });
75
+
76
+ releaseWebSocket({ entry: oldest, poolKey, keep: true });
77
+ releaseWebSocket({ entry: otherAccount, poolKey, keep: true });
78
+ releaseWebSocket({ entry: otherCache, poolKey, keep: true });
79
+ releaseWebSocket({ entry: latest, poolKey, keep: true });
80
+
81
+ const first = await acquireWebSocket({
82
+ auth: authA, poolKey, cacheKey: 'cache-a', forceFresh: false,
83
+ });
84
+ assert.equal(first.entry, latest, 'latest completed compatible chain wins');
85
+ assert.equal(first.reused, true);
86
+ assert.equal(latest.busy, true, 'acquire reserves the selected entry');
87
+
88
+ const second = await acquireWebSocket({
89
+ auth: authA, poolKey, cacheKey: 'cache-a', forceFresh: false,
90
+ });
91
+ assert.equal(second.entry, oldest, 'reserved latest entry cannot be acquired concurrently');
92
+ assert.equal(second.entry.lastResponseId, 'resp-old');
93
+ assert.notEqual(second.entry, otherAccount);
94
+ assert.notEqual(second.entry, otherCache);
95
+ });
96
+
31
97
  test('acquire timeout reconnects successfully with progress, not a terminal WS error', async () => {
32
98
  const oldWrite = process.stderr.write;
33
99
  const oldQuiet = process.env.MIXDOG_QUIET_PROVIDER_LOG;
@@ -37,6 +37,7 @@ import {
37
37
  import {
38
38
  _cacheObservationForTest,
39
39
  _cacheContinuityResetReasonForTest,
40
+ sendViaWebSocket,
40
41
  } from '../src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs';
41
42
  import {
42
43
  _withCodexWsClientMetadata,
@@ -1786,13 +1787,23 @@ test('openai oauth ws delta: ws-delta mode uses previous_response_id without tur
1786
1787
  entry: {
1787
1788
  ...entry,
1788
1789
  lastRequestInput: [body.input[0]],
1789
- lastResponseItems: [{ type: 'function_call', call_id: 'call_1', name: 'tool', arguments: '{}' }],
1790
+ lastResponseItems: [{
1791
+ type: 'function_call',
1792
+ call_id: 'call_1',
1793
+ name: 'tool',
1794
+ arguments: '{"api_key":"provider-function-secret","nested":{"z":2,"a":1}}',
1795
+ }],
1790
1796
  },
1791
1797
  body: {
1792
1798
  ...body,
1793
1799
  input: [
1794
1800
  body.input[0],
1795
- { type: 'function_call', call_id: 'call_other', name: 'tool', arguments: '{}' },
1801
+ {
1802
+ type: 'function_call',
1803
+ call_id: 'call_other',
1804
+ name: 'tool',
1805
+ arguments: '{"nested":{"a":1,"z":2},"api_key":"replayed-function-secret"}',
1806
+ },
1796
1807
  body.input[1],
1797
1808
  ],
1798
1809
  },
@@ -1800,6 +1811,193 @@ test('openai oauth ws delta: ws-delta mode uses previous_response_id without tur
1800
1811
  assert.equal(responseMismatch.mode, 'full');
1801
1812
  assert.equal(responseMismatch.reason, 'response_output_mismatch:function_call');
1802
1813
  assert.equal(responseMismatch.frame.previous_response_id, undefined);
1814
+ const mismatch = responseMismatch.responseOutputMismatch;
1815
+ assert.deepEqual({
1816
+ expectedType: mismatch.response_output_mismatch_expected_type,
1817
+ actualType: mismatch.response_output_mismatch_actual_type,
1818
+ expectedCount: mismatch.response_output_mismatch_expected_response_item_count,
1819
+ actualCount: mismatch.response_output_mismatch_actual_replayed_input_item_count,
1820
+ }, {
1821
+ expectedType: 'function_call',
1822
+ actualType: 'function_call',
1823
+ expectedCount: 1,
1824
+ actualCount: 2,
1825
+ });
1826
+ assert.match(mismatch.response_output_mismatch_expected_hash, /^[a-f0-9]{64}$/);
1827
+ assert.match(mismatch.response_output_mismatch_actual_hash, /^[a-f0-9]{64}$/);
1828
+ const traceSafe = JSON.stringify(mismatch);
1829
+ assert.equal(traceSafe.includes('call_other'), false);
1830
+ assert.equal(traceSafe.includes('provider-function-secret'), false);
1831
+ assert.equal(traceSafe.includes('replayed-function-secret'), false);
1832
+ } finally {
1833
+ if (prevTransport == null) delete process.env.MIXDOG_OAI_TRANSPORT;
1834
+ else process.env.MIXDOG_OAI_TRANSPORT = prevTransport;
1835
+ }
1836
+ });
1837
+
1838
+ test('openai oauth ws delta: message mismatch diagnostics hash normalized content without tracing it', () => {
1839
+ const prevTransport = process.env.MIXDOG_OAI_TRANSPORT;
1840
+ try {
1841
+ process.env.MIXDOG_OAI_TRANSPORT = 'ws-delta';
1842
+ const prior = { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'prior' }] };
1843
+ const expected = { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'provider secret' }] };
1844
+ const actual = { type: 'message', role: 'assistant', content: [{ type: 'input_text', text: 'replayed secret' }] };
1845
+ const delta = _computeDelta({
1846
+ entry: {
1847
+ lastRequestSansInput: '{"model":"gpt-5.5"}',
1848
+ lastResponseId: 'resp_prev',
1849
+ lastRequestInput: [prior],
1850
+ lastResponseItems: [expected],
1851
+ },
1852
+ body: { model: 'gpt-5.5', input: [prior, actual] },
1853
+ });
1854
+ const mismatch = delta.responseOutputMismatch;
1855
+ assert.equal(delta.reason, 'response_output_mismatch:message');
1856
+ assert.equal(mismatch.response_output_mismatch_expected_type, 'message');
1857
+ assert.equal(mismatch.response_output_mismatch_actual_type, 'message');
1858
+ assert.notEqual(mismatch.response_output_mismatch_expected_hash, mismatch.response_output_mismatch_actual_hash);
1859
+ const traceSafe = JSON.stringify(mismatch);
1860
+ assert.equal(traceSafe.includes('provider secret'), false);
1861
+ assert.equal(traceSafe.includes('replayed secret'), false);
1862
+ } finally {
1863
+ if (prevTransport == null) delete process.env.MIXDOG_OAI_TRANSPORT;
1864
+ else process.env.MIXDOG_OAI_TRANSPORT = prevTransport;
1865
+ }
1866
+ });
1867
+
1868
+ test('openai oauth ws delta: diagnostic hashes normalize equivalent message and function-call forms', () => {
1869
+ const prevTransport = process.env.MIXDOG_OAI_TRANSPORT;
1870
+ try {
1871
+ process.env.MIXDOG_OAI_TRANSPORT = 'ws-delta';
1872
+ const prior = { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'prior' }] };
1873
+ const mismatch = (expected, actual) => _computeDelta({
1874
+ entry: {
1875
+ lastRequestSansInput: '{"model":"gpt-5.5"}',
1876
+ lastResponseId: 'resp_prev',
1877
+ lastRequestInput: [prior],
1878
+ lastResponseItems: [expected],
1879
+ },
1880
+ body: { model: 'gpt-5.5', input: [prior, actual] },
1881
+ }).responseOutputMismatch;
1882
+ const message = { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'same sensitive message' }] };
1883
+ const messageExpected = mismatch(message, {
1884
+ type: 'message',
1885
+ role: 'assistant',
1886
+ content: [{ type: 'output_text', text: 'different message' }],
1887
+ });
1888
+ const messageActual = mismatch({
1889
+ type: 'message',
1890
+ role: 'assistant',
1891
+ content: [{ type: 'output_text', text: 'different message' }],
1892
+ }, {
1893
+ type: 'message',
1894
+ role: 'assistant',
1895
+ content: [{ type: 'input_text', text: 'same sensitive message' }],
1896
+ });
1897
+ assert.equal(
1898
+ messageExpected.response_output_mismatch_expected_hash,
1899
+ messageActual.response_output_mismatch_actual_hash,
1900
+ );
1901
+
1902
+ const functionExpected = (argumentsValue) => mismatch({
1903
+ type: 'function_call',
1904
+ call_id: 'call_same',
1905
+ name: 'tool',
1906
+ arguments: argumentsValue,
1907
+ }, {
1908
+ type: 'function_call',
1909
+ call_id: 'call_different',
1910
+ name: 'tool',
1911
+ arguments: '{"ignored":"replayed-function-secret"}',
1912
+ });
1913
+ assert.equal(
1914
+ functionExpected('{"api_key":"function-secret","nested":{"z":2,"a":1}}').response_output_mismatch_expected_hash,
1915
+ functionExpected('{"nested":{"a":1,"z":2},"api_key":"function-secret"}').response_output_mismatch_expected_hash,
1916
+ );
1917
+ } finally {
1918
+ if (prevTransport == null) delete process.env.MIXDOG_OAI_TRANSPORT;
1919
+ else process.env.MIXDOG_OAI_TRANSPORT = prevTransport;
1920
+ }
1921
+ });
1922
+
1923
+ test('openai oauth ws delta: mismatch diagnostics reach transport and cache_break without sensitive values', async () => {
1924
+ const prevTransport = process.env.MIXDOG_OAI_TRANSPORT;
1925
+ try {
1926
+ process.env.MIXDOG_OAI_TRANSPORT = 'ws-delta';
1927
+ const rows = [];
1928
+ const prior = { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'prior' }] };
1929
+ const cases = [{
1930
+ expected: {
1931
+ type: 'function_call',
1932
+ call_id: 'call_expected',
1933
+ name: 'tool',
1934
+ arguments: '{"api_key":"provider-function-secret","nested":{"z":2,"a":1}}',
1935
+ },
1936
+ actual: {
1937
+ type: 'function_call',
1938
+ call_id: 'call_actual',
1939
+ name: 'tool',
1940
+ arguments: '{"nested":{"a":1,"z":2},"api_key":"replayed-function-secret"}',
1941
+ },
1942
+ }, {
1943
+ expected: {
1944
+ type: 'custom_tool_call',
1945
+ call_id: 'custom_expected',
1946
+ name: 'apply_patch',
1947
+ input: 'provider-custom-input-secret',
1948
+ },
1949
+ actual: {
1950
+ type: 'custom_tool_call',
1951
+ call_id: 'custom_actual',
1952
+ name: 'apply_patch',
1953
+ input: 'replayed-custom-input-secret',
1954
+ },
1955
+ }];
1956
+ for (const { expected, actual } of cases) {
1957
+ const body = { model: 'gpt-5.5', input: [prior, actual] };
1958
+ const entry = {
1959
+ socket: { close() {} },
1960
+ lastRequestSansInput: _stableStringify(_sansInput(body)),
1961
+ lastResponseId: 'resp_prev',
1962
+ lastRequestInput: [prior],
1963
+ lastResponseItems: [expected],
1964
+ };
1965
+ const expectedDiagnostics = _computeDelta({ entry, body }).responseOutputMismatch;
1966
+ await sendViaWebSocket({
1967
+ auth: { type: 'xai', access_token: 'test-token' },
1968
+ body,
1969
+ poolKey: `mismatch-trace-${expected.type}`,
1970
+ cacheKey: 'mismatch-trace-test',
1971
+ iteration: 1,
1972
+ useModel: 'gpt-5.5',
1973
+ traceProvider: 'xai',
1974
+ _acquireWithRetryFn: async () => ({ entry, reused: false }),
1975
+ _sendFrameFn: async () => {},
1976
+ _streamFn: async () => ({
1977
+ content: 'ok',
1978
+ model: 'gpt-5.5',
1979
+ toolCalls: [],
1980
+ usage: {},
1981
+ responseId: 'resp_next',
1982
+ responseItems: [],
1983
+ closeSocket: true,
1984
+ }),
1985
+ _agentTraceFn: (row) => rows.push(row),
1986
+ });
1987
+ const emitted = rows.filter((row) => row.payload?.response_output_mismatch_expected_type === expected.type);
1988
+ assert.deepEqual(emitted.map((row) => row.kind).sort(), ['cache_break', 'transport']);
1989
+ for (const row of emitted) assert.deepEqual(
1990
+ Object.fromEntries(Object.keys(expectedDiagnostics).map((key) => [key, row.payload[key]])),
1991
+ expectedDiagnostics,
1992
+ );
1993
+ }
1994
+ const traceText = JSON.stringify(rows);
1995
+ for (const secret of [
1996
+ 'provider-function-secret',
1997
+ 'replayed-function-secret',
1998
+ 'provider-custom-input-secret',
1999
+ 'replayed-custom-input-secret',
2000
+ ]) assert.equal(traceText.includes(secret), false);
1803
2001
  } finally {
1804
2002
  if (prevTransport == null) delete process.env.MIXDOG_OAI_TRANSPORT;
1805
2003
  else process.env.MIXDOG_OAI_TRANSPORT = prevTransport;
@@ -0,0 +1,102 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+
8
+ test('root session bench promotes actionable compact mismatches without hiding intentional transitions', () => {
9
+ const dir = mkdtempSync(join(tmpdir(), 'mixdog-session-bench-'));
10
+ const trace = join(dir, 'trace.jsonl');
11
+ const sessionId = 'cache-break-observability';
12
+ const rows = [
13
+ ...Array.from({ length: 4 }, (_, index) => ({
14
+ ts: index + 1, sessionId, iteration: index + 1, kind: 'cache_break',
15
+ payload: { reason: 'input_prefix_mismatch', intentional_transition: 'automatic_compaction' },
16
+ })),
17
+ ...Array.from({ length: 2 }, (_, index) => ({
18
+ ts: index + 5, sessionId, iteration: index + 5, kind: 'cache_break',
19
+ payload: { reason: 'input_prefix_mismatch', intentional_transition: 'transcript_rebuild' },
20
+ })),
21
+ ...Array.from({ length: 2 }, (_, index) => ({
22
+ ts: index + 7, sessionId, iteration: index + 7, kind: 'cache_break',
23
+ payload: {
24
+ reason: 'request_properties_changed',
25
+ intentional_transition: 'explorer_hard_cap_final_tool_choice_none',
26
+ request_tool_choice: 'none',
27
+ },
28
+ })),
29
+ {
30
+ ts: 9, sessionId, iteration: 9, kind: 'cache_break',
31
+ payload: { reason: 'response_output_mismatch:function_call' },
32
+ },
33
+ {
34
+ ts: 10, sessionId, iteration: 10, kind: 'cache_break',
35
+ payload: { reason: 'input_prefix_mismatch', intentional_transition: 'unknown_transition' },
36
+ },
37
+ {
38
+ ts: 11, sessionId, iteration: 11, kind: 'cache_break',
39
+ payload: {
40
+ reason: 'response_output_mismatch:function_call',
41
+ intentional_transition: 'automatic_compaction',
42
+ },
43
+ },
44
+ {
45
+ ts: 12, sessionId: `${sessionId}:compact`, iteration: 1, kind: 'cache_break',
46
+ payload: { reason: 'input_prefix_mismatch' },
47
+ },
48
+ {
49
+ ts: 13, sessionId, iteration: 13, kind: 'cache_break',
50
+ payload: {
51
+ reason: 'request_properties_changed',
52
+ intentional_transition: 'explorer_hard_cap_final_tool_choice_none',
53
+ },
54
+ },
55
+ ...Array.from({ length: 6 }, (_, index) => ({
56
+ ts: index + 14, sessionId, iteration: index + 14, kind: 'cache_break',
57
+ payload: { reason: 'response_output_mismatch:function_call' },
58
+ })),
59
+ ];
60
+ writeFileSync(trace, `${rows.map((row) => JSON.stringify(row)).join('\n')}\n`);
61
+ try {
62
+ const output = execFileSync(process.execPath, [
63
+ 'scripts/session-bench.mjs', '--json', '--trace', trace, '--session', sessionId,
64
+ ], { cwd: process.cwd(), encoding: 'utf8' });
65
+ const report = JSON.parse(output);
66
+ assert.equal(report.cache.cache_breaks.length, 19, 'raw evidence is retained');
67
+ assert.equal(report.cache.intentional_cache_breaks.length, 8);
68
+ assert.equal(report.cache.actionable_cache_breaks.length, 11);
69
+ assert.deepEqual(
70
+ report.cache.intentional_cache_breaks.map((row) => row.phase),
71
+ [
72
+ ...Array(4).fill('intentional_automatic_compaction'),
73
+ ...Array(2).fill('intentional_transcript_rebuild'),
74
+ ...Array(2).fill('intentional_explorer_hard_cap_final_tool_choice_none'),
75
+ ],
76
+ );
77
+ assert.equal(report.cache.actionable_cache_breaks[0].reason, 'response_output_mismatch:function_call');
78
+ const actionableByTs = new Map(report.cache.actionable_cache_breaks.map((row) => [row.ts, row]));
79
+ assert.equal(actionableByTs.get(10).intentional_transition, 'unknown_transition');
80
+ assert.equal(actionableByTs.get(11).intentional_transition, 'automatic_compaction');
81
+ assert.equal(actionableByTs.get(12).session_id, `${sessionId}:compact`);
82
+ assert.equal(actionableByTs.get(13).intentional_transition, 'explorer_hard_cap_final_tool_choice_none');
83
+ const cacheBreakIssues = report.issues.filter((issue) => issue.type === 'cache_break');
84
+ assert.equal(cacheBreakIssues.length, 10, 'the cache-break issue cap remains bounded');
85
+ assert.equal(cacheBreakIssues.filter((issue) => issue.session_id === sessionId).length, 9);
86
+ assert.ok(
87
+ report.issues.some((issue) => issue.type === 'cache_break' && issue.session_id === `${sessionId}:compact`),
88
+ 'the root report must surface a child compact mismatch as an issue',
89
+ );
90
+ assert.ok(
91
+ report.rankings.some((ranking) => ranking.type === 'cache_breaks' && ranking.message.includes('11 actionable cache break(s)')),
92
+ 'the root report must rank child compact mismatches with other actionable breaks',
93
+ );
94
+ const text = execFileSync(process.execPath, [
95
+ 'scripts/session-bench.mjs', '--trace', trace, '--session', sessionId,
96
+ ], { cwd: process.cwd(), encoding: 'utf8' });
97
+ assert.match(text, /compact cache breaks \(actionable\):/);
98
+ assert.doesNotMatch(text, /compact cache resets \(intentional\):/);
99
+ } finally {
100
+ rmSync(dir, { recursive: true, force: true });
101
+ }
102
+ });