mixdog 0.9.47 → 0.9.50

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 (88) hide show
  1. package/README.md +6 -6
  2. package/package.json +19 -9
  3. package/scripts/agent-parallel-smoke.mjs +4 -1
  4. package/scripts/agent-route-batch-test.mjs +40 -0
  5. package/scripts/code-graph-aggregate-cwd-test.mjs +154 -0
  6. package/scripts/code-graph-description-contract.mjs +185 -0
  7. package/scripts/code-graph-disk-hit-test.mjs +55 -0
  8. package/scripts/code-graph-dispatch-test.mjs +96 -0
  9. package/scripts/compact-pressure-test.mjs +40 -0
  10. package/scripts/deferred-tool-loading-test.mjs +233 -0
  11. package/scripts/embedding-worker-exit-test.mjs +76 -0
  12. package/scripts/execution-completion-dedup-test.mjs +48 -0
  13. package/scripts/explore-prompt-policy-test.mjs +88 -3
  14. package/scripts/live-worker-smoke.mjs +68 -16
  15. package/scripts/memory-core-input-test.mjs +33 -13
  16. package/scripts/native-edit-wire-test.mjs +152 -0
  17. package/scripts/openai-oauth-ws-1006-retry-test.mjs +294 -16
  18. package/scripts/patch-binary-cache-test.mjs +181 -0
  19. package/scripts/prompt-immediate-render-test.mjs +89 -0
  20. package/scripts/provider-toolcall-test.mjs +280 -15
  21. package/scripts/shell-failure-diagnostics-test.mjs +211 -0
  22. package/scripts/smoke-loop.mjs +9 -3
  23. package/scripts/smoke.mjs +5 -106
  24. package/scripts/statusline-quota-hysteresis-test.mjs +26 -1
  25. package/scripts/streaming-tail-window-test.mjs +29 -0
  26. package/scripts/tool-failures.mjs +21 -3
  27. package/scripts/tool-smoke.mjs +263 -38
  28. package/scripts/tool-tui-presentation-test.mjs +17 -1
  29. package/scripts/tui-perf-run.ps1 +26 -0
  30. package/scripts/tui-transcript-perf-test.mjs +7 -1
  31. package/scripts/verify-release-assets-test.mjs +281 -0
  32. package/scripts/verify-release-assets.mjs +293 -0
  33. package/scripts/windows-hide-spawn-options-test.mjs +19 -0
  34. package/src/cli.mjs +1 -0
  35. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +16 -5
  36. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +35 -11
  37. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +35 -11
  38. package/src/runtime/agent/orchestrator/providers/custom-tool-wire.mjs +28 -0
  39. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +1 -1
  40. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +34 -34
  41. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +45 -38
  42. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +36 -11
  43. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +4 -4
  44. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +10 -6
  45. package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +4 -3
  46. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +4 -3
  47. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +16 -1
  48. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +2 -2
  49. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +0 -1
  50. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +13 -8
  51. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +30 -16
  52. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +25 -8
  53. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +69 -4
  54. package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +5 -4
  55. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -1
  56. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +12 -12
  57. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +12 -7
  58. package/src/runtime/agent/orchestrator/tools/patch-binary-fetcher.mjs +77 -18
  59. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  60. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +99 -24
  61. package/src/runtime/memory/lib/embedding-provider.mjs +3 -2
  62. package/src/runtime/memory/lib/memory-action-handlers.mjs +21 -11
  63. package/src/runtime/memory/tool-defs.mjs +1 -2
  64. package/src/session-runtime/context-status.mjs +25 -16
  65. package/src/session-runtime/model-route-api.mjs +2 -0
  66. package/src/session-runtime/runtime-core.mjs +2 -2
  67. package/src/session-runtime/session-turn-api.mjs +4 -1
  68. package/src/session-runtime/tool-catalog.mjs +113 -19
  69. package/src/session-runtime/tool-defs.mjs +1 -0
  70. package/src/standalone/agent-tool/helpers.mjs +25 -6
  71. package/src/standalone/agent-tool.mjs +75 -41
  72. package/src/tui/App.jsx +4 -0
  73. package/src/tui/app/input-parsers.mjs +8 -9
  74. package/src/tui/components/Markdown.jsx +6 -1
  75. package/src/tui/components/Message.jsx +11 -2
  76. package/src/tui/components/PromptInput.jsx +19 -21
  77. package/src/tui/components/Spinner.jsx +4 -4
  78. package/src/tui/components/StatusLine.jsx +6 -6
  79. package/src/tui/components/TranscriptItem.jsx +2 -2
  80. package/src/tui/components/prompt-input/immediate-render.mjs +47 -0
  81. package/src/tui/components/tool-execution/surface-detail.mjs +14 -9
  82. package/src/tui/dist/index.mjs +130 -45
  83. package/src/tui/engine/agent-job-feed.mjs +21 -2
  84. package/src/tui/engine/turn.mjs +12 -1
  85. package/src/tui/markdown/measure-rendered-rows.mjs +1 -1
  86. package/src/tui/markdown/streaming-markdown.mjs +20 -0
  87. package/src/ui/statusline.mjs +5 -5
  88. package/src/vendor/statusline/src/gateway/session-routes.mjs +22 -10
@@ -30,6 +30,7 @@ import {
30
30
  shouldRecordObservedForProvider,
31
31
  } from '../src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs';
32
32
  import { executeBuiltinTool } from '../src/runtime/agent/orchestrator/tools/builtin.mjs';
33
+ import { executeFuzzyFindTool } from '../src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs';
33
34
  import { applyGrepContextLeadPolicy, GREP_CONTEXT_MAX, validateBuiltinArgs } from '../src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs';
34
35
  import { normaliseReadLineWindowArgs } from '../src/runtime/agent/orchestrator/tools/builtin/read-args.mjs';
35
36
  import { BUILTIN_TOOLS } from '../src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs';
@@ -44,10 +45,16 @@ import { TOOL_DEFS as SEARCH_TOOL_DEFS } from '../src/runtime/search/tool-defs.m
44
45
  import { TOOL_DEFS as CHANNEL_TOOL_DEFS } from '../src/runtime/channels/tool-defs.mjs';
45
46
  import { AGENT_OWNER } from '../src/runtime/agent/orchestrator/agent-owner.mjs';
46
47
  import { recursiveWrapperToolNameForPublicAgent } from '../src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs';
48
+ import {
49
+ applyDeferredToolSurface,
50
+ reconcileDeferredMcpToolCatalog,
51
+ } from '../src/session-runtime/tool-catalog.mjs';
52
+ import { prepareDeferredToolCallThrough } from '../src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs';
47
53
  import { composeSystemPrompt } from '../src/runtime/agent/orchestrator/context/collect.mjs';
48
54
  import { setInternalToolsProvider } from '../src/runtime/agent/orchestrator/internal-tools.mjs';
49
55
  import { prepareAgentSession } from '../src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs';
50
56
  import { resolveHiddenRoleSchemaAllowedTools } from '../src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs';
57
+ import { assertCodeGraphDescriptionContract } from './code-graph-description-contract.mjs';
51
58
  import { getHiddenAgent, resolveAgentSessionPermission } from '../src/runtime/agent/orchestrator/internal-agents.mjs';
52
59
 
53
60
  const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
@@ -492,8 +499,8 @@ const findOut = await executeBuiltinTool('find', {
492
499
  }, root);
493
500
  assertOk('find', findOut, /scripts[\\/]tool-smoke\.mjs/i);
494
501
 
495
- // find query[] batch: fan-out must emit one section per query in caller order
496
- // and share the broad enumeration sweep across queries (single-flight dedup).
502
+ // End-to-end find query[] batch: fan-out must emit one section per query in
503
+ // caller order.
497
504
  const findBatchOut = await executeBuiltinTool('find', {
498
505
  query: ['tool smoke', 'smoke'],
499
506
  path: '.',
@@ -514,6 +521,48 @@ const findBatchOut = await executeBuiltinTool('find', {
514
521
  }
515
522
  }
516
523
 
524
+ // Exercise the test-only rg seam directly so this verifies the in-flight
525
+ // single-flight rather than merely observing cached output from the real tree.
526
+ // TTL=0 rules out persistent broad-enumeration cache reuse; the delayed listing
527
+ // keeps both exact-name workers concurrent while the first sweep is in flight.
528
+ {
529
+ const previousFindEnumCacheTtl = process.env.MIXDOG_FIND_ENUM_CACHE_TTL_MS;
530
+ const firstQuery = 'tool-smoke-single-flight-first.mjs';
531
+ const secondQuery = 'tool-smoke-single-flight-second.mjs';
532
+ const firstPath = `fixtures/${firstQuery}`;
533
+ const secondPath = `fixtures/${secondQuery}`;
534
+ let broadEnumerationCalls = 0;
535
+ process.env.MIXDOG_FIND_ENUM_CACHE_TTL_MS = '0';
536
+ try {
537
+ const singleFlightOut = await executeFuzzyFindTool({
538
+ query: [firstQuery, secondQuery],
539
+ path: '.',
540
+ head_limit: 5,
541
+ }, root, {
542
+ __runRg: async () => {
543
+ broadEnumerationCalls += 1;
544
+ await new Promise((resolve) => setTimeout(resolve, 20));
545
+ return `${firstPath}\n${secondPath}\n`;
546
+ },
547
+ });
548
+ const s = String(singleFlightOut);
549
+ const iA = s.indexOf(`# find ${firstQuery}`);
550
+ const iB = s.indexOf(`# find ${secondQuery}`);
551
+ if (broadEnumerationCalls !== 1) {
552
+ throw new Error(`find query[] must share exactly one broad enumeration, got ${broadEnumerationCalls}`);
553
+ }
554
+ if (iA < 0 || iB < 0 || !(iA < iB)) {
555
+ throw new Error(`find query[] seam must preserve section order:\n${s}`);
556
+ }
557
+ if (!s.slice(iA, iB).includes(firstPath) || !s.slice(iB).includes(secondPath)) {
558
+ throw new Error(`find query[] seam must retain both exact-name bodies:\n${s}`);
559
+ }
560
+ } finally {
561
+ if (previousFindEnumCacheTtl === undefined) delete process.env.MIXDOG_FIND_ENUM_CACHE_TTL_MS;
562
+ else process.env.MIXDOG_FIND_ENUM_CACHE_TTL_MS = previousFindEnumCacheTtl;
563
+ }
564
+ }
565
+
517
566
  const readOut = await executeBuiltinTool('read', {
518
567
  path: 'scripts/smoke.mjs',
519
568
  offset: 0,
@@ -1922,6 +1971,9 @@ const memoryProps = memoryTool?.inputSchema?.properties || {};
1922
1971
  if (!/mutation/i.test(memoryTool?.description || '') || !/Exact confirmation phrase/i.test(memoryProps.confirm?.description || '')) {
1923
1972
  throw new Error('memory schema must preserve mutation/destructive confirmation guidance');
1924
1973
  }
1974
+ if (memoryProps.category || /category/i.test(memoryTool?.description || '')) {
1975
+ throw new Error('memory mutation schema must not expose category');
1976
+ }
1925
1977
  const searchTool = SEARCH_TOOL_DEFS.find((tool) => tool.name === 'search');
1926
1978
  const searchProps = searchTool?.inputSchema?.properties || {};
1927
1979
  if (!/Runs synchronously/i.test(searchTool?.description || '') || searchProps.mode || searchProps.action || searchProps.task_id || !searchProps.query?.anyOf || !/array for fan-out/i.test(searchProps.query?.description || '')) {
@@ -1940,7 +1992,8 @@ if (!/offset/i.test(webFetchProps.startIndex?.description || '') || !/Maximum ch
1940
1992
  }
1941
1993
  if (!/deferred tools/i.test(TOOL_SEARCH_TOOL.description || '')
1942
1994
  || !TOOL_SEARCH_TOOL.inputSchema?.properties?.names
1943
- || !TOOL_SEARCH_TOOL.inputSchema?.properties?.select) {
1995
+ || !TOOL_SEARCH_TOOL.inputSchema?.properties?.select
1996
+ || TOOL_SEARCH_TOOL.inputSchema?.additionalProperties !== false) {
1944
1997
  throw new Error('load_tool schema must preserve loader guidance plus names + legacy select fields');
1945
1998
  }
1946
1999
  const toolSearchSession = {
@@ -1994,6 +2047,7 @@ if (!Array.isArray(toolSearchSession.deferredDiscoveredTools) || !toolSearchSess
1994
2047
  throw new Error('tool_search must persist discovered tool state on the session');
1995
2048
  }
1996
2049
  const nativeToolSearchSession = {
2050
+ provider: 'openai-oauth',
1997
2051
  tools: smokeCatalog.filter((tool) => fullDefaults.has(tool?.name)),
1998
2052
  deferredToolCatalog: smokeCatalog.slice(),
1999
2053
  deferredSelectedTools: [...fullDefaults],
@@ -2001,21 +2055,111 @@ const nativeToolSearchSession = {
2001
2055
  deferredProviderMode: 'native',
2002
2056
  deferredNativeTools: true,
2003
2057
  };
2058
+ nativeToolSearchSession.deferredCallableTools = nativeToolSearchSession.tools.map((tool) => tool.name);
2059
+ const nativeBaseToolsJson = JSON.stringify(nativeToolSearchSession.tools);
2060
+ const nativeBaseRequest = buildRequestBody(
2061
+ [{ role: 'user', content: 'load shell' }],
2062
+ 'gpt-5.4',
2063
+ nativeToolSearchSession.tools,
2064
+ { sessionId: 'deferred-stability', session: nativeToolSearchSession },
2065
+ );
2004
2066
  const nativeSelectResult = JSON.parse(__renderToolSearchForTest({ select: 'shell,recall' }, nativeToolSearchSession, 'full'));
2005
- if (nativeSelectResult.activeTools.includes('shell') || nativeSelectResult.activeTools.includes('recall')) {
2006
- throw new Error(`native tool_search must not mutate active tool schemas: ${JSON.stringify(nativeSelectResult)}`);
2007
- }
2008
2067
  for (const name of ['shell', 'task', 'recall']) {
2009
- if (!nativeSelectResult.discoveredTools.includes(name)) {
2010
- throw new Error(`native tool_search missing discovered ${name}: ${JSON.stringify(nativeSelectResult)}`);
2068
+ if (!nativeSelectResult.activeTools.includes(name)) {
2069
+ throw new Error(`native load_tool must register ${name} as callable: ${JSON.stringify(nativeSelectResult)}`);
2011
2070
  }
2012
2071
  }
2072
+ if (JSON.stringify(nativeToolSearchSession.tools) !== nativeBaseToolsJson
2073
+ || nativeToolSearchSession.tools.some((tool) => tool?.name === 'shell')) {
2074
+ throw new Error(`native load_tool must keep the base tools array byte-stable: ${JSON.stringify(nativeToolSearchSession.tools)}`);
2075
+ }
2013
2076
  if (!nativeSelectResult.nativeToolSearch?.openaiTools?.some((tool) => tool?.name === 'shell' && tool?.defer_loading === true)) {
2014
2077
  throw new Error(`native tool_search must return OpenAI loadable deferred tools: ${JSON.stringify(nativeSelectResult.nativeToolSearch)}`);
2015
2078
  }
2016
2079
  if (!nativeSelectResult.nativeToolSearch?.toolReferences?.includes('shell')) {
2017
2080
  throw new Error(`native tool_search must return Anthropic tool references: ${JSON.stringify(nativeSelectResult.nativeToolSearch)}`);
2018
2081
  }
2082
+ const nativeToolCountAfterFirstLoad = nativeToolSearchSession.tools.length;
2083
+ const nativeRepeatResult = JSON.parse(__renderToolSearchForTest({ select: 'shell,recall' }, nativeToolSearchSession, 'full'));
2084
+ if (nativeRepeatResult.loaded.length
2085
+ || !['shell', 'task', 'recall'].every((name) => nativeRepeatResult.alreadyActive.includes(name))
2086
+ || nativeRepeatResult.nativeToolSearch?.toolReferences?.length
2087
+ || nativeRepeatResult.nativeToolSearch?.openaiTools?.length
2088
+ || nativeToolSearchSession.tools.length !== nativeToolCountAfterFirstLoad) {
2089
+ throw new Error(`repeated native load_tool must report already-active without reinjection: ${JSON.stringify(nativeRepeatResult)}`);
2090
+ }
2091
+ const nativeHistory = [
2092
+ { role: 'user', content: 'load shell' },
2093
+ {
2094
+ role: 'assistant',
2095
+ content: '',
2096
+ toolCalls: [{ id: 'search-1', name: 'load_tool', arguments: { names: ['shell'] }, nativeType: 'tool_search_call' }],
2097
+ },
2098
+ {
2099
+ role: 'tool',
2100
+ toolCallId: 'search-1',
2101
+ content: nativeSelectResult.nativeToolSearch.summary,
2102
+ nativeToolSearch: nativeSelectResult.nativeToolSearch,
2103
+ },
2104
+ ];
2105
+ const nativeFollowupRequest = buildRequestBody(
2106
+ nativeHistory,
2107
+ 'gpt-5.4',
2108
+ nativeToolSearchSession.tools,
2109
+ { sessionId: 'deferred-stability', session: nativeToolSearchSession },
2110
+ );
2111
+ if (JSON.stringify(nativeFollowupRequest.tools) !== JSON.stringify(nativeBaseRequest.tools)
2112
+ || nativeFollowupRequest.prompt_cache_key !== nativeBaseRequest.prompt_cache_key) {
2113
+ throw new Error('OpenAI native loading must not change tools or prompt_cache_key');
2114
+ }
2115
+ const nativeOutput = nativeFollowupRequest.input.find((item) => item?.type === 'tool_search_output');
2116
+ if (!nativeOutput?.tools?.some((tool) => tool?.name === 'shell')
2117
+ || nativeFollowupRequest.tools.some((tool) => tool?.name === 'shell')) {
2118
+ throw new Error(`OpenAI loaded schemas must exist only in tool_search_output history: ${JSON.stringify(nativeFollowupRequest)}`);
2119
+ }
2120
+ const directMcpSession = {
2121
+ provider: 'openai-oauth',
2122
+ toolSpec: 'full',
2123
+ tools: [{ name: 'load_tool', inputSchema: { type: 'object', properties: {} } }],
2124
+ deferredToolCatalog: [
2125
+ { name: 'load_tool', inputSchema: { type: 'object', properties: {} } },
2126
+ { name: 'mcp__demo__ping', annotations: { readOnlyHint: true }, inputSchema: { type: 'object', properties: {} } },
2127
+ ],
2128
+ deferredCallableTools: ['load_tool', 'mcp__demo__ping'],
2129
+ deferredProviderMode: 'native',
2130
+ deferredNativeTools: true,
2131
+ };
2132
+ prepareDeferredToolCallThrough(directMcpSession, 'mcp__demo__ping', {});
2133
+ if (directMcpSession.tools.some((tool) => tool?.name === 'mcp__demo__ping')
2134
+ || !directMcpSession.deferredCallableTools.includes('mcp__demo__ping')) {
2135
+ throw new Error('subsequent native MCP calls must use the callable registry without session.tools promotion');
2136
+ }
2137
+ const readonlyReportingSession = {
2138
+ tools: [TOOL_SEARCH_TOOL],
2139
+ deferredToolCatalog: smokeCatalog.slice(),
2140
+ deferredSelectedTools: ['load_tool'],
2141
+ };
2142
+ const readonlyReportingResult = JSON.parse(__renderToolSearchForTest(
2143
+ { names: ['shell', 'definitely_missing_tool'] },
2144
+ readonlyReportingSession,
2145
+ 'readonly',
2146
+ {
2147
+ mcpStatus: () => ({
2148
+ servers: [
2149
+ { name: 'connecting-mcp', status: 'disconnected' },
2150
+ { name: 'failed-mcp', status: 'failed' },
2151
+ ],
2152
+ }),
2153
+ },
2154
+ ));
2155
+ if (!readonlyReportingResult.blocked?.some((entry) => entry?.name === 'shell' && entry?.reason === 'readonly mode')
2156
+ || !readonlyReportingResult.missing.includes('definitely_missing_tool')
2157
+ || !readonlyReportingResult.pendingMcpServers?.includes('connecting-mcp')
2158
+ || !readonlyReportingResult.failedMcpServers?.includes('failed-mcp')
2159
+ || !/retry next turn/i.test(readonlyReportingResult.note || '')
2160
+ || !/unavailable/i.test(readonlyReportingResult.note || '')) {
2161
+ throw new Error(`load_tool must preserve readonly and MCP status reporting: ${JSON.stringify(readonlyReportingResult)}`);
2162
+ }
2019
2163
  const nativePatchSearchSession = {
2020
2164
  provider: 'openai-oauth',
2021
2165
  tools: smokeCatalog.filter((tool) => fullDefaults.has(tool?.name) && tool?.name !== 'apply_patch'),
@@ -2033,24 +2177,17 @@ if (nativePatchTool?.type !== 'custom' || nativePatchTool?.format?.syntax !== 'l
2033
2177
  if (nativePatchTool.defer_loading === true || nativePatchTool.parameters) {
2034
2178
  throw new Error(`native tool_search custom apply_patch must not be downgraded to deferred function schema: ${JSON.stringify(nativePatchTool)}`);
2035
2179
  }
2036
- const nativeGrokPatchSearchSession = {
2037
- provider: 'grok-oauth',
2038
- tools: smokeCatalog.filter((tool) => fullDefaults.has(tool?.name) && tool?.name !== 'apply_patch'),
2039
- deferredToolCatalog: smokeCatalog.slice(),
2040
- deferredSelectedTools: [...fullDefaults].filter((name) => name !== 'apply_patch'),
2041
- deferredDiscoveredTools: [],
2042
- deferredProviderMode: 'native',
2043
- deferredNativeTools: true,
2044
- };
2045
- const nativeGrokPatchSelectResult = JSON.parse(__renderToolSearchForTest({ select: 'apply_patch' }, nativeGrokPatchSearchSession, 'full'));
2046
- const nativeGrokPatchTool = nativeGrokPatchSelectResult.nativeToolSearch?.openaiTools?.find((tool) => tool?.name === 'apply_patch');
2047
- if (nativeGrokPatchTool?.type !== 'function' || nativeGrokPatchTool?.format || nativeGrokPatchTool?.defer_loading !== true) {
2048
- throw new Error(`Grok native tool_search apply_patch must use JSON function schema, not OpenAI custom: ${JSON.stringify(nativeGrokPatchTool)}`);
2049
- }
2050
- if (nativeGrokPatchTool.parameters?.properties?.patch?.type !== 'string') {
2051
- throw new Error(`Grok native tool_search apply_patch must preserve patch JSON schema: ${JSON.stringify(nativeGrokPatchTool)}`);
2052
- }
2053
- // Native query-select discovers (without mutating active schemas); aliases expand.
2180
+ const grokCanonicalSession = { provider: 'grok-oauth', tools: [], messages: [] };
2181
+ applyDeferredToolSurface(grokCanonicalSession, 'full', smokeCatalog, { provider: 'grok-oauth' });
2182
+ const grokCanonicalJson = JSON.stringify(grokCanonicalSession.tools);
2183
+ const grokLoadResult = JSON.parse(__renderToolSearchForTest({ names: ['apply_patch'] }, grokCanonicalSession, 'full'));
2184
+ if (grokCanonicalSession.deferredNativeTools
2185
+ || grokLoadResult.nativeToolSearch
2186
+ || JSON.stringify(grokCanonicalSession.tools) !== grokCanonicalJson
2187
+ || !grokLoadResult.alreadyActive.includes('apply_patch')) {
2188
+ throw new Error(`Grok must use a fixed canonical ordinary-function surface: ${JSON.stringify(grokLoadResult)}`);
2189
+ }
2190
+ // Native query-select explicitly loads onto the active surface; aliases expand.
2054
2191
  const nativeSelectQuerySession = {
2055
2192
  tools: smokeCatalog.filter((tool) => fullDefaults.has(tool?.name)),
2056
2193
  deferredToolCatalog: smokeCatalog.slice(),
@@ -2061,13 +2198,10 @@ const nativeSelectQuerySession = {
2061
2198
  };
2062
2199
  const nativeSelectQueryResult = JSON.parse(__renderToolSearchForTest({ query: 'select:search' }, nativeSelectQuerySession, 'full'));
2063
2200
  for (const name of ['search', 'web_fetch']) {
2064
- if (!nativeSelectQueryResult.discoveredTools.includes(name)) {
2065
- throw new Error(`native tool_search query-select should discover ${name}: ${JSON.stringify(nativeSelectQueryResult)}`);
2201
+ if (!nativeSelectQueryResult.activeTools.includes(name)) {
2202
+ throw new Error(`native tool_search query-select should load ${name}: ${JSON.stringify(nativeSelectQueryResult)}`);
2066
2203
  }
2067
2204
  }
2068
- if (nativeSelectQueryResult.activeTools.includes('search') || nativeSelectQueryResult.activeTools.includes('web_fetch')) {
2069
- throw new Error(`native tool_search must not mutate active schemas: ${JSON.stringify(nativeSelectQueryResult)}`);
2070
- }
2071
2205
  if (!nativeSelectQueryResult.nativeToolSearch?.toolReferences?.includes('search')) {
2072
2206
  throw new Error(`native query-select must return nativeToolSearch payload: ${JSON.stringify(nativeSelectQueryResult.nativeToolSearch)}`);
2073
2207
  }
@@ -2083,6 +2217,9 @@ const nativeLateMcpSearchSession = {
2083
2217
  deferredNativeTools: true,
2084
2218
  };
2085
2219
  const nativeLateMcpSelectResult = JSON.parse(__renderToolSearchForTest({ names: ['mcp__late__ping'] }, nativeLateMcpSearchSession, 'full'));
2220
+ if (nativeLateMcpSearchSession.tools.some((tool) => tool?.name === 'mcp__late__ping')) {
2221
+ throw new Error(`native late MCP load must not promote its schema onto session.tools: ${JSON.stringify(nativeLateMcpSearchSession.tools)}`);
2222
+ }
2086
2223
  if (!nativeLateMcpSelectResult.nativeToolSearch?.toolReferences?.includes('mcp__late__ping')) {
2087
2224
  throw new Error(`native late MCP load must include nativeToolSearch payload: ${JSON.stringify(nativeLateMcpSelectResult)}`);
2088
2225
  }
@@ -2104,6 +2241,22 @@ for (const q of ['run tests', 'web docs', 'memory previous', 'status']) {
2104
2241
  throw new Error(`native tool_search plain query "${q}" must not auto-load/discover: ${JSON.stringify(r)}`);
2105
2242
  }
2106
2243
  }
2244
+ const geminiManifestSession = { provider: 'gemini', tools: [], messages: [] };
2245
+ const manifestBase = [
2246
+ { name: 'load_tool', inputSchema: { type: 'object', properties: {} } },
2247
+ { name: 'read', annotations: { readOnlyHint: true }, inputSchema: { type: 'object', properties: {} } },
2248
+ ];
2249
+ applyDeferredToolSurface(geminiManifestSession, 'full', manifestBase, { provider: 'gemini' });
2250
+ const geminiTurnManifest = JSON.stringify(geminiManifestSession.tools);
2251
+ const geminiLate = { name: 'mcp__gemini__late', inputSchema: { type: 'object', properties: {} } };
2252
+ // Continuations use the same array; only the next user-turn reconciliation may replace it.
2253
+ if (JSON.stringify(geminiManifestSession.tools) !== geminiTurnManifest) {
2254
+ throw new Error('Gemini manifest changed within a user turn');
2255
+ }
2256
+ reconcileDeferredMcpToolCatalog(geminiManifestSession, [geminiLate]);
2257
+ if (!geminiManifestSession.tools.some((tool) => tool.name === 'mcp__gemini__late')) {
2258
+ throw new Error('Gemini must adopt the complete ordered live manifest at the next user turn');
2259
+ }
2107
2260
  // Skill-style deferred manifest: `- name: description` lines, `<`/`>` sanitized,
2108
2261
  // bare names allowed, header instructs direct calls, empty pool → ''.
2109
2262
  const manifestText = buildDeferredToolManifest([
@@ -2158,8 +2311,38 @@ const grepHeadLimitDescription = grepTool?.inputSchema?.properties?.head_limit?.
2158
2311
  if (!/pattern\[\] batches variants/i.test(grepPatternDescription) || !/File\/dir scope/i.test(grepPathDescription)) {
2159
2312
  throw new Error('grep schema must keep compact pattern/path guidance');
2160
2313
  }
2161
- if (!/content search/i.test(grepTool?.description || '')) {
2162
- throw new Error('grep description must state its content-search contract');
2314
+ const grepPredicateNegator = /\b(?:no|not|never|cannot|can(?:not|'t)|does(?:\s+not|n't)|do(?:\s+not|n't)|did(?:\s+not|n't)|unable(?:\s+|-)to|fail(?:s|ed|ing)?(?:\s*[-–]\s*|\s+)to|without|forbid(?:s|den)?|prohibit(?:s|ed|ing)?)(?:\s+\w+){0,2}\s+/i;
2315
+ const hasPositiveGrepSequence = (description, pattern, negatedPredicate) => {
2316
+ const match = description.match(pattern);
2317
+ return Boolean(match) && !(negatedPredicate?.test(match[0]));
2318
+ };
2319
+ const grepRoutingVerb = '(?:us(?:e|es|ed|ing)|rout(?:e|es|ed|ing)|target(?:s|ed|ing)?)';
2320
+ const grepRouteRelation = `(?:\\s*→\\s*|\\s+\\b${grepRoutingVerb}\\b(?:\\s+(?:to|via|with))?\\s+)\\bgrep\\b`;
2321
+ const grepGroupedRoutePattern = new RegExp(`\\bliteral\\b\\s*(?:or|and|/)\\s*\\bregex\\b(?:(?![.;]|→|\\b${grepRoutingVerb}\\b)[\\s\\S]){0,80}?${grepRouteRelation}`, 'i');
2322
+ const grepRoutePattern = (subject, alternateSubject) => new RegExp(`\\b${subject}\\b(?:(?![.;]|→|\\b(?:${alternateSubject}|${grepRoutingVerb})\\b)[\\s\\S]){0,80}?${grepRouteRelation}`, 'i');
2323
+ const grepNegatedRoute = new RegExp(`${grepPredicateNegator.source}\\bgrep\\b`, 'i');
2324
+ const hasGrepDescriptionContract = (description) => (
2325
+ (hasPositiveGrepSequence(description, grepGroupedRoutePattern, grepNegatedRoute)
2326
+ || (hasPositiveGrepSequence(description, grepRoutePattern('literal', 'regex'), grepNegatedRoute)
2327
+ && hasPositiveGrepSequence(description, grepRoutePattern('regex', 'literal'), grepNegatedRoute)))
2328
+ && hasPositiveGrepSequence(description, /\bnonzero\b[\s\S]{0,80}?\bcontent_with_context\b[\s\S]{0,80}?\bresolve\w*\b/i, new RegExp(`${grepPredicateNegator.source}\\bresolve\\w*\\b`, 'i'))
2329
+ && hasPositiveGrepSequence(description, /\bonly\b[\s\S]{0,80}?\bzero(?:\s*\/\s*|\s+or\s+)error\b[\s\S]{0,80}?\bchange\w*\b[\s\S]{0,80}?\btoken(?:s)?\b[\s\S]{0,80}?\bscope\b/i, new RegExp(`${grepPredicateNegator.source}\\bchange\\w*\\b`, 'i'))
2330
+ && hasPositiveGrepSequence(description, /\bfiles_with_matches(?:\s*\/\s*|\s+and\s+)count\b[\s\S]{0,80}?\bexistence\b[\s\S]{0,80}?\bmodes?\b/i, new RegExp(`${grepPredicateNegator.source}\\bexistence\\s+modes?\\b`, 'i'))
2331
+ );
2332
+ const grepDescriptionProbe = 'literal and regex → grep; nonzero content_with_context resolves; only zero/error results may change tokens and scope; files_with_matches/count are existence modes.';
2333
+ const grepEquivalentPositiveProbe = 'literal and regex use grep; nonzero content_with_context returns not an empty result and resolves; only zero/error results may change tokens and scope; files_with_matches/count are existence modes.';
2334
+ const grepSeparateClausePositiveProbe = 'literal uses grep; regex routes to grep; nonzero content_with_context resolves; only zero/error results may change tokens and scope; files_with_matches/count are existence modes.';
2335
+ const grepPolarityMutations = [
2336
+ grepDescriptionProbe.replace('literal and regex → grep', 'literal → read; regex → grep'),
2337
+ grepDescriptionProbe.replace('literal and regex → grep', 'literal targets read, while regex uses grep'),
2338
+ grepDescriptionProbe.replace('literal and regex → grep', 'literal and regex do not use grep'),
2339
+ grepDescriptionProbe.replace('resolves', 'does not resolve'),
2340
+ grepDescriptionProbe.replace('resolves', 'fails to resolve'),
2341
+ grepDescriptionProbe.replace('may change', 'cannot change'),
2342
+ grepDescriptionProbe.replace('are existence modes', 'are never existence modes'),
2343
+ ];
2344
+ if (!hasGrepDescriptionContract(grepTool?.description || '') || !hasGrepDescriptionContract(grepEquivalentPositiveProbe) || !hasGrepDescriptionContract(grepSeparateClausePositiveProbe) || grepPolarityMutations.some(hasGrepDescriptionContract)) {
2345
+ throw new Error('grep description must state its literal/regex locator, resolution qualifiers, and result-mode contract');
2163
2346
  }
2164
2347
  if (!/Glob filter/i.test(grepGlobDescription)) {
2165
2348
  throw new Error('grep glob schema must describe scope narrowing');
@@ -2179,15 +2362,57 @@ const listTool = BUILTIN_TOOLS.find((tool) => tool.name === 'list');
2179
2362
  if (!/exact glob patterns/i.test(globTool?.description || '')) {
2180
2363
  throw new Error('glob description must route exact-pattern unknown paths before read/grep/list');
2181
2364
  }
2182
- if (!/unknown partial paths\/names/i.test(findTool?.description || '') || !/verifies paths for grep\/glob/i.test(findTool?.description || '')) {
2183
- throw new Error('find description must advertise unverified path/name lookup and verified outputs');
2365
+ const findClauseNegator = '(?:not|never|(?:do|does|did)\\s+not|(?:do|does|did)n\'t|(?:is|are|was|were)\\s+not|(?:is|are|was|were)n\'t|should\\s+not|shouldn\'t|must\\s+not|mustn\'t|can(?:not|\\s+not)|can\'t|could\\s+not|couldn\'t|will\\s+not|won\'t|fail(?:s|ed|ing)?\\s+to)';
2366
+ const findPredicateModifiers = '(?:\\s+(?:necessarily|generally|strictly|really|simply|always|still|currently|typically|ordinarily|be|been|being|considered|deemed|regarded)){0,3}';
2367
+ const hasPositiveFindClause = (description, positive, negated) => positive.test(description) && !negated.test(description);
2368
+ const findUnknownPathsPattern = /\b(?:fuzzy\s+)?(?:lookup|find)\b(?:\s+[\w']+){0,3}\s+only\s+for\s+unknown\s+partial\s+paths\/names\b/i;
2369
+ const findUnknownPathsNegation = new RegExp(`\\b${findClauseNegator}${findPredicateModifiers}\\s+(?:use\\s+)?(?:fuzzy\\s+)?(?:lookup|find)\\b(?:\\s+[\\w']+){0,3}\\s+only\\s+for\\s+unknown\\s+partial\\s+paths\\/names|\\b(?:fuzzy\\s+)?(?:lookup|find)\\b(?:\\s+[\\w']+){0,3}\\s+${findClauseNegator}${findPredicateModifiers}\\s+only\\s+for\\s+unknown\\s+partial\\s+paths\\/names`, 'i');
2370
+ const findRootExclusionPattern = /\b(?:not\s+for|excludes?)\s+(?:the\s+)?project\s+root\s+(?:or|and)\s+already-verified\s+roots\b|\b(?:the\s+)?project\s+root\s+(?:or|and)\s+already-verified\s+roots\s+(?:are\s+)?excluded\b|\balready-verified\s+roots\s+(?:or|and)\s+(?:the\s+)?project\s+root\s+(?:are\s+)?excluded\b/i;
2371
+ const findRootExclusionNegation = new RegExp(`\\b${findClauseNegator}${findPredicateModifiers}\\s+exclude\\s+(?:the\\s+)?(?:project\\s+root|already-verified\\s+roots)\\b|\\b(?:project\\s+root\\s+(?:or|and)\\s+already-verified\\s+roots|already-verified\\s+roots\\s+(?:or|and)\\s+project\\s+root|project\\s+root|already-verified\\s+roots)\\s+(?:(?:is|are|was|were)\\s+)?${findClauseNegator}${findPredicateModifiers}\\s+excluded\\b`, 'i');
2372
+ const findVerifiedPathsPattern = /\b(?:output|returned)\s+paths\b(?:\s+[\w']+){0,4}\s+verified\s+(?:downstream|for\s+downstream\s+use)\b|\bdownstream\s+paths\b(?:\s+[\w']+){0,4}\s+verified\b|\bpaths\b(?:\s+[\w']+){0,4}\s+verified\s+(?:downstream|for\s+downstream\s+use)\b/i;
2373
+ const findVerifiedPathsNegation = new RegExp(`\\b(?:output|returned|downstream)\\s+paths\\b(?:\\s+[\\w']+){0,4}\\s+${findClauseNegator}${findPredicateModifiers}\\s+verified\\b|\\bpaths\\b(?:\\s+[\\w']+){0,4}\\s+${findClauseNegator}${findPredicateModifiers}\\s+verified\\b|\\b(?:output|returned|downstream)\\s+paths\\b(?:\\s+[\\w']+){0,4}\\s+unverified\\b`, 'i');
2374
+ const findFileContentsPattern = /\b(?:not\s+for|excludes?)\s+file\s+contents\b|\bfile\s+contents\b(?:\s+[\w']+){0,3}\s+excluded\b/i;
2375
+ const findFileContentsNegation = new RegExp(`\\b${findClauseNegator}${findPredicateModifiers}\\s+exclude\\s+file\\s+contents\\b|\\bfile\\s+contents\\s+(?:(?:is|are|was|were)\\s+)?${findClauseNegator}${findPredicateModifiers}\\s+excluded\\b`, 'i');
2376
+ const hasFindDescriptionContract = (description) => (
2377
+ hasPositiveFindClause(description, findUnknownPathsPattern, findUnknownPathsNegation)
2378
+ && hasPositiveFindClause(description, findRootExclusionPattern, findRootExclusionNegation)
2379
+ && hasPositiveFindClause(description, findVerifiedPathsPattern, findVerifiedPathsNegation)
2380
+ && hasPositiveFindClause(description, findFileContentsPattern, findFileContentsNegation)
2381
+ );
2382
+ const findDescriptionProbe = 'Fuzzy lookup only for unknown partial paths/names; excludes project root and already-verified roots. Output paths are verified downstream. Not for file contents.';
2383
+ const findEquivalentPositiveProbe = 'File contents are excluded. Paths verified downstream are returned. Already-verified roots and project root are excluded. Lookup is only for unknown partial paths/names.';
2384
+ const findPolarityMutations = [
2385
+ findDescriptionProbe.replace('Fuzzy lookup only', 'Do not use fuzzy lookup only'),
2386
+ findDescriptionProbe.replace('excludes project root', 'does not exclude project root'),
2387
+ findDescriptionProbe.replace('Output paths are verified downstream', 'Downstream paths not verified'),
2388
+ findDescriptionProbe.replace('Not for file contents', 'Does not exclude file contents'),
2389
+ findDescriptionProbe.replace('Fuzzy lookup only', 'Fuzzy lookup is not only'),
2390
+ findDescriptionProbe.replace('excludes project root', 'should not exclude project root'),
2391
+ findDescriptionProbe.replace('excludes project root', 'must not exclude project root'),
2392
+ findDescriptionProbe.replace('excludes project root', 'can not exclude project root'),
2393
+ findDescriptionProbe.replace('Output paths are verified downstream', 'Output paths cannot be verified downstream'),
2394
+ findDescriptionProbe.replace('Not for file contents', 'File contents must not be excluded'),
2395
+ findDescriptionProbe.replace('Output paths are verified downstream', 'Output paths are verified'),
2396
+ findDescriptionProbe.replace('Fuzzy lookup only', 'Fuzzy lookup isn\'t only'),
2397
+ findDescriptionProbe.replace('excludes project root and already-verified roots', 'project root and already-verified roots shouldn\'t be excluded'),
2398
+ findDescriptionProbe.replace('Output paths are verified downstream', 'Output paths can\'t be verified downstream'),
2399
+ findDescriptionProbe.replace('Not for file contents', 'File contents aren\'t excluded'),
2400
+ findDescriptionProbe.replace('Fuzzy lookup only', 'Lookup isn\'t necessarily only'),
2401
+ findDescriptionProbe.replace('Output paths are verified downstream', 'Output paths shouldn\'t be considered verified downstream'),
2402
+ ];
2403
+ if (!hasFindDescriptionContract(findTool?.description || '') || !hasFindDescriptionContract(findEquivalentPositiveProbe) || findPolarityMutations.some(hasFindDescriptionContract)) {
2404
+ throw new Error('find description must limit lookup to unknown partial paths/names, exclude roots and file contents, and return verified paths');
2184
2405
  }
2185
2406
  if (!/List directory entries/i.test(listTool?.description || '') || !/path\[\]/i.test(listTool?.inputSchema?.properties?.path?.description || '')) {
2186
2407
  throw new Error('list description must require verified directories and locator-first unknown dirs');
2187
2408
  }
2188
- if (!/symbol modes use symbols\[\]/i.test(codeGraphProps.mode?.description || '') || !/symbols \(file outline\) use files\[\]/i.test(codeGraphProps.mode?.description || '') || !/one symbols\[\] call/i.test(codeGraphProps.symbols?.description || '')) {
2189
- throw new Error('code_graph schema fields must stay compact and repo-local');
2190
- }
2409
+ const codeGraphModeDescription = codeGraphProps.mode?.description || '';
2410
+ const codeGraphSymbolsDescription = codeGraphProps.symbols?.description || '';
2411
+ assertCodeGraphDescriptionContract({
2412
+ description: codeGraphDescription,
2413
+ modeDescription: codeGraphModeDescription,
2414
+ symbolsDescription: codeGraphSymbolsDescription,
2415
+ });
2191
2416
 
2192
2417
  const longToolSearchText = compactToolSearchDescription(`${patchDescription}\n${patchDescription}`);
2193
2418
  if (longToolSearchText.length > 220 || /\n/.test(longToolSearchText)) {
@@ -24,7 +24,10 @@ import {
24
24
  agentArgsWithResultMetadata,
25
25
  parseAgentJob,
26
26
  } from '../src/tui/engine/agent-envelope.mjs';
27
- import { agentActionTitle } from '../src/tui/components/tool-execution/surface-detail.mjs';
27
+ import {
28
+ agentActionTitle,
29
+ toolSearchLoadedSummary,
30
+ } from '../src/tui/components/tool-execution/surface-detail.mjs';
28
31
 
29
32
  test('tool action copy keeps Add/Delete patch verbs and human read offsets', () => {
30
33
  assert.equal(
@@ -38,6 +41,19 @@ test('tool action copy keeps Add/Delete patch verbs and human read offsets', ()
38
41
  assert.match(summarizeToolArgs('read', { path: 'a.mjs', offset: 0, limit: 10 }), /lines 1-10/);
39
42
  });
40
43
 
44
+ test('load_tool presentation distinguishes first load from already-active repeats', () => {
45
+ assert.equal(
46
+ toolSearchLoadedSummary('Loaded deferred tools: shell\nAlready active: task'),
47
+ 'Loaded: shell · Already active: task',
48
+ );
49
+ assert.equal(
50
+ toolSearchLoadedSummary(JSON.stringify({
51
+ selected: { tools: { added: [], already: ['shell', 'task'] } },
52
+ })),
53
+ 'Already active: shell, task',
54
+ );
55
+ });
56
+
41
57
  test('Agent aggregation is batch-scoped and keeps category action wording', () => {
42
58
  const entry = aggregateToolCategoryEntry('agent', { type: 'spawn' }, 'Agent');
43
59
  assert.equal(formatAggregateHeader({ [entry.key]: { ...entry, count: 2 } }, { pending: false }), 'Called 2 agents');
@@ -0,0 +1,26 @@
1
+ # TUI typing-lag perf measurement launcher.
2
+ #
3
+ # Enables the render/event-loop probes with tuned thresholds, routes probe
4
+ # output to a DEDICATED fresh log, then launches mixdog exactly like `mixdog`.
5
+ # Reproduce the lag (long transcript + streaming, type continuously), then
6
+ # /exit. The log is then read to pin the bottleneck.
7
+ $ErrorActionPreference = 'Stop'
8
+ $repo = Split-Path -Parent $PSScriptRoot
9
+ $log = Join-Path $repo 'tui-perf.log'
10
+
11
+ # Probe gates. Thresholds lowered below the defaults (80ms stall / 120ms gap)
12
+ # because the synthetic measurement put real stalls at ~40-60ms, under the
13
+ # defaults, so they would never be logged.
14
+ $env:MIXDOG_TUI_PERF = '1'
15
+ $env:MIXDOG_TUI_PERF_STALL_MS = '40'
16
+ $env:MIXDOG_TUI_PERF_RENDER_GAP_MS = '50'
17
+ $env:MIXDOG_TUI_LOOP_PROBE = '1'
18
+ $env:MIXDOG_TUI_STDERR_LOG = $log
19
+
20
+ # Start from a clean log so the read is unambiguous.
21
+ if (Test-Path $log) { Remove-Item $log -Force }
22
+
23
+ Write-Host "perf log -> $log" -ForegroundColor Cyan
24
+ Write-Host 'Reproduce: long transcript + streaming, type continuously to trigger lag, then /exit.' -ForegroundColor Cyan
25
+
26
+ node (Join-Path $repo 'src\cli.mjs') @args
@@ -8,7 +8,7 @@ import { buildTranscriptRowIndexIncremental } from '../src/tui/app/transcript-wi
8
8
 
9
9
  const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
10
10
 
11
- function makeTurnHarness(ask, stateOverrides = {}) {
11
+ function makeTurnHarness(ask, stateOverrides = {}, bagOverrides = {}) {
12
12
  let seq = 0;
13
13
  let state = {
14
14
  items: [],
@@ -84,18 +84,22 @@ function makeTurnHarness(ask, stateOverrides = {}) {
84
84
  flushDeferredExecutionPendingResumeKick: () => {},
85
85
  drain: async () => {},
86
86
  drainPendingSteering: () => [],
87
+ ...bagOverrides,
87
88
  };
88
89
  return { runTurn: createRunTurn(bag), getState: () => state };
89
90
  }
90
91
 
91
92
  test('successful mid-turn compact trims history but preserves live turn references', async () => {
92
93
  let preservedTail = false;
94
+ let contextSyncs = 0;
93
95
  const harness = makeTurnHarness(async (_text, options) => {
94
96
  options.onTextDelta('before compact\n');
95
97
  await wait(30);
96
98
  options.onCompactEvent({ status: 'compacted', trigger: 'reactive' });
99
+ assert.equal(contextSyncs, 1, 'compact event must refresh context before returning');
97
100
  preservedTail = harness.getState().streamingTail?.text === 'before compact\n';
98
101
  options.onCompactEvent({ status: 'compacted', trigger: 'reactive' });
102
+ assert.equal(contextSyncs, 2, 'each compact event refreshes context immediately');
99
103
  options.onTextDelta('after compact\n');
100
104
  return { result: { content: 'before compact\nafter compact\n' }, session: { messages: [] } };
101
105
  }, {
@@ -103,6 +107,8 @@ test('successful mid-turn compact trims history but preserves live turn referenc
103
107
  { id: 'old', kind: 'assistant', text: 'old history' },
104
108
  { id: 'current-user', kind: 'user', text: 'go' },
105
109
  ],
110
+ }, {
111
+ syncContextStats: () => { contextSyncs += 1; },
106
112
  });
107
113
 
108
114
  assert.equal(await harness.runTurn('go', { submittedIds: ['current-user'] }), 'done');