mixdog 0.8.1 → 0.9.1

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 (299) hide show
  1. package/README.md +47 -23
  2. package/package.json +34 -28
  3. package/scripts/_test-folder-dialog.mjs +30 -0
  4. package/scripts/agent-parallel-smoke.mjs +388 -0
  5. package/scripts/agent-tag-reuse-smoke.mjs +183 -0
  6. package/scripts/background-task-meta-smoke.mjs +38 -0
  7. package/scripts/boot-smoke.mjs +52 -9
  8. package/scripts/build-runtime-linux.sh +348 -0
  9. package/scripts/build-runtime-macos.sh +217 -0
  10. package/scripts/build-runtime-windows.ps1 +242 -0
  11. package/scripts/compact-active-turn-test.mjs +68 -0
  12. package/scripts/compact-smoke.mjs +859 -129
  13. package/scripts/compact-trigger-migration-smoke.mjs +187 -0
  14. package/scripts/fix-brief-fn.mjs +35 -0
  15. package/scripts/fix-format-tool-surface.mjs +24 -0
  16. package/scripts/fix-tool-exec-visible.mjs +42 -0
  17. package/scripts/generate-runtime-manifest.mjs +166 -0
  18. package/scripts/hook-bus-test.mjs +330 -0
  19. package/scripts/lead-workflow-smoke.mjs +33 -39
  20. package/scripts/live-worker-smoke.mjs +43 -37
  21. package/scripts/llm-trace-summary.mjs +315 -0
  22. package/scripts/memory-meta-concurrency-test.mjs +20 -0
  23. package/scripts/output-style-smoke.mjs +56 -15
  24. package/scripts/parent-abort-link-test.mjs +44 -0
  25. package/scripts/patch-agent-brief.mjs +48 -0
  26. package/scripts/patch-app.mjs +21 -0
  27. package/scripts/patch-app2.mjs +18 -0
  28. package/scripts/patch-dist-brief.mjs +96 -0
  29. package/scripts/patch-tool-exec.mjs +70 -0
  30. package/scripts/pretool-ask-runtime-test.mjs +54 -0
  31. package/scripts/provider-toolcall-test.mjs +376 -0
  32. package/scripts/reactive-compact-persist-smoke.mjs +124 -0
  33. package/scripts/sanitize-tool-pairs-test.mjs +260 -0
  34. package/scripts/session-context-bench.mjs +205 -33
  35. package/scripts/session-ingest-smoke.mjs +177 -0
  36. package/scripts/set-effort-config-test.mjs +41 -0
  37. package/scripts/smoke-runtime-negative.ps1 +106 -0
  38. package/scripts/smoke-runtime-negative.sh +97 -0
  39. package/scripts/smoke.mjs +25 -0
  40. package/scripts/tool-result-hook-test.mjs +48 -0
  41. package/scripts/tool-smoke.mjs +1222 -90
  42. package/scripts/toolcall-args-test.mjs +150 -0
  43. package/scripts/tui-background-failure-smoke.mjs +73 -0
  44. package/scripts/usage-metrics-epoch-smoke.mjs +114 -0
  45. package/src/agents/debugger/AGENT.md +8 -0
  46. package/src/agents/explore/AGENT.md +4 -0
  47. package/src/agents/heavy-worker/AGENT.md +9 -3
  48. package/src/agents/maintainer/AGENT.md +4 -0
  49. package/src/agents/reviewer/AGENT.md +8 -0
  50. package/src/agents/scheduler-task/AGENT.md +12 -0
  51. package/src/agents/scheduler-task/agent.json +6 -0
  52. package/src/agents/webhook-handler/AGENT.md +12 -0
  53. package/src/agents/webhook-handler/agent.json +6 -0
  54. package/src/agents/worker/AGENT.md +9 -3
  55. package/src/app.mjs +77 -3
  56. package/src/defaults/hidden-roles.json +17 -12
  57. package/src/headless-role.mjs +117 -0
  58. package/src/help.mjs +30 -0
  59. package/src/hooks/lib/permission-evaluator.cjs +11 -475
  60. package/src/lib/keychain-cjs.cjs +9 -1
  61. package/src/lib/mixdog-debug.cjs +0 -29
  62. package/src/lib/plugin-paths.cjs +1 -7
  63. package/src/lib/rules-builder.cjs +242 -98
  64. package/src/lib/text-utils.cjs +1 -1
  65. package/src/mixdog-session-runtime.mjs +2211 -446
  66. package/src/output-styles/default.md +12 -28
  67. package/src/output-styles/extreme-simple.md +9 -6
  68. package/src/output-styles/simple.md +22 -9
  69. package/src/repl.mjs +108 -59
  70. package/src/rules/agent/00-common.md +15 -0
  71. package/src/rules/{bridge → agent}/20-skip-protocol.md +1 -2
  72. package/src/rules/agent/30-explorer.md +22 -0
  73. package/src/rules/{bridge → agent}/40-cycle1-agent.md +7 -0
  74. package/src/rules/{bridge → agent}/41-cycle2-agent.md +7 -0
  75. package/src/rules/{bridge → agent}/42-cycle3-agent.md +7 -0
  76. package/src/rules/lead/01-general.md +9 -5
  77. package/src/rules/lead/04-workflow.md +51 -12
  78. package/src/rules/lead/lead-tool.md +6 -0
  79. package/src/rules/shared/01-tool.md +12 -1
  80. package/src/runtime/agent/orchestrator/activity-bus.mjs +7 -18
  81. package/src/runtime/agent/orchestrator/agent-owner.mjs +11 -0
  82. package/src/runtime/agent/orchestrator/{smart-bridge/bridge-llm.mjs → agent-runtime/agent-dispatch.mjs} +138 -111
  83. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +94 -0
  84. package/src/runtime/agent/orchestrator/{smart-bridge → agent-runtime}/cache-strategy.mjs +32 -23
  85. package/src/runtime/agent/orchestrator/{smart-bridge → agent-runtime}/session-builder.mjs +33 -27
  86. package/src/runtime/agent/orchestrator/{bridge-trace.mjs → agent-trace.mjs} +132 -81
  87. package/src/runtime/agent/orchestrator/cache-mtime.mjs +0 -21
  88. package/src/runtime/agent/orchestrator/config.mjs +174 -55
  89. package/src/runtime/agent/orchestrator/context/collect.mjs +195 -487
  90. package/src/runtime/agent/orchestrator/dispatch-persist.mjs +1 -1
  91. package/src/runtime/agent/orchestrator/internal-roles.mjs +77 -29
  92. package/src/runtime/agent/orchestrator/internal-tools.mjs +5 -6
  93. package/src/runtime/agent/orchestrator/mcp/client.mjs +15 -9
  94. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -1
  95. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +380 -268
  96. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +131 -93
  97. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +236 -4
  98. package/src/runtime/agent/orchestrator/providers/custom-tool-wire.mjs +49 -0
  99. package/src/runtime/agent/orchestrator/providers/gemini.mjs +58 -13
  100. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -149
  101. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +132 -2
  102. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +4 -1
  103. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +45 -0
  104. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +61 -116
  105. package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +25 -0
  106. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +79 -255
  107. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +203 -71
  108. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +512 -312
  109. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +343 -518
  110. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +6 -6
  111. package/src/runtime/agent/orchestrator/providers/registry.mjs +88 -51
  112. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +289 -11
  113. package/src/runtime/agent/orchestrator/providers/statusline-route-meta.mjs +41 -8
  114. package/src/runtime/agent/orchestrator/session/compact.mjs +1173 -267
  115. package/src/runtime/agent/orchestrator/session/context-utils.mjs +199 -36
  116. package/src/runtime/agent/orchestrator/session/loop.mjs +840 -612
  117. package/src/runtime/agent/orchestrator/session/manager.mjs +1574 -459
  118. package/src/runtime/agent/orchestrator/session/manager.reactive-persist.test.mjs +107 -0
  119. package/src/runtime/agent/orchestrator/session/store.mjs +67 -29
  120. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +2 -2
  121. package/src/runtime/agent/orchestrator/stall-policy.mjs +31 -16
  122. package/src/runtime/agent/orchestrator/tool-loop-guard.mjs +3 -219
  123. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +35 -8
  124. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +19 -8
  125. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +10 -53
  126. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +60 -37
  127. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +21 -2
  128. package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +1 -1
  129. package/src/runtime/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +0 -7
  130. package/src/runtime/agent/orchestrator/tools/builtin/glob-walk.mjs +1 -3
  131. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +36 -12
  132. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +2 -0
  133. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -2
  134. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +5 -12
  135. package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +1 -1
  136. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +4 -36
  137. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +2 -40
  138. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +148 -27
  139. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +2 -2
  140. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +43 -75
  141. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +90 -20
  142. package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -5
  143. package/src/runtime/agent/orchestrator/tools/code-graph-state.mjs +86 -0
  144. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +11 -11
  145. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4106 -4019
  146. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +33 -4
  147. package/src/runtime/agent/orchestrator/tools/patch.mjs +90 -6
  148. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +6 -4
  149. package/src/runtime/agent/orchestrator/tools/result-compression.mjs +4 -4
  150. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +8 -1
  151. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +4 -4
  152. package/src/runtime/channels/index.mjs +149 -51
  153. package/src/runtime/channels/lib/cli-worker-host.mjs +1 -8
  154. package/src/runtime/channels/lib/config.mjs +0 -1
  155. package/src/runtime/channels/lib/drop-trace.mjs +1 -1
  156. package/src/runtime/channels/lib/executor.mjs +0 -3
  157. package/src/runtime/channels/lib/memory-client.mjs +0 -38
  158. package/src/runtime/channels/lib/output-forwarder.mjs +1 -8
  159. package/src/runtime/channels/lib/runtime-paths.mjs +0 -6
  160. package/src/runtime/channels/lib/scheduler.mjs +18 -14
  161. package/src/runtime/channels/lib/session-discovery.mjs +3 -6
  162. package/src/runtime/channels/lib/tool-format.mjs +0 -1
  163. package/src/runtime/channels/lib/transcript-discovery.mjs +4 -12
  164. package/src/runtime/channels/lib/webhook.mjs +1 -1
  165. package/src/runtime/channels/tool-defs.mjs +29 -29
  166. package/src/runtime/lib/keychain-cjs.cjs +0 -1
  167. package/src/runtime/memory/data/runtime-manifest.json +6 -7
  168. package/src/runtime/memory/index.mjs +519 -118
  169. package/src/runtime/memory/lib/agent-ipc.mjs +29 -12
  170. package/src/runtime/memory/lib/core-memory-store.mjs +2 -2
  171. package/src/runtime/memory/lib/embedding-model-config.mjs +55 -0
  172. package/src/runtime/memory/lib/embedding-provider.mjs +31 -4
  173. package/src/runtime/memory/lib/embedding-worker.mjs +19 -10
  174. package/src/runtime/memory/lib/llm-worker-host.mjs +0 -4
  175. package/src/runtime/memory/lib/memory-cycle1.mjs +28 -7
  176. package/src/runtime/memory/lib/memory-cycle2.mjs +4 -5
  177. package/src/runtime/memory/lib/memory-cycle3.mjs +2 -2
  178. package/src/runtime/memory/lib/memory-ops-policy.mjs +2 -2
  179. package/src/runtime/memory/lib/memory-session-merge.mjs +38 -0
  180. package/src/runtime/memory/lib/memory.mjs +88 -9
  181. package/src/runtime/memory/lib/model-profile.mjs +1 -1
  182. package/src/runtime/memory/lib/pg/adapter.mjs +1 -1
  183. package/src/runtime/memory/lib/pg/supervisor.mjs +12 -0
  184. package/src/runtime/memory/lib/runtime-fetcher.mjs +80 -21
  185. package/src/runtime/memory/lib/session-ingest.mjs +196 -0
  186. package/src/runtime/memory/lib/trace-store.mjs +96 -51
  187. package/src/runtime/memory/tool-defs.mjs +46 -37
  188. package/src/runtime/search/index.mjs +104 -473
  189. package/src/runtime/search/lib/config.mjs +0 -4
  190. package/src/runtime/search/lib/state.mjs +1 -15
  191. package/src/runtime/search/lib/web-tools.mjs +44 -25
  192. package/src/runtime/search/tool-defs.mjs +16 -23
  193. package/src/runtime/shared/abort-controller.mjs +1 -1
  194. package/src/runtime/shared/atomic-file.mjs +4 -3
  195. package/src/runtime/shared/background-tasks.mjs +122 -11
  196. package/src/runtime/shared/child-spawn-gate.mjs +139 -0
  197. package/src/runtime/shared/config.mjs +7 -4
  198. package/src/runtime/shared/err-text.mjs +131 -4
  199. package/src/runtime/shared/llm/cost.mjs +2 -2
  200. package/src/runtime/shared/llm/http-agent.mjs +23 -7
  201. package/src/runtime/shared/llm/index.mjs +34 -11
  202. package/src/runtime/shared/llm/usage-log.mjs +4 -4
  203. package/src/runtime/shared/markdown-frontmatter.mjs +56 -0
  204. package/src/runtime/shared/singleton-owner.mjs +104 -0
  205. package/src/runtime/shared/tool-execution-contract.mjs +199 -20
  206. package/src/runtime/shared/tool-execution-contract.test.mjs +183 -0
  207. package/src/runtime/shared/tool-surface.mjs +624 -95
  208. package/src/runtime/shared/user-data-guard.mjs +0 -2
  209. package/src/standalone/agent-task-status.mjs +203 -0
  210. package/src/standalone/agent-task-status.test.mjs +76 -0
  211. package/src/standalone/agent-tool.mjs +1913 -0
  212. package/src/standalone/channel-worker.mjs +370 -14
  213. package/src/standalone/explore-tool.mjs +165 -70
  214. package/src/standalone/folder-dialog.mjs +314 -0
  215. package/src/standalone/hook-bus.mjs +898 -22
  216. package/src/standalone/memory-runtime-proxy.mjs +320 -0
  217. package/src/standalone/projects.mjs +226 -0
  218. package/src/standalone/provider-admin.mjs +41 -24
  219. package/src/standalone/seeds.mjs +1 -78
  220. package/src/standalone/usage-dashboard.mjs +96 -8
  221. package/src/tui/App.jsx +4800 -2140
  222. package/src/tui/components/AnsiText.jsx +39 -28
  223. package/src/tui/components/ContextPanel.jsx +14 -4
  224. package/src/tui/components/Markdown.jsx +43 -77
  225. package/src/tui/components/MarkdownTable.jsx +9 -184
  226. package/src/tui/components/Message.jsx +28 -11
  227. package/src/tui/components/Picker.jsx +95 -56
  228. package/src/tui/components/PromptInput.jsx +428 -239
  229. package/src/tui/components/QueuedCommands.jsx +1 -1
  230. package/src/tui/components/SlashCommandPalette.jsx +27 -21
  231. package/src/tui/components/Spinner.jsx +67 -38
  232. package/src/tui/components/StatusLine.jsx +606 -38
  233. package/src/tui/components/TextEntryPanel.jsx +128 -9
  234. package/src/tui/components/ToolExecution.jsx +597 -362
  235. package/src/tui/components/TurnDone.jsx +3 -3
  236. package/src/tui/components/UsagePanel.jsx +3 -5
  237. package/src/tui/components/tool-output-format.mjs +499 -0
  238. package/src/tui/components/tool-output-format.test.mjs +312 -0
  239. package/src/tui/dist/index.mjs +9192 -2399
  240. package/src/tui/engine-runtime-notification.test.mjs +115 -0
  241. package/src/tui/engine-tool-result-text.test.mjs +75 -0
  242. package/src/tui/engine.mjs +1455 -279
  243. package/src/tui/figures.mjs +21 -40
  244. package/src/tui/index.jsx +75 -31
  245. package/src/tui/input-editing.mjs +25 -0
  246. package/src/tui/markdown/format-token.mjs +671 -69
  247. package/src/tui/markdown/format-token.test.mjs +312 -0
  248. package/src/tui/markdown/render-ansi.mjs +94 -0
  249. package/src/tui/markdown/render-ansi.test.mjs +108 -0
  250. package/src/tui/markdown/stream-fence.mjs +34 -0
  251. package/src/tui/markdown/stream-fence.test.mjs +26 -0
  252. package/src/tui/markdown/table-layout.mjs +250 -0
  253. package/src/tui/paste-attachments.mjs +0 -7
  254. package/src/tui/spinner-verbs.mjs +1 -2
  255. package/src/tui/statusline-ansi-bridge.mjs +172 -0
  256. package/src/tui/statusline-ansi-bridge.test.mjs +159 -0
  257. package/src/tui/theme.mjs +756 -24
  258. package/src/tui/time-format.mjs +1 -1
  259. package/src/tui/transcript-tool-failures.mjs +67 -0
  260. package/src/tui/transcript-tool-failures.test.mjs +111 -0
  261. package/src/ui/ansi.mjs +1 -2
  262. package/src/ui/markdown.mjs +85 -26
  263. package/src/ui/markdown.test.mjs +70 -0
  264. package/src/ui/model-display.mjs +121 -0
  265. package/src/ui/session-stats.mjs +44 -0
  266. package/src/ui/statusline-context-label.test.mjs +15 -0
  267. package/src/ui/statusline.mjs +386 -178
  268. package/src/ui/tool-card.mjs +2 -13
  269. package/src/vendor/statusline/bin/statusline-lib.mjs +1 -620
  270. package/src/vendor/statusline/bin/statusline-route.mjs +169 -37
  271. package/src/vendor/statusline/bin/statusline-route.test.mjs +80 -0
  272. package/src/vendor/statusline/scripts/lib/gateway-settings.mjs +3 -3
  273. package/src/vendor/statusline/src/gateway/claude-current.mjs +1 -1
  274. package/src/vendor/statusline/src/gateway/route-meta.mjs +44 -6
  275. package/src/vendor/statusline/src/gateway/session-routes.mjs +1 -1
  276. package/src/workflows/default/WORKFLOW.md +12 -5
  277. package/src/workflows/default/workflow.json +0 -1
  278. package/src/workflows/solo/WORKFLOW.md +15 -0
  279. package/src/workflows/solo/workflow.json +7 -0
  280. package/vendor/ink/build/ink.js +54 -8
  281. package/vendor/ink/build/output.js +6 -1
  282. package/src/agents/scheduler-task.md +0 -3
  283. package/src/agents/web-researcher/AGENT.md +0 -3
  284. package/src/agents/web-researcher/agent.json +0 -6
  285. package/src/agents/webhook-handler.md +0 -3
  286. package/src/hooks/lib/permission-rules.cjs +0 -170
  287. package/src/hooks/lib/settings-loader.cjs +0 -112
  288. package/src/lib/hook-pipe-path.cjs +0 -10
  289. package/src/rules/bridge/00-common.md +0 -5
  290. package/src/rules/bridge/30-explorer.md +0 -4
  291. package/src/rules/lead/00-tool-lead.md +0 -5
  292. package/src/rules/shared/00-language.md +0 -3
  293. package/src/runtime/agent/orchestrator/tools/builtin/native-edit-runner.mjs +0 -110
  294. package/src/runtime/agent/orchestrator/tools/mutation-content-cache.mjs +0 -67
  295. package/src/runtime/channels/lib/hook-pipe-server.mjs +0 -671
  296. package/src/runtime/memory/lib/bridge-trace-queries.mjs +0 -120
  297. package/src/runtime/shared/llm/pid-cleanup.mjs +0 -27
  298. package/src/standalone/bridge-tool.mjs +0 -1414
  299. package/src/tui/runtime/shared/process-shutdown.mjs +0 -1
@@ -1,23 +1,57 @@
1
1
  #!/usr/bin/env node
2
- import { dirname, resolve } from 'node:path';
2
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { dirname, join, resolve } from 'node:path';
3
5
  import { fileURLToPath } from 'node:url';
4
- import { compactToolSearchDescription, defaultDeferredToolNames, TOOL_SEARCH_TOOL } from '../src/mixdog-session-runtime.mjs';
6
+ import { __renderToolSearchForTest, compactToolSearchDescription, defaultDeferredToolNames, SKILL_TOOL, TOOL_SEARCH_TOOL } from '../src/mixdog-session-runtime.mjs';
5
7
  import { buildExplorerPrompt, EXPLORE_TOOL, MAX_FANOUT_QUERIES, normalizeExploreQueries } from '../src/standalone/explore-tool.mjs';
6
- import { BRIDGE_TOOL, createStandaloneBridge, resolveBridgeExecutionMode } from '../src/standalone/bridge-tool.mjs';
8
+ import { AGENT_TOOL, createStandaloneAgent } from '../src/standalone/agent-tool.mjs';
9
+ import { parseHeadlessRoleCommand } from '../src/app.mjs';
10
+ import { buildHeadlessSpawnArgs } from '../src/headless-role.mjs';
11
+ import { createStandaloneChannelWorker } from '../src/standalone/channel-worker.mjs';
12
+ import { OpenAIOAuthProvider, buildRequestBody, sendViaHttpSse } from '../src/runtime/agent/orchestrator/providers/openai-oauth.mjs';
13
+ import { _logicalResponseItemMatch, _resolveOpenAiPromptCacheRatePolicy } from '../src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs';
14
+ import { _mergePendingMessageEntries, closeSession, createSession, drainPendingMessages, enqueuePendingMessage, resumeSession } from '../src/runtime/agent/orchestrator/session/manager.mjs';
15
+ import {
16
+ contentHasImage,
17
+ normalizeContentForAnthropic,
18
+ normalizeContentForGeminiParts,
19
+ normalizeContentForOpenAIChat,
20
+ normalizeContentForOpenAIResponses,
21
+ sanitizeContentForStoredHistory,
22
+ } from '../src/runtime/agent/orchestrator/providers/media-normalization.mjs';
23
+ import { initProviders } from '../src/runtime/agent/orchestrator/providers/registry.mjs';
24
+ import {
25
+ cacheCapabilityForProvider,
26
+ resolveCacheStrategy,
27
+ shouldMarkWarmForProvider,
28
+ shouldRecordObservedForProvider,
29
+ } from '../src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs';
7
30
  import { executeBuiltinTool } from '../src/runtime/agent/orchestrator/tools/builtin.mjs';
8
31
  import { validateBuiltinArgs } from '../src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs';
9
32
  import { BUILTIN_TOOLS } from '../src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs';
33
+ import { runResultCacheInFlight } from '../src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs';
10
34
  import { executeCodeGraphTool } from '../src/runtime/agent/orchestrator/tools/code-graph.mjs';
11
35
  import { CODE_GRAPH_TOOL_DEFS } from '../src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs';
12
36
  import { executePatchTool } from '../src/runtime/agent/orchestrator/tools/patch.mjs';
13
37
  import { PATCH_TOOL_DEFS } from '../src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs';
14
38
  import { TOOL_DEFS as MEMORY_TOOL_DEFS } from '../src/runtime/memory/tool-defs.mjs';
39
+ import { mergeSessionRowsIntoGlobal } from '../src/runtime/memory/lib/memory-session-merge.mjs';
15
40
  import { TOOL_DEFS as SEARCH_TOOL_DEFS } from '../src/runtime/search/tool-defs.mjs';
16
41
  import { TOOL_DEFS as CHANNEL_TOOL_DEFS } from '../src/runtime/channels/tool-defs.mjs';
17
- import { classifyBridgeWorkerGitMutationCommand } from '../src/runtime/agent/orchestrator/tool-loop-guard.mjs';
42
+ import { AGENT_OWNER } from '../src/runtime/agent/orchestrator/agent-owner.mjs';
43
+ import { composeSystemPrompt } from '../src/runtime/agent/orchestrator/context/collect.mjs';
44
+ import { setInternalToolsProvider } from '../src/runtime/agent/orchestrator/internal-tools.mjs';
45
+ import { prepareAgentSession } from '../src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs';
46
+ import { resolveHiddenRoleSchemaAllowedTools } from '../src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs';
47
+ import { getHiddenRole, resolveAgentSessionPermission } from '../src/runtime/agent/orchestrator/internal-roles.mjs';
18
48
 
19
49
  const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
20
50
 
51
+ function assert(condition, message) {
52
+ if (!condition) throw new Error(message);
53
+ }
54
+
21
55
  function assertOk(name, result, pattern = null) {
22
56
  const text = String(result || '');
23
57
  if (!text || /^Error[\s:[]/.test(text)) {
@@ -29,6 +63,291 @@ function assertOk(name, result, pattern = null) {
29
63
  return text;
30
64
  }
31
65
 
66
+ {
67
+ const sid = `tool-smoke-rich-pending-${process.pid}-${Date.now()}`.replace(/[^A-Za-z0-9_-]/g, '_');
68
+ const richContent = [
69
+ { type: 'text', text: 'look at this' },
70
+ { type: 'image', data: 'abc', mimeType: 'image/png' },
71
+ ];
72
+ const depth = enqueuePendingMessage(sid, { text: 'look at this\n[Image]', content: richContent });
73
+ assert(depth >= 1, `rich pending enqueue should return queue depth, got ${depth}`);
74
+ const drained = drainPendingMessages(sid);
75
+ assert(drained.length === 1, `rich pending drain should dedupe memory+persisted entries, got ${drained.length}`);
76
+ assert(Array.isArray(drained[0]?.content), `rich pending drain should preserve content array: ${JSON.stringify(drained)}`);
77
+ assert(drained[0].content.some((part) => part?.type === 'image' && part?.data === 'abc'), `rich pending drain lost image part: ${JSON.stringify(drained)}`);
78
+ const merged = _mergePendingMessageEntries([...drained, 'plain follow-up']);
79
+ assert(Array.isArray(merged?.content), `rich pending merge should preserve structured content: ${JSON.stringify(merged)}`);
80
+ assert(merged.content.some((part) => part?.type === 'image' && part?.data === 'abc'), `rich pending merge lost image part: ${JSON.stringify(merged)}`);
81
+ assert(
82
+ merged.content.some((part) => part?.type === 'text' && /plain follow-up/.test(part.text || '')),
83
+ `rich pending merge should keep later text follow-up: ${JSON.stringify(merged)}`,
84
+ );
85
+ assert(drainPendingMessages(sid).length === 0, 'rich pending drain should remove persisted fallback after first drain');
86
+ await new Promise((resolve) => setImmediate(resolve));
87
+ assert(drainPendingMessages(sid).length === 0, 'rich pending async mirror must not resurrect an already-drained message');
88
+ }
89
+
90
+ {
91
+ const sid = `tool-smoke-async-pending-${process.pid}-${Date.now()}`.replace(/[^A-Za-z0-9_-]/g, '_');
92
+ enqueuePendingMessage(sid, 'persisted pending text');
93
+ await new Promise((resolve) => setImmediate(resolve));
94
+ const drained = drainPendingMessages(sid);
95
+ assert(drained.length === 1 && drained[0] === 'persisted pending text', `async pending mirror should persist fallback text: ${JSON.stringify(drained)}`);
96
+ }
97
+
98
+ {
99
+ let computes = 0;
100
+ const key = `tool-smoke-inflight-${Date.now()}-${Math.random()}`;
101
+ const [a, b] = await Promise.all([
102
+ runResultCacheInFlight(key, async () => {
103
+ computes += 1;
104
+ await new Promise((resolve) => setTimeout(resolve, 15));
105
+ return 'shared-result';
106
+ }),
107
+ runResultCacheInFlight(key, async () => {
108
+ computes += 1;
109
+ return 'duplicate-result';
110
+ }),
111
+ ]);
112
+ assert(computes === 1, `in-flight result cache should compute once, computed ${computes}`);
113
+ assert(a === 'shared-result' && b === 'shared-result', 'in-flight result cache should share the first result');
114
+ }
115
+
116
+ {
117
+ const fullPolicy = _resolveOpenAiPromptCacheRatePolicy({}, {
118
+ mode: 'full',
119
+ frameInputItems: 40,
120
+ deltaTokens: 9000,
121
+ hasPreviousResponseId: false,
122
+ });
123
+ assert(fullPolicy.policy === 'full_guard' && fullPolicy.limitPerMin === 12, `full cache lane should keep 12rpm guard: ${JSON.stringify(fullPolicy)}`);
124
+
125
+ const deltaPolicy = _resolveOpenAiPromptCacheRatePolicy({}, {
126
+ mode: 'delta',
127
+ frameInputItems: 1,
128
+ deltaTokens: 9000,
129
+ hasPreviousResponseId: true,
130
+ });
131
+ assert(deltaPolicy.policy === 'delta_relaxed' && deltaPolicy.limitPerMin === 60, `small delta should use relaxed cache lane rpm: ${JSON.stringify(deltaPolicy)}`);
132
+
133
+ const largeDeltaPolicy = _resolveOpenAiPromptCacheRatePolicy({}, {
134
+ mode: 'delta',
135
+ frameInputItems: 20,
136
+ deltaTokens: 9000,
137
+ hasPreviousResponseId: true,
138
+ });
139
+ assert(largeDeltaPolicy.policy === 'delta_guarded' && largeDeltaPolicy.limitPerMin === 12, `large delta should fall back to full guard: ${JSON.stringify(largeDeltaPolicy)}`);
140
+
141
+ const customDeltaPolicy = _resolveOpenAiPromptCacheRatePolicy({ openaiCacheLaneDeltaRateLimitPerMin: 90 }, {
142
+ mode: 'delta',
143
+ frameInputItems: 1,
144
+ deltaTokens: 9000,
145
+ hasPreviousResponseId: true,
146
+ });
147
+ assert(customDeltaPolicy.limitPerMin === 90, `custom delta rpm should be honored: ${JSON.stringify(customDeltaPolicy)}`);
148
+
149
+ const unlimitedDeltaPolicy = _resolveOpenAiPromptCacheRatePolicy({ openaiCacheLaneDeltaRateLimitPerMin: 0 }, {
150
+ mode: 'delta',
151
+ frameInputItems: 1,
152
+ deltaTokens: 9000,
153
+ hasPreviousResponseId: true,
154
+ });
155
+ assert(unlimitedDeltaPolicy.policy === 'delta_unlimited' && unlimitedDeltaPolicy.limitPerMin === 0, `delta rpm=0 should disable delta rate wait: ${JSON.stringify(unlimitedDeltaPolicy)}`);
156
+
157
+ const originalFunctionCall = {
158
+ type: 'function_call',
159
+ call_id: 'call_tool_1',
160
+ name: 'shell',
161
+ arguments: JSON.stringify({ command: 'Get-Content -Path src/runtime/agent/orchestrator/session/loop.mjs' }),
162
+ };
163
+ const compactedReplayFunctionCall = {
164
+ type: 'function_call',
165
+ call_id: 'call_tool_1',
166
+ name: 'shell',
167
+ arguments: JSON.stringify({ command: '[mixdog compacted 74 bytes]' }),
168
+ };
169
+ assert(
170
+ _logicalResponseItemMatch(compactedReplayFunctionCall, originalFunctionCall),
171
+ 'function_call replay should match by call_id/name even when history compacts arguments',
172
+ );
173
+ assert(
174
+ !_logicalResponseItemMatch({ ...compactedReplayFunctionCall, call_id: 'call_tool_2' }, originalFunctionCall),
175
+ 'function_call replay must not match a different call_id',
176
+ );
177
+ const originalCustomCall = {
178
+ type: 'custom_tool_call',
179
+ call_id: 'call_patch_1',
180
+ name: 'apply_patch',
181
+ input: '*** Begin Patch\n*** Add File: a.txt\n+ok\n*** End Patch\n',
182
+ };
183
+ assert(
184
+ _logicalResponseItemMatch({ ...originalCustomCall, input: '[mixdog compacted patch]' }, originalCustomCall),
185
+ 'custom_tool_call replay should match by call_id/name even when history compacts patch input',
186
+ );
187
+ assert(
188
+ !_logicalResponseItemMatch({ ...originalCustomCall, call_id: 'call_patch_2' }, originalCustomCall),
189
+ 'custom_tool_call replay must not match a different call_id',
190
+ );
191
+ }
192
+
193
+ {
194
+ const publicStrategy = resolveCacheStrategy('worker');
195
+ assert(publicStrategy.tools === 'none', `Anthropic tools must not spend a cache_control BP: ${JSON.stringify(publicStrategy)}`);
196
+ assert(publicStrategy.system === '1h' && publicStrategy.tier3 === '1h' && publicStrategy.messages === '1h', `public cache tiers changed unexpectedly: ${JSON.stringify(publicStrategy)}`);
197
+ assert(cacheCapabilityForProvider('anthropic-oauth') === 'explicit-breakpoint', 'Anthropic OAuth should remain explicit-breakpoint');
198
+ assert(cacheCapabilityForProvider('openai-oauth') === 'key-prefix', 'OpenAI OAuth should remain key-prefix');
199
+ assert(cacheCapabilityForProvider('xai') === 'key-prefix', 'xAI should remain key-prefix');
200
+ assert(cacheCapabilityForProvider('grok-oauth') === 'key-prefix', 'Grok OAuth should remain key-prefix');
201
+ assert(cacheCapabilityForProvider('gemini') === 'managed-explicit', 'Gemini should be provider-managed explicit cachedContents');
202
+ assert(shouldMarkWarmForProvider('gemini') === true, 'Gemini provider-managed cache should count as warmable');
203
+ assert(shouldRecordObservedForProvider('gemini') === false, 'Gemini is no longer implicit-observed only');
204
+ assert(shouldRecordObservedForProvider('deepseek') === true, 'DeepSeek should remain observed-only');
205
+ }
206
+
207
+ {
208
+ const prevTraceDisable = process.env.MIXDOG_AGENT_TRACE_DISABLE;
209
+ process.env.MIXDOG_AGENT_TRACE_DISABLE = '1';
210
+ try {
211
+ const provider = new OpenAIOAuthProvider({});
212
+ provider.ensureAuth = async () => ({ access_token: 'fake-token' });
213
+ const calls = [];
214
+ const fakeWs = async () => {
215
+ calls.push('ws');
216
+ return { content: 'ws-ok' };
217
+ };
218
+ const fakeHttp = async () => {
219
+ calls.push('http');
220
+ return { content: 'http-ok' };
221
+ };
222
+ const imageTurnContent = [
223
+ { type: 'text', text: 'look' },
224
+ { type: 'image', data: 'abc', mimeType: 'image/png' },
225
+ ];
226
+ await provider.send(
227
+ [
228
+ { role: 'system', content: 'sys' },
229
+ { role: 'user', content: imageTurnContent },
230
+ ],
231
+ 'gpt-5.5',
232
+ [],
233
+ { _sendViaWebSocketFn: fakeWs, _sendViaHttpSseFn: fakeHttp, sessionId: 'tool-smoke-image-ws' },
234
+ );
235
+ if (provider._forceHttpFallback) {
236
+ throw new Error('image WS send must not poison future OpenAI OAuth sends');
237
+ }
238
+ const storedImageTurnContent = sanitizeContentForStoredHistory(imageTurnContent);
239
+ if (contentHasImage(storedImageTurnContent)) {
240
+ throw new Error(`stored image history must not retain provider-visible image parts: ${JSON.stringify(storedImageTurnContent)}`);
241
+ }
242
+ await provider.send(
243
+ [
244
+ { role: 'system', content: 'sys' },
245
+ { role: 'user', content: storedImageTurnContent },
246
+ { role: 'assistant', content: 'image received' },
247
+ { role: 'user', content: 'plain ping text, no image' },
248
+ ],
249
+ 'gpt-5.5',
250
+ [],
251
+ { _sendViaWebSocketFn: fakeWs, _sendViaHttpSseFn: fakeHttp, sessionId: 'tool-smoke-plain-after-image' },
252
+ );
253
+ await provider.send(
254
+ [
255
+ { role: 'system', content: 'sys' },
256
+ { role: 'user', content: 'forced HTTP fallback probe' },
257
+ ],
258
+ 'gpt-5.5',
259
+ [],
260
+ { _sendViaWebSocketFn: fakeWs, _sendViaHttpSseFn: fakeHttp, forceHttpFallback: true, sessionId: 'tool-smoke-forced-http-fallback' },
261
+ );
262
+ if (calls.join(',') !== 'ws,ws,http') {
263
+ throw new Error(`image should use WS first while forced fallback still uses HTTP: ${calls.join(',')}`);
264
+ }
265
+ } finally {
266
+ if (prevTraceDisable == null) delete process.env.MIXDOG_AGENT_TRACE_DISABLE;
267
+ else process.env.MIXDOG_AGENT_TRACE_DISABLE = prevTraceDisable;
268
+ }
269
+ }
270
+
271
+ {
272
+ const anthropicImages = normalizeContentForAnthropic([
273
+ { type: 'input_image', image_url: 'data:image/png;base64,abc' },
274
+ { type: 'image_url', image_url: { url: 'https://example.com/a.png' } },
275
+ { type: 'input_image', file_id: 'file_123' },
276
+ { type: 'input_text', text: 'look' },
277
+ ]);
278
+ assert(
279
+ anthropicImages[0]?.type === 'image'
280
+ && anthropicImages[0]?.source?.type === 'base64'
281
+ && anthropicImages[0]?.source?.media_type === 'image/png'
282
+ && anthropicImages[0]?.source?.data === 'abc',
283
+ `Anthropic data-url image normalization failed: ${JSON.stringify(anthropicImages[0])}`,
284
+ );
285
+ assert(
286
+ anthropicImages[1]?.type === 'image'
287
+ && anthropicImages[1]?.source?.type === 'url'
288
+ && anthropicImages[1]?.source?.url === 'https://example.com/a.png',
289
+ `Anthropic URL image normalization failed: ${JSON.stringify(anthropicImages[1])}`,
290
+ );
291
+ assert(
292
+ anthropicImages[2]?.type === 'image'
293
+ && anthropicImages[2]?.source?.type === 'file'
294
+ && anthropicImages[2]?.source?.file_id === 'file_123',
295
+ `Anthropic file image normalization failed: ${JSON.stringify(anthropicImages[2])}`,
296
+ );
297
+ const storedFileImage = sanitizeContentForStoredHistory([{ type: 'input_image', file_id: 'file_123' }]);
298
+ assert(!contentHasImage(storedFileImage), `stored file image history must be sanitized: ${JSON.stringify(storedFileImage)}`);
299
+ }
300
+
301
+ {
302
+ const geminiImages = normalizeContentForGeminiParts([
303
+ { type: 'input_image', image_url: 'data:image/png;base64,abc' },
304
+ { type: 'image_url', image_url: { url: 'https://example.com/a.png' } },
305
+ { fileData: { mimeType: 'image/jpeg', fileUri: 'https://generativelanguage.googleapis.com/v1beta/files/abc' } },
306
+ { type: 'input_image', file_id: 'file_123' },
307
+ ]);
308
+ assert(
309
+ geminiImages[0]?.inlineData?.mimeType === 'image/png'
310
+ && geminiImages[0]?.inlineData?.data === 'abc',
311
+ `Gemini data-url image normalization failed: ${JSON.stringify(geminiImages[0])}`,
312
+ );
313
+ assert(
314
+ geminiImages[1]?.fileData?.fileUri === 'https://example.com/a.png',
315
+ `Gemini URL image normalization failed: ${JSON.stringify(geminiImages[1])}`,
316
+ );
317
+ assert(
318
+ geminiImages[2]?.fileData?.mimeType === 'image/jpeg'
319
+ && geminiImages[2]?.fileData?.fileUri === 'https://generativelanguage.googleapis.com/v1beta/files/abc',
320
+ `Gemini fileData image normalization failed: ${JSON.stringify(geminiImages[2])}`,
321
+ );
322
+ assert(
323
+ /unsupported image file_id for Gemini/.test(geminiImages[3]?.text || ''),
324
+ `Gemini incompatible file_id must be explicit text, got: ${JSON.stringify(geminiImages[3])}`,
325
+ );
326
+ }
327
+
328
+ {
329
+ const grokChatImages = normalizeContentForOpenAIChat([
330
+ { type: 'image_url', image_url: { url: 'https://example.com/a.png' } },
331
+ { type: 'input_image', file_id: 'file_123' },
332
+ ]);
333
+ assert(
334
+ grokChatImages[0]?.type === 'image_url'
335
+ && grokChatImages[0]?.image_url?.url === 'https://example.com/a.png',
336
+ `OpenAI-compatible URL image normalization failed: ${JSON.stringify(grokChatImages[0])}`,
337
+ );
338
+ assert(
339
+ /unsupported image file_id for OpenAI Chat-compatible/.test(grokChatImages[1]?.text || ''),
340
+ `OpenAI-compatible chat file_id must be explicit text, got: ${JSON.stringify(grokChatImages[1])}`,
341
+ );
342
+ const grokResponsesImages = normalizeContentForOpenAIResponses([
343
+ { type: 'input_image', file_id: 'file_123' },
344
+ ]);
345
+ assert(
346
+ grokResponsesImages[0]?.type === 'input_image' && grokResponsesImages[0]?.file_id === 'file_123',
347
+ `OpenAI-compatible Responses file_id normalization failed: ${JSON.stringify(grokResponsesImages[0])}`,
348
+ );
349
+ }
350
+
32
351
  const listOut = await executeBuiltinTool('list', { path: 'scripts', head_limit: 20 }, root);
33
352
  assertOk('list', listOut, /smoke\.mjs/);
34
353
 
@@ -40,6 +359,13 @@ const grepOut = await executeBuiltinTool('grep', {
40
359
  }, root);
41
360
  assertOk('grep', grepOut, /smoke\.mjs/);
42
361
 
362
+ const redundantAllFilesGlobGrepOut = await executeBuiltinTool('grep', {
363
+ pattern: 'standalone mixdog CLI/TUI coding agent',
364
+ glob: '**/*',
365
+ head_limit: 10,
366
+ }, root);
367
+ assertOk('grep redundant all-files glob', redundantAllFilesGlobGrepOut, /scripts[\\/](?:boot-smoke|tool-smoke|smoke)\.mjs|src[\\/]help\.mjs/);
368
+
43
369
  const implicitRefsGlobOut = await executeBuiltinTool('glob', {
44
370
  pattern: '**/agent-session.ts',
45
371
  head_limit: 20,
@@ -55,6 +381,13 @@ const explicitSrcGlobOut = await executeBuiltinTool('glob', {
55
381
  }, root);
56
382
  assertOk('glob explicit src', explicitSrcGlobOut, /src[\\/].*engine\.mjs/i);
57
383
 
384
+ const findOut = await executeBuiltinTool('find', {
385
+ query: 'tool smoke',
386
+ path: '.',
387
+ head_limit: 10,
388
+ }, root);
389
+ assertOk('find', findOut, /scripts[\\/]tool-smoke\.mjs/i);
390
+
58
391
  const readOut = await executeBuiltinTool('read', {
59
392
  path: 'scripts/smoke.mjs',
60
393
  line: 1,
@@ -213,47 +546,6 @@ if (!/exactly one window family/i.test(readWindowErr || '')) {
213
546
  throw new Error(`read mixed-window guard failed: err=${readWindowErr} args=${JSON.stringify(mixedReadWindow)}`);
214
547
  }
215
548
 
216
- const modelDefaultAsync = resolveBridgeExecutionMode(
217
- {},
218
- { invocationSource: 'model-tool' },
219
- 'sync',
220
- );
221
- if (modelDefaultAsync !== 'async') throw new Error(`bridge model-tool default mode should be async, got ${modelDefaultAsync}`);
222
-
223
- const explicitSync = resolveBridgeExecutionMode(
224
- { wait: true, mode: 'sync', async: false },
225
- { invocationSource: 'model-tool' },
226
- 'sync',
227
- );
228
- if (explicitSync !== 'sync') throw new Error(`bridge explicit sync mode should be honored, got ${explicitSync}`);
229
-
230
- const userSync = resolveBridgeExecutionMode(
231
- { wait: true },
232
- { invocationSource: 'user-command' },
233
- 'async',
234
- );
235
- if (userSync !== 'sync') throw new Error(`bridge user-command wait mode should be sync, got ${userSync}`);
236
-
237
- for (const command of [
238
- 'git status --short',
239
- 'git diff -- src/mixdog-session-runtime.mjs',
240
- 'Write-Output "git push"',
241
- ]) {
242
- const blocked = classifyBridgeWorkerGitMutationCommand(command);
243
- if (blocked) throw new Error(`bridge git guard should allow readonly/non-command form ${JSON.stringify(command)}; got ${blocked}`);
244
- }
245
- for (const [command, expected] of [
246
- ['git push', 'git push'],
247
- ['git -C . commit -m smoke', 'git commit'],
248
- ['npm test && git add -A', 'git add'],
249
- ['bash -lc "git push"', 'git push'],
250
- ['cmd /c git commit -m smoke', 'git commit'],
251
- ['powershell -Command "git stash"', 'git stash'],
252
- ]) {
253
- const blocked = classifyBridgeWorkerGitMutationCommand(command);
254
- if (blocked !== expected) throw new Error(`bridge git guard mismatch for ${JSON.stringify(command)}: got ${blocked}, expected ${expected}`);
255
- }
256
-
257
549
  function assertHas(set, name) {
258
550
  if (!set.has(name)) throw new Error(`default tool surface missing ${name}: ${[...set].join(', ')}`);
259
551
  }
@@ -270,28 +562,32 @@ const smokeCatalog = [
270
562
  ...SEARCH_TOOL_DEFS,
271
563
  ...CHANNEL_TOOL_DEFS,
272
564
  EXPLORE_TOOL,
273
- BRIDGE_TOOL,
565
+ AGENT_TOOL,
566
+ SKILL_TOOL,
274
567
  TOOL_SEARCH_TOOL,
275
568
  ].filter(Boolean);
276
569
 
277
570
  const fullDefaults = defaultDeferredToolNames(smokeCatalog, 'full');
278
- if (fullDefaults.size !== 13) {
279
- throw new Error(`full default surface should stay 13 tools, got ${fullDefaults.size}: ${[...fullDefaults].join(', ')}`);
571
+ if (fullDefaults.size !== 10) {
572
+ throw new Error(`full default surface should stay 10 tools, got ${fullDefaults.size}: ${[...fullDefaults].join(', ')}`);
280
573
  }
281
- for (const name of ['read', 'code_graph', 'grep', 'glob', 'list', 'apply_patch', 'explore', 'bridge', 'shell', 'recall', 'search', 'web_fetch', 'tool_search']) {
574
+ for (const name of ['read', 'code_graph', 'grep', 'find', 'glob', 'list', 'apply_patch', 'explore', 'Skill', 'tool_search']) {
282
575
  assertHas(fullDefaults, name);
283
576
  }
284
- for (const name of ['task']) {
577
+ for (const name of ['shell', 'task', 'agent', 'recall', 'search', 'web_fetch', 'cwd']) {
285
578
  assertLacks(fullDefaults, name);
286
579
  }
287
580
 
288
581
  const leadDefaults = defaultDeferredToolNames(smokeCatalog, 'lead');
289
- if (leadDefaults.size !== 14) {
290
- throw new Error(`lead default surface should stay 14 tools, got ${leadDefaults.size}: ${[...leadDefaults].join(', ')}`);
582
+ if (leadDefaults.size !== 16) {
583
+ throw new Error(`lead default surface should stay 16 tools for this static catalog, got ${leadDefaults.size}: ${[...leadDefaults].join(', ')}`);
291
584
  }
292
- for (const name of ['read', 'code_graph', 'grep', 'glob', 'list', 'shell', 'task', 'apply_patch', 'explore', 'bridge', 'recall', 'search', 'web_fetch', 'tool_search']) {
585
+ for (const name of ['read', 'code_graph', 'grep', 'find', 'glob', 'list', 'shell', 'task', 'apply_patch', 'explore', 'agent', 'recall', 'search', 'web_fetch', 'Skill', 'tool_search']) {
293
586
  assertHas(leadDefaults, name);
294
587
  }
588
+ if (TOOL_SEARCH_TOOL.annotations?.agentHidden !== true) {
589
+ throw new Error('tool_search must stay Lead-only / standalone-only; agent sessions keep fixed schemas without deferred loading');
590
+ }
295
591
  function toolSchemaSize(tool) {
296
592
  const desc = String(tool?.description || '');
297
593
  const schema = JSON.stringify(tool?.input_schema || tool?.inputSchema || {});
@@ -308,7 +604,7 @@ if (surfaceSize > 17000) {
308
604
  for (const [name, cap] of [
309
605
  ['apply_patch', 1300],
310
606
  ['code_graph', 1550],
311
- ['bridge', 2500],
607
+ ['agent', 2500],
312
608
  ['recall', 2400],
313
609
  ['search', 3200],
314
610
  ['web_fetch', 900],
@@ -320,25 +616,69 @@ for (const [name, cap] of [
320
616
  }
321
617
 
322
618
  const readonlyDefaults = defaultDeferredToolNames(smokeCatalog, 'readonly');
323
- if (readonlyDefaults.size !== 7) {
324
- throw new Error(`readonly default surface should stay 7 tools, got ${readonlyDefaults.size}: ${[...readonlyDefaults].join(', ')}`);
619
+ if (readonlyDefaults.size !== 9) {
620
+ throw new Error(`readonly default surface should stay 9 tools, got ${readonlyDefaults.size}: ${[...readonlyDefaults].join(', ')}`);
325
621
  }
326
- for (const name of ['read', 'code_graph', 'grep', 'glob', 'list', 'explore', 'tool_search']) {
622
+ for (const name of ['read', 'code_graph', 'grep', 'find', 'glob', 'list', 'explore', 'Skill', 'tool_search']) {
327
623
  assertHas(readonlyDefaults, name);
328
624
  }
329
- for (const name of ['apply_patch', 'bridge', 'shell']) {
625
+ for (const name of ['apply_patch', 'agent', 'shell']) {
330
626
  assertLacks(readonlyDefaults, name);
331
627
  }
332
628
 
333
- const bridgeProps = BRIDGE_TOOL.inputSchema?.properties || {};
334
- if (!bridgeProps.mode || bridgeProps.wait) throw new Error('bridge schema should expose mode but not legacy wait');
335
- if (!/Prefer async by default/i.test(BRIDGE_TOOL.description || '') || !/distinct tags/i.test(BRIDGE_TOOL.description || '') || !/completion notification/i.test(BRIDGE_TOOL.description || '') || !/do not call status\/read/i.test(BRIDGE_TOOL.description || '')) {
336
- throw new Error('bridge description must preserve async tagged delegation contract');
629
+ const agentProps = AGENT_TOOL.inputSchema?.properties || {};
630
+ if (agentProps.mode || agentProps.wait) throw new Error('agent schema should not expose execution mode controls');
631
+ {
632
+ const heavyPrompt = composeSystemPrompt({
633
+ role: 'heavy-worker',
634
+ provider: 'anthropic-oauth',
635
+ agentRules: '# Tool Use',
636
+ skillManifest: '',
637
+ });
638
+ if (!heavyPrompt.stableSystemContext.includes('## heavy-worker') || !heavyPrompt.stableSystemContext.includes('Complex implementation agent')) {
639
+ throw new Error(`heavy-worker AGENT.md must be included in scoped role instructions: ${heavyPrompt.stableSystemContext}`);
640
+ }
641
+ const workerPrompt = composeSystemPrompt({
642
+ role: 'worker',
643
+ provider: 'anthropic-oauth',
644
+ agentRules: '# Tool Use',
645
+ skillManifest: '',
646
+ });
647
+ if (!workerPrompt.stableSystemContext.includes('## worker') || !workerPrompt.stableSystemContext.includes('Basic implementation agent')) {
648
+ throw new Error(`worker AGENT.md must be included in scoped role instructions: ${workerPrompt.stableSystemContext}`);
649
+ }
337
650
  }
338
- const bridgeSmoke = createStandaloneBridge({
651
+ {
652
+ const shorthand = parseHeadlessRoleCommand(['reviewer', 'check', 'this']);
653
+ if (shorthand?.role !== 'reviewer' || shorthand?.message !== 'check this') {
654
+ throw new Error(`headless shorthand command parse failed: ${JSON.stringify(shorthand)}`);
655
+ }
656
+ const explicit = parseHeadlessRoleCommand(['role', 'debug', 'trace', 'failure']);
657
+ if (!explicit?.error || !/mixdog <role> <message/.test(explicit.error)) {
658
+ throw new Error(`headless role subcommand must be rejected: ${JSON.stringify(explicit)}`);
659
+ }
660
+ const tuiDefault = parseHeadlessRoleCommand([]);
661
+ if (tuiDefault !== null) {
662
+ throw new Error(`empty argv must keep TUI default: ${JSON.stringify(tuiDefault)}`);
663
+ }
664
+ const modelOnlySpawn = buildHeadlessSpawnArgs({
665
+ role: 'reviewer',
666
+ tag: 'headless-smoke',
667
+ cwd: root,
668
+ message: 'check this',
669
+ model: 'haiku',
670
+ });
671
+ if (modelOnlySpawn.model !== 'haiku' || modelOnlySpawn.provider) {
672
+ throw new Error(`headless model-only route must preserve --model without forcing provider: ${JSON.stringify(modelOnlySpawn)}`);
673
+ }
674
+ }
675
+ if (!/always start background tasks/i.test(AGENT_TOOL.description || '') || !/distinct tags/i.test(AGENT_TOOL.description || '') || !/completion notification/i.test(AGENT_TOOL.description || '') || !/do not (?:call|poll) status\/read/i.test(AGENT_TOOL.description || '')) {
676
+ throw new Error('agent description must preserve async tagged delegation contract');
677
+ }
678
+ const agentSmoke = createStandaloneAgent({
339
679
  cfgMod: {
340
680
  loadConfig: () => ({ providers: {}, presets: [] }),
341
- resolveRuntimeSpec: () => { throw new Error('bridge smoke should not resolve runtime for read/list errors'); },
681
+ resolveRuntimeSpec: () => { throw new Error('agent smoke should not resolve runtime for read/list errors'); },
342
682
  },
343
683
  reg: { initProviders: async () => {} },
344
684
  mgr: {
@@ -350,52 +690,642 @@ const bridgeSmoke = createStandaloneBridge({
350
690
  cwd: root,
351
691
  defaultMode: 'async',
352
692
  });
353
- const bridgeMissingJob = await bridgeSmoke.execute({ type: 'read', task_id: 'job_missing_smoke' }, { invocationSource: 'model-tool', cwd: root });
354
- if (!/^Error[\s:[]/.test(String(bridgeMissingJob)) || !/job_missing_smoke/.test(String(bridgeMissingJob))) {
355
- throw new Error(`bridge missing job must return Error result:\n${bridgeMissingJob}`);
693
+ const agentMissingJob = await agentSmoke.execute({ type: 'read', task_id: 'task_missing_smoke' }, { invocationSource: 'model-tool', cwd: root });
694
+ if (!/^Error[\s:[]/.test(String(agentMissingJob)) || !/task_missing_smoke/.test(String(agentMissingJob))) {
695
+ throw new Error(`agent missing task must return Error result:\n${agentMissingJob}`);
696
+ }
697
+ const agentBadType = await agentSmoke.execute({ type: 'definitely_bad_type' }, { invocationSource: 'model-tool', cwd: root });
698
+ if (!/^Error[\s:[]/.test(String(agentBadType)) || !/unknown type/i.test(String(agentBadType))) {
699
+ throw new Error(`agent unknown type must return Error result:\n${agentBadType}`);
700
+ }
701
+
702
+ async function waitForSmoke(predicate, label, timeoutMs = 1000) {
703
+ const deadline = Date.now() + timeoutMs;
704
+ while (Date.now() < deadline) {
705
+ if (predicate()) return;
706
+ await new Promise((resolveWait) => setTimeout(resolveWait, 20));
707
+ }
708
+ throw new Error(`timed out waiting for ${label}`);
709
+ }
710
+
711
+ const channelWorkerTmp = mkdtempSync(join(tmpdir(), 'mixdog-channel-worker-env-'));
712
+ let channelEnvWorker = null;
713
+ const prevChannelDaemon = process.env.MIXDOG_CHANNEL_DAEMON;
714
+ const prevChannelSingleton = process.env.MIXDOG_CHANNEL_SINGLETON;
715
+ const prevChannelWorkerProcess = process.env.MIXDOG_CHANNEL_WORKER_PROCESS;
716
+ const prevRuntimeRoot = process.env.MIXDOG_RUNTIME_ROOT;
717
+ const prevEnvOut = process.env.SMOKE_CHANNEL_ENV_OUT;
718
+ try {
719
+ const entry = join(channelWorkerTmp, 'entry.mjs');
720
+ const dataDir = join(channelWorkerTmp, 'data');
721
+ const runtimeDir = join(channelWorkerTmp, 'runtime');
722
+ const envOut = join(channelWorkerTmp, 'env.json');
723
+ mkdirSync(dataDir, { recursive: true });
724
+ mkdirSync(runtimeDir, { recursive: true });
725
+ writeFileSync(entry, `
726
+ import { writeFileSync } from 'node:fs';
727
+ writeFileSync(process.env.SMOKE_CHANNEL_ENV_OUT, JSON.stringify({
728
+ cliOwned: process.env.MIXDOG_CLI_OWNED,
729
+ daemon: process.env.MIXDOG_CHANNEL_DAEMON,
730
+ }));
731
+ process.send?.({ type: 'ready' });
732
+ process.on('message', (msg) => {
733
+ if (msg?.type === 'shutdown') process.exit(0);
734
+ });
735
+ setInterval(() => {}, 10000);
736
+ `);
737
+ process.env.MIXDOG_CHANNEL_DAEMON = '1';
738
+ process.env.MIXDOG_CHANNEL_SINGLETON = '1';
739
+ process.env.MIXDOG_CHANNEL_WORKER_PROCESS = '1';
740
+ process.env.MIXDOG_RUNTIME_ROOT = runtimeDir;
741
+ process.env.SMOKE_CHANNEL_ENV_OUT = envOut;
742
+ channelEnvWorker = createStandaloneChannelWorker({
743
+ entry,
744
+ rootDir: root,
745
+ dataDir,
746
+ cwd: root,
747
+ });
748
+ await channelEnvWorker.start();
749
+ const childEnv = JSON.parse(readFileSync(envOut, 'utf8'));
750
+ if (childEnv.daemon !== '1') {
751
+ throw new Error(`channel daemon smoke expected daemon=1, got ${childEnv.daemon}`);
752
+ }
753
+ if (childEnv.cliOwned !== '0') {
754
+ throw new Error(`channel daemon must advertise owner HTTP (MIXDOG_CLI_OWNED=0), got ${childEnv.cliOwned}`);
755
+ }
756
+ } finally {
757
+ try { await channelEnvWorker?.stop?.('channel-worker-env-smoke', { force: true }); } catch {}
758
+ if (prevChannelDaemon == null) delete process.env.MIXDOG_CHANNEL_DAEMON;
759
+ else process.env.MIXDOG_CHANNEL_DAEMON = prevChannelDaemon;
760
+ if (prevChannelSingleton == null) delete process.env.MIXDOG_CHANNEL_SINGLETON;
761
+ else process.env.MIXDOG_CHANNEL_SINGLETON = prevChannelSingleton;
762
+ if (prevChannelWorkerProcess == null) delete process.env.MIXDOG_CHANNEL_WORKER_PROCESS;
763
+ else process.env.MIXDOG_CHANNEL_WORKER_PROCESS = prevChannelWorkerProcess;
764
+ if (prevRuntimeRoot == null) delete process.env.MIXDOG_RUNTIME_ROOT;
765
+ else process.env.MIXDOG_RUNTIME_ROOT = prevRuntimeRoot;
766
+ if (prevEnvOut == null) delete process.env.SMOKE_CHANNEL_ENV_OUT;
767
+ else process.env.SMOKE_CHANNEL_ENV_OUT = prevEnvOut;
768
+ rmSync(channelWorkerTmp, { recursive: true, force: true });
356
769
  }
357
- const bridgeBadType = await bridgeSmoke.execute({ type: 'definitely_bad_type' }, { invocationSource: 'model-tool', cwd: root });
358
- if (!/^Error[\s:[]/.test(String(bridgeBadType)) || !/unknown type/i.test(String(bridgeBadType))) {
359
- throw new Error(`bridge unknown type must return Error result:\n${bridgeBadType}`);
770
+
771
+ const agentNotifyTmp = mkdtempSync(join(tmpdir(), 'mixdog-agent-notify-'));
772
+ try {
773
+ const ownerNotifications = [];
774
+ const workerQueued = [];
775
+ const agentNotifySmoke = createStandaloneAgent({
776
+ cfgMod: {
777
+ loadConfig: () => ({
778
+ providers: { 'openai-oauth': { enabled: true } },
779
+ presets: [
780
+ { id: 'sonnet-high', name: 'sonnet-high', provider: 'openai-oauth', model: 'smoke-model', type: 'agent', tools: 'full' },
781
+ { id: 'haiku', name: 'HAIKU', provider: 'openai-oauth', model: 'smoke-haiku', type: 'agent', tools: 'full' },
782
+ ],
783
+ }),
784
+ resolveRuntimeSpec: () => ({ scopeKey: 'smoke-notify', lane: 'agent' }),
785
+ },
786
+ reg: { initProviders },
787
+ mgr: {
788
+ askSession: async (sessionId, _prompt, _context, _onToolCall, _cwdOverride, _prefetch, askOpts = {}) => {
789
+ const nestedText = `background task\ntask_id: task_shell_notify_smoke\nsurface: shell\noperation: shell\nstatus: completed\nstarted: 2026-01-01T00:00:00.000Z\nfinished: 2026-01-01T00:00:01.000Z\n\nnested background done for ${sessionId}`;
790
+ askOpts.notifyFn?.(nestedText, {
791
+ type: 'shell_task_result',
792
+ execution_surface: 'shell',
793
+ execution_id: 'task_shell_notify_smoke',
794
+ status: 'completed',
795
+ });
796
+ askOpts.onTerminalResult?.({ content: 'worker completed' }, { sessionId, beforeSave: true });
797
+ return { content: 'worker completed' };
798
+ },
799
+ enqueuePendingMessage: (sessionId, message) => {
800
+ workerQueued.push({ sessionId, message });
801
+ return 1;
802
+ },
803
+ getSession: () => null,
804
+ listSessions: () => [],
805
+ closeSession: () => false,
806
+ hideSessionFromList: () => false,
807
+ },
808
+ dataDir: agentNotifyTmp,
809
+ cwd: root,
810
+ defaultMode: 'async',
811
+ });
812
+ const notifyContext = {
813
+ invocationSource: 'model-tool',
814
+ callerCwd: root,
815
+ callerSessionId: 'sess_owner_notify_smoke',
816
+ clientHostPid: 424242,
817
+ notifyFn: (text, meta) => {
818
+ ownerNotifications.push({ text, meta });
819
+ return true;
820
+ },
821
+ };
822
+ const notifyStart = await agentNotifySmoke.execute({ type: 'spawn', agent: 'worker', tag: 'notify-smoke', prompt: 'notify smoke' }, notifyContext);
823
+ if (!/agent task:/i.test(String(notifyStart)) || !/status: running/i.test(String(notifyStart))) {
824
+ throw new Error(`agent async notify smoke did not start task:\n${notifyStart}`);
825
+ }
826
+ await waitForSmoke(
827
+ () => ownerNotifications.some((event) => /task_shell_notify_smoke/.test(event.text))
828
+ && workerQueued.some((event) => /task_shell_notify_smoke/.test(event.message)),
829
+ 'agent child background completion routing',
830
+ );
831
+ await waitForSmoke(
832
+ () => ownerNotifications.some((event) => /worker completed/.test(event.text)),
833
+ 'agent early completion routing',
834
+ );
835
+ const agentCompletionCount = ownerNotifications.filter((event) => /worker completed/.test(event.text)).length;
836
+ if (agentCompletionCount !== 1) {
837
+ throw new Error(`agent early completion should suppress duplicate final notify, got ${agentCompletionCount}: ${JSON.stringify(ownerNotifications)}`);
838
+ }
839
+ await agentNotifySmoke.execute({ type: 'cleanup', force: true }, notifyContext);
840
+ } finally {
841
+ rmSync(agentNotifyTmp, { recursive: true, force: true });
360
842
  }
361
843
  if (EXPLORE_TOOL.annotations?.readOnlyHint !== true || EXPLORE_TOOL.annotations?.destructiveHint === true) {
362
844
  throw new Error('explore must stay read-only so readonly surfaces can use it');
363
845
  }
846
+ if (EXPLORE_TOOL.annotations?.agentHidden === true) {
847
+ throw new Error('explore must stay visible to agent sessions');
848
+ }
364
849
  const exploreProps = EXPLORE_TOOL.inputSchema?.properties || {};
365
- if (!/Broad-scope locator only/i.test(EXPLORE_TOOL.description || '') || !/code_graph\/grep\/glob first/i.test(EXPLORE_TOOL.description || '')) {
366
- throw new Error('explore description must preserve broad-locator and direct-tool-first guidance');
850
+ if (!/Repo anchor locator/i.test(EXPLORE_TOOL.description || '') || !/broad\/uncertain/i.test(EXPLORE_TOOL.description || '') || !/independent targets/i.test(EXPLORE_TOOL.description || '') || (EXPLORE_TOOL.description || '').length > 90) {
851
+ throw new Error('explore description must stay compact and anchor-oriented');
367
852
  }
368
- if (!/Never pass a whole brief/i.test(exploreProps.query?.description || '') || !/relevant repo or subtree/i.test(exploreProps.cwd?.description || '')) {
369
- throw new Error('explore schema must preserve query narrowness and cwd narrowing guidance');
853
+ if (!/Narrow locator query/i.test(exploreProps.query?.description || '') || !/independent targets/i.test(exploreProps.query?.description || '') || !/Project\/root/i.test(exploreProps.cwd?.description || '')) {
854
+ throw new Error('explore schema must stay compact and preserve query/cwd shape');
370
855
  }
371
- const normalizedExplore = normalizeExploreQueries('["where is model selection?"," ","which file owns bridge async?"]');
856
+ const normalizedExplore = normalizeExploreQueries('["where is model selection?"," ","which file owns agent async?"]');
372
857
  if (normalizedExplore.length !== 2 || normalizedExplore[0] !== 'where is model selection?') {
373
858
  throw new Error(`explore query normalization failed: ${JSON.stringify(normalizedExplore)}`);
374
859
  }
375
860
  if (MAX_FANOUT_QUERIES !== 8) throw new Error(`explore fanout cap changed: ${MAX_FANOUT_QUERIES}`);
376
- const explorerPrompt = buildExplorerPrompt('where is <bridge> & status?');
377
- if (!explorerPrompt.includes('&lt;bridge&gt;') || !explorerPrompt.includes('&amp;') || /verdicts, ratings, or recommendations/.test(explorerPrompt) === false) {
861
+ const explorerPrompt = buildExplorerPrompt('where is <agent> & status?');
862
+ if (!explorerPrompt.includes('&lt;agent&gt;') || !explorerPrompt.includes('&amp;') || /verdicts, ratings, or recommendations/.test(explorerPrompt) === false) {
378
863
  throw new Error(`explorer prompt contract failed: ${explorerPrompt}`);
379
864
  }
380
- const patchDescription = PATCH_TOOL_DEFS[0]?.inputSchema?.properties?.patch?.description || '';
865
+ setInternalToolsProvider({
866
+ executor: async () => 'tool-smoke internal tool',
867
+ tools: [
868
+ EXPLORE_TOOL,
869
+ { name: 'memory', description: 'Destructive memory surface.', inputSchema: { type: 'object', properties: {} }, annotations: { destructiveHint: true } },
870
+ { name: 'recall', description: 'Memory recall surface.', inputSchema: { type: 'object', properties: {} }, annotations: { readOnlyHint: true } },
871
+ { name: 'search', description: 'Web search surface.', inputSchema: { type: 'object', properties: {} }, annotations: { readOnlyHint: true, openWorldHint: true } },
872
+ { name: 'reply', description: 'Channel reply surface.', inputSchema: { type: 'object', properties: {} }, annotations: { destructiveHint: true } },
873
+ { name: 'edit_message', description: 'Channel edit surface.', inputSchema: { type: 'object', properties: {} }, annotations: { destructiveHint: true } },
874
+ { name: 'web_fetch', description: 'Web fetch surface.', inputSchema: { type: 'object', properties: {} }, annotations: { readOnlyHint: true, openWorldHint: true } },
875
+ { name: 'reload_config', description: 'Config reload surface.', inputSchema: { type: 'object', properties: {} }, annotations: { destructiveHint: true } },
876
+ { name: 'inject_command', description: 'Command injection surface.', inputSchema: { type: 'object', properties: {} }, annotations: { destructiveHint: true } },
877
+ ],
878
+ });
879
+ {
880
+ await initProviders({ 'openai-oauth': { enabled: true } });
881
+ const skillManifestTmp = mkdtempSync(join(tmpdir(), 'mixdog-skill-manifest-'));
882
+ try {
883
+ const skillDir = join(skillManifestTmp, '.mixdog', 'skills', 'demo-skill');
884
+ mkdirSync(skillDir, { recursive: true });
885
+ writeFileSync(join(skillDir, 'SKILL.md'), [
886
+ '---',
887
+ 'name: demo-skill',
888
+ 'description: Use when validating compact skill manifest matching.',
889
+ '---',
890
+ '',
891
+ '# Demo Skill',
892
+ '',
893
+ 'Use this skill for manifest smoke tests.',
894
+ '',
895
+ ].join('\n'));
896
+ const skillSession = createSession({
897
+ provider: 'openai-oauth',
898
+ model: 'tool-smoke-model',
899
+ owner: 'cli',
900
+ role: 'lead',
901
+ cwd: skillManifestTmp,
902
+ permission: 'read-write',
903
+ });
904
+ try {
905
+ const visible = (skillSession.messages || []).map((m) => String(m.content || '')).join('\n');
906
+ if (!/available-skills/i.test(visible) || !/demo-skill/i.test(visible) || !/Skill\(\{"name":"<skill-name>"\}\)/.test(visible)) {
907
+ throw new Error(`lead skill manifest missing compact skill listing: ${visible.slice(0, 1200)}`);
908
+ }
909
+ const skillToolNames = (skillSession.tools || []).map((tool) => tool?.name).filter(Boolean);
910
+ if (!skillToolNames.includes('Skill')) {
911
+ throw new Error(`lead skill manifest session must expose Skill loader: ${skillToolNames.join(', ')}`);
912
+ }
913
+ } finally {
914
+ closeSession(skillSession.id, 'tool-smoke');
915
+ }
916
+ const agentSkillSession = createSession({
917
+ provider: 'openai-oauth',
918
+ model: 'tool-smoke-model',
919
+ owner: AGENT_OWNER,
920
+ role: 'worker',
921
+ cwd: skillManifestTmp,
922
+ permission: 'read-write',
923
+ });
924
+ try {
925
+ const systemVisible = (agentSkillSession.messages || [])
926
+ .filter((m) => m?.role === 'system')
927
+ .map((m) => String(m.content || ''))
928
+ .join('\n');
929
+ // Agent (Pool B/C) sessions FREEZE the Skill meta-tool into the schema
930
+ // unconditionally so the tool bytes stay bit-identical across roles/cwds
931
+ // (provider cache shard stability). The BP1 manifest rides alongside it
932
+ // so the model knows which Skill names exist — a loader without the
933
+ // manifest cannot be targeted. Both must be present together.
934
+ if (!/available-skills/i.test(systemVisible) || !/demo-skill/i.test(systemVisible) || !/Skill\(\{"name":"<skill-name>"\}\)/.test(systemVisible)) {
935
+ throw new Error(`agent BP1 must carry the compact skill manifest alongside the frozen Skill tool: ${systemVisible.slice(0, 1200)}`);
936
+ }
937
+ if (!/# Tool Use/i.test(systemVisible) || !/# Agent Constraints/i.test(systemVisible)) {
938
+ throw new Error(`agent system layers must carry BP1 tool policy and BP2 role rules: ${systemVisible.slice(0, 1200)}`);
939
+ }
940
+ const agentSkillToolNames = (agentSkillSession.tools || []).map((tool) => tool?.name).filter(Boolean);
941
+ if (agentSkillToolNames.includes('Skill')) {
942
+ throw new Error(`read-write agent schema must omit Skill loader: ${agentSkillToolNames.join(', ')}`);
943
+ }
944
+ } finally {
945
+ closeSession(agentSkillSession.id, 'tool-smoke');
946
+ }
947
+ } finally {
948
+ rmSync(skillManifestTmp, { recursive: true, force: true });
949
+ }
950
+ const explorerSession = createSession({
951
+ provider: 'openai-oauth',
952
+ model: 'tool-smoke-model',
953
+ owner: AGENT_OWNER,
954
+ role: 'explorer',
955
+ cwd: root,
956
+ permission: 'read',
957
+ skipSkills: true,
958
+ schemaAllowedTools: ['code_graph', 'find', 'glob', 'list', 'grep', 'read'],
959
+ });
960
+ try {
961
+ const visible = (explorerSession.messages || []).map((m) => String(m.content || '')).join('\n');
962
+ const systemVisible = (explorerSession.messages || [])
963
+ .filter((m) => m?.role === 'system')
964
+ .map((m) => String(m.content || ''))
965
+ .join('\n');
966
+ const userReminderVisible = (explorerSession.messages || [])
967
+ .filter((m) => m?.role === 'user')
968
+ .map((m) => String(m.content || ''))
969
+ .join('\n');
970
+ if (!/Read-only retrieval role/i.test(visible) || /# environment/i.test(visible) || /git operations deferred to Lead/i.test(visible)) {
971
+ throw new Error(`explorer hidden retrieval context should stay slim: ${visible.slice(0, 1200)}`);
972
+ }
973
+ if (!/# Role: explorer/i.test(systemVisible) || /# Role: explorer/i.test(userReminderVisible) || !/Locator only/i.test(systemVisible)) {
974
+ throw new Error(`explorer role md must ride BP2 system, not BP3 user reminder: system=${systemVisible.slice(0, 600)} user=${userReminderVisible.slice(0, 600)}`);
975
+ }
976
+ const visibleBytes = Buffer.byteLength(visible, 'utf8');
977
+ if (visibleBytes > 1800) {
978
+ throw new Error(`explorer hidden retrieval context too large: ${visibleBytes} bytes`);
979
+ }
980
+ } finally {
981
+ closeSession(explorerSession.id, 'tool-smoke');
982
+ }
983
+ const workerSession = createSession({
984
+ provider: 'openai-oauth',
985
+ model: 'tool-smoke-model',
986
+ owner: AGENT_OWNER,
987
+ role: 'worker',
988
+ cwd: root,
989
+ permission: 'read-write',
990
+ taskBrief: 'Implement a scoped smoke check.',
991
+ });
992
+ try {
993
+ const visible = (workerSession.messages || []).map((m) => String(m.content || '')).join('\n');
994
+ const userReminderVisible = (workerSession.messages || [])
995
+ .filter((m) => m?.role === 'user')
996
+ .map((m) => String(m.content || ''))
997
+ .join('\n');
998
+ if (/(^|\n)# role\n/i.test(visible) || /(^|\n)permission:/i.test(visible)) {
999
+ throw new Error(`agent context must not repeat raw role/permission labels: ${visible.slice(0, 1200)}`);
1000
+ }
1001
+ if (/# role-identity/i.test(visible)) {
1002
+ throw new Error(`agent context must not repeat role identity: ${visible.slice(0, 1200)}`);
1003
+ }
1004
+ if (/# task-brief/i.test(visible)) {
1005
+ throw new Error(`agent context must not repeat task brief: ${visible.slice(0, 1200)}`);
1006
+ }
1007
+ if (/available-skills/i.test(userReminderVisible)) {
1008
+ throw new Error(`agent skill manifest must stay in system BP1, not user reminders: ${userReminderVisible.slice(0, 1200)}`);
1009
+ }
1010
+ if (/(^|\n)# environment/i.test(visible)) {
1011
+ throw new Error(`agent context must not inject environment reminder: ${visible.slice(0, 1200)}`);
1012
+ }
1013
+ const workerToolNames = (workerSession.tools || []).map((tool) => tool?.name).filter(Boolean);
1014
+ if (workerToolNames.includes('tool_search')) {
1015
+ throw new Error(`agent session schema must not expose deferred tool_search: ${workerToolNames.join(', ')}`);
1016
+ }
1017
+ if (workerToolNames.includes('shell')) {
1018
+ throw new Error(`read-write agent session schema must not expose shell: ${workerToolNames.join(', ')}`);
1019
+ }
1020
+ for (const name of ['skills_list', 'skill_view', 'skill_execute']) {
1021
+ if (workerToolNames.includes(name)) {
1022
+ throw new Error(`agent session schema must not expose legacy skill tool ${name}: ${workerToolNames.join(', ')}`);
1023
+ }
1024
+ }
1025
+ } finally {
1026
+ closeSession(workerSession.id, 'tool-smoke');
1027
+ }
1028
+ const readAgentSession = createSession({
1029
+ provider: 'openai-oauth',
1030
+ model: 'tool-smoke-model',
1031
+ owner: AGENT_OWNER,
1032
+ role: 'worker',
1033
+ cwd: root,
1034
+ permission: 'read',
1035
+ });
1036
+ const writeAgentSession = createSession({
1037
+ provider: 'openai-oauth',
1038
+ model: 'tool-smoke-model',
1039
+ owner: AGENT_OWNER,
1040
+ role: 'worker',
1041
+ cwd: root,
1042
+ permission: 'read-write',
1043
+ });
1044
+ const fullAgentSession = createSession({
1045
+ provider: 'openai-oauth',
1046
+ model: 'tool-smoke-model',
1047
+ owner: AGENT_OWNER,
1048
+ role: 'worker',
1049
+ cwd: root,
1050
+ permission: 'full',
1051
+ });
1052
+ const publicExploreSession = createSession({
1053
+ provider: 'openai-oauth',
1054
+ model: 'tool-smoke-model',
1055
+ owner: AGENT_OWNER,
1056
+ role: 'explore',
1057
+ cwd: root,
1058
+ permission: 'read',
1059
+ });
1060
+ try {
1061
+ const readTools = (readAgentSession.tools || []).map((tool) => tool?.name).filter(Boolean);
1062
+ const writeTools = (writeAgentSession.tools || []).map((tool) => tool?.name).filter(Boolean);
1063
+ const fullTools = (fullAgentSession.tools || []).map((tool) => tool?.name).filter(Boolean);
1064
+ const publicExploreTools = (publicExploreSession.tools || []).map((tool) => tool?.name).filter(Boolean);
1065
+ const expectedReadTools = ['code_graph', 'find', 'glob', 'list', 'grep', 'read', 'explore', 'search', 'web_fetch'];
1066
+ const expectedWriteTools = ['code_graph', 'find', 'glob', 'list', 'grep', 'read', 'apply_patch', 'explore', 'search', 'web_fetch'];
1067
+ if (JSON.stringify(readTools) !== JSON.stringify(expectedReadTools)) {
1068
+ throw new Error(`read agent schema must be fixed allow-list: expected=${expectedReadTools.join(', ')} actual=${readTools.join(', ')}`);
1069
+ }
1070
+ if (JSON.stringify(writeTools) !== JSON.stringify(expectedWriteTools)) {
1071
+ throw new Error(`read-write agent schema must be fixed allow-list: expected=${expectedWriteTools.join(', ')} actual=${writeTools.join(', ')}`);
1072
+ }
1073
+ if (readTools.includes('tool_search') || writeTools.includes('tool_search')) {
1074
+ throw new Error(`agent session fixed schemas must omit tool_search: read=${readTools.join(', ')} write=${writeTools.join(', ')}`);
1075
+ }
1076
+ if (readTools.includes('shell') || writeTools.includes('shell')) {
1077
+ throw new Error(`read/read-write agent schemas must omit shell: read=${readTools.join(', ')} write=${writeTools.join(', ')}`);
1078
+ }
1079
+ for (const name of ['shell', 'apply_patch', 'task', 'diagnostics', 'open_config', 'Skill']) {
1080
+ if (readTools.includes(name)) {
1081
+ throw new Error(`read agent schema must omit non-read tool ${name}: read=${readTools.join(', ')}`);
1082
+ }
1083
+ }
1084
+ for (const name of ['apply_patch', 'task', 'diagnostics']) {
1085
+ if (name === 'apply_patch' && !writeTools.includes(name)) {
1086
+ throw new Error(`read-write agent schema must preserve apply_patch: write=${writeTools.join(', ')}`);
1087
+ }
1088
+ if (name !== 'apply_patch' && writeTools.includes(name)) {
1089
+ throw new Error(`read-write agent schema must omit non-edit tool ${name}: write=${writeTools.join(', ')}`);
1090
+ }
1091
+ }
1092
+ for (const name of ['open_config', 'Skill']) {
1093
+ if (writeTools.includes(name)) {
1094
+ throw new Error(`read-write agent schema must omit config/skill tool ${name}: write=${writeTools.join(', ')}`);
1095
+ }
1096
+ }
1097
+ for (const name of ['memory', 'recall', 'reply', 'edit_message', 'reload_config', 'inject_command']) {
1098
+ if (readTools.includes(name) || writeTools.includes(name)) {
1099
+ throw new Error(`read/read-write agent schema must not expose full-runtime internal tool ${name}: read=${readTools.join(', ')} write=${writeTools.join(', ')}`);
1100
+ }
1101
+ }
1102
+ if (!readTools.includes('explore') || !writeTools.includes('explore')) {
1103
+ throw new Error(`read/read-write agent schemas must expose explore: read=${readTools.join(', ')} write=${writeTools.join(', ')}`);
1104
+ }
1105
+ if (!fullTools.includes('shell')) {
1106
+ throw new Error(`full agent schema must retain shell: full=${fullTools.join(', ')}`);
1107
+ }
1108
+ if (!fullTools.includes('explore')) {
1109
+ throw new Error(`full agent schema must expose explore: full=${fullTools.join(', ')}`);
1110
+ }
1111
+ if (publicExploreTools.includes('explore')) {
1112
+ throw new Error(`public explore role must not expose recursive explore tool: ${publicExploreTools.join(', ')}`);
1113
+ }
1114
+ } finally {
1115
+ closeSession(readAgentSession.id, 'tool-smoke');
1116
+ closeSession(writeAgentSession.id, 'tool-smoke');
1117
+ closeSession(fullAgentSession.id, 'tool-smoke');
1118
+ closeSession(publicExploreSession.id, 'tool-smoke');
1119
+ }
1120
+ const resumeAgentSession = createSession({
1121
+ provider: 'openai-oauth',
1122
+ model: 'tool-smoke-model',
1123
+ owner: AGENT_OWNER,
1124
+ role: 'worker',
1125
+ cwd: root,
1126
+ permission: 'read-write',
1127
+ });
1128
+ try {
1129
+ const resumed = await resumeSession(resumeAgentSession.id, 'full');
1130
+ const resumedTools = (resumed?.tools || []).map((tool) => tool?.name).filter(Boolean);
1131
+ const expectedWriteTools = ['code_graph', 'find', 'glob', 'list', 'grep', 'read', 'apply_patch', 'explore', 'search', 'web_fetch'];
1132
+ if (JSON.stringify(resumedTools) !== JSON.stringify(expectedWriteTools)) {
1133
+ throw new Error(`resumed read-write agent schema must keep fixed allow-list: expected=${expectedWriteTools.join(', ')} actual=${resumedTools.join(', ')}`);
1134
+ }
1135
+ } finally {
1136
+ closeSession(resumeAgentSession.id, 'tool-smoke');
1137
+ }
1138
+ const noneAgentSession = createSession({
1139
+ provider: 'openai-oauth',
1140
+ model: 'tool-smoke-model',
1141
+ owner: AGENT_OWNER,
1142
+ role: 'worker',
1143
+ cwd: root,
1144
+ permission: 'none',
1145
+ });
1146
+ try {
1147
+ const resumedNone = await resumeSession(noneAgentSession.id, 'full');
1148
+ const noneTools = (resumedNone?.tools || []).map((tool) => tool?.name).filter(Boolean);
1149
+ if (noneTools.length !== 0) {
1150
+ throw new Error(`resumed permission=none agent schema must stay empty: actual=${noneTools.join(', ')}`);
1151
+ }
1152
+ } finally {
1153
+ closeSession(noneAgentSession.id, 'tool-smoke');
1154
+ }
1155
+ const objectPermissionSession = createSession({
1156
+ provider: 'openai-oauth',
1157
+ model: 'tool-smoke-model',
1158
+ owner: AGENT_OWNER,
1159
+ role: 'worker',
1160
+ cwd: root,
1161
+ permission: { allow: ['read', 'grep'], deny: ['grep'] },
1162
+ });
1163
+ try {
1164
+ const resumedObject = await resumeSession(objectPermissionSession.id, 'full');
1165
+ const objectTools = (resumedObject?.tools || []).map((tool) => tool?.name).filter(Boolean);
1166
+ if (JSON.stringify(objectTools) !== JSON.stringify(['read'])) {
1167
+ throw new Error(`resumed object-permission agent schema must reapply allow/deny and agent filters: actual=${objectTools.join(', ')}`);
1168
+ }
1169
+ } finally {
1170
+ closeSession(objectPermissionSession.id, 'tool-smoke');
1171
+ }
1172
+ const hiddenRoles = JSON.parse(readFileSync(join(root, 'src', 'defaults', 'hidden-roles.json'), 'utf8')).roles || [];
1173
+ const hiddenPreset = { id: 'hidden-smoke', name: 'hidden-smoke', type: 'agent', provider: 'openai-oauth', model: 'tool-smoke-model', tools: 'full' };
1174
+ const hiddenRuntimeSpec = { scopeKey: 'hidden-role-smoke', lane: 'agent' };
1175
+ const hiddenBadTools = new Set(['shell', 'task', 'diagnostics', 'open_config', 'Skill', 'memory', 'reply', 'edit_message', 'recall', 'reload_config', 'inject_command']);
1176
+ const expectedForHiddenRole = (permission, schemaAllowedTools) => {
1177
+ if (Array.isArray(schemaAllowedTools)) return schemaAllowedTools.slice();
1178
+ if (permission === 'none') return [];
1179
+ if (permission === 'read') return ['code_graph', 'find', 'glob', 'list', 'grep', 'read', 'explore', 'search', 'web_fetch'];
1180
+ if (permission === 'read-write') return ['code_graph', 'find', 'glob', 'list', 'grep', 'read', 'apply_patch', 'explore', 'search', 'web_fetch'];
1181
+ return null;
1182
+ };
1183
+ for (const entry of hiddenRoles) {
1184
+ const role = String(entry?.name || '').trim();
1185
+ if (!role) continue;
1186
+ const hidden = getHiddenRole(role);
1187
+ const permission = resolveAgentSessionPermission(role, hidden?.permission || null);
1188
+ const schemaAllowedTools = resolveHiddenRoleSchemaAllowedTools(hidden);
1189
+ const { session } = prepareAgentSession({
1190
+ role,
1191
+ presetName: 'hidden-smoke',
1192
+ preset: hiddenPreset,
1193
+ runtimeSpec: hiddenRuntimeSpec,
1194
+ permission,
1195
+ cwd: root,
1196
+ sourceType: 'hidden-role-smoke',
1197
+ sourceName: role,
1198
+ schemaAllowedTools,
1199
+ });
1200
+ try {
1201
+ const tools = (session.tools || []).map((tool) => tool?.name).filter(Boolean);
1202
+ const resumed = await resumeSession(session.id, 'full');
1203
+ const resumedTools = (resumed?.tools || []).map((tool) => tool?.name).filter(Boolean);
1204
+ const expected = expectedForHiddenRole(permission, schemaAllowedTools);
1205
+ if (expected && (JSON.stringify(tools) !== JSON.stringify(expected) || JSON.stringify(resumedTools) !== JSON.stringify(expected))) {
1206
+ throw new Error(`hidden role ${role} schema mismatch: expected=${expected.join(', ')} tools=${tools.join(', ')} resumed=${resumedTools.join(', ')}`);
1207
+ }
1208
+ const leaked = tools.filter((name) => hiddenBadTools.has(name) && !(expected || []).includes(name));
1209
+ if (leaked.length) {
1210
+ throw new Error(`hidden role ${role} leaked forbidden full-runtime tools: ${leaked.join(', ')} from ${tools.join(', ')}`);
1211
+ }
1212
+ const systemVisible = (session.messages || [])
1213
+ .filter((m) => m?.role === 'system')
1214
+ .map((m) => String(m.content || ''))
1215
+ .join('\n');
1216
+ if (/available-skills|Skill\(/i.test(systemVisible)) {
1217
+ throw new Error(`hidden role ${role} must not carry Skill manifest without Skill tool`);
1218
+ }
1219
+ if (/effective-cwd|Override cwd|# environment|# task-brief/i.test(systemVisible)) {
1220
+ throw new Error(`hidden role ${role} must not carry cwd/environment/task-brief injection`);
1221
+ }
1222
+ } finally {
1223
+ closeSession(session.id, 'tool-smoke');
1224
+ }
1225
+ }
1226
+ }
1227
+ const patchTool = PATCH_TOOL_DEFS[0];
1228
+ const patchDescription = patchTool?.inputSchema?.properties?.patch?.description || '';
381
1229
  if (!/V4A/i.test(patchDescription) || !/one (?:file )?block per target file/i.test(patchDescription) || !/exact current context/i.test(patchDescription)) {
382
- throw new Error('apply_patch schema must keep V4A, per-target block, and exact-context guidance');
1230
+ throw new Error('apply_patch JSON fallback schema must keep V4A, per-target block, and exact-context guidance');
1231
+ }
1232
+ if (!/FREEFORM tool/i.test(patchTool?.freeformDescription || '') || patchTool?.freeform?.type !== 'grammar' || patchTool?.freeform?.syntax !== 'lark') {
1233
+ throw new Error(`apply_patch must expose freeform grammar metadata: ${JSON.stringify(patchTool)}`);
1234
+ }
1235
+ for (const requiredGrammarLine of [
1236
+ 'start: begin_patch hunk+ end_patch',
1237
+ 'add_hunk: "*** Add File: " filename LF add_line+',
1238
+ 'change_move: "*** Move to: " filename LF',
1239
+ '%import common.LF',
1240
+ ]) {
1241
+ if (!patchTool.freeform.definition.includes(requiredGrammarLine)) {
1242
+ throw new Error(`apply_patch freeform grammar missing required line: ${requiredGrammarLine}`);
1243
+ }
1244
+ }
1245
+ {
1246
+ const rawPatch = '*** Begin Patch\n*** Add File: custom-wire.txt\n+ok\n*** End Patch\n';
1247
+ const body = buildRequestBody(
1248
+ [
1249
+ { role: 'system', content: 'sys' },
1250
+ { role: 'user', content: 'patch please' },
1251
+ {
1252
+ role: 'assistant',
1253
+ content: '',
1254
+ toolCalls: [{ id: 'call_patch_1', name: 'apply_patch', arguments: { patch: rawPatch }, nativeType: 'custom_tool_call' }],
1255
+ },
1256
+ { role: 'tool', toolCallId: 'call_patch_1', content: 'OK' },
1257
+ ],
1258
+ 'gpt-5.5',
1259
+ PATCH_TOOL_DEFS,
1260
+ {},
1261
+ );
1262
+ const wirePatchTool = body.tools?.find((tool) => tool.name === 'apply_patch');
1263
+ if (wirePatchTool?.type !== 'custom' || wirePatchTool?.format?.syntax !== 'lark') {
1264
+ throw new Error(`OpenAI Responses apply_patch must serialize as a custom grammar tool: ${JSON.stringify(wirePatchTool)}`);
1265
+ }
1266
+ if (!/FREEFORM tool/i.test(wirePatchTool.description || '')) {
1267
+ throw new Error(`OpenAI Responses apply_patch must use freeform description: ${JSON.stringify(wirePatchTool)}`);
1268
+ }
1269
+ const customCall = body.input?.find((item) => item.type === 'custom_tool_call');
1270
+ const customOutput = body.input?.find((item) => item.type === 'custom_tool_call_output');
1271
+ if (customCall?.input !== rawPatch || customCall?.call_id !== 'call_patch_1') {
1272
+ throw new Error(`custom apply_patch replay must keep raw patch input: ${JSON.stringify(body.input)}`);
1273
+ }
1274
+ if (customOutput?.call_id !== 'call_patch_1' || customOutput?.output !== 'OK') {
1275
+ throw new Error(`custom apply_patch output must replay as custom_tool_call_output: ${JSON.stringify(body.input)}`);
1276
+ }
1277
+ }
1278
+ {
1279
+ const rawPatch = '*** Begin Patch\n*** Add File: custom-parser.txt\n+ok\n*** End Patch\n';
1280
+ const encoder = new TextEncoder();
1281
+ const frames = [
1282
+ { type: 'response.created', response: { id: 'resp_custom_patch', model: 'gpt-5.5' } },
1283
+ { type: 'response.custom_tool_call_input.delta', delta: rawPatch.slice(0, 16) },
1284
+ { type: 'response.output_item.done', item: { type: 'custom_tool_call', call_id: 'call_patch_sse', name: 'apply_patch', input: rawPatch } },
1285
+ { type: 'response.completed', response: { id: 'resp_custom_patch', model: 'gpt-5.5', usage: { input_tokens: 1, output_tokens: 1 }, output: [] } },
1286
+ ];
1287
+ const bodyText = frames.map((frame) => `data: ${JSON.stringify(frame)}\n\n`).join('');
1288
+ let emitted = null;
1289
+ const response = await sendViaHttpSse({
1290
+ auth: { access_token: 'fake-token', account_id: '' },
1291
+ body: { model: 'gpt-5.5', input: [], stream: true },
1292
+ opts: {},
1293
+ onToolCall: (call) => { emitted = call; },
1294
+ externalSignal: null,
1295
+ poolKey: 'tool-smoke-custom-patch',
1296
+ cacheKey: 'tool-smoke-custom-patch',
1297
+ iteration: 1,
1298
+ useModel: 'gpt-5.5',
1299
+ fetchFn: async () => new Response(new ReadableStream({
1300
+ start(controller) {
1301
+ controller.enqueue(encoder.encode(bodyText));
1302
+ controller.close();
1303
+ },
1304
+ }), { status: 200, headers: { 'content-type': 'text/event-stream' } }),
1305
+ });
1306
+ const call = response.toolCalls?.[0];
1307
+ if (call?.nativeType !== 'custom_tool_call' || call?.name !== 'apply_patch' || call?.arguments?.patch !== rawPatch) {
1308
+ throw new Error(`custom apply_patch SSE parser must produce internal patch args: ${JSON.stringify(response.toolCalls)}`);
1309
+ }
1310
+ if (emitted?.arguments?.patch !== rawPatch) {
1311
+ throw new Error(`custom apply_patch SSE parser must eager-emit patch args: ${JSON.stringify(emitted)}`);
1312
+ }
383
1313
  }
384
1314
  const readPathDescription = BUILTIN_TOOLS.find((tool) => tool.name === 'read')?.inputSchema?.properties?.path?.description || '';
385
- if (!/file path only/i.test(readPathDescription)) {
1315
+ if (!/File path or array/i.test(readPathDescription) || !/Dirs use list/i.test(readPathDescription)) {
386
1316
  throw new Error('read schema must keep directory-vs-file guidance');
387
1317
  }
388
1318
  const readDescription = BUILTIN_TOOLS.find((tool) => tool.name === 'read')?.description || '';
389
- if (!/specific file window or symbol body/i.test(readDescription) || !/after narrowing/i.test(readDescription)) {
1319
+ if (!/known file path\(s\)/i.test(readDescription) || !/line\+context/i.test(readDescription)) {
390
1320
  throw new Error('read description must stay narrow-target oriented');
391
1321
  }
392
1322
  const codeGraphDescription = CODE_GRAPH_TOOL_DEFS[0]?.description || '';
393
1323
  const codeGraphProps = CODE_GRAPH_TOOL_DEFS[0]?.inputSchema?.properties || {};
394
- if (!/Top-level entry for code-related questions/i.test(codeGraphDescription) || !/Use before read/i.test(codeGraphDescription)) {
395
- throw new Error('code_graph description must stay top-level for code questions');
1324
+ if (!/Code structure/i.test(codeGraphDescription) || !/symbols/i.test(codeGraphDescription) || codeGraphDescription.length > 90) {
1325
+ throw new Error('code_graph description must stay compact and structure-oriented');
396
1326
  }
397
- if (!/Operation:/i.test(codeGraphProps.mode?.description || '') || !/Directory scope is only for references\/callers/i.test(codeGraphProps.file?.description || '')) {
398
- throw new Error('code_graph schema must explain mode and file scoping');
1327
+ if (!/^Operation\.$/i.test(codeGraphProps.mode?.description || '') || !/^Source file\.$/i.test(codeGraphProps.file?.description || '')) {
1328
+ throw new Error('code_graph schema must keep compact field descriptions');
399
1329
  }
400
1330
  const recallTool = MEMORY_TOOL_DEFS.find((tool) => tool.name === 'recall');
401
1331
  const recallProps = recallTool?.inputSchema?.properties || {};
@@ -405,6 +1335,64 @@ if (!/when in doubt, recall first/i.test(recallTool?.description || '') || !reca
405
1335
  if (!/array for independent fan-out/i.test(recallProps.query?.description || '') || !/Project pool selector/i.test(recallProps.projectScope?.description || '')) {
406
1336
  throw new Error('recall schema must explain fan-out query and project scope filters');
407
1337
  }
1338
+ // Cross-session / raw recall surface: includeMembers stays a chunk-member
1339
+ // output knob, includeRaw exposes unchunked raw/episode turns, and sessionOnly
1340
+ // is the explicit opt-in that restores the old single-session hard scope.
1341
+ if (!/chunk members/i.test(recallProps.includeMembers?.description || '') || !/does not widen the search pool/i.test(recallProps.includeMembers?.description || '')) {
1342
+ throw new Error('recall includeMembers must stay scoped to chunk-member output only');
1343
+ }
1344
+ if (!recallProps.includeRaw || !/raw\/episode/i.test(recallProps.includeRaw?.description || '')) {
1345
+ throw new Error('recall schema must expose includeRaw for unchunked raw/episode turns');
1346
+ }
1347
+ if (!recallProps.sessionOnly || !/session only/i.test(recallProps.sessionOnly?.description || '')) {
1348
+ throw new Error('recall schema must expose sessionOnly as the explicit single-session opt-in');
1349
+ }
1350
+ // Behaviour-level checks for the cross-session merge contract. These exercise
1351
+ // the pure mergeSessionRowsIntoGlobal() helper (no DB) so the starve-prevention
1352
+ // + dedupe + includeRaw-parity invariants are guarded, not just the schema.
1353
+ {
1354
+ // 1) Starve prevention: a flood of session rows must NOT push global hybrid
1355
+ // hits off the first page. Global rows carry a real retrievalScore; the
1356
+ // session rows (score 0) must sort AFTER them under importance.
1357
+ const globalHits = [
1358
+ { id: 1, retrievalScore: 0.9, ts: 100 },
1359
+ { id: 2, retrievalScore: 0.8, ts: 110 },
1360
+ ];
1361
+ const sessionFlood = Array.from({ length: 20 }, (_, i) => ({ id: 1000 + i, retrievalScore: 0, ts: 200 + i }));
1362
+ const mergedImportance = mergeSessionRowsIntoGlobal(globalHits, sessionFlood, { sort: 'importance' });
1363
+ if (mergedImportance.slice(0, 2).map((r) => r.id).join(',') !== '1,2') {
1364
+ throw new Error(`session merge must not starve global first page under importance: ${JSON.stringify(mergedImportance.slice(0, 3))}`);
1365
+ }
1366
+ if (mergedImportance.length !== globalHits.length + sessionFlood.length) {
1367
+ throw new Error('session merge must append all non-duplicate session rows');
1368
+ }
1369
+ // 2) Dedupe by id AND by global root member id (member/leaf double-output).
1370
+ const globalWithMembers = [{ id: 5, retrievalScore: 0.7, ts: 100, members: [{ id: 51 }, { id: 52 }] }];
1371
+ const sessionDupes = [
1372
+ { id: 5, retrievalScore: 0, ts: 300 }, // dup root id
1373
+ { id: 51, retrievalScore: 0, ts: 301 }, // dup member id
1374
+ { id: 99, retrievalScore: 0, ts: 302 }, // genuinely new
1375
+ ];
1376
+ const mergedDedupe = mergeSessionRowsIntoGlobal(globalWithMembers, sessionDupes, { sort: 'importance' });
1377
+ const dedupeIds = mergedDedupe.map((r) => Number(r.id)).sort((a, b) => a - b);
1378
+ if (dedupeIds.join(',') !== '5,99') {
1379
+ throw new Error(`session merge must dedupe root+member ids, leaving only new rows: ${JSON.stringify(dedupeIds)}`);
1380
+ }
1381
+ // 3) date sort keeps newest-first across the merged set.
1382
+ const mergedDate = mergeSessionRowsIntoGlobal(
1383
+ [{ id: 1, retrievalScore: 0.9, ts: 100 }],
1384
+ [{ id: 2, retrievalScore: 0, ts: 999 }],
1385
+ { sort: 'date' },
1386
+ );
1387
+ if (Number(mergedDate[0].id) !== 2) {
1388
+ throw new Error(`session merge under date sort must order by ts desc: ${JSON.stringify(mergedDate)}`);
1389
+ }
1390
+ // 4) Empty session rows is a no-op passthrough (no crash, same array).
1391
+ const passthrough = mergeSessionRowsIntoGlobal(globalHits, [], { sort: 'importance' });
1392
+ if (passthrough.length !== globalHits.length) {
1393
+ throw new Error('session merge with no session rows must be a passthrough');
1394
+ }
1395
+ }
408
1396
  const memoryTool = MEMORY_TOOL_DEFS.find((tool) => tool.name === 'memory');
409
1397
  const memoryProps = memoryTool?.inputSchema?.properties || {};
410
1398
  if (!/explicit mutation/i.test(memoryTool?.description || '') || !/Destructive jobs require exact confirm/i.test(memoryTool?.description || '') || !/Exact confirmation phrase/i.test(memoryProps.confirm?.description || '')) {
@@ -412,8 +1400,8 @@ if (!/explicit mutation/i.test(memoryTool?.description || '') || !/Destructive j
412
1400
  }
413
1401
  const searchTool = SEARCH_TOOL_DEFS.find((tool) => tool.name === 'search');
414
1402
  const searchProps = searchTool?.inputSchema?.properties || {};
415
- if (!/Prefer mode=async/i.test(searchTool?.description || '') || !searchProps.query?.anyOf || !/array for fan-out/i.test(searchProps.query?.description || '')) {
416
- throw new Error('search schema must preserve async guidance and string/array query shape');
1403
+ 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 || '')) {
1404
+ throw new Error('search schema must preserve sync execution guidance and string/array query shape');
417
1405
  }
418
1406
  if (!/Default web/i.test(searchProps.type?.description || '') || !/locale hint/i.test(searchProps.locale?.description || '') || !/Default low/i.test(searchProps.contextSize?.description || '')) {
419
1407
  throw new Error('search schema must describe type, locale, and contextSize defaults');
@@ -426,9 +1414,153 @@ if (!/Use after search/i.test(webFetchTool?.description || '') || !webFetchProps
426
1414
  if (!/offset/i.test(webFetchProps.startIndex?.description || '') || !/Maximum characters/i.test(webFetchProps.maxLength?.description || '')) {
427
1415
  throw new Error('web_fetch schema must describe paging window fields');
428
1416
  }
429
- if (!/tools\/skills/i.test(TOOL_SEARCH_TOOL.description || '') || !/deferred/i.test(TOOL_SEARCH_TOOL.description || '') || !TOOL_SEARCH_TOOL.inputSchema?.properties?.select) {
1417
+ if (!/deferred tools/i.test(TOOL_SEARCH_TOOL.description || '') || !TOOL_SEARCH_TOOL.inputSchema?.properties?.select) {
430
1418
  throw new Error('tool_search schema must preserve selection guidance and select field');
431
1419
  }
1420
+ const toolSearchSession = {
1421
+ tools: smokeCatalog.filter((tool) => fullDefaults.has(tool?.name)),
1422
+ deferredToolCatalog: smokeCatalog.slice(),
1423
+ deferredSelectedTools: [...fullDefaults],
1424
+ };
1425
+ const searchOnlyResult = JSON.parse(__renderToolSearchForTest({ query: 'shell' }, toolSearchSession, 'full'));
1426
+ for (const name of ['shell', 'task']) {
1427
+ if (!searchOnlyResult.selected?.tools?.added?.includes(name)) {
1428
+ throw new Error(`tool_search high-confidence query should auto-load ${name}: ${JSON.stringify(searchOnlyResult.selected)}`);
1429
+ }
1430
+ }
1431
+ if (!searchOnlyResult.activeTools.includes('shell') || !searchOnlyResult.activeTools.includes('task')) {
1432
+ throw new Error(`tool_search query should activate legacy selected tools: ${searchOnlyResult.activeTools.join(',')}`);
1433
+ }
1434
+ const bulkSelectResult = JSON.parse(__renderToolSearchForTest({ query: 'select:shell,recall' }, toolSearchSession, 'full'));
1435
+ for (const name of ['shell', 'task', 'recall']) {
1436
+ if (!bulkSelectResult.activeTools.includes(name)) {
1437
+ throw new Error(`tool_search bulk select missing ${name}: ${JSON.stringify(bulkSelectResult)}`);
1438
+ }
1439
+ }
1440
+ const prefixedSelectSession = {
1441
+ tools: smokeCatalog.filter((tool) => fullDefaults.has(tool?.name)),
1442
+ deferredToolCatalog: smokeCatalog.slice(),
1443
+ deferredSelectedTools: [...fullDefaults],
1444
+ };
1445
+ const prefixedSelectResult = JSON.parse(__renderToolSearchForTest({ select: 'select:shell,recall' }, prefixedSelectSession, 'full'));
1446
+ if (!prefixedSelectResult.activeTools.includes('shell') || !prefixedSelectResult.activeTools.includes('recall')) {
1447
+ throw new Error(`tool_search select field should accept select: prefix: ${JSON.stringify(prefixedSelectResult)}`);
1448
+ }
1449
+ if (!Array.isArray(toolSearchSession.deferredDiscoveredTools) || !toolSearchSession.deferredDiscoveredTools.includes('shell')) {
1450
+ throw new Error('tool_search must persist discovered tool state on the session');
1451
+ }
1452
+ const nativeToolSearchSession = {
1453
+ tools: smokeCatalog.filter((tool) => fullDefaults.has(tool?.name)),
1454
+ deferredToolCatalog: smokeCatalog.slice(),
1455
+ deferredSelectedTools: [...fullDefaults],
1456
+ deferredDiscoveredTools: [],
1457
+ deferredProviderMode: 'native',
1458
+ deferredNativeTools: true,
1459
+ };
1460
+ const nativeSelectResult = JSON.parse(__renderToolSearchForTest({ select: 'shell,recall' }, nativeToolSearchSession, 'full'));
1461
+ if (nativeSelectResult.activeTools.includes('shell') || nativeSelectResult.activeTools.includes('recall')) {
1462
+ throw new Error(`native tool_search must not mutate active tool schemas: ${JSON.stringify(nativeSelectResult)}`);
1463
+ }
1464
+ for (const name of ['shell', 'task', 'recall']) {
1465
+ if (!nativeSelectResult.discoveredTools.includes(name)) {
1466
+ throw new Error(`native tool_search missing discovered ${name}: ${JSON.stringify(nativeSelectResult)}`);
1467
+ }
1468
+ }
1469
+ if (!nativeSelectResult.nativeToolSearch?.openaiTools?.some((tool) => tool?.name === 'shell' && tool?.defer_loading === true)) {
1470
+ throw new Error(`native tool_search must return OpenAI loadable deferred tools: ${JSON.stringify(nativeSelectResult.nativeToolSearch)}`);
1471
+ }
1472
+ if (!nativeSelectResult.nativeToolSearch?.toolReferences?.includes('shell')) {
1473
+ throw new Error(`native tool_search must return Anthropic tool references: ${JSON.stringify(nativeSelectResult.nativeToolSearch)}`);
1474
+ }
1475
+ const nativePatchSearchSession = {
1476
+ provider: 'openai-oauth',
1477
+ tools: smokeCatalog.filter((tool) => fullDefaults.has(tool?.name) && tool?.name !== 'apply_patch'),
1478
+ deferredToolCatalog: smokeCatalog.slice(),
1479
+ deferredSelectedTools: [...fullDefaults].filter((name) => name !== 'apply_patch'),
1480
+ deferredDiscoveredTools: [],
1481
+ deferredProviderMode: 'native',
1482
+ deferredNativeTools: true,
1483
+ };
1484
+ const nativePatchSelectResult = JSON.parse(__renderToolSearchForTest({ select: 'apply_patch' }, nativePatchSearchSession, 'full'));
1485
+ const nativePatchTool = nativePatchSelectResult.nativeToolSearch?.openaiTools?.find((tool) => tool?.name === 'apply_patch');
1486
+ if (nativePatchTool?.type !== 'custom' || nativePatchTool?.format?.syntax !== 'lark') {
1487
+ throw new Error(`native tool_search must preserve apply_patch as OpenAI custom freeform: ${JSON.stringify(nativePatchSelectResult.nativeToolSearch)}`);
1488
+ }
1489
+ if (nativePatchTool.defer_loading === true || nativePatchTool.parameters) {
1490
+ throw new Error(`native tool_search custom apply_patch must not be downgraded to deferred function schema: ${JSON.stringify(nativePatchTool)}`);
1491
+ }
1492
+ const nativeGrokPatchSearchSession = {
1493
+ provider: 'grok-oauth',
1494
+ tools: smokeCatalog.filter((tool) => fullDefaults.has(tool?.name) && tool?.name !== 'apply_patch'),
1495
+ deferredToolCatalog: smokeCatalog.slice(),
1496
+ deferredSelectedTools: [...fullDefaults].filter((name) => name !== 'apply_patch'),
1497
+ deferredDiscoveredTools: [],
1498
+ deferredProviderMode: 'native',
1499
+ deferredNativeTools: true,
1500
+ };
1501
+ const nativeGrokPatchSelectResult = JSON.parse(__renderToolSearchForTest({ select: 'apply_patch' }, nativeGrokPatchSearchSession, 'full'));
1502
+ const nativeGrokPatchTool = nativeGrokPatchSelectResult.nativeToolSearch?.openaiTools?.find((tool) => tool?.name === 'apply_patch');
1503
+ if (nativeGrokPatchTool?.type !== 'function' || nativeGrokPatchTool?.format || nativeGrokPatchTool?.defer_loading !== true) {
1504
+ throw new Error(`Grok native tool_search apply_patch must use JSON function schema, not OpenAI custom: ${JSON.stringify(nativeGrokPatchTool)}`);
1505
+ }
1506
+ if (nativeGrokPatchTool.parameters?.properties?.patch?.type !== 'string') {
1507
+ throw new Error(`Grok native tool_search apply_patch must preserve patch JSON schema: ${JSON.stringify(nativeGrokPatchTool)}`);
1508
+ }
1509
+ const nativeRunQuerySession = {
1510
+ tools: smokeCatalog.filter((tool) => fullDefaults.has(tool?.name)),
1511
+ deferredToolCatalog: smokeCatalog.slice(),
1512
+ deferredSelectedTools: [...fullDefaults],
1513
+ deferredDiscoveredTools: [],
1514
+ deferredProviderMode: 'native',
1515
+ deferredNativeTools: true,
1516
+ };
1517
+ const nativeRunQueryResult = JSON.parse(__renderToolSearchForTest({ query: 'run tests' }, nativeRunQuerySession, 'full'));
1518
+ for (const name of ['shell', 'task']) {
1519
+ if (!nativeRunQueryResult.discoveredTools.includes(name)) {
1520
+ throw new Error(`native tool_search run/tests query should discover ${name}: ${JSON.stringify(nativeRunQueryResult)}`);
1521
+ }
1522
+ }
1523
+ if (nativeRunQueryResult.activeTools.includes('shell') || nativeRunQueryResult.activeTools.includes('task')) {
1524
+ throw new Error(`native tool_search query must not mutate active schemas: ${JSON.stringify(nativeRunQueryResult)}`);
1525
+ }
1526
+ const nativeWebQuerySession = {
1527
+ tools: smokeCatalog.filter((tool) => fullDefaults.has(tool?.name)),
1528
+ deferredToolCatalog: smokeCatalog.slice(),
1529
+ deferredSelectedTools: [...fullDefaults],
1530
+ deferredDiscoveredTools: [],
1531
+ deferredProviderMode: 'native',
1532
+ deferredNativeTools: true,
1533
+ };
1534
+ const nativeWebQueryResult = JSON.parse(__renderToolSearchForTest({ query: 'web docs' }, nativeWebQuerySession, 'full'));
1535
+ for (const name of ['search', 'web_fetch']) {
1536
+ if (!nativeWebQueryResult.discoveredTools.includes(name)) {
1537
+ throw new Error(`native tool_search web/docs query should discover ${name}: ${JSON.stringify(nativeWebQueryResult)}`);
1538
+ }
1539
+ }
1540
+ const nativeRecallQuerySession = {
1541
+ tools: smokeCatalog.filter((tool) => fullDefaults.has(tool?.name)),
1542
+ deferredToolCatalog: smokeCatalog.slice(),
1543
+ deferredSelectedTools: [...fullDefaults],
1544
+ deferredDiscoveredTools: [],
1545
+ deferredProviderMode: 'native',
1546
+ deferredNativeTools: true,
1547
+ };
1548
+ const nativeRecallQueryResult = JSON.parse(__renderToolSearchForTest({ query: 'memory previous' }, nativeRecallQuerySession, 'full'));
1549
+ if (!nativeRecallQueryResult.discoveredTools.includes('recall') || nativeRecallQueryResult.discoveredTools.includes('memory')) {
1550
+ throw new Error(`native tool_search memory previous should discover recall only: ${JSON.stringify(nativeRecallQueryResult)}`);
1551
+ }
1552
+ const ambiguousStatusSession = {
1553
+ tools: smokeCatalog.filter((tool) => fullDefaults.has(tool?.name)),
1554
+ deferredToolCatalog: smokeCatalog.slice(),
1555
+ deferredSelectedTools: [...fullDefaults],
1556
+ deferredDiscoveredTools: [],
1557
+ deferredProviderMode: 'native',
1558
+ deferredNativeTools: true,
1559
+ };
1560
+ const ambiguousStatusResult = JSON.parse(__renderToolSearchForTest({ query: 'status' }, ambiguousStatusSession, 'full'));
1561
+ if (ambiguousStatusResult.selected || ambiguousStatusResult.discoveredTools.length) {
1562
+ throw new Error(`tool_search ambiguous status query must not auto-load: ${JSON.stringify(ambiguousStatusResult)}`);
1563
+ }
432
1564
  const replyTool = CHANNEL_TOOL_DEFS.find((tool) => tool.name === 'reply');
433
1565
  if (!/configured channel/i.test(replyTool?.description || '') || !/local .*paths/i.test(replyTool?.inputSchema?.properties?.files?.description || '')) {
434
1566
  throw new Error('channel reply schema must describe target channel and attachment paths');
@@ -440,8 +1572,8 @@ if (!/NOT for URLs/i.test(fetchTool?.description || '') || !/web_fetch/i.test(fe
440
1572
  const grepTool = BUILTIN_TOOLS.find((tool) => tool.name === 'grep');
441
1573
  const grepPatternDescription = grepTool?.inputSchema?.properties?.pattern?.description || '';
442
1574
  const grepPathDescription = grepTool?.inputSchema?.properties?.path?.description || '';
443
- if (!/(array for OR|array = OR)/i.test(grepPatternDescription) || !/one file or directory/i.test(grepPathDescription)) {
444
- throw new Error('grep schema must keep array-OR and one-path guidance');
1575
+ if (!/Array = OR/i.test(grepPatternDescription) || !/^File or directory\.$/i.test(grepPathDescription)) {
1576
+ throw new Error('grep schema must keep compact pattern/path guidance');
445
1577
  }
446
1578
 
447
1579
  const longToolSearchText = compactToolSearchDescription(`${patchDescription}\n${patchDescription}`);