mixdog 0.8.0 → 0.9.0

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 (285) hide show
  1. package/README.md +47 -23
  2. package/package.json +33 -27
  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 +344 -0
  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 +1223 -95
  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 -7
  62. package/src/lib/rules-builder.cjs +240 -96
  63. package/src/lib/text-utils.cjs +1 -1
  64. package/src/mixdog-session-runtime.mjs +2325 -450
  65. package/src/output-styles/default.md +12 -28
  66. package/src/output-styles/extreme-simple.md +9 -6
  67. package/src/output-styles/simple.md +22 -9
  68. package/src/repl.mjs +118 -59
  69. package/src/rules/agent/00-common.md +15 -0
  70. package/src/rules/{bridge → agent}/20-skip-protocol.md +1 -2
  71. package/src/rules/agent/30-explorer.md +22 -0
  72. package/src/rules/{bridge → agent}/40-cycle1-agent.md +7 -0
  73. package/src/rules/{bridge → agent}/41-cycle2-agent.md +7 -0
  74. package/src/rules/{bridge → agent}/42-cycle3-agent.md +7 -0
  75. package/src/rules/lead/01-general.md +9 -5
  76. package/src/rules/lead/04-workflow.md +51 -12
  77. package/src/rules/lead/lead-tool.md +6 -0
  78. package/src/rules/shared/01-tool.md +12 -1
  79. package/src/runtime/agent/orchestrator/activity-bus.mjs +7 -18
  80. package/src/runtime/agent/orchestrator/agent-owner.mjs +11 -0
  81. package/src/runtime/agent/orchestrator/{smart-bridge/bridge-llm.mjs → agent-runtime/agent-dispatch.mjs} +138 -111
  82. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +94 -0
  83. package/src/runtime/agent/orchestrator/{smart-bridge → agent-runtime}/cache-strategy.mjs +32 -23
  84. package/src/runtime/agent/orchestrator/{smart-bridge → agent-runtime}/session-builder.mjs +33 -27
  85. package/src/runtime/agent/orchestrator/{bridge-trace.mjs → agent-trace.mjs} +132 -81
  86. package/src/runtime/agent/orchestrator/cache-mtime.mjs +0 -21
  87. package/src/runtime/agent/orchestrator/config.mjs +174 -55
  88. package/src/runtime/agent/orchestrator/context/collect.mjs +195 -487
  89. package/src/runtime/agent/orchestrator/dispatch-persist.mjs +1 -1
  90. package/src/runtime/agent/orchestrator/internal-roles.mjs +77 -29
  91. package/src/runtime/agent/orchestrator/internal-tools.mjs +5 -6
  92. package/src/runtime/agent/orchestrator/mcp/client.mjs +15 -9
  93. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -1
  94. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +377 -243
  95. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +146 -93
  96. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +236 -4
  97. package/src/runtime/agent/orchestrator/providers/custom-tool-wire.mjs +49 -0
  98. package/src/runtime/agent/orchestrator/providers/gemini.mjs +58 -13
  99. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -149
  100. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +132 -2
  101. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +4 -1
  102. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +45 -0
  103. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +61 -116
  104. package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +25 -0
  105. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +79 -255
  106. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +221 -70
  107. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +477 -147
  108. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +343 -496
  109. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +6 -6
  110. package/src/runtime/agent/orchestrator/providers/registry.mjs +88 -51
  111. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +31 -11
  112. package/src/runtime/agent/orchestrator/providers/statusline-route-meta.mjs +41 -8
  113. package/src/runtime/agent/orchestrator/session/cache/post-edit-marks.mjs +4 -4
  114. package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +1 -1
  115. package/src/runtime/agent/orchestrator/session/compact.mjs +1173 -267
  116. package/src/runtime/agent/orchestrator/session/context-utils.mjs +199 -36
  117. package/src/runtime/agent/orchestrator/session/loop.mjs +851 -674
  118. package/src/runtime/agent/orchestrator/session/manager.mjs +1593 -466
  119. package/src/runtime/agent/orchestrator/session/manager.reactive-persist.test.mjs +107 -0
  120. package/src/runtime/agent/orchestrator/session/store.mjs +291 -46
  121. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +2 -2
  122. package/src/runtime/agent/orchestrator/stall-policy.mjs +31 -16
  123. package/src/runtime/agent/orchestrator/tool-loop-guard.mjs +3 -219
  124. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +34 -7
  125. package/src/runtime/agent/orchestrator/tools/builtin/advisory-lock.mjs +1 -1
  126. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +19 -0
  127. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +9 -9
  128. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +60 -37
  129. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +21 -2
  130. package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +1 -1
  131. package/src/runtime/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +0 -7
  132. package/src/runtime/agent/orchestrator/tools/builtin/glob-walk.mjs +1 -3
  133. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +36 -12
  134. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +2 -0
  135. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -2
  136. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +5 -12
  137. package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +1 -1
  138. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +4 -36
  139. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +2 -40
  140. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +148 -27
  141. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +2 -2
  142. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +43 -75
  143. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +90 -20
  144. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +1 -1
  145. package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -5
  146. package/src/runtime/agent/orchestrator/tools/code-graph-state.mjs +86 -0
  147. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +11 -11
  148. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4106 -4019
  149. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +33 -4
  150. package/src/runtime/agent/orchestrator/tools/patch.mjs +90 -6
  151. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +6 -4
  152. package/src/runtime/agent/orchestrator/tools/result-compression.mjs +4 -4
  153. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +8 -1
  154. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +4 -4
  155. package/src/runtime/channels/index.mjs +152 -24
  156. package/src/runtime/channels/lib/scheduler.mjs +18 -14
  157. package/src/runtime/channels/lib/session-discovery.mjs +3 -2
  158. package/src/runtime/channels/lib/tool-format.mjs +0 -2
  159. package/src/runtime/channels/lib/transcript-discovery.mjs +3 -2
  160. package/src/runtime/channels/lib/webhook.mjs +1 -1
  161. package/src/runtime/channels/tool-defs.mjs +29 -29
  162. package/src/runtime/memory/index.mjs +635 -107
  163. package/src/runtime/memory/lib/agent-ipc.mjs +29 -12
  164. package/src/runtime/memory/lib/core-memory-store.mjs +2 -2
  165. package/src/runtime/memory/lib/embedding-model-config.mjs +55 -0
  166. package/src/runtime/memory/lib/embedding-provider.mjs +31 -4
  167. package/src/runtime/memory/lib/embedding-worker.mjs +19 -10
  168. package/src/runtime/memory/lib/memory-cycle1.mjs +38 -17
  169. package/src/runtime/memory/lib/memory-cycle2.mjs +6 -7
  170. package/src/runtime/memory/lib/memory-cycle3.mjs +4 -4
  171. package/src/runtime/memory/lib/memory-ops-policy.mjs +2 -1
  172. package/src/runtime/memory/lib/memory-session-merge.mjs +38 -0
  173. package/src/runtime/memory/lib/memory.mjs +88 -9
  174. package/src/runtime/memory/lib/model-profile.mjs +1 -1
  175. package/src/runtime/memory/lib/pg/adapter.mjs +15 -1
  176. package/src/runtime/memory/lib/pg/supervisor.mjs +12 -0
  177. package/src/runtime/memory/lib/runtime-fetcher.mjs +37 -3
  178. package/src/runtime/memory/lib/session-ingest.mjs +194 -0
  179. package/src/runtime/memory/lib/trace-store.mjs +96 -51
  180. package/src/runtime/memory/tool-defs.mjs +46 -37
  181. package/src/runtime/search/index.mjs +102 -466
  182. package/src/runtime/search/lib/web-tools.mjs +45 -25
  183. package/src/runtime/search/tool-defs.mjs +16 -23
  184. package/src/runtime/shared/abort-controller.mjs +1 -1
  185. package/src/runtime/shared/atomic-file.mjs +4 -3
  186. package/src/runtime/shared/background-tasks.mjs +122 -11
  187. package/src/runtime/shared/child-spawn-gate.mjs +145 -0
  188. package/src/runtime/shared/config.mjs +7 -4
  189. package/src/runtime/shared/err-text.mjs +131 -4
  190. package/src/runtime/shared/llm/cost.mjs +2 -2
  191. package/src/runtime/shared/llm/http-agent.mjs +23 -7
  192. package/src/runtime/shared/llm/index.mjs +34 -11
  193. package/src/runtime/shared/llm/usage-log.mjs +4 -4
  194. package/src/runtime/shared/markdown-frontmatter.mjs +56 -0
  195. package/src/runtime/shared/singleton-owner.mjs +104 -0
  196. package/src/runtime/shared/tool-execution-contract.mjs +199 -20
  197. package/src/runtime/shared/tool-execution-contract.test.mjs +183 -0
  198. package/src/runtime/shared/tool-surface.mjs +624 -98
  199. package/src/runtime/shared/user-data-guard.mjs +0 -2
  200. package/src/standalone/agent-task-status.mjs +203 -0
  201. package/src/standalone/agent-task-status.test.mjs +76 -0
  202. package/src/standalone/agent-tool.mjs +1913 -0
  203. package/src/standalone/channel-worker.mjs +370 -14
  204. package/src/standalone/explore-tool.mjs +165 -70
  205. package/src/standalone/folder-dialog.mjs +314 -0
  206. package/src/standalone/hook-bus.mjs +898 -22
  207. package/src/standalone/memory-runtime-proxy.mjs +320 -0
  208. package/src/standalone/projects.mjs +226 -0
  209. package/src/standalone/provider-admin.mjs +41 -24
  210. package/src/standalone/seeds.mjs +2 -69
  211. package/src/standalone/usage-dashboard.mjs +96 -8
  212. package/src/tui/App.jsx +4798 -2153
  213. package/src/tui/components/AnsiText.jsx +39 -28
  214. package/src/tui/components/ContextPanel.jsx +87 -29
  215. package/src/tui/components/Markdown.jsx +43 -77
  216. package/src/tui/components/MarkdownTable.jsx +9 -184
  217. package/src/tui/components/Message.jsx +28 -11
  218. package/src/tui/components/Picker.jsx +95 -56
  219. package/src/tui/components/PromptInput.jsx +367 -239
  220. package/src/tui/components/QueuedCommands.jsx +1 -1
  221. package/src/tui/components/SlashCommandPalette.jsx +27 -21
  222. package/src/tui/components/Spinner.jsx +67 -38
  223. package/src/tui/components/StatusLine.jsx +606 -38
  224. package/src/tui/components/TextEntryPanel.jsx +128 -9
  225. package/src/tui/components/ToolExecution.jsx +617 -368
  226. package/src/tui/components/TurnDone.jsx +3 -3
  227. package/src/tui/components/UsagePanel.jsx +3 -5
  228. package/src/tui/components/tool-output-format.mjs +365 -0
  229. package/src/tui/components/tool-output-format.test.mjs +220 -0
  230. package/src/tui/dist/index.mjs +8915 -2418
  231. package/src/tui/engine-runtime-notification.test.mjs +115 -0
  232. package/src/tui/engine-tool-result-text.test.mjs +75 -0
  233. package/src/tui/engine.mjs +1455 -279
  234. package/src/tui/figures.mjs +21 -40
  235. package/src/tui/index.jsx +75 -31
  236. package/src/tui/input-editing.mjs +25 -0
  237. package/src/tui/markdown/format-token.mjs +511 -68
  238. package/src/tui/markdown/format-token.test.mjs +216 -0
  239. package/src/tui/markdown/render-ansi.mjs +94 -0
  240. package/src/tui/markdown/render-ansi.test.mjs +108 -0
  241. package/src/tui/markdown/stream-fence.mjs +34 -0
  242. package/src/tui/markdown/stream-fence.test.mjs +26 -0
  243. package/src/tui/markdown/table-layout.mjs +250 -0
  244. package/src/tui/paste-attachments.mjs +0 -7
  245. package/src/tui/spinner-verbs.mjs +1 -2
  246. package/src/tui/statusline-ansi-bridge.mjs +172 -0
  247. package/src/tui/statusline-ansi-bridge.test.mjs +159 -0
  248. package/src/tui/theme.mjs +746 -24
  249. package/src/tui/time-format.mjs +1 -1
  250. package/src/tui/transcript-tool-failures.mjs +67 -0
  251. package/src/tui/transcript-tool-failures.test.mjs +111 -0
  252. package/src/ui/ansi.mjs +1 -2
  253. package/src/ui/markdown.mjs +85 -26
  254. package/src/ui/markdown.test.mjs +70 -0
  255. package/src/ui/model-display.mjs +121 -0
  256. package/src/ui/session-stats.mjs +44 -0
  257. package/src/ui/statusline-context-label.test.mjs +15 -0
  258. package/src/ui/statusline.mjs +386 -178
  259. package/src/ui/tool-card.mjs +3 -16
  260. package/src/vendor/statusline/bin/statusline-lib.mjs +8 -4
  261. package/src/vendor/statusline/bin/statusline-route.mjs +169 -37
  262. package/src/vendor/statusline/bin/statusline-route.test.mjs +80 -0
  263. package/src/vendor/statusline/scripts/lib/gateway-settings.mjs +3 -3
  264. package/src/vendor/statusline/src/gateway/claude-current.mjs +1 -1
  265. package/src/vendor/statusline/src/gateway/route-meta.mjs +44 -6
  266. package/src/vendor/statusline/src/gateway/session-routes.mjs +1 -1
  267. package/src/workflows/default/WORKFLOW.md +12 -5
  268. package/src/workflows/default/workflow.json +0 -1
  269. package/src/workflows/solo/WORKFLOW.md +15 -0
  270. package/src/workflows/solo/workflow.json +7 -0
  271. package/vendor/ink/build/output.js +6 -1
  272. package/src/agents/scheduler-task.md +0 -3
  273. package/src/agents/web-researcher/AGENT.md +0 -3
  274. package/src/agents/web-researcher/agent.json +0 -6
  275. package/src/agents/webhook-handler.md +0 -3
  276. package/src/rules/bridge/00-common.md +0 -5
  277. package/src/rules/bridge/30-explorer.md +0 -4
  278. package/src/rules/lead/00-tool-lead.md +0 -5
  279. package/src/rules/shared/00-language.md +0 -3
  280. package/src/runtime/agent/orchestrator/tools/builtin/native-edit-runner.mjs +0 -110
  281. package/src/runtime/agent/orchestrator/tools/mutation-content-cache.mjs +0 -67
  282. package/src/runtime/memory/lib/bridge-trace-queries.mjs +0 -120
  283. package/src/runtime/shared/llm/pid-cleanup.mjs +0 -27
  284. package/src/standalone/bridge-tool.mjs +0 -1414
  285. 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,32 +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 !== 12) {
279
- throw new Error(`full default surface should stay 12 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', '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 ['shell', 'edit', 'write']) {
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
  }
295
- for (const name of ['edit', 'write']) {
296
- assertLacks(leadDefaults, name);
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');
297
590
  }
298
-
299
591
  function toolSchemaSize(tool) {
300
592
  const desc = String(tool?.description || '');
301
593
  const schema = JSON.stringify(tool?.input_schema || tool?.inputSchema || {});
@@ -306,13 +598,13 @@ const surfaceSize = [...fullDefaults].reduce((sum, name) => {
306
598
  const tool = smokeCatalog.find((item) => item?.name === name);
307
599
  return sum + toolSchemaSize(tool);
308
600
  }, 0);
309
- if (surfaceSize > 14000) {
310
- throw new Error(`full default tool surface too large: ${surfaceSize} chars (cap 14000)`);
601
+ if (surfaceSize > 17000) {
602
+ throw new Error(`full default tool surface too large: ${surfaceSize} chars (cap 17000)`);
311
603
  }
312
604
  for (const [name, cap] of [
313
605
  ['apply_patch', 1300],
314
606
  ['code_graph', 1550],
315
- ['bridge', 2500],
607
+ ['agent', 2500],
316
608
  ['recall', 2400],
317
609
  ['search', 3200],
318
610
  ['web_fetch', 900],
@@ -324,25 +616,69 @@ for (const [name, cap] of [
324
616
  }
325
617
 
326
618
  const readonlyDefaults = defaultDeferredToolNames(smokeCatalog, 'readonly');
327
- if (readonlyDefaults.size !== 7) {
328
- 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(', ')}`);
329
621
  }
330
- 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']) {
331
623
  assertHas(readonlyDefaults, name);
332
624
  }
333
- for (const name of ['apply_patch', 'bridge', 'shell', 'edit', 'write']) {
625
+ for (const name of ['apply_patch', 'agent', 'shell']) {
334
626
  assertLacks(readonlyDefaults, name);
335
627
  }
336
628
 
337
- const bridgeProps = BRIDGE_TOOL.inputSchema?.properties || {};
338
- if (!bridgeProps.mode || bridgeProps.wait) throw new Error('bridge schema should expose mode but not legacy wait');
339
- 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 || '')) {
340
- 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
+ }
341
650
  }
342
- 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({
343
679
  cfgMod: {
344
680
  loadConfig: () => ({ providers: {}, presets: [] }),
345
- 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'); },
346
682
  },
347
683
  reg: { initProviders: async () => {} },
348
684
  mgr: {
@@ -354,52 +690,642 @@ const bridgeSmoke = createStandaloneBridge({
354
690
  cwd: root,
355
691
  defaultMode: 'async',
356
692
  });
357
- const bridgeMissingJob = await bridgeSmoke.execute({ type: 'read', task_id: 'job_missing_smoke' }, { invocationSource: 'model-tool', cwd: root });
358
- if (!/^Error[\s:[]/.test(String(bridgeMissingJob)) || !/job_missing_smoke/.test(String(bridgeMissingJob))) {
359
- 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 });
360
769
  }
361
- const bridgeBadType = await bridgeSmoke.execute({ type: 'definitely_bad_type' }, { invocationSource: 'model-tool', cwd: root });
362
- if (!/^Error[\s:[]/.test(String(bridgeBadType)) || !/unknown type/i.test(String(bridgeBadType))) {
363
- 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 });
364
842
  }
365
843
  if (EXPLORE_TOOL.annotations?.readOnlyHint !== true || EXPLORE_TOOL.annotations?.destructiveHint === true) {
366
844
  throw new Error('explore must stay read-only so readonly surfaces can use it');
367
845
  }
846
+ if (EXPLORE_TOOL.annotations?.agentHidden === true) {
847
+ throw new Error('explore must stay visible to agent sessions');
848
+ }
368
849
  const exploreProps = EXPLORE_TOOL.inputSchema?.properties || {};
369
- if (!/Broad-scope locator only/i.test(EXPLORE_TOOL.description || '') || !/code_graph\/grep\/glob first/i.test(EXPLORE_TOOL.description || '')) {
370
- 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');
371
852
  }
372
- if (!/Never pass a whole brief/i.test(exploreProps.query?.description || '') || !/relevant repo or subtree/i.test(exploreProps.cwd?.description || '')) {
373
- 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');
374
855
  }
375
- 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?"]');
376
857
  if (normalizedExplore.length !== 2 || normalizedExplore[0] !== 'where is model selection?') {
377
858
  throw new Error(`explore query normalization failed: ${JSON.stringify(normalizedExplore)}`);
378
859
  }
379
860
  if (MAX_FANOUT_QUERIES !== 8) throw new Error(`explore fanout cap changed: ${MAX_FANOUT_QUERIES}`);
380
- const explorerPrompt = buildExplorerPrompt('where is <bridge> & status?');
381
- 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) {
382
863
  throw new Error(`explorer prompt contract failed: ${explorerPrompt}`);
383
864
  }
384
- 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 || '';
385
1229
  if (!/V4A/i.test(patchDescription) || !/one (?:file )?block per target file/i.test(patchDescription) || !/exact current context/i.test(patchDescription)) {
386
- 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
+ }
387
1313
  }
388
1314
  const readPathDescription = BUILTIN_TOOLS.find((tool) => tool.name === 'read')?.inputSchema?.properties?.path?.description || '';
389
- if (!/file path only/i.test(readPathDescription)) {
1315
+ if (!/File path or array/i.test(readPathDescription) || !/Dirs use list/i.test(readPathDescription)) {
390
1316
  throw new Error('read schema must keep directory-vs-file guidance');
391
1317
  }
392
1318
  const readDescription = BUILTIN_TOOLS.find((tool) => tool.name === 'read')?.description || '';
393
- 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)) {
394
1320
  throw new Error('read description must stay narrow-target oriented');
395
1321
  }
396
1322
  const codeGraphDescription = CODE_GRAPH_TOOL_DEFS[0]?.description || '';
397
1323
  const codeGraphProps = CODE_GRAPH_TOOL_DEFS[0]?.inputSchema?.properties || {};
398
- if (!/Top-level entry for code-related questions/i.test(codeGraphDescription) || !/Use before read/i.test(codeGraphDescription)) {
399
- 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');
400
1326
  }
401
- if (!/Operation:/i.test(codeGraphProps.mode?.description || '') || !/Directory scope is only for references\/callers/i.test(codeGraphProps.file?.description || '')) {
402
- 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');
403
1329
  }
404
1330
  const recallTool = MEMORY_TOOL_DEFS.find((tool) => tool.name === 'recall');
405
1331
  const recallProps = recallTool?.inputSchema?.properties || {};
@@ -409,6 +1335,64 @@ if (!/when in doubt, recall first/i.test(recallTool?.description || '') || !reca
409
1335
  if (!/array for independent fan-out/i.test(recallProps.query?.description || '') || !/Project pool selector/i.test(recallProps.projectScope?.description || '')) {
410
1336
  throw new Error('recall schema must explain fan-out query and project scope filters');
411
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
+ }
412
1396
  const memoryTool = MEMORY_TOOL_DEFS.find((tool) => tool.name === 'memory');
413
1397
  const memoryProps = memoryTool?.inputSchema?.properties || {};
414
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 || '')) {
@@ -416,8 +1400,8 @@ if (!/explicit mutation/i.test(memoryTool?.description || '') || !/Destructive j
416
1400
  }
417
1401
  const searchTool = SEARCH_TOOL_DEFS.find((tool) => tool.name === 'search');
418
1402
  const searchProps = searchTool?.inputSchema?.properties || {};
419
- if (!/Prefer mode=async/i.test(searchTool?.description || '') || !searchProps.query?.anyOf || !/array for fan-out/i.test(searchProps.query?.description || '')) {
420
- 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');
421
1405
  }
422
1406
  if (!/Default web/i.test(searchProps.type?.description || '') || !/locale hint/i.test(searchProps.locale?.description || '') || !/Default low/i.test(searchProps.contextSize?.description || '')) {
423
1407
  throw new Error('search schema must describe type, locale, and contextSize defaults');
@@ -430,9 +1414,153 @@ if (!/Use after search/i.test(webFetchTool?.description || '') || !webFetchProps
430
1414
  if (!/offset/i.test(webFetchProps.startIndex?.description || '') || !/Maximum characters/i.test(webFetchProps.maxLength?.description || '')) {
431
1415
  throw new Error('web_fetch schema must describe paging window fields');
432
1416
  }
433
- 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) {
434
1418
  throw new Error('tool_search schema must preserve selection guidance and select field');
435
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
+ }
436
1564
  const replyTool = CHANNEL_TOOL_DEFS.find((tool) => tool.name === 'reply');
437
1565
  if (!/configured channel/i.test(replyTool?.description || '') || !/local .*paths/i.test(replyTool?.inputSchema?.properties?.files?.description || '')) {
438
1566
  throw new Error('channel reply schema must describe target channel and attachment paths');
@@ -444,8 +1572,8 @@ if (!/NOT for URLs/i.test(fetchTool?.description || '') || !/web_fetch/i.test(fe
444
1572
  const grepTool = BUILTIN_TOOLS.find((tool) => tool.name === 'grep');
445
1573
  const grepPatternDescription = grepTool?.inputSchema?.properties?.pattern?.description || '';
446
1574
  const grepPathDescription = grepTool?.inputSchema?.properties?.path?.description || '';
447
- if (!/(array for OR|array = OR)/i.test(grepPatternDescription) || !/one file or directory/i.test(grepPathDescription)) {
448
- 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');
449
1577
  }
450
1578
 
451
1579
  const longToolSearchText = compactToolSearchDescription(`${patchDescription}\n${patchDescription}`);