@neurocode-ai/core 1.18.8

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 (490) hide show
  1. package/bin/neurocode +199 -0
  2. package/bunfig.toml +2 -0
  3. package/drizzle.config.ts +10 -0
  4. package/package.json +131 -0
  5. package/schema.json +2071 -0
  6. package/script/fix-node-pty.ts +28 -0
  7. package/script/migration.ts +196 -0
  8. package/src/account/sql.ts +39 -0
  9. package/src/account.ts +101 -0
  10. package/src/agent.ts +111 -0
  11. package/src/aisdk.ts +235 -0
  12. package/src/background-job.ts +365 -0
  13. package/src/catalog.ts +301 -0
  14. package/src/command.ts +64 -0
  15. package/src/config/agent.ts +25 -0
  16. package/src/config/attachments.ts +15 -0
  17. package/src/config/command.ts +12 -0
  18. package/src/config/compaction.ts +15 -0
  19. package/src/config/experimental.ts +18 -0
  20. package/src/config/formatter.ts +12 -0
  21. package/src/config/lsp.ts +18 -0
  22. package/src/config/markdown.ts +36 -0
  23. package/src/config/mcp.ts +48 -0
  24. package/src/config/plugin/agent.ts +179 -0
  25. package/src/config/plugin/command.ts +83 -0
  26. package/src/config/plugin/external.ts +91 -0
  27. package/src/config/plugin/provider.ts +113 -0
  28. package/src/config/plugin/reference.ts +70 -0
  29. package/src/config/plugin/skill.ts +50 -0
  30. package/src/config/plugin.ts +13 -0
  31. package/src/config/provider.ts +71 -0
  32. package/src/config/reference.ts +22 -0
  33. package/src/config/tool-output.ts +9 -0
  34. package/src/config/watcher.ts +7 -0
  35. package/src/config.ts +227 -0
  36. package/src/control-plane/move-session.ts +148 -0
  37. package/src/control-plane/workspace.sql.ts +20 -0
  38. package/src/credential/sql.ts +14 -0
  39. package/src/credential.ts +138 -0
  40. package/src/cross-spawn-spawner.ts +507 -0
  41. package/src/data-migration.sql.ts +6 -0
  42. package/src/database/database.ts +57 -0
  43. package/src/database/migration/20260127222353_familiar_lady_ursula.ts +107 -0
  44. package/src/database/migration/20260211171708_add_project_commands.ts +11 -0
  45. package/src/database/migration/20260213144116_wakeful_the_professor.ts +23 -0
  46. package/src/database/migration/20260225215848_workspace.ts +19 -0
  47. package/src/database/migration/20260227213759_add_session_workspace_id.ts +12 -0
  48. package/src/database/migration/20260228203230_blue_harpoon.ts +30 -0
  49. package/src/database/migration/20260303231226_add_workspace_fields.ts +15 -0
  50. package/src/database/migration/20260309230000_move_org_to_state.ts +15 -0
  51. package/src/database/migration/20260312043431_session_message_cursor.ts +16 -0
  52. package/src/database/migration/20260323234822_events.ts +26 -0
  53. package/src/database/migration/20260410174513_workspace-name.ts +29 -0
  54. package/src/database/migration/20260413175956_chief_energizer.ts +24 -0
  55. package/src/database/migration/20260423070820_add_icon_url_override.ts +14 -0
  56. package/src/database/migration/20260427172553_slow_nightmare.ts +30 -0
  57. package/src/database/migration/20260428004200_add_session_path.ts +11 -0
  58. package/src/database/migration/20260501142318_next_venus.ts +12 -0
  59. package/src/database/migration/20260504145000_add_sync_owner.ts +11 -0
  60. package/src/database/migration/20260507164347_add_workspace_time.ts +11 -0
  61. package/src/database/migration/20260510033149_session_usage.ts +56 -0
  62. package/src/database/migration/20260511000411_data_migration_state.ts +16 -0
  63. package/src/database/migration/20260511173437_session-metadata.ts +16 -0
  64. package/src/database/migration/20260601010001_normalize_storage_paths.ts +22 -0
  65. package/src/database/migration/20260601202201_amazing_prowler.ts +11 -0
  66. package/src/database/migration/20260602002951_lowly_union_jack.ts +24 -0
  67. package/src/database/migration/20260602182828_add_project_directories.ts +20 -0
  68. package/src/database/migration/20260603001617_session_message_projection_indexes.ts +19 -0
  69. package/src/database/migration/20260603040000_session_message_projection_order.ts +19 -0
  70. package/src/database/migration/20260603141458_session_input_inbox.ts +25 -0
  71. package/src/database/migration/20260603160727_jittery_ezekiel_stane.ts +20 -0
  72. package/src/database/migration/20260604172448_event_sourced_session_input.ts +47 -0
  73. package/src/database/migration/20260605003541_add_session_context_snapshot.ts +21 -0
  74. package/src/database/migration/20260605042240_add_context_epoch_agent.ts +11 -0
  75. package/src/database/migration/20260611035744_credential.ts +25 -0
  76. package/src/database/migration/20260611192811_lush_chimera.ts +25 -0
  77. package/src/database/migration/20260612174303_project_dir_strategy.ts +29 -0
  78. package/src/database/migration/20260622142730_simplify_session_context_epoch.ts +13 -0
  79. package/src/database/migration/20260622170816_reset_v2_session_state.ts +17 -0
  80. package/src/database/migration/20260622202450_simplify_session_input.ts +17 -0
  81. package/src/database/migration.gen.ts +44 -0
  82. package/src/database/migration.ts +81 -0
  83. package/src/database/path.ts +91 -0
  84. package/src/database/schema.gen.ts +274 -0
  85. package/src/database/schema.sql.ts +10 -0
  86. package/src/database/sqlite.bun.ts +183 -0
  87. package/src/database/sqlite.node.ts +178 -0
  88. package/src/database/sqlite.ts +8 -0
  89. package/src/effect/app-node-builder.ts +23 -0
  90. package/src/effect/app-node-platform.ts +18 -0
  91. package/src/effect/app-node.ts +14 -0
  92. package/src/effect/dfdf +1 -0
  93. package/src/effect/keyed-mutex.ts +45 -0
  94. package/src/effect/layer-node.ts +333 -0
  95. package/src/effect/memo-map.ts +3 -0
  96. package/src/effect/runtime.ts +21 -0
  97. package/src/effect/service-use.ts +43 -0
  98. package/src/event/sql.ts +25 -0
  99. package/src/event.ts +638 -0
  100. package/src/file-mutation.ts +207 -0
  101. package/src/file.ts +6 -0
  102. package/src/filesystem/fff.bun.ts +140 -0
  103. package/src/filesystem/fff.node.ts +138 -0
  104. package/src/filesystem/ignore.ts +67 -0
  105. package/src/filesystem/protected.ts +53 -0
  106. package/src/filesystem/search.ts +239 -0
  107. package/src/filesystem/watcher.ts +140 -0
  108. package/src/filesystem.ts +118 -0
  109. package/src/flag/flag.ts +78 -0
  110. package/src/fs-util.ts +274 -0
  111. package/src/git.ts +987 -0
  112. package/src/github-copilot/README.md +5 -0
  113. package/src/github-copilot/chat/convert-to-openai-compatible-chat-messages.ts +170 -0
  114. package/src/github-copilot/chat/get-response-metadata.ts +15 -0
  115. package/src/github-copilot/chat/map-openai-compatible-finish-reason.ts +19 -0
  116. package/src/github-copilot/chat/openai-compatible-api-types.ts +64 -0
  117. package/src/github-copilot/chat/openai-compatible-chat-language-model.ts +815 -0
  118. package/src/github-copilot/chat/openai-compatible-chat-options.ts +28 -0
  119. package/src/github-copilot/chat/openai-compatible-metadata-extractor.ts +44 -0
  120. package/src/github-copilot/chat/openai-compatible-prepare-tools.ts +83 -0
  121. package/src/github-copilot/copilot-provider.ts +100 -0
  122. package/src/github-copilot/openai-compatible-error.ts +27 -0
  123. package/src/github-copilot/responses/convert-to-openai-responses-input.ts +335 -0
  124. package/src/github-copilot/responses/map-openai-responses-finish-reason.ts +22 -0
  125. package/src/github-copilot/responses/openai-config.ts +18 -0
  126. package/src/github-copilot/responses/openai-error.ts +22 -0
  127. package/src/github-copilot/responses/openai-responses-api-types.ts +214 -0
  128. package/src/github-copilot/responses/openai-responses-language-model.ts +1770 -0
  129. package/src/github-copilot/responses/openai-responses-prepare-tools.ts +173 -0
  130. package/src/github-copilot/responses/openai-responses-settings.ts +1 -0
  131. package/src/github-copilot/responses/tool/code-interpreter.ts +87 -0
  132. package/src/github-copilot/responses/tool/file-search.ts +127 -0
  133. package/src/github-copilot/responses/tool/image-generation.ts +114 -0
  134. package/src/github-copilot/responses/tool/local-shell.ts +64 -0
  135. package/src/github-copilot/responses/tool/web-search-preview.ts +103 -0
  136. package/src/github-copilot/responses/tool/web-search.ts +102 -0
  137. package/src/global.ts +87 -0
  138. package/src/id/id.ts +47 -0
  139. package/src/image/photon.ts +94 -0
  140. package/src/image.ts +81 -0
  141. package/src/installation/version.ts +8 -0
  142. package/src/instruction-context.ts +101 -0
  143. package/src/integration/connection.ts +12 -0
  144. package/src/integration.ts +520 -0
  145. package/src/location-mutation.ts +162 -0
  146. package/src/location-service-map.ts +18 -0
  147. package/src/location-services.ts +115 -0
  148. package/src/location.ts +39 -0
  149. package/src/markdown.d.ts +4 -0
  150. package/src/model.ts +41 -0
  151. package/src/models-dev.ts +260 -0
  152. package/src/npm-config.ts +40 -0
  153. package/src/npm.ts +269 -0
  154. package/src/oauth/page.ts +276 -0
  155. package/src/observability/logging.ts +71 -0
  156. package/src/observability/otlp.ts +79 -0
  157. package/src/observability/shared.ts +1 -0
  158. package/src/observability.ts +24 -0
  159. package/src/patch.ts +197 -0
  160. package/src/permission/saved.ts +79 -0
  161. package/src/permission/sql.ts +20 -0
  162. package/src/permission.ts +310 -0
  163. package/src/plugin/agent.ts +206 -0
  164. package/src/plugin/command/initialize.txt +65 -0
  165. package/src/plugin/command/review.txt +100 -0
  166. package/src/plugin/command.ts +25 -0
  167. package/src/plugin/host.ts +219 -0
  168. package/src/plugin/internal.ts +153 -0
  169. package/src/plugin/layer-map.example.ts +94 -0
  170. package/src/plugin/models-dev.ts +183 -0
  171. package/src/plugin/promise.ts +93 -0
  172. package/src/plugin/provider/alibaba.ts +15 -0
  173. package/src/plugin/provider/amazon-bedrock.ts +126 -0
  174. package/src/plugin/provider/anthropic.ts +27 -0
  175. package/src/plugin/provider/azure.ts +82 -0
  176. package/src/plugin/provider/cerebras.ts +26 -0
  177. package/src/plugin/provider/cloudflare-ai-gateway.ts +81 -0
  178. package/src/plugin/provider/cloudflare-workers-ai.ts +81 -0
  179. package/src/plugin/provider/cohere.ts +15 -0
  180. package/src/plugin/provider/deepinfra.ts +15 -0
  181. package/src/plugin/provider/dynamic.ts +31 -0
  182. package/src/plugin/provider/gateway.ts +15 -0
  183. package/src/plugin/provider/github-copilot.ts +52 -0
  184. package/src/plugin/provider/gitlab.ts +65 -0
  185. package/src/plugin/provider/google-vertex.ts +173 -0
  186. package/src/plugin/provider/google.ts +15 -0
  187. package/src/plugin/provider/groq.ts +15 -0
  188. package/src/plugin/provider/kilo.ts +21 -0
  189. package/src/plugin/provider/llmgateway.ts +26 -0
  190. package/src/plugin/provider/mistral.ts +15 -0
  191. package/src/plugin/provider/nvidia.ts +22 -0
  192. package/src/plugin/provider/openai-compatible.ts +17 -0
  193. package/src/plugin/provider/openai.ts +292 -0
  194. package/src/plugin/provider/opencode.ts +311 -0
  195. package/src/plugin/provider/openrouter.ts +36 -0
  196. package/src/plugin/provider/perplexity.ts +15 -0
  197. package/src/plugin/provider/sap-ai-core.ts +46 -0
  198. package/src/plugin/provider/snowflake-cortex.ts +89 -0
  199. package/src/plugin/provider/togetherai.ts +15 -0
  200. package/src/plugin/provider/venice.ts +15 -0
  201. package/src/plugin/provider/vercel.ts +27 -0
  202. package/src/plugin/provider/xai.ts +22 -0
  203. package/src/plugin/provider/zenmux.ts +21 -0
  204. package/src/plugin/provider.ts +71 -0
  205. package/src/plugin/skill/customize-neurocode.md +452 -0
  206. package/src/plugin/skill.ts +31 -0
  207. package/src/plugin/variant.ts +39 -0
  208. package/src/plugin.ts +167 -0
  209. package/src/policy.ts +49 -0
  210. package/src/process.ts +261 -0
  211. package/src/project/copy-strategies.ts +35 -0
  212. package/src/project/copy.ts +292 -0
  213. package/src/project/directories.ts +158 -0
  214. package/src/project/schema.ts +16 -0
  215. package/src/project/sql.ts +35 -0
  216. package/src/project.ts +136 -0
  217. package/src/provider.ts +25 -0
  218. package/src/pty/protocol.ts +37 -0
  219. package/src/pty/pty.bun.ts +26 -0
  220. package/src/pty/pty.node.ts +29 -0
  221. package/src/pty/pty.ts +25 -0
  222. package/src/pty/schema.ts +1 -0
  223. package/src/pty/ticket.ts +56 -0
  224. package/src/pty.ts +318 -0
  225. package/src/public-event-manifest.ts +7 -0
  226. package/src/question.ts +153 -0
  227. package/src/reference/guidance.ts +69 -0
  228. package/src/reference.ts +125 -0
  229. package/src/repository-cache.ts +259 -0
  230. package/src/repository.ts +214 -0
  231. package/src/ripgrep/binary.ts +132 -0
  232. package/src/ripgrep.ts +281 -0
  233. package/src/schema.ts +79 -0
  234. package/src/session/compaction.ts +241 -0
  235. package/src/session/context-epoch.ts +174 -0
  236. package/src/session/error.ts +24 -0
  237. package/src/session/event.ts +2 -0
  238. package/src/session/execution/local.ts +46 -0
  239. package/src/session/execution.ts +34 -0
  240. package/src/session/history.ts +101 -0
  241. package/src/session/info.ts +50 -0
  242. package/src/session/input.ts +288 -0
  243. package/src/session/message-updater.ts +397 -0
  244. package/src/session/message.ts +2 -0
  245. package/src/session/projector.ts +458 -0
  246. package/src/session/prompt.ts +1 -0
  247. package/src/session/revert.ts +121 -0
  248. package/src/session/run-coordinator.ts +104 -0
  249. package/src/session/runner/index.ts +28 -0
  250. package/src/session/runner/llm.ts +432 -0
  251. package/src/session/runner/max-steps.ts +16 -0
  252. package/src/session/runner/model.ts +218 -0
  253. package/src/session/runner/publish-llm-event.ts +423 -0
  254. package/src/session/runner/to-llm-message.ts +171 -0
  255. package/src/session/schema.ts +9 -0
  256. package/src/session/sql.ts +176 -0
  257. package/src/session/store.ts +63 -0
  258. package/src/session/todo.ts +78 -0
  259. package/src/session.ts +486 -0
  260. package/src/share/sql.ts +13 -0
  261. package/src/shell.ts +226 -0
  262. package/src/skill/discovery.ts +213 -0
  263. package/src/skill/guidance.ts +76 -0
  264. package/src/skill.ts +132 -0
  265. package/src/snapshot.ts +266 -0
  266. package/src/state.ts +128 -0
  267. package/src/system-context/builtins.ts +50 -0
  268. package/src/system-context/index.ts +320 -0
  269. package/src/system-context/registry.ts +49 -0
  270. package/src/tool/AGENTS.md +59 -0
  271. package/src/tool/application-tools.ts +57 -0
  272. package/src/tool/apply-patch.ts +219 -0
  273. package/src/tool/bash.ts +207 -0
  274. package/src/tool/builtins.ts +48 -0
  275. package/src/tool/edit.ts +223 -0
  276. package/src/tool/glob.ts +105 -0
  277. package/src/tool/grep.ts +137 -0
  278. package/src/tool/http-body.ts +30 -0
  279. package/src/tool/question.ts +94 -0
  280. package/src/tool/read-filesystem.ts +366 -0
  281. package/src/tool/read.ts +117 -0
  282. package/src/tool/registry.ts +147 -0
  283. package/src/tool/skill.ts +109 -0
  284. package/src/tool/todowrite.ts +62 -0
  285. package/src/tool/tool.ts +162 -0
  286. package/src/tool/tools.ts +13 -0
  287. package/src/tool/webfetch.ts +218 -0
  288. package/src/tool/websearch.ts +260 -0
  289. package/src/tool/write.ts +101 -0
  290. package/src/tool-output-store.ts +211 -0
  291. package/src/util/array.ts +10 -0
  292. package/src/util/binary.ts +41 -0
  293. package/src/util/effect-flock.ts +284 -0
  294. package/src/util/encode.ts +51 -0
  295. package/src/util/error.ts +70 -0
  296. package/src/util/flock.ts +358 -0
  297. package/src/util/glob.ts +34 -0
  298. package/src/util/hash.ts +11 -0
  299. package/src/util/identifier.ts +1 -0
  300. package/src/util/iife.ts +3 -0
  301. package/src/util/lazy.ts +11 -0
  302. package/src/util/module.ts +10 -0
  303. package/src/util/path.ts +37 -0
  304. package/src/util/retry.ts +42 -0
  305. package/src/util/slug.ts +74 -0
  306. package/src/util/token.ts +5 -0
  307. package/src/util/which.ts +14 -0
  308. package/src/util/wildcard.ts +14 -0
  309. package/src/v1/config/agent.ts +89 -0
  310. package/src/v1/config/attachment.ts +25 -0
  311. package/src/v1/config/command.ts +13 -0
  312. package/src/v1/config/config.ts +192 -0
  313. package/src/v1/config/console-state.ts +16 -0
  314. package/src/v1/config/error.ts +39 -0
  315. package/src/v1/config/formatter.ts +13 -0
  316. package/src/v1/config/layout.ts +6 -0
  317. package/src/v1/config/lsp.ts +80 -0
  318. package/src/v1/config/mcp.ts +63 -0
  319. package/src/v1/config/migrate.ts +254 -0
  320. package/src/v1/config/permission.ts +50 -0
  321. package/src/v1/config/plugin.ts +9 -0
  322. package/src/v1/config/provider-options.ts +227 -0
  323. package/src/v1/config/provider.ts +121 -0
  324. package/src/v1/config/server.ts +19 -0
  325. package/src/v1/config/skills.ts +13 -0
  326. package/src/v1/permission.ts +33 -0
  327. package/src/v1/session.ts +68 -0
  328. package/src/v2-schema.ts +3 -0
  329. package/src/workspace.ts +6 -0
  330. package/sst-env.d.ts +10 -0
  331. package/test/agent.test.ts +131 -0
  332. package/test/application-tools.test.ts +287 -0
  333. package/test/background-job.test.ts +106 -0
  334. package/test/catalog.test.ts +353 -0
  335. package/test/command.test.ts +56 -0
  336. package/test/config/agent.test.ts +349 -0
  337. package/test/config/command.test.ts +83 -0
  338. package/test/config/config.test.ts +798 -0
  339. package/test/config/fixtures/plugin/directory-plugin.ts +13 -0
  340. package/test/config/plugin.test.ts +248 -0
  341. package/test/config/provider-options.test.ts +224 -0
  342. package/test/config/provider.test.ts +269 -0
  343. package/test/config/skill.test.ts +80 -0
  344. package/test/credential.test.ts +36 -0
  345. package/test/database-migration.test.ts +644 -0
  346. package/test/effect/cross-spawn-spawner.test.ts +426 -0
  347. package/test/effect/keyed-mutex.test.ts +73 -0
  348. package/test/effect/layer-node/layer-node-types.test.ts +143 -0
  349. package/test/effect/layer-node/layer-node.test.ts +261 -0
  350. package/test/effect/layer-node/node-build.test.ts +125 -0
  351. package/test/effect/observability.test.ts +109 -0
  352. package/test/event.test.ts +1124 -0
  353. package/test/file-mutation.test.ts +368 -0
  354. package/test/filesystem/filesystem.test.ts +387 -0
  355. package/test/filesystem/ignore.test.ts +10 -0
  356. package/test/filesystem/search.test.ts +44 -0
  357. package/test/filesystem/watcher.test.ts +266 -0
  358. package/test/fixture/effect-flock-worker.ts +60 -0
  359. package/test/fixture/flock-worker.ts +72 -0
  360. package/test/fixture/git.ts +49 -0
  361. package/test/fixture/location.ts +26 -0
  362. package/test/fixture/tmpdir.ts +25 -0
  363. package/test/fixtures/recordings/session-runner/openai-chat-streams-text.json +27 -0
  364. package/test/git.test.ts +164 -0
  365. package/test/github-copilot/convert-to-copilot-messages.test.ts +523 -0
  366. package/test/github-copilot/copilot-chat-model.test.ts +592 -0
  367. package/test/github-copilot/openai-responses-language-model.test.ts +206 -0
  368. package/test/global.test.ts +16 -0
  369. package/test/instruction-context.test.ts +323 -0
  370. package/test/integration.test.ts +349 -0
  371. package/test/legacy-event-schema.test.ts +16 -0
  372. package/test/lib/effect.ts +53 -0
  373. package/test/lib/tool.ts +20 -0
  374. package/test/location-filesystem.test.ts +69 -0
  375. package/test/location-layer.test.ts +226 -0
  376. package/test/location-mutation.test.ts +178 -0
  377. package/test/location.test.ts +42 -0
  378. package/test/model.test.ts +23 -0
  379. package/test/models.test.ts +290 -0
  380. package/test/move-session.test.ts +235 -0
  381. package/test/npm-config.test.ts +51 -0
  382. package/test/npm.test.ts +84 -0
  383. package/test/oauth-page.test.ts +15 -0
  384. package/test/patch.test.ts +68 -0
  385. package/test/permission.test.ts +315 -0
  386. package/test/plugin/command.test.ts +47 -0
  387. package/test/plugin/fixture.ts +52 -0
  388. package/test/plugin/fixtures/config-effect-plugin.ts +15 -0
  389. package/test/plugin/fixtures/config-promise-plugin.ts +13 -0
  390. package/test/plugin/fixtures/invalid-plugin.ts +1 -0
  391. package/test/plugin/fixtures/models-dev.json +14 -0
  392. package/test/plugin/fixtures/provider-factory.ts +9 -0
  393. package/test/plugin/host.ts +303 -0
  394. package/test/plugin/models-dev.test.ts +170 -0
  395. package/test/plugin/promise.test.ts +67 -0
  396. package/test/plugin/provider-alibaba.test.ts +92 -0
  397. package/test/plugin/provider-amazon-bedrock.test.ts +622 -0
  398. package/test/plugin/provider-anthropic.test.ts +94 -0
  399. package/test/plugin/provider-azure-cognitive-services.test.ts +199 -0
  400. package/test/plugin/provider-azure.test.ts +293 -0
  401. package/test/plugin/provider-cerebras.test.ts +136 -0
  402. package/test/plugin/provider-cloudflare-ai-gateway.test.ts +426 -0
  403. package/test/plugin/provider-cloudflare-workers-ai.test.ts +279 -0
  404. package/test/plugin/provider-cohere.test.ts +123 -0
  405. package/test/plugin/provider-deepinfra.test.ts +159 -0
  406. package/test/plugin/provider-dynamic.test.ts +184 -0
  407. package/test/plugin/provider-gateway.test.ts +115 -0
  408. package/test/plugin/provider-github-copilot.test.ts +278 -0
  409. package/test/plugin/provider-gitlab.test.ts +308 -0
  410. package/test/plugin/provider-google-vertex-anthropic.test.ts +264 -0
  411. package/test/plugin/provider-google-vertex.test.ts +387 -0
  412. package/test/plugin/provider-google.test.ts +80 -0
  413. package/test/plugin/provider-groq.test.ts +121 -0
  414. package/test/plugin/provider-kilo.test.ts +99 -0
  415. package/test/plugin/provider-llmgateway.test.ts +80 -0
  416. package/test/plugin/provider-mistral.test.ts +129 -0
  417. package/test/plugin/provider-nvidia.test.ts +97 -0
  418. package/test/plugin/provider-openai-compatible.test.ts +107 -0
  419. package/test/plugin/provider-openai.test.ts +176 -0
  420. package/test/plugin/provider-opencode.test.ts +423 -0
  421. package/test/plugin/provider-openrouter.test.ts +113 -0
  422. package/test/plugin/provider-perplexity.test.ts +122 -0
  423. package/test/plugin/provider-sap-ai-core.test.ts +159 -0
  424. package/test/plugin/provider-snowflake-cortex.test.ts +261 -0
  425. package/test/plugin/provider-togetherai.test.ts +129 -0
  426. package/test/plugin/provider-venice.test.ts +115 -0
  427. package/test/plugin/provider-vercel.test.ts +83 -0
  428. package/test/plugin/provider-xai.test.ts +125 -0
  429. package/test/plugin/provider-zenmux.test.ts +110 -0
  430. package/test/plugin/skill.test.ts +25 -0
  431. package/test/plugin/variant.test.ts +67 -0
  432. package/test/plugin.test.ts +71 -0
  433. package/test/policy.test.ts +85 -0
  434. package/test/preload.ts +5 -0
  435. package/test/process/process.test.ts +368 -0
  436. package/test/project-copy.test.ts +400 -0
  437. package/test/project-directories.test.ts +61 -0
  438. package/test/project.test.ts +221 -0
  439. package/test/provider-mistral.test.ts +282 -0
  440. package/test/provider-xai-responses.test.ts +32 -0
  441. package/test/pty/info-schema.test.ts +32 -0
  442. package/test/pty/protocol.test.ts +27 -0
  443. package/test/pty/pty-session.test.ts +240 -0
  444. package/test/pty/ticket.test.ts +62 -0
  445. package/test/question.test.ts +114 -0
  446. package/test/reference-guidance.test.ts +76 -0
  447. package/test/reference.test.ts +78 -0
  448. package/test/repository-cache.test.ts +153 -0
  449. package/test/repository.test.ts +71 -0
  450. package/test/ripgrep.test.ts +65 -0
  451. package/test/session-compaction.test.ts +27 -0
  452. package/test/session-create.test.ts +425 -0
  453. package/test/session-history.test.ts +165 -0
  454. package/test/session-projector.test.ts +533 -0
  455. package/test/session-prompt.test.ts +584 -0
  456. package/test/session-run-coordinator.test.ts +418 -0
  457. package/test/session-runner-message.test.ts +501 -0
  458. package/test/session-runner-model.test.ts +347 -0
  459. package/test/session-runner-recorded.test.ts +193 -0
  460. package/test/session-runner-tool-events.test.ts +136 -0
  461. package/test/session-runner-tool-registry.test.ts +452 -0
  462. package/test/session-runner.test.ts +3365 -0
  463. package/test/session-todo.test.ts +94 -0
  464. package/test/session-tool-progress.test.ts +158 -0
  465. package/test/shared-schema.test.ts +206 -0
  466. package/test/shell.test.ts +108 -0
  467. package/test/skill/guidance.test.ts +144 -0
  468. package/test/skill-discovery.test.ts +165 -0
  469. package/test/skill.test.ts +125 -0
  470. package/test/snapshot.test.ts +178 -0
  471. package/test/state.test.ts +115 -0
  472. package/test/system-context/builtins.test.ts +129 -0
  473. package/test/system-context/index.test.ts +307 -0
  474. package/test/system-context/registry.test.ts +114 -0
  475. package/test/tool-apply-patch.test.ts +436 -0
  476. package/test/tool-bash.test.ts +437 -0
  477. package/test/tool-edit.test.ts +434 -0
  478. package/test/tool-output-store.test.ts +247 -0
  479. package/test/tool-question.test.ts +162 -0
  480. package/test/tool-read-filesystem.test.ts +118 -0
  481. package/test/tool-read.test.ts +674 -0
  482. package/test/tool-skill.test.ts +149 -0
  483. package/test/tool-todowrite.test.ts +125 -0
  484. package/test/tool-webfetch.test.ts +281 -0
  485. package/test/tool-websearch.test.ts +316 -0
  486. package/test/tool-write.test.ts +305 -0
  487. package/test/util/effect-flock.test.ts +392 -0
  488. package/test/util/flock.test.ts +428 -0
  489. package/test/util/which.test.ts +100 -0
  490. package/tsconfig.json +7 -0
@@ -0,0 +1,3365 @@
1
+ import { describe, expect } from "bun:test"
2
+ import {
3
+ LLMClient,
4
+ LLMError,
5
+ LLMEvent,
6
+ Model,
7
+ TransportReason,
8
+ InvalidRequestReason,
9
+ type LLMClientShape,
10
+ type LLMRequest,
11
+ } from "@neurocode-ai/llm"
12
+ import * as OpenAIChat from "@neurocode-ai/llm/protocols/openai-chat"
13
+ import { Database } from "@neurocode-ai/core/database/database"
14
+ import { makeLocationNode } from "@neurocode-ai/core/effect/app-node"
15
+ import { AppNodeBuilder } from "@neurocode-ai/core/effect/app-node-builder"
16
+ import { LayerNodePlatform } from "@neurocode-ai/core/effect/app-node-platform"
17
+ import { LayerNode } from "@neurocode-ai/core/effect/layer-node"
18
+ import { EventV2 } from "@neurocode-ai/core/event"
19
+ import { PermissionV2 } from "@neurocode-ai/core/permission"
20
+ import { EventTable } from "@neurocode-ai/core/event/sql"
21
+ import { Project } from "@neurocode-ai/core/project"
22
+ import { ProjectTable } from "@neurocode-ai/core/project/sql"
23
+ import { QuestionV2 } from "@neurocode-ai/core/question"
24
+ import { AbsolutePath } from "@neurocode-ai/core/schema"
25
+ import { SessionV2 } from "@neurocode-ai/core/session"
26
+ import { Snapshot } from "@neurocode-ai/core/snapshot"
27
+ import { ContextSnapshotDecodeError } from "@neurocode-ai/core/session/error"
28
+ import { SessionEvent } from "@neurocode-ai/core/session/event"
29
+ import { SessionInput } from "@neurocode-ai/core/session/input"
30
+ import { SessionMessage } from "@neurocode-ai/core/session/message"
31
+ import { Prompt } from "@neurocode-ai/core/session/prompt"
32
+ import { SessionProjector } from "@neurocode-ai/core/session/projector"
33
+ import { SessionExecution } from "@neurocode-ai/core/session/execution"
34
+ import { SessionRunCoordinator } from "@neurocode-ai/core/session/run-coordinator"
35
+ import { SessionRunner } from "@neurocode-ai/core/session/runner"
36
+ import * as SessionRunnerLLM from "@neurocode-ai/core/session/runner/llm"
37
+ import { SessionRunnerModel } from "@neurocode-ai/core/session/runner/model"
38
+ import { ToolRegistry } from "@neurocode-ai/core/tool/registry"
39
+ import { ApplicationTools } from "@neurocode-ai/core/tool/application-tools"
40
+ import { AgentV2 } from "@neurocode-ai/core/agent"
41
+ import { Config } from "@neurocode-ai/core/config"
42
+ import { ConfigCompaction } from "@neurocode-ai/core/config/compaction"
43
+ import { Tool } from "@neurocode-ai/core/tool/tool"
44
+ import {
45
+ SessionContextEpochTable,
46
+ SessionInputTable,
47
+ SessionMessageTable,
48
+ SessionTable,
49
+ } from "@neurocode-ai/core/session/sql"
50
+ import { SessionStore } from "@neurocode-ai/core/session/store"
51
+ import { SystemContext } from "@neurocode-ai/core/system-context"
52
+ import { SystemContextRegistry } from "@neurocode-ai/core/system-context/registry"
53
+ import { SkillGuidance } from "@neurocode-ai/core/skill/guidance"
54
+ import { ReferenceGuidance } from "@neurocode-ai/core/reference/guidance"
55
+ import { ModelV2 } from "@neurocode-ai/core/model"
56
+ import { Location } from "@neurocode-ai/core/location"
57
+ import { ProviderV2 } from "@neurocode-ai/core/provider"
58
+ import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } from "effect"
59
+ import { asc, eq } from "drizzle-orm"
60
+ import { testEffect } from "./lib/effect"
61
+
62
+ const requests: LLMRequest[] = []
63
+ let response: LLMEvent[] = []
64
+ let responses: LLMEvent[][] | undefined
65
+ let responseStream: Stream.Stream<LLMEvent, LLMError> | undefined
66
+ let streamGate: Deferred.Deferred<void> | undefined
67
+ let streamStarted: Deferred.Deferred<void> | undefined
68
+ let streamFailure: LLMError | undefined
69
+ let toolExecutionGate: Deferred.Deferred<void> | undefined
70
+ let toolExecutionsStarted: Deferred.Deferred<void> | undefined
71
+ let toolExecutionsReady = 5
72
+ let activeToolExecutions = 0
73
+ let maxActiveToolExecutions = 0
74
+ const client = Layer.succeed(
75
+ LLMClient.Service,
76
+ LLMClient.Service.of({
77
+ prepare: () => Effect.die("unused"),
78
+ stream: ((request: LLMRequest) => {
79
+ requests.push(request)
80
+ if (responseStream) {
81
+ const stream = responseStream
82
+ responseStream = undefined
83
+ return stream
84
+ }
85
+ const events = streamFailure
86
+ ? Stream.fail(streamFailure)
87
+ : Stream.fromIterable(responses === undefined ? response : (responses.shift() ?? []))
88
+ if (!streamGate) return events
89
+ return Stream.unwrap(
90
+ (streamStarted ? Deferred.succeed(streamStarted, undefined) : Effect.void).pipe(
91
+ Effect.andThen(Deferred.await(streamGate)),
92
+ Effect.as(events),
93
+ ),
94
+ )
95
+ }) as unknown as LLMClientShape["stream"],
96
+ generate: () => Effect.die("unused"),
97
+ }),
98
+ )
99
+ const model = Model.make({ id: "fake-model", provider: "fake", route: OpenAIChat.route })
100
+ const replacementModel = Model.make({ id: "replacement", provider: "fake", route: OpenAIChat.route })
101
+ const compactModel = Model.make({
102
+ id: "compact",
103
+ provider: "fake",
104
+ route: OpenAIChat.route.with({ limits: { context: 4_000, output: 50 } }),
105
+ })
106
+ const recoveryModel = Model.make({
107
+ id: "recovery",
108
+ provider: "fake",
109
+ route: OpenAIChat.route.with({ limits: { context: 20_000, output: 1_000 } }),
110
+ })
111
+ const authorizations: Tool.Context[] = []
112
+ const executions: string[] = []
113
+ const permission = Layer.succeed(
114
+ PermissionV2.Service,
115
+ PermissionV2.Service.of({
116
+ assert: () => Effect.die("unused"),
117
+ ask: () => Effect.die("unused"),
118
+ reply: () => Effect.die("unused"),
119
+ get: () => Effect.die("unused"),
120
+ forSession: () => Effect.die("unused"),
121
+ list: () => Effect.die("unused"),
122
+ }),
123
+ )
124
+ const echo = Layer.effectDiscard(
125
+ ToolRegistry.Service.use((registry) =>
126
+ registry.register({
127
+ echo: Tool.make({
128
+ description: "Echo text",
129
+ input: Schema.Struct({ text: Schema.String }),
130
+ output: Schema.Struct({ text: Schema.String }),
131
+ toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
132
+ execute: ({ text }, context) =>
133
+ Effect.gen(function* () {
134
+ authorizations.push(context)
135
+ executions.push(text)
136
+ activeToolExecutions++
137
+ maxActiveToolExecutions = Math.max(maxActiveToolExecutions, activeToolExecutions)
138
+ if (activeToolExecutions === toolExecutionsReady && toolExecutionsStarted) {
139
+ yield* Deferred.succeed(toolExecutionsStarted, undefined)
140
+ }
141
+ if (toolExecutionGate) yield* Deferred.await(toolExecutionGate)
142
+ return { text }
143
+ }).pipe(Effect.ensuring(Effect.sync(() => activeToolExecutions--))),
144
+ }),
145
+ defect: Tool.make({
146
+ description: "Fail unexpectedly",
147
+ input: Schema.Struct({}),
148
+ output: Schema.Struct({}),
149
+ execute: () => Effect.die("unexpected tool defect"),
150
+ }),
151
+ }),
152
+ ),
153
+ )
154
+ const echoNode = makeLocationNode({ name: "test/session-runner-tools", layer: echo, deps: [ToolRegistry.node] })
155
+ let modelResolveHook = Effect.void
156
+ let currentModel = model
157
+ const models = SessionRunnerModel.layerWith((session) =>
158
+ modelResolveHook.pipe(Effect.as(session.model?.id === "replacement" ? replacementModel : currentModel)),
159
+ )
160
+ const systemContextKey = SystemContext.Key.make("test/context")
161
+ let systemBaseline = "Initial context"
162
+ let systemRemoved = false
163
+ let systemUnavailable = false
164
+ let systemLoadHook = Effect.void
165
+ const skillBaselines = new Map<AgentV2.ID, string>()
166
+ const systemContext = Layer.effectDiscard(
167
+ SystemContextRegistry.Service.pipe(
168
+ Effect.flatMap((registry) =>
169
+ registry.register({
170
+ key: systemContextKey,
171
+ load: Effect.sync(() =>
172
+ SystemContext.combine(
173
+ systemRemoved
174
+ ? []
175
+ : [
176
+ SystemContext.make({
177
+ key: systemContextKey,
178
+ codec: Schema.toCodecJson(Schema.String),
179
+ load: systemLoadHook.pipe(
180
+ Effect.andThen(
181
+ Effect.sync(() => (systemUnavailable ? SystemContext.unavailable : systemBaseline)),
182
+ ),
183
+ ),
184
+ baseline: String,
185
+ update: (_previous, current) => current,
186
+ removed: () => "System context source removed: test/context",
187
+ }),
188
+ ],
189
+ ),
190
+ ),
191
+ }),
192
+ ),
193
+ ),
194
+ ).pipe(Layer.provideMerge(AppNodeBuilder.build(SystemContextRegistry.node)))
195
+ const skillGuidance = Layer.mock(SkillGuidance.Service, {
196
+ load: (agent) =>
197
+ Effect.succeed(
198
+ skillBaselines.has(agent.id)
199
+ ? SystemContext.make({
200
+ key: SystemContext.Key.make("test/skill-guidance"),
201
+ codec: Schema.toCodecJson(Schema.String),
202
+ load: Effect.succeed(skillBaselines.get(agent.id)!),
203
+ baseline: String,
204
+ update: (_previous, current) => current,
205
+ removed: () => "Skill guidance removed",
206
+ })
207
+ : SystemContext.empty,
208
+ ),
209
+ })
210
+ const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) })
211
+ const config = Layer.succeed(
212
+ Config.Service,
213
+ Config.Service.of({
214
+ entries: () =>
215
+ Effect.succeed([
216
+ new Config.Document({
217
+ type: "document",
218
+ info: new Config.Info({
219
+ compaction: new ConfigCompaction.Info({
220
+ buffer: 3_000,
221
+ keep: new ConfigCompaction.Keep({ tokens: 1_000 }),
222
+ }),
223
+ }),
224
+ }),
225
+ ]),
226
+ }),
227
+ )
228
+ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
229
+ [Snapshot.node, Snapshot.noopLayer],
230
+ [LayerNodePlatform.llmClient, client],
231
+ [SessionRunnerModel.node, models],
232
+ [SystemContextRegistry.node, systemContext],
233
+ [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
234
+ [SkillGuidance.node, skillGuidance],
235
+ [ReferenceGuidance.node, referenceGuidance],
236
+ [PermissionV2.node, permission],
237
+ [Config.node, config],
238
+ ])
239
+ const execution = Layer.effect(
240
+ SessionExecution.Service,
241
+ Effect.gen(function* () {
242
+ const sessionRunner = yield* SessionRunner.Service
243
+ const coordinator = yield* SessionRunCoordinator.make<SessionV2.ID, SessionRunner.RunError>({
244
+ drain: (sessionID, force) => sessionRunner.run({ sessionID, force }),
245
+ })
246
+ return SessionExecution.Service.of({
247
+ active: coordinator.active,
248
+ resume: coordinator.run,
249
+ wake: coordinator.wake,
250
+ interrupt: coordinator.interrupt,
251
+ })
252
+ }),
253
+ ).pipe(Layer.provide(runnerLayer))
254
+ const it = testEffect(
255
+ AppNodeBuilder.build(
256
+ LayerNode.group([
257
+ Database.node,
258
+ EventV2.node,
259
+ QuestionV2.node,
260
+ SessionProjector.node,
261
+ SessionStore.node,
262
+ ApplicationTools.node,
263
+ AgentV2.node,
264
+ ToolRegistry.node,
265
+ ToolRegistry.toolsNode,
266
+ echoNode,
267
+ SessionRunnerModel.node,
268
+ SystemContextRegistry.node,
269
+ SkillGuidance.node,
270
+ ReferenceGuidance.node,
271
+ Config.node,
272
+ Snapshot.node,
273
+ SessionRunnerLLM.node,
274
+ SessionExecution.node,
275
+ SessionV2.node,
276
+ ]),
277
+ [
278
+ [LayerNodePlatform.llmClient, client],
279
+ [PermissionV2.node, permission],
280
+ [SessionRunnerModel.node, models],
281
+ [SystemContextRegistry.node, systemContext],
282
+ [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
283
+ [SkillGuidance.node, skillGuidance],
284
+ [ReferenceGuidance.node, referenceGuidance],
285
+ [Snapshot.node, Snapshot.noopLayer],
286
+ [SessionExecution.node, execution],
287
+ [Config.node, config],
288
+ ],
289
+ ),
290
+ )
291
+ const sessionID = SessionV2.ID.make("ses_runner_test")
292
+ const otherSessionID = SessionV2.ID.make("ses_runner_other")
293
+
294
+ const insertSession = (id: SessionV2.ID) =>
295
+ Effect.gen(function* () {
296
+ const { db } = yield* Database.Service
297
+ yield* db
298
+ .insert(SessionTable)
299
+ .values({
300
+ id,
301
+ project_id: Project.ID.global,
302
+ slug: id,
303
+ directory: "/project",
304
+ title: "test",
305
+ version: "test",
306
+ })
307
+ .onConflictDoNothing()
308
+ .run()
309
+ .pipe(Effect.orDie)
310
+ })
311
+
312
+ const setup = Effect.gen(function* () {
313
+ const { db } = yield* Database.Service
314
+ response = []
315
+ systemBaseline = "Initial context"
316
+ systemRemoved = false
317
+ systemUnavailable = false
318
+ systemLoadHook = Effect.void
319
+ modelResolveHook = Effect.void
320
+ currentModel = model
321
+ skillBaselines.clear()
322
+ responses = undefined
323
+ streamFailure = undefined
324
+ responseStream = undefined
325
+ streamGate = undefined
326
+ streamStarted = undefined
327
+ toolExecutionGate = undefined
328
+ toolExecutionsStarted = undefined
329
+ toolExecutionsReady = 5
330
+ activeToolExecutions = 0
331
+ maxActiveToolExecutions = 0
332
+ yield* db
333
+ .insert(ProjectTable)
334
+ .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
335
+ .onConflictDoNothing()
336
+ .run()
337
+ .pipe(Effect.orDie)
338
+ yield* insertSession(sessionID)
339
+ })
340
+
341
+ const providerUnavailable = () =>
342
+ new LLMError({
343
+ module: "test",
344
+ method: "stream",
345
+ reason: new TransportReason({ message: "Provider unavailable" }),
346
+ })
347
+
348
+ const setupOverflowRecovery = Effect.gen(function* () {
349
+ yield* setup
350
+ const session = yield* SessionV2.Service
351
+ response = fragmentFixture("text", "text-earlier", ["Earlier answer"]).completeEvents
352
+ yield* session.prompt({
353
+ sessionID,
354
+ prompt: Prompt.make({ text: "Earlier question ".repeat(700) }),
355
+ resume: false,
356
+ })
357
+ yield* session.resume(sessionID)
358
+ currentModel = recoveryModel
359
+ requests.length = 0
360
+ return session
361
+ })
362
+
363
+ const messageTexts = (request: LLMRequest, role: "user" | "system") =>
364
+ request.messages.flatMap((message) =>
365
+ message.role === role ? message.content.flatMap((content) => (content.type === "text" ? [content.text] : [])) : [],
366
+ )
367
+ const userTexts = (request: LLMRequest) => messageTexts(request, "user")
368
+ const systemTexts = (request: LLMRequest) => messageTexts(request, "system")
369
+
370
+ const replaySessionProjection = (id: SessionV2.ID) =>
371
+ Effect.gen(function* () {
372
+ const { db } = yield* Database.Service
373
+ const events = yield* EventV2.Service
374
+ const recorded = yield* db
375
+ .select()
376
+ .from(EventTable)
377
+ .where(eq(EventTable.aggregate_id, id))
378
+ .orderBy(asc(EventTable.seq))
379
+ .all()
380
+ .pipe(Effect.orDie)
381
+
382
+ yield* events.remove(id)
383
+ yield* db.delete(SessionInputTable).where(eq(SessionInputTable.session_id, id)).run().pipe(Effect.orDie)
384
+ yield* db.delete(SessionMessageTable).where(eq(SessionMessageTable.session_id, id)).run().pipe(Effect.orDie)
385
+ yield* events.replayAll(
386
+ recorded.map((event) => ({
387
+ id: event.id,
388
+ aggregateID: event.aggregate_id,
389
+ seq: event.seq,
390
+ type: event.type,
391
+ data: event.data,
392
+ })),
393
+ )
394
+ })
395
+
396
+ type FragmentKind = "text" | "reasoning" | "tool input"
397
+
398
+ type FragmentFixture = {
399
+ readonly delta: EventV2.Definition
400
+ readonly completeEvents: LLMEvent[]
401
+ readonly partialEvents: LLMEvent[]
402
+ readonly expectedAssistant: unknown
403
+ readonly expectedContent: unknown
404
+ }
405
+
406
+ const fragmentKinds: readonly FragmentKind[] = ["text", "reasoning", "tool input"]
407
+
408
+ const fragmentID = (kind: FragmentKind, suffix: string) => `${kind === "tool input" ? "call" : kind}-${suffix}`
409
+
410
+ const fragmentFixture = (kind: FragmentKind, id: string, chunks: readonly string[]): FragmentFixture => {
411
+ const text = chunks.join("")
412
+ switch (kind) {
413
+ case "text": {
414
+ const partialEvents = [
415
+ LLMEvent.stepStart({ index: 0 }),
416
+ LLMEvent.textStart({ id }),
417
+ ...chunks.map((text) => LLMEvent.textDelta({ id, text })),
418
+ ]
419
+ const expectedContent = { type: "text", id, text }
420
+ return {
421
+ delta: SessionEvent.Text.Delta,
422
+ partialEvents,
423
+ completeEvents: [
424
+ ...partialEvents,
425
+ LLMEvent.textEnd({ id }),
426
+ LLMEvent.stepFinish({ index: 0, reason: "stop" }),
427
+ LLMEvent.finish({ reason: "stop" }),
428
+ ],
429
+ expectedAssistant: { type: "assistant", finish: "stop", content: [expectedContent] },
430
+ expectedContent,
431
+ }
432
+ }
433
+ case "reasoning": {
434
+ const partialEvents = [
435
+ LLMEvent.stepStart({ index: 0 }),
436
+ LLMEvent.reasoningStart({ id }),
437
+ ...chunks.map((text) => LLMEvent.reasoningDelta({ id, text })),
438
+ ]
439
+ const expectedContent = { type: "reasoning", id, text }
440
+ return {
441
+ delta: SessionEvent.Reasoning.Delta,
442
+ partialEvents,
443
+ completeEvents: [
444
+ ...partialEvents,
445
+ LLMEvent.reasoningEnd({ id }),
446
+ LLMEvent.stepFinish({ index: 0, reason: "stop" }),
447
+ LLMEvent.finish({ reason: "stop" }),
448
+ ],
449
+ expectedAssistant: { type: "assistant", finish: "stop", content: [expectedContent] },
450
+ expectedContent,
451
+ }
452
+ }
453
+ case "tool input": {
454
+ const partialEvents = [
455
+ LLMEvent.stepStart({ index: 0 }),
456
+ LLMEvent.toolInputStart({ id, name: "echo" }),
457
+ ...chunks.map((text) => LLMEvent.toolInputDelta({ id, name: "echo", text })),
458
+ ]
459
+ const expectedContent = { type: "tool", id, state: { status: "pending", input: text } }
460
+ return {
461
+ delta: SessionEvent.Tool.Input.Delta,
462
+ partialEvents,
463
+ completeEvents: [...partialEvents, LLMEvent.toolInputEnd({ id, name: "echo" })],
464
+ expectedAssistant: { type: "assistant", content: [expectedContent] },
465
+ expectedContent,
466
+ }
467
+ }
468
+ }
469
+ }
470
+
471
+ const verifyEphemeralDeltas = (kind: FragmentKind) =>
472
+ Effect.gen(function* () {
473
+ yield* setup
474
+ const session = yield* SessionV2.Service
475
+ const prompt = `Stream ${kind}`
476
+ const chunks = Array.from({ length: 32 }, (_, index) => `${index},`)
477
+ const fixture = fragmentFixture(kind, fragmentID(kind, "many"), chunks)
478
+ const expectedContext = [{ type: "user", text: prompt }, fixture.expectedAssistant]
479
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: prompt }), resume: false })
480
+ const events = yield* EventV2.Service
481
+ const live = yield* events.subscribe(fixture.delta).pipe(Stream.take(32), Stream.runCollect, Effect.forkScoped)
482
+ yield* Effect.yieldNow
483
+ response = fixture.completeEvents
484
+
485
+ yield* session.resume(sessionID)
486
+
487
+ const { db } = yield* Database.Service
488
+ const deltas = yield* db
489
+ .select({ type: EventTable.type })
490
+ .from(EventTable)
491
+ .where(eq(EventTable.type, EventV2.versionedType(fixture.delta.type, 1)))
492
+ .all()
493
+ .pipe(Effect.orDie)
494
+ expect(Array.from(yield* Fiber.join(live))).toHaveLength(32)
495
+ expect(deltas).toHaveLength(0)
496
+ expect(yield* session.context(sessionID)).toMatchObject(expectedContext)
497
+
498
+ yield* replaySessionProjection(sessionID)
499
+
500
+ expect(yield* session.context(sessionID)).toMatchObject(expectedContext)
501
+ })
502
+
503
+ const verifyPartialFlushOnFailure = (kind: FragmentKind) =>
504
+ Effect.gen(function* () {
505
+ yield* setup
506
+ const session = yield* SessionV2.Service
507
+ const prompt = `Fail after ${kind}`
508
+ const fixture = fragmentFixture(kind, fragmentID(kind, "partial"), ["Partial"])
509
+ const failure = providerUnavailable()
510
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: prompt }), resume: false })
511
+ responseStream = Stream.concat(Stream.fromIterable(fixture.partialEvents), Stream.fail(failure))
512
+
513
+ expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure)
514
+ expect(yield* session.context(sessionID)).toMatchObject([
515
+ { type: "user", text: prompt },
516
+ {
517
+ type: "assistant",
518
+ finish: "error",
519
+ error: { type: "unknown", message: "Provider unavailable" },
520
+ content: [fixture.expectedContent],
521
+ },
522
+ ])
523
+ })
524
+
525
+ const verifyPartialFlushOnInterruption = (kind: FragmentKind) =>
526
+ Effect.gen(function* () {
527
+ yield* setup
528
+ const session = yield* SessionV2.Service
529
+ const prompt = `Interrupt after ${kind}`
530
+ const fixture = fragmentFixture(kind, fragmentID(kind, "interrupted"), ["Partial"])
531
+ const streamed = yield* Deferred.make<void>()
532
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: prompt }), resume: false })
533
+ responseStream = Stream.concat(
534
+ Stream.fromIterable(fixture.partialEvents),
535
+ Stream.fromEffect(Deferred.succeed(streamed, undefined)).pipe(Stream.flatMap(() => Stream.never)),
536
+ )
537
+
538
+ const runner = yield* SessionRunner.Service
539
+ const fiber = yield* runner.run({ sessionID, force: true }).pipe(Effect.forkChild)
540
+ yield* Deferred.await(streamed)
541
+ yield* Fiber.interrupt(fiber)
542
+ expect(yield* session.context(sessionID)).toMatchObject([
543
+ { type: "user", text: prompt },
544
+ {
545
+ type: "assistant",
546
+ finish: "error",
547
+ error: { type: "unknown", message: "Provider turn interrupted" },
548
+ content: [
549
+ kind === "tool input"
550
+ ? { type: "tool", id: fragmentID(kind, "interrupted"), state: { status: "error" } }
551
+ : fixture.expectedContent,
552
+ ],
553
+ },
554
+ ])
555
+ })
556
+
557
+ describe("SessionRunnerLLM", () => {
558
+ it.effect("advertises and executes a globally attached application tool", () =>
559
+ Effect.gen(function* () {
560
+ yield* setup
561
+ const applicationTools = yield* ApplicationTools.Service
562
+ const session = yield* SessionV2.Service
563
+ const contexts: Tool.Context[] = []
564
+ yield* applicationTools.register({
565
+ application_context: Tool.make({
566
+ description: "Read application context",
567
+ input: Schema.Struct({ query: Schema.String }),
568
+ output: Schema.Struct({ answer: Schema.String }),
569
+ execute: ({ query }, context) =>
570
+ Effect.sync(() => {
571
+ contexts.push(context)
572
+ return { answer: query.toUpperCase() }
573
+ }),
574
+ }),
575
+ })
576
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Use application context" }), resume: false })
577
+ responses = [
578
+ [
579
+ LLMEvent.stepStart({ index: 0 }),
580
+ LLMEvent.toolCall({ id: "call-application", name: "application_context", input: { query: "hello" } }),
581
+ LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
582
+ LLMEvent.finish({ reason: "tool-calls" }),
583
+ ],
584
+ [],
585
+ ]
586
+
587
+ yield* session.resume(sessionID)
588
+
589
+ expect(requests[0]?.tools.map((tool) => tool.name)).toContain("application_context")
590
+ expect(contexts).toEqual([
591
+ {
592
+ sessionID,
593
+ agent: AgentV2.ID.make("build"),
594
+ assistantMessageID: expect.stringMatching(/^msg_/),
595
+ toolCallID: "call-application",
596
+ },
597
+ ])
598
+ expect(yield* session.context(sessionID)).toMatchObject([
599
+ { type: "user", text: "Use application context" },
600
+ {
601
+ type: "assistant",
602
+ content: [
603
+ {
604
+ type: "tool",
605
+ id: "call-application",
606
+ state: { status: "completed", structured: { answer: "HELLO" } },
607
+ },
608
+ ],
609
+ },
610
+ ])
611
+ }),
612
+ )
613
+
614
+ it.effect("starts a real runner turn after default prompt recording", () =>
615
+ Effect.gen(function* () {
616
+ yield* setup
617
+ const session = yield* SessionV2.Service
618
+ requests.length = 0
619
+ responses = undefined
620
+ streamGate = undefined
621
+ streamStarted = undefined
622
+ response = []
623
+
624
+ const message = yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Run automatically" }) })
625
+
626
+ expect(requests).toHaveLength(1)
627
+ expect(yield* session.messages({ sessionID })).toMatchObject([
628
+ { id: message.id, type: "user", text: "Run automatically" },
629
+ ])
630
+ }),
631
+ )
632
+
633
+ it.effect("streams one request with registry definitions from chronological V2 user history", () =>
634
+ Effect.gen(function* () {
635
+ yield* setup
636
+ const session = yield* SessionV2.Service
637
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
638
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
639
+
640
+ requests.length = 0
641
+ responses = undefined
642
+ streamGate = undefined
643
+ streamStarted = undefined
644
+ response = []
645
+ yield* session.resume(sessionID)
646
+
647
+ expect(requests).toHaveLength(1)
648
+ expect(requests[0]?.model).toBe(model)
649
+ expect(requests[0]?.tools.map((tool) => tool.name)).toEqual(["echo", "defect"])
650
+ expect(requests[0]?.messages.map((message) => ({ role: message.role, content: message.content }))).toEqual([
651
+ { role: "user", content: [{ type: "text", text: "First" }] },
652
+ { role: "user", content: [{ type: "text", text: "Second" }] },
653
+ ])
654
+ expect(yield* session.messages({ sessionID })).toHaveLength(2)
655
+ }),
656
+ )
657
+
658
+ it.effect("retries the first provider turn after system context becomes available", () =>
659
+ Effect.gen(function* () {
660
+ yield* setup
661
+ const session = yield* SessionV2.Service
662
+ const { db } = yield* Database.Service
663
+ const messageID = SessionMessage.ID.create()
664
+ systemUnavailable = true
665
+ yield* session.prompt({ id: messageID, sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
666
+ requests.length = 0
667
+
668
+ const exit = yield* session.resume(sessionID).pipe(Effect.exit)
669
+
670
+ expect(Exit.isFailure(exit)).toBe(true)
671
+ if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(SystemContext.InitializationBlocked)
672
+ expect(requests).toHaveLength(0)
673
+ expect(yield* SessionInput.hasPending(db, sessionID, "steer")).toBe(true)
674
+ expect(
675
+ yield* db
676
+ .select()
677
+ .from(SessionContextEpochTable)
678
+ .where(eq(SessionContextEpochTable.session_id, sessionID))
679
+ .get(),
680
+ ).toBeUndefined()
681
+
682
+ systemUnavailable = false
683
+ yield* session.prompt({ id: messageID, sessionID, prompt: Prompt.make({ text: "First" }) })
684
+
685
+ expect(requests).toHaveLength(1)
686
+ expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user"])
687
+ }),
688
+ )
689
+
690
+ it.effect("interrupts a source Location runner after a Session moves", () =>
691
+ Effect.gen(function* () {
692
+ yield* setup
693
+ const session = yield* SessionV2.Service
694
+ const events = yield* EventV2.Service
695
+ const { db } = yield* Database.Service
696
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
697
+ requests.length = 0
698
+ response = []
699
+ yield* session.resume(sessionID)
700
+
701
+ yield* events.publish(SessionEvent.Moved, {
702
+ sessionID,
703
+ timestamp: DateTime.makeUnsafe(1),
704
+ location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }),
705
+ })
706
+ expect(
707
+ yield* db
708
+ .select()
709
+ .from(SessionContextEpochTable)
710
+ .where(eq(SessionContextEpochTable.session_id, sessionID))
711
+ .get(),
712
+ ).toBeUndefined()
713
+
714
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
715
+ const exit = yield* session.resume(sessionID).pipe(Effect.exit)
716
+
717
+ expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
718
+ expect(requests).toHaveLength(1)
719
+ expect(yield* SessionInput.hasPending(db, sessionID, "steer")).toBe(true)
720
+ }),
721
+ )
722
+
723
+ it.effect("fails gracefully when a stored context snapshot cannot be decoded", () =>
724
+ Effect.gen(function* () {
725
+ yield* setup
726
+ const session = yield* SessionV2.Service
727
+ const { db } = yield* Database.Service
728
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
729
+ response = []
730
+ yield* session.resume(sessionID)
731
+ yield* db
732
+ .update(SessionContextEpochTable)
733
+ .set({ snapshot: { invalid: { value: "bad" } } })
734
+ .where(eq(SessionContextEpochTable.session_id, sessionID))
735
+ .run()
736
+ .pipe(Effect.orDie)
737
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
738
+ requests.length = 0
739
+
740
+ const exit = yield* session.resume(sessionID).pipe(Effect.exit)
741
+
742
+ expect(Exit.isFailure(exit)).toBe(true)
743
+ if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(ContextSnapshotDecodeError)
744
+ expect(requests).toHaveLength(0)
745
+ }),
746
+ )
747
+
748
+ it.effect("reuses one durable baseline after the context producer changes", () =>
749
+ Effect.gen(function* () {
750
+ yield* setup
751
+ const session = yield* SessionV2.Service
752
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
753
+
754
+ requests.length = 0
755
+ response = []
756
+ yield* session.resume(sessionID)
757
+ systemBaseline = "Changed context"
758
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
759
+ yield* session.resume(sessionID)
760
+
761
+ expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
762
+ ["Initial context"],
763
+ ["Initial context"],
764
+ ])
765
+ expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "user", "system"])
766
+ expect(requests[1]?.messages.at(-1)?.content).toEqual([{ type: "text", text: "Changed context" }])
767
+ expect(yield* session.messages({ sessionID })).toHaveLength(3)
768
+ const { db } = yield* Database.Service
769
+ expect(
770
+ yield* db
771
+ .select({ id: EventTable.id })
772
+ .from(EventTable)
773
+ .where(eq(EventTable.type, "session.next.context.updated.1"))
774
+ .all()
775
+ .pipe(Effect.orDie),
776
+ ).toHaveLength(1)
777
+ yield* replaySessionProjection(sessionID)
778
+ expect(yield* session.messages({ sessionID })).toHaveLength(3)
779
+ }),
780
+ )
781
+
782
+ it.effect("includes the effective default agent system before durable context", () =>
783
+ Effect.gen(function* () {
784
+ yield* setup
785
+ const agent = yield* AgentV2.Service
786
+ yield* agent.transform((editor) =>
787
+ editor.update(AgentV2.ID.make("build"), (agent) => {
788
+ agent.system = "Build agent instructions"
789
+ agent.mode = "primary"
790
+ }),
791
+ )
792
+ const session = yield* SessionV2.Service
793
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
794
+
795
+ requests.length = 0
796
+ response = fragmentFixture("text", "text-build", ["Done"]).completeEvents
797
+ yield* session.resume(sessionID)
798
+
799
+ expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Build agent instructions", "Initial context"])
800
+ }),
801
+ )
802
+
803
+ it.effect("uses the configured default agent system for omitted-agent sessions", () =>
804
+ Effect.gen(function* () {
805
+ yield* setup
806
+ const agent = yield* AgentV2.Service
807
+ yield* agent.transform((editor) => {
808
+ editor.update(AgentV2.ID.make("build"), (agent) => {
809
+ agent.system = "Build agent instructions"
810
+ agent.mode = "primary"
811
+ })
812
+ editor.update(AgentV2.ID.make("reviewer"), (agent) => {
813
+ agent.system = "Reviewer instructions"
814
+ agent.mode = "primary"
815
+ })
816
+ editor.default(AgentV2.ID.make("reviewer"))
817
+ })
818
+ const session = yield* SessionV2.Service
819
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
820
+
821
+ requests.length = 0
822
+ response = fragmentFixture("text", "text-reviewer", ["Done"]).completeEvents
823
+ yield* session.resume(sessionID)
824
+
825
+ expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Reviewer instructions", "Initial context"])
826
+ expect((yield* session.messages({ sessionID }))[0]).toMatchObject({ type: "assistant", agent: "reviewer" })
827
+ }),
828
+ )
829
+
830
+ it.effect("uses an explicitly selected non-build agent system", () =>
831
+ Effect.gen(function* () {
832
+ yield* setup
833
+ const { db } = yield* Database.Service
834
+ const agent = yield* AgentV2.Service
835
+ yield* agent.transform((editor) =>
836
+ editor.update(AgentV2.ID.make("reviewer"), (agent) => {
837
+ agent.system = "Reviewer instructions"
838
+ agent.mode = "primary"
839
+ }),
840
+ )
841
+ yield* db
842
+ .update(SessionTable)
843
+ .set({ agent: "reviewer" })
844
+ .where(eq(SessionTable.id, sessionID))
845
+ .run()
846
+ .pipe(Effect.orDie)
847
+ const session = yield* SessionV2.Service
848
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
849
+
850
+ requests.length = 0
851
+ response = fragmentFixture("text", "text-selected", ["Done"]).completeEvents
852
+ yield* session.resume(sessionID)
853
+
854
+ expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Reviewer instructions", "Initial context"])
855
+ expect((yield* session.messages({ sessionID }))[0]).toMatchObject({ type: "assistant", agent: "reviewer" })
856
+ }),
857
+ )
858
+
859
+ it.effect("updates selected-agent skill guidance after an agent switch", () =>
860
+ Effect.gen(function* () {
861
+ yield* setup
862
+ const session = yield* SessionV2.Service
863
+ const events = yield* EventV2.Service
864
+ skillBaselines.set(AgentV2.ID.make("build"), "Build skills")
865
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
866
+
867
+ requests.length = 0
868
+ response = []
869
+ yield* session.resume(sessionID)
870
+ skillBaselines.set(AgentV2.ID.make("reviewer"), "Reviewer skills")
871
+ yield* events.publish(SessionEvent.AgentSwitched, {
872
+ sessionID,
873
+ messageID: SessionMessage.ID.create(),
874
+ timestamp: DateTime.makeUnsafe(1),
875
+ agent: "reviewer",
876
+ })
877
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
878
+ yield* session.resume(sessionID)
879
+
880
+ expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
881
+ ["Initial context\n\nBuild skills"],
882
+ ["Initial context\n\nBuild skills"],
883
+ ])
884
+ expect(systemTexts(requests[1]!)).toContainEqual(expect.stringContaining("Reviewer skills"))
885
+ }),
886
+ )
887
+
888
+ it.effect("keeps the sampled agent when selection changes during observation", () =>
889
+ Effect.gen(function* () {
890
+ yield* setup
891
+ const session = yield* SessionV2.Service
892
+ const events = yield* EventV2.Service
893
+ skillBaselines.set(AgentV2.ID.make("build"), "Build skills")
894
+ skillBaselines.set(AgentV2.ID.make("reviewer"), "Reviewer skills")
895
+ let switched = false
896
+ systemLoadHook = Effect.suspend(() => {
897
+ if (switched) return Effect.void
898
+ switched = true
899
+ return events
900
+ .publish(SessionEvent.AgentSwitched, {
901
+ sessionID,
902
+ messageID: SessionMessage.ID.create(),
903
+ timestamp: DateTime.makeUnsafe(1),
904
+ agent: "reviewer",
905
+ })
906
+ .pipe(Effect.asVoid)
907
+ })
908
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
909
+
910
+ requests.length = 0
911
+ response = []
912
+ yield* session.resume(sessionID)
913
+
914
+ expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
915
+ ["Initial context\n\nBuild skills"],
916
+ ])
917
+ }),
918
+ )
919
+
920
+ it.effect("keeps the sampled model when selection changes during model resolution", () =>
921
+ Effect.gen(function* () {
922
+ yield* setup
923
+ const session = yield* SessionV2.Service
924
+ const events = yield* EventV2.Service
925
+ let switched = false
926
+ modelResolveHook = Effect.suspend(() => {
927
+ if (switched) return Effect.void
928
+ switched = true
929
+ return events
930
+ .publish(SessionEvent.ModelSwitched, {
931
+ sessionID,
932
+ messageID: SessionMessage.ID.create(),
933
+ timestamp: DateTime.makeUnsafe(1),
934
+ model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
935
+ })
936
+ .pipe(Effect.asVoid)
937
+ })
938
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
939
+
940
+ requests.length = 0
941
+ response = []
942
+ yield* session.resume(sessionID)
943
+ expect(requests.map((request) => request.model)).toEqual([model])
944
+ expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([["Initial context"]])
945
+ }),
946
+ )
947
+
948
+ it.effect("admits removed context as a chronological System message", () =>
949
+ Effect.gen(function* () {
950
+ yield* setup
951
+ const session = yield* SessionV2.Service
952
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
953
+
954
+ requests.length = 0
955
+ response = []
956
+ yield* session.resume(sessionID)
957
+ systemRemoved = true
958
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
959
+ yield* session.resume(sessionID)
960
+
961
+ expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "user", "system"])
962
+ expect(requests[1]?.messages.at(-1)?.content).toEqual([
963
+ { type: "text", text: "System context source removed: test/context" },
964
+ ])
965
+ expect(yield* session.messages({ sessionID })).toHaveLength(3)
966
+ }),
967
+ )
968
+
969
+ it.effect("keeps the baseline and chronological System updates after a model switch", () =>
970
+ Effect.gen(function* () {
971
+ yield* setup
972
+ const session = yield* SessionV2.Service
973
+ const events = yield* EventV2.Service
974
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
975
+
976
+ requests.length = 0
977
+ response = []
978
+ yield* session.resume(sessionID)
979
+ systemBaseline = "Changed context"
980
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
981
+ yield* session.resume(sessionID)
982
+ yield* events.publish(SessionEvent.ModelSwitched, {
983
+ sessionID,
984
+ messageID: SessionMessage.ID.create(),
985
+ timestamp: DateTime.makeUnsafe(1),
986
+ model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
987
+ })
988
+ systemBaseline = "Replacement context"
989
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Third" }), resume: false })
990
+ yield* session.resume(sessionID)
991
+
992
+ expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
993
+ ["Initial context"],
994
+ ["Initial context"],
995
+ ["Initial context"],
996
+ ])
997
+ expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "user", "system"])
998
+ expect(requests[2]?.messages.filter((message) => message.role === "system")).toHaveLength(2)
999
+ expect((yield* session.context(sessionID)).map((message) => message.type)).toEqual([
1000
+ "user",
1001
+ "user",
1002
+ "system",
1003
+ "model-switched",
1004
+ "user",
1005
+ "system",
1006
+ ])
1007
+ yield* replaySessionProjection(sessionID)
1008
+ expect(yield* session.messages({ sessionID })).toHaveLength(6)
1009
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fourth" }), resume: false })
1010
+ yield* session.resume(sessionID)
1011
+ }),
1012
+ )
1013
+
1014
+ it.effect("preserves the baseline while context is temporarily unavailable", () =>
1015
+ Effect.gen(function* () {
1016
+ yield* setup
1017
+ const session = yield* SessionV2.Service
1018
+ const events = yield* EventV2.Service
1019
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
1020
+
1021
+ requests.length = 0
1022
+ response = []
1023
+ yield* session.resume(sessionID)
1024
+ yield* events.publish(SessionEvent.ModelSwitched, {
1025
+ sessionID,
1026
+ messageID: SessionMessage.ID.create(),
1027
+ timestamp: DateTime.makeUnsafe(1),
1028
+ model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
1029
+ })
1030
+ systemUnavailable = true
1031
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
1032
+ yield* session.resume(sessionID)
1033
+ systemUnavailable = false
1034
+ systemBaseline = "Replacement context"
1035
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Third" }), resume: false })
1036
+ yield* session.resume(sessionID)
1037
+
1038
+ expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
1039
+ ["Initial context"],
1040
+ ["Initial context"],
1041
+ ["Initial context"],
1042
+ ])
1043
+ }),
1044
+ )
1045
+
1046
+ it.effect("rebuilds the baseline directly after completed compaction", () =>
1047
+ Effect.gen(function* () {
1048
+ yield* setup
1049
+ const session = yield* SessionV2.Service
1050
+ const events = yield* EventV2.Service
1051
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
1052
+
1053
+ requests.length = 0
1054
+ response = []
1055
+ yield* session.resume(sessionID)
1056
+ const compactionID = SessionMessage.ID.create()
1057
+ yield* events.publish(SessionEvent.Compaction.Started, {
1058
+ sessionID,
1059
+ messageID: compactionID,
1060
+ timestamp: DateTime.makeUnsafe(1),
1061
+ reason: "manual",
1062
+ })
1063
+ yield* events.publish(SessionEvent.Compaction.Ended, {
1064
+ sessionID,
1065
+ messageID: compactionID,
1066
+ timestamp: DateTime.makeUnsafe(2),
1067
+ reason: "manual",
1068
+ text: "summary",
1069
+ recent: "",
1070
+ })
1071
+ systemBaseline = "Replacement context"
1072
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
1073
+ yield* session.resume(sessionID)
1074
+
1075
+ expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
1076
+ ["Initial context"],
1077
+ ["Replacement context"],
1078
+ ])
1079
+ yield* replaySessionProjection(sessionID)
1080
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Third" }), resume: false })
1081
+ yield* session.resume(sessionID)
1082
+ }),
1083
+ )
1084
+
1085
+ it.effect("automatically compacts into a completed summary and retained recent turn", () =>
1086
+ Effect.gen(function* () {
1087
+ yield* setup
1088
+ const session = yield* SessionV2.Service
1089
+ response = fragmentFixture("text", "text-first", ["Earlier answer"]).completeEvents
1090
+ yield* session.prompt({
1091
+ sessionID,
1092
+ prompt: Prompt.make({ text: "Earlier question ".repeat(180) }),
1093
+ resume: false,
1094
+ })
1095
+ yield* session.resume(sessionID)
1096
+
1097
+ currentModel = compactModel
1098
+ requests.length = 0
1099
+ responses = [
1100
+ fragmentFixture("text", "text-summary", ["## Objective\n- Preserve the task"]).completeEvents,
1101
+ fragmentFixture("text", "text-final", ["Continued"]).completeEvents,
1102
+ ]
1103
+ yield* session.prompt({
1104
+ sessionID,
1105
+ prompt: Prompt.make({ text: "Recent exact request ".repeat(180) }),
1106
+ resume: false,
1107
+ })
1108
+ yield* session.resume(sessionID)
1109
+
1110
+ expect(requests).toHaveLength(2)
1111
+ expect(userTexts(requests[0])[0]).toContain("## Objective")
1112
+ expect(userTexts(requests[1])).toHaveLength(1)
1113
+ expect(userTexts(requests[1])[0]).toContain("<summary>\n## Objective\n- Preserve the task\n</summary>")
1114
+ expect(userTexts(requests[1])[0]).toContain(`[User]: ${"Recent exact request ".repeat(180)}`)
1115
+
1116
+ const context = yield* (yield* SessionStore.Service).context(sessionID)
1117
+ expect(context.map((message) => message.type)).toEqual(["compaction", "assistant"])
1118
+ expect(context[0]).toMatchObject({
1119
+ type: "compaction",
1120
+ summary: "## Objective\n- Preserve the task",
1121
+ })
1122
+
1123
+ requests.length = 0
1124
+ executions.length = 0
1125
+ responses = [
1126
+ fragmentFixture("text", "text-summary-2", ["## Objective\n- Preserve the updated task"]).completeEvents,
1127
+ fragmentFixture("text", "text-final-2", ["Continued again"]).completeEvents,
1128
+ ]
1129
+ yield* session.prompt({
1130
+ sessionID,
1131
+ prompt: Prompt.make({ text: "Newest exact request ".repeat(180) }),
1132
+ resume: false,
1133
+ })
1134
+ yield* session.resume(sessionID)
1135
+
1136
+ expect(requests).toHaveLength(2)
1137
+ expect(userTexts(requests[0])[0]).toContain(
1138
+ "<previous-summary>\n## Objective\n- Preserve the task\n</previous-summary>",
1139
+ )
1140
+ expect(userTexts(requests[0])[0]).toContain("Recent exact request")
1141
+ expect((yield* (yield* SessionStore.Service).context(sessionID))[0]).toMatchObject({
1142
+ type: "compaction",
1143
+ summary: "## Objective\n- Preserve the updated task",
1144
+ })
1145
+ }),
1146
+ )
1147
+
1148
+ it.effect("forces one compaction and retries after provider context overflow", () =>
1149
+ Effect.gen(function* () {
1150
+ const session = yield* setupOverflowRecovery
1151
+ responses = [
1152
+ [
1153
+ LLMEvent.stepStart({ index: 0 }),
1154
+ LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }),
1155
+ ],
1156
+ fragmentFixture("text", "text-summary", ["## Objective\n- Recover overflow"]).completeEvents,
1157
+ fragmentFixture("text", "text-final", ["Recovered"]).completeEvents,
1158
+ ]
1159
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false })
1160
+ yield* session.resume(sessionID)
1161
+
1162
+ expect(requests).toHaveLength(3)
1163
+ expect(userTexts(requests[1])[0]).toContain("## Objective")
1164
+ expect(userTexts(requests[2])[0]).toContain("<summary>\n## Objective\n- Recover overflow\n</summary>")
1165
+ expect(yield* session.context(sessionID)).toMatchObject([
1166
+ { type: "compaction", summary: "## Objective\n- Recover overflow" },
1167
+ { type: "assistant", finish: "stop" },
1168
+ ])
1169
+ yield* replaySessionProjection(sessionID)
1170
+ expect(yield* session.context(sessionID)).toMatchObject([
1171
+ { type: "compaction" },
1172
+ { type: "assistant", finish: "stop" },
1173
+ ])
1174
+ }),
1175
+ )
1176
+
1177
+ it.effect("persists a second context overflow after one recovery", () =>
1178
+ Effect.gen(function* () {
1179
+ const session = yield* setupOverflowRecovery
1180
+ const overflow = () => [
1181
+ LLMEvent.stepStart({ index: 0 }),
1182
+ LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }),
1183
+ ]
1184
+ responses = [
1185
+ overflow(),
1186
+ fragmentFixture("text", "text-summary", ["## Objective\n- Recover once"]).completeEvents,
1187
+ overflow(),
1188
+ ]
1189
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false })
1190
+ yield* session.resume(sessionID)
1191
+
1192
+ expect(requests).toHaveLength(3)
1193
+ expect(yield* session.context(sessionID)).toMatchObject([
1194
+ { type: "compaction" },
1195
+ { type: "assistant", finish: "error", error: { message: "prompt too long" } },
1196
+ ])
1197
+ }),
1198
+ )
1199
+
1200
+ it.effect("recovers once from a raw context overflow failure", () =>
1201
+ Effect.gen(function* () {
1202
+ const session = yield* setupOverflowRecovery
1203
+ responseStream = Stream.fail(
1204
+ new LLMError({
1205
+ module: "test",
1206
+ method: "stream",
1207
+ reason: new InvalidRequestReason({
1208
+ message: "prompt too long",
1209
+ classification: "context-overflow",
1210
+ }),
1211
+ }),
1212
+ )
1213
+ responses = [
1214
+ fragmentFixture("text", "text-summary", ["## Objective\n- Recover raw overflow"]).completeEvents,
1215
+ fragmentFixture("text", "text-final", ["Recovered"]).completeEvents,
1216
+ ]
1217
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false })
1218
+ yield* session.resume(sessionID)
1219
+
1220
+ expect(requests).toHaveLength(3)
1221
+ expect(yield* session.context(sessionID)).toMatchObject([
1222
+ { type: "compaction", summary: "## Objective\n- Recover raw overflow" },
1223
+ { type: "assistant", finish: "stop" },
1224
+ ])
1225
+ }),
1226
+ )
1227
+
1228
+ it.effect("publishes the original overflow when recovery summarization fails", () =>
1229
+ Effect.gen(function* () {
1230
+ const session = yield* setupOverflowRecovery
1231
+ responses = [
1232
+ [LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
1233
+ [LLMEvent.providerError({ message: "summary unavailable" })],
1234
+ ]
1235
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false })
1236
+ yield* session.resume(sessionID)
1237
+
1238
+ expect(requests).toHaveLength(2)
1239
+ const context = yield* session.context(sessionID)
1240
+ expect(context.some((message) => message.type === "compaction")).toBe(false)
1241
+ expect(context.slice(-2)).toMatchObject([
1242
+ { type: "user", text: "Continue" },
1243
+ { type: "assistant", finish: "error", error: { message: "prompt too long" } },
1244
+ ])
1245
+ }),
1246
+ )
1247
+
1248
+ it.effect("interrupts overflow recovery while the summary provider is running", () =>
1249
+ Effect.gen(function* () {
1250
+ const session = yield* setupOverflowRecovery
1251
+ responses = [
1252
+ [LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
1253
+ fragmentFixture("text", "text-summary", ["## Objective\n- Interrupted"]).completeEvents,
1254
+ ]
1255
+ const firstGate = yield* Deferred.make<void>()
1256
+ const summaryGate = yield* Deferred.make<void>()
1257
+ streamGate = firstGate
1258
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false })
1259
+ const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
1260
+ while (requests.length < 1) yield* Effect.yieldNow
1261
+ streamGate = summaryGate
1262
+ yield* Deferred.succeed(firstGate, undefined)
1263
+ while (requests.length < 2) yield* Effect.yieldNow
1264
+
1265
+ yield* session.interrupt(sessionID)
1266
+ expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
1267
+ streamGate = undefined
1268
+ expect(requests).toHaveLength(2)
1269
+ expect((yield* session.context(sessionID)).some((message) => message.type === "compaction")).toBe(false)
1270
+ }),
1271
+ )
1272
+
1273
+ it.effect("preserves effective System updates while compaction rebaseline is blocked", () =>
1274
+ Effect.gen(function* () {
1275
+ yield* setup
1276
+ const session = yield* SessionV2.Service
1277
+ const events = yield* EventV2.Service
1278
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
1279
+
1280
+ requests.length = 0
1281
+ response = []
1282
+ yield* session.resume(sessionID)
1283
+ systemBaseline = "Changed context"
1284
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
1285
+ yield* session.resume(sessionID)
1286
+ const compactionID = SessionMessage.ID.create()
1287
+ yield* events.publish(SessionEvent.Compaction.Started, {
1288
+ sessionID,
1289
+ messageID: compactionID,
1290
+ timestamp: DateTime.makeUnsafe(1),
1291
+ reason: "manual",
1292
+ })
1293
+ yield* events.publish(SessionEvent.Compaction.Ended, {
1294
+ sessionID,
1295
+ messageID: compactionID,
1296
+ timestamp: DateTime.makeUnsafe(2),
1297
+ reason: "manual",
1298
+ text: "summary",
1299
+ recent: "",
1300
+ })
1301
+ systemUnavailable = true
1302
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Third" }), resume: false })
1303
+ yield* session.resume(sessionID)
1304
+
1305
+ expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Initial context"])
1306
+ expect(systemTexts(requests.at(-1)!)).toContain("Changed context")
1307
+ }),
1308
+ )
1309
+
1310
+ it.effect("projects reasoning and tool events without executing or continuing tools", () =>
1311
+ Effect.gen(function* () {
1312
+ yield* setup
1313
+ const session = yield* SessionV2.Service
1314
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Use tools" }), resume: false })
1315
+
1316
+ requests.length = 0
1317
+ responses = undefined
1318
+ streamGate = undefined
1319
+ streamStarted = undefined
1320
+ response = [
1321
+ LLMEvent.stepStart({ index: 0 }),
1322
+ LLMEvent.reasoningStart({ id: "reasoning-1" }),
1323
+ LLMEvent.reasoningDelta({ id: "reasoning-1", text: "Think" }),
1324
+ LLMEvent.reasoningEnd({ id: "reasoning-1" }),
1325
+ LLMEvent.toolInputStart({ id: "call-error", name: "write" }),
1326
+ LLMEvent.toolInputDelta({ id: "call-error", name: "write", text: '{"path":"README.md"}' }),
1327
+ LLMEvent.toolInputEnd({ id: "call-error", name: "write" }),
1328
+ LLMEvent.toolCall({ id: "call-error", name: "write", input: { path: "README.md" }, providerExecuted: true }),
1329
+ LLMEvent.toolError({ id: "call-error", name: "write", message: "Denied" }),
1330
+ LLMEvent.toolResult({ id: "call-error", name: "write", result: { type: "error", value: "Denied" } }),
1331
+ LLMEvent.toolCall({
1332
+ id: "call-provider",
1333
+ name: "web_search",
1334
+ input: { query: "hello" },
1335
+ providerExecuted: true,
1336
+ providerMetadata: { fake: { source: "provider" } },
1337
+ }),
1338
+ LLMEvent.toolResult({
1339
+ id: "call-provider",
1340
+ name: "web_search",
1341
+ result: {
1342
+ type: "content",
1343
+ value: [
1344
+ { type: "text", text: "Hello" },
1345
+ { type: "file", uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" },
1346
+ ],
1347
+ },
1348
+ providerExecuted: true,
1349
+ providerMetadata: { fake: { source: "provider" } },
1350
+ }),
1351
+ LLMEvent.stepFinish({
1352
+ index: 0,
1353
+ reason: "tool-calls",
1354
+ usage: {
1355
+ inputTokens: 10,
1356
+ nonCachedInputTokens: 8,
1357
+ outputTokens: 4,
1358
+ reasoningTokens: 1,
1359
+ cacheReadInputTokens: 2,
1360
+ },
1361
+ }),
1362
+ LLMEvent.finish({ reason: "tool-calls" }),
1363
+ ]
1364
+
1365
+ yield* session.resume(sessionID)
1366
+
1367
+ expect(requests).toHaveLength(1)
1368
+ expect(requests[0]?.tools.map((tool) => tool.name)).toEqual(["echo", "defect"])
1369
+ expect(yield* session.context(sessionID)).toMatchObject([
1370
+ { type: "user", text: "Use tools" },
1371
+ {
1372
+ type: "assistant",
1373
+ finish: "tool-calls",
1374
+ tokens: { input: 8, output: 3, reasoning: 1, cache: { read: 2, write: 0 } },
1375
+ content: [
1376
+ { type: "reasoning", id: "reasoning-1", text: "Think" },
1377
+ {
1378
+ type: "tool",
1379
+ id: "call-error",
1380
+ name: "write",
1381
+ state: {
1382
+ status: "error",
1383
+ input: { path: "README.md" },
1384
+ error: { type: "unknown", message: "Denied" },
1385
+ },
1386
+ },
1387
+ {
1388
+ type: "tool",
1389
+ id: "call-provider",
1390
+ name: "web_search",
1391
+ provider: { executed: true, metadata: { fake: { source: "provider" } } },
1392
+ state: {
1393
+ status: "completed",
1394
+ input: { query: "hello" },
1395
+ structured: {},
1396
+ content: [
1397
+ { type: "text", text: "Hello" },
1398
+ { type: "file", mime: "image/png", uri: "data:image/png;base64,aGVsbG8=", name: "hello.png" },
1399
+ ],
1400
+ },
1401
+ },
1402
+ ],
1403
+ },
1404
+ ])
1405
+ }),
1406
+ )
1407
+
1408
+ it.effect("continues with reloaded history after durably settling one local tool call", () =>
1409
+ Effect.gen(function* () {
1410
+ yield* setup
1411
+ const session = yield* SessionV2.Service
1412
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Echo this" }), resume: false })
1413
+
1414
+ requests.length = 0
1415
+ authorizations.length = 0
1416
+ executions.length = 0
1417
+ streamGate = undefined
1418
+ streamStarted = undefined
1419
+ responses = [
1420
+ [
1421
+ LLMEvent.stepStart({ index: 0 }),
1422
+ LLMEvent.toolCall({ id: "call-echo", name: "echo", input: { text: "hello" } }),
1423
+ LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
1424
+ LLMEvent.finish({ reason: "tool-calls" }),
1425
+ ],
1426
+ [
1427
+ LLMEvent.stepStart({ index: 0 }),
1428
+ LLMEvent.textStart({ id: "text-final" }),
1429
+ LLMEvent.textDelta({ id: "text-final", text: "Done" }),
1430
+ LLMEvent.textEnd({ id: "text-final" }),
1431
+ LLMEvent.stepFinish({ index: 0, reason: "stop" }),
1432
+ LLMEvent.finish({ reason: "stop" }),
1433
+ ],
1434
+ ]
1435
+
1436
+ yield* session.resume(sessionID)
1437
+
1438
+ expect(requests).toHaveLength(2)
1439
+ expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"])
1440
+ expect(authorizations).toMatchObject([{ sessionID, toolCallID: "call-echo" }])
1441
+ expect(executions).toEqual(["hello"])
1442
+ expect(yield* session.context(sessionID)).toMatchObject([
1443
+ { type: "user", text: "Echo this" },
1444
+ {
1445
+ type: "assistant",
1446
+ finish: "tool-calls",
1447
+ content: [
1448
+ {
1449
+ type: "tool",
1450
+ id: "call-echo",
1451
+ name: "echo",
1452
+ state: {
1453
+ status: "completed",
1454
+ input: { text: "hello" },
1455
+ structured: { text: "hello" },
1456
+ content: [{ type: "text", text: "hello" }],
1457
+ },
1458
+ },
1459
+ ],
1460
+ },
1461
+ { type: "assistant", finish: "stop", content: [{ type: "text", id: "text-final", text: "Done" }] },
1462
+ ])
1463
+ }),
1464
+ )
1465
+
1466
+ it.effect("reloads a model switch before a tool-driven continuation turn", () =>
1467
+ Effect.gen(function* () {
1468
+ yield* setup
1469
+ const session = yield* SessionV2.Service
1470
+ const events = yield* EventV2.Service
1471
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Echo this" }), resume: false })
1472
+
1473
+ requests.length = 0
1474
+ responses = [
1475
+ [
1476
+ LLMEvent.stepStart({ index: 0 }),
1477
+ LLMEvent.toolCall({ id: "call-echo", name: "echo", input: { text: "hello" } }),
1478
+ LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
1479
+ LLMEvent.finish({ reason: "tool-calls" }),
1480
+ ],
1481
+ [
1482
+ LLMEvent.stepStart({ index: 0 }),
1483
+ LLMEvent.stepFinish({ index: 0, reason: "stop" }),
1484
+ LLMEvent.finish({ reason: "stop" }),
1485
+ ],
1486
+ ]
1487
+ toolExecutionGate = yield* Deferred.make<void>()
1488
+ toolExecutionsStarted = yield* Deferred.make<void>()
1489
+ toolExecutionsReady = 1
1490
+ const run = yield* Effect.forkChild(session.resume(sessionID))
1491
+ yield* Deferred.await(toolExecutionsStarted)
1492
+ yield* events.publish(SessionEvent.ModelSwitched, {
1493
+ sessionID,
1494
+ messageID: SessionMessage.ID.create(),
1495
+ timestamp: DateTime.makeUnsafe(1),
1496
+ model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
1497
+ })
1498
+ systemBaseline = "Replacement context"
1499
+ yield* Deferred.succeed(toolExecutionGate, undefined)
1500
+ yield* Fiber.join(run)
1501
+
1502
+ expect(requests.map((request) => request.model)).toEqual([model, replacementModel])
1503
+ expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
1504
+ ["Initial context"],
1505
+ ["Initial context"],
1506
+ ])
1507
+ expect(systemTexts(requests[1]!)).toContain("Replacement context")
1508
+ }),
1509
+ )
1510
+
1511
+ it.effect("restores durable reasoning provider metadata in a second-turn request", () =>
1512
+ Effect.gen(function* () {
1513
+ yield* setup
1514
+ const session = yield* SessionV2.Service
1515
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Think first" }), resume: false })
1516
+
1517
+ requests.length = 0
1518
+ response = [
1519
+ LLMEvent.stepStart({ index: 0 }),
1520
+ LLMEvent.reasoningStart({ id: "reasoning-anthropic" }),
1521
+ LLMEvent.reasoningDelta({ id: "reasoning-anthropic", text: "Signed thought" }),
1522
+ LLMEvent.reasoningEnd({ id: "reasoning-anthropic", providerMetadata: { anthropic: { signature: "sig_1" } } }),
1523
+ LLMEvent.reasoningStart({
1524
+ id: "reasoning-openai",
1525
+ providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
1526
+ }),
1527
+ LLMEvent.reasoningDelta({ id: "reasoning-openai", text: "Encrypted thought" }),
1528
+ LLMEvent.reasoningEnd({
1529
+ id: "reasoning-openai",
1530
+ providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
1531
+ }),
1532
+ LLMEvent.stepFinish({ index: 0, reason: "stop" }),
1533
+ LLMEvent.finish({ reason: "stop" }),
1534
+ ]
1535
+ yield* session.resume(sessionID)
1536
+ yield* replaySessionProjection(sessionID)
1537
+
1538
+ expect(yield* session.context(sessionID)).toMatchObject([
1539
+ { type: "user", text: "Think first" },
1540
+ {
1541
+ type: "assistant",
1542
+ content: [
1543
+ { type: "reasoning", text: "Signed thought", providerMetadata: { anthropic: { signature: "sig_1" } } },
1544
+ {
1545
+ type: "reasoning",
1546
+ text: "Encrypted thought",
1547
+ providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
1548
+ },
1549
+ ],
1550
+ },
1551
+ ])
1552
+
1553
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false })
1554
+ response = []
1555
+ yield* session.resume(sessionID)
1556
+
1557
+ expect(requests[1]?.messages[1]?.content).toEqual([
1558
+ { type: "reasoning", text: "Signed thought", providerMetadata: { anthropic: { signature: "sig_1" } } },
1559
+ {
1560
+ type: "reasoning",
1561
+ text: "Encrypted thought",
1562
+ providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
1563
+ },
1564
+ ])
1565
+ }),
1566
+ )
1567
+
1568
+ it.effect("replays durable provider-executed tool results inline in a second-turn request", () =>
1569
+ Effect.gen(function* () {
1570
+ yield* setup
1571
+ const session = yield* SessionV2.Service
1572
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Search first" }), resume: false })
1573
+
1574
+ requests.length = 0
1575
+ response = [
1576
+ LLMEvent.stepStart({ index: 0 }),
1577
+ LLMEvent.toolCall({
1578
+ id: "hosted-search",
1579
+ name: "web_search",
1580
+ input: { query: "Effect" },
1581
+ providerExecuted: true,
1582
+ providerMetadata: { openai: { itemId: "hosted-search" } },
1583
+ }),
1584
+ LLMEvent.toolResult({
1585
+ id: "hosted-search",
1586
+ name: "web_search",
1587
+ result: { type: "json", value: [{ title: "Effect" }] },
1588
+ providerExecuted: true,
1589
+ providerMetadata: { anthropic: { blockType: "web_search_tool_result" } },
1590
+ }),
1591
+ LLMEvent.stepFinish({ index: 0, reason: "stop" }),
1592
+ LLMEvent.finish({ reason: "stop" }),
1593
+ ]
1594
+ yield* session.resume(sessionID)
1595
+ yield* replaySessionProjection(sessionID)
1596
+
1597
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false })
1598
+ response = []
1599
+ yield* session.resume(sessionID)
1600
+
1601
+ expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "user"])
1602
+ expect(requests[1]?.messages[1]?.content).toMatchObject([
1603
+ {
1604
+ type: "tool-call",
1605
+ id: "hosted-search",
1606
+ name: "web_search",
1607
+ input: { query: "Effect" },
1608
+ providerExecuted: true,
1609
+ providerMetadata: { openai: { itemId: "hosted-search" } },
1610
+ },
1611
+ {
1612
+ type: "tool-result",
1613
+ id: "hosted-search",
1614
+ name: "web_search",
1615
+ result: { type: "json", value: [{ title: "Effect" }] },
1616
+ providerExecuted: true,
1617
+ providerMetadata: { anthropic: { blockType: "web_search_tool_result" } },
1618
+ },
1619
+ ])
1620
+ }),
1621
+ )
1622
+
1623
+ it.effect("starts recorded local tools eagerly and awaits settlement before continuing", () =>
1624
+ Effect.gen(function* () {
1625
+ yield* setup
1626
+ const session = yield* SessionV2.Service
1627
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Echo five times" }), resume: false })
1628
+
1629
+ requests.length = 0
1630
+ executions.length = 0
1631
+ toolExecutionGate = yield* Deferred.make<void>()
1632
+ toolExecutionsStarted = yield* Deferred.make<void>()
1633
+ const providerGate = yield* Deferred.make<void>()
1634
+ response = []
1635
+ responses = undefined
1636
+ const initial = Stream.fromIterable([
1637
+ LLMEvent.stepStart({ index: 0 }),
1638
+ ...Array.from({ length: 5 }, (_, index) =>
1639
+ LLMEvent.toolCall({ id: `call-echo-${index}`, name: "echo", input: { text: `${index}` } }),
1640
+ ),
1641
+ ])
1642
+ const final = Stream.fromIterable([
1643
+ LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
1644
+ LLMEvent.finish({ reason: "tool-calls" }),
1645
+ ])
1646
+ streamGate = undefined
1647
+ responseStream = Stream.concat(
1648
+ initial,
1649
+ Stream.fromEffect(Deferred.await(providerGate)).pipe(Stream.flatMap(() => final)),
1650
+ )
1651
+
1652
+ const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
1653
+ yield* Deferred.await(toolExecutionsStarted)
1654
+
1655
+ expect(executions).toHaveLength(5)
1656
+ expect(maxActiveToolExecutions).toBe(5)
1657
+ expect(yield* session.context(sessionID)).toMatchObject([
1658
+ { type: "user", text: "Echo five times" },
1659
+ {
1660
+ type: "assistant",
1661
+ content: Array.from({ length: 5 }, (_, index) => ({
1662
+ type: "tool",
1663
+ id: `call-echo-${index}`,
1664
+ state: { status: "running", input: { text: `${index}` } },
1665
+ })),
1666
+ },
1667
+ ])
1668
+
1669
+ yield* Deferred.succeed(providerGate, undefined)
1670
+ yield* Effect.yieldNow
1671
+ expect(requests).toHaveLength(1)
1672
+
1673
+ yield* Deferred.succeed(toolExecutionGate, undefined)
1674
+ yield* Fiber.join(run)
1675
+ toolExecutionGate = undefined
1676
+ toolExecutionsStarted = undefined
1677
+
1678
+ expect(executions).toHaveLength(5)
1679
+ expect(maxActiveToolExecutions).toBe(5)
1680
+ expect(requests).toHaveLength(2)
1681
+ }),
1682
+ )
1683
+
1684
+ it.effect("settles repeated provider-local tool call IDs against their owning assistant messages", () =>
1685
+ Effect.gen(function* () {
1686
+ yield* setup
1687
+ const session = yield* SessionV2.Service
1688
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Echo twice" }), resume: false })
1689
+
1690
+ requests.length = 0
1691
+ executions.length = 0
1692
+ responses = [
1693
+ [
1694
+ LLMEvent.stepStart({ index: 0 }),
1695
+ LLMEvent.toolCall({ id: "tool_0", name: "echo", input: { text: "first" } }),
1696
+ LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
1697
+ LLMEvent.finish({ reason: "tool-calls" }),
1698
+ ],
1699
+ [
1700
+ LLMEvent.stepStart({ index: 0 }),
1701
+ LLMEvent.toolCall({ id: "tool_0", name: "echo", input: { text: "second" } }),
1702
+ LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
1703
+ LLMEvent.finish({ reason: "tool-calls" }),
1704
+ ],
1705
+ [],
1706
+ ]
1707
+
1708
+ yield* session.resume(sessionID)
1709
+
1710
+ expect(executions).toEqual(["first", "second"])
1711
+ expect(requests).toHaveLength(3)
1712
+ expect(yield* session.context(sessionID)).toMatchObject([
1713
+ { type: "user", text: "Echo twice" },
1714
+ {
1715
+ type: "assistant",
1716
+ content: [
1717
+ {
1718
+ type: "tool",
1719
+ id: "tool_0",
1720
+ state: { status: "completed", structured: { text: "first" }, content: [{ type: "text", text: "first" }] },
1721
+ },
1722
+ ],
1723
+ },
1724
+ {
1725
+ type: "assistant",
1726
+ content: [
1727
+ {
1728
+ type: "tool",
1729
+ id: "tool_0",
1730
+ state: {
1731
+ status: "completed",
1732
+ structured: { text: "second" },
1733
+ content: [{ type: "text", text: "second" }],
1734
+ },
1735
+ },
1736
+ ],
1737
+ },
1738
+ ])
1739
+
1740
+ yield* replaySessionProjection(sessionID)
1741
+
1742
+ expect(yield* session.context(sessionID)).toMatchObject([
1743
+ { type: "user", text: "Echo twice" },
1744
+ {
1745
+ type: "assistant",
1746
+ content: [
1747
+ {
1748
+ type: "tool",
1749
+ id: "tool_0",
1750
+ state: { status: "completed", structured: { text: "first" }, content: [{ type: "text", text: "first" }] },
1751
+ },
1752
+ ],
1753
+ },
1754
+ {
1755
+ type: "assistant",
1756
+ content: [
1757
+ {
1758
+ type: "tool",
1759
+ id: "tool_0",
1760
+ state: {
1761
+ status: "completed",
1762
+ structured: { text: "second" },
1763
+ content: [{ type: "text", text: "second" }],
1764
+ },
1765
+ },
1766
+ ],
1767
+ },
1768
+ ])
1769
+ }),
1770
+ )
1771
+
1772
+ it.effect("joins concurrent resume calls into one active provider run", () =>
1773
+ Effect.gen(function* () {
1774
+ yield* setup
1775
+ const session = yield* SessionV2.Service
1776
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Run once" }), resume: false })
1777
+
1778
+ requests.length = 0
1779
+ responses = undefined
1780
+ response = [
1781
+ LLMEvent.stepStart({ index: 0 }),
1782
+ LLMEvent.textStart({ id: "text-once" }),
1783
+ LLMEvent.textDelta({ id: "text-once", text: "Once" }),
1784
+ LLMEvent.textEnd({ id: "text-once" }),
1785
+ LLMEvent.stepFinish({ index: 0, reason: "stop" }),
1786
+ LLMEvent.finish({ reason: "stop" }),
1787
+ ]
1788
+ streamGate = yield* Deferred.make<void>()
1789
+ streamStarted = yield* Deferred.make<void>()
1790
+
1791
+ const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
1792
+ yield* Deferred.await(streamStarted)
1793
+ const second = yield* session.resume(sessionID).pipe(Effect.forkChild)
1794
+ yield* Effect.yieldNow
1795
+
1796
+ expect(requests).toHaveLength(1)
1797
+ yield* Deferred.succeed(streamGate, undefined)
1798
+ yield* Fiber.join(first)
1799
+ yield* Fiber.join(second)
1800
+ streamGate = undefined
1801
+ streamStarted = undefined
1802
+
1803
+ expect(requests).toHaveLength(1)
1804
+ expect(yield* session.context(sessionID)).toMatchObject([
1805
+ { type: "user", text: "Run once" },
1806
+ { type: "assistant", finish: "stop", content: [{ type: "text", id: "text-once", text: "Once" }] },
1807
+ ])
1808
+ }),
1809
+ )
1810
+
1811
+ it.effect("steers an active provider turn with newly recorded prompts", () =>
1812
+ Effect.gen(function* () {
1813
+ yield* setup
1814
+ const session = yield* SessionV2.Service
1815
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start working" }), resume: false })
1816
+
1817
+ requests.length = 0
1818
+ responses = [
1819
+ [
1820
+ LLMEvent.stepStart({ index: 0 }),
1821
+ LLMEvent.stepFinish({ index: 0, reason: "stop" }),
1822
+ LLMEvent.finish({ reason: "stop" }),
1823
+ ],
1824
+ [
1825
+ LLMEvent.stepStart({ index: 0 }),
1826
+ LLMEvent.stepFinish({ index: 0, reason: "stop" }),
1827
+ LLMEvent.finish({ reason: "stop" }),
1828
+ ],
1829
+ ]
1830
+ streamGate = yield* Deferred.make<void>()
1831
+ streamStarted = yield* Deferred.make<void>()
1832
+
1833
+ const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
1834
+ yield* Deferred.await(streamStarted)
1835
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Change direction" }) })
1836
+ yield* Deferred.succeed(streamGate, undefined)
1837
+ yield* Fiber.join(first)
1838
+ streamGate = undefined
1839
+ streamStarted = undefined
1840
+ yield* Effect.yieldNow
1841
+
1842
+ expect(requests).toHaveLength(2)
1843
+ expect(userTexts(requests[0]!)).toEqual(["Start working"])
1844
+ expect(userTexts(requests[1]!)).toEqual(["Start working", "Change direction"])
1845
+ expect((yield* session.context(sessionID)).map((message) => message.type)).toEqual([
1846
+ "user",
1847
+ "assistant",
1848
+ "user",
1849
+ "assistant",
1850
+ ])
1851
+ }),
1852
+ )
1853
+
1854
+ it.effect("promotes queued input after continuation ends", () =>
1855
+ Effect.gen(function* () {
1856
+ yield* setup
1857
+ const session = yield* SessionV2.Service
1858
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start working" }), resume: false })
1859
+
1860
+ requests.length = 0
1861
+ responses = [
1862
+ [
1863
+ LLMEvent.stepStart({ index: 0 }),
1864
+ LLMEvent.toolCall({ id: "call-echo", name: "echo", input: { text: "hello" } }),
1865
+ LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
1866
+ LLMEvent.finish({ reason: "tool-calls" }),
1867
+ ],
1868
+ [
1869
+ LLMEvent.stepStart({ index: 0 }),
1870
+ LLMEvent.stepFinish({ index: 0, reason: "stop" }),
1871
+ LLMEvent.finish({ reason: "stop" }),
1872
+ ],
1873
+ [
1874
+ LLMEvent.stepStart({ index: 0 }),
1875
+ LLMEvent.stepFinish({ index: 0, reason: "stop" }),
1876
+ LLMEvent.finish({ reason: "stop" }),
1877
+ ],
1878
+ ]
1879
+ streamGate = yield* Deferred.make<void>()
1880
+ streamStarted = yield* Deferred.make<void>()
1881
+
1882
+ const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
1883
+ yield* Deferred.await(streamStarted)
1884
+ yield* session.prompt({
1885
+ sessionID,
1886
+ prompt: Prompt.make({ text: "Wait until continuation ends" }),
1887
+ delivery: "queue",
1888
+ })
1889
+ yield* Deferred.succeed(streamGate, undefined)
1890
+ yield* Fiber.join(first)
1891
+ streamGate = undefined
1892
+ streamStarted = undefined
1893
+
1894
+ expect(requests).toHaveLength(3)
1895
+ expect(userTexts(requests[0]!)).toEqual(["Start working"])
1896
+ expect(userTexts(requests[1]!)).toEqual(["Start working"])
1897
+ expect(userTexts(requests[2]!)).toEqual(["Start working", "Wait until continuation ends"])
1898
+ }),
1899
+ )
1900
+
1901
+ it.effect("preserves durable queued input for a later wake after interruption", () =>
1902
+ Effect.gen(function* () {
1903
+ yield* setup
1904
+ const session = yield* SessionV2.Service
1905
+ const { db } = yield* Database.Service
1906
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Interrupt current work" }), resume: false })
1907
+
1908
+ requests.length = 0
1909
+ responses = [
1910
+ [],
1911
+ [
1912
+ LLMEvent.stepStart({ index: 0 }),
1913
+ LLMEvent.stepFinish({ index: 0, reason: "stop" }),
1914
+ LLMEvent.finish({ reason: "stop" }),
1915
+ ],
1916
+ ]
1917
+ streamGate = yield* Deferred.make<void>()
1918
+ streamStarted = yield* Deferred.make<void>()
1919
+
1920
+ const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
1921
+ yield* Deferred.await(streamStarted)
1922
+ yield* session.prompt({
1923
+ sessionID,
1924
+ prompt: Prompt.make({ text: "Run after interrupt" }),
1925
+ delivery: "queue",
1926
+ })
1927
+ yield* session.interrupt(sessionID)
1928
+ expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
1929
+ expect(requests).toHaveLength(1)
1930
+ expect(yield* SessionInput.hasPending(db, sessionID, "queue")).toBe(true)
1931
+ const resumed = yield* session.resume(sessionID).pipe(Effect.forkChild)
1932
+ while (requests.length < 2) yield* Effect.yieldNow
1933
+ yield* Deferred.succeed(streamGate, undefined)
1934
+ yield* Fiber.join(resumed)
1935
+ streamGate = undefined
1936
+ streamStarted = undefined
1937
+
1938
+ expect(requests).toHaveLength(2)
1939
+ expect(userTexts(requests[0]!)).toEqual(["Interrupt current work"])
1940
+ expect(userTexts(requests[1]!)).toEqual(["Interrupt current work", "Run after interrupt"])
1941
+ }),
1942
+ )
1943
+
1944
+ it.effect("preserves durable steering input for a later resume after interruption", () =>
1945
+ Effect.gen(function* () {
1946
+ yield* setup
1947
+ const session = yield* SessionV2.Service
1948
+ const { db } = yield* Database.Service
1949
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Interrupt current work" }), resume: false })
1950
+
1951
+ requests.length = 0
1952
+ responses = [
1953
+ [],
1954
+ [
1955
+ LLMEvent.stepStart({ index: 0 }),
1956
+ LLMEvent.stepFinish({ index: 0, reason: "stop" }),
1957
+ LLMEvent.finish({ reason: "stop" }),
1958
+ ],
1959
+ ]
1960
+ streamGate = yield* Deferred.make<void>()
1961
+ streamStarted = yield* Deferred.make<void>()
1962
+
1963
+ const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
1964
+ yield* Deferred.await(streamStarted)
1965
+ yield* session.prompt({
1966
+ sessionID,
1967
+ prompt: Prompt.make({ text: "Steer after interrupt" }),
1968
+ })
1969
+ yield* session.interrupt(sessionID)
1970
+ expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
1971
+ expect(requests).toHaveLength(1)
1972
+ expect(yield* SessionInput.hasPending(db, sessionID, "steer")).toBe(true)
1973
+
1974
+ const resumed = yield* session.resume(sessionID).pipe(Effect.forkChild)
1975
+ while (requests.length < 2) yield* Effect.yieldNow
1976
+ yield* Deferred.succeed(streamGate, undefined)
1977
+ yield* Fiber.join(resumed)
1978
+ streamGate = undefined
1979
+ streamStarted = undefined
1980
+
1981
+ expect(requests).toHaveLength(2)
1982
+ expect(userTexts(requests[0]!)).toEqual(["Interrupt current work"])
1983
+ expect(userTexts(requests[1]!)).toEqual(["Interrupt current work", "Steer after interrupt"])
1984
+ }),
1985
+ )
1986
+
1987
+ it.effect("promotes queued inputs one at a time in FIFO order", () =>
1988
+ Effect.gen(function* () {
1989
+ yield* setup
1990
+ const session = yield* SessionV2.Service
1991
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start working" }), resume: false })
1992
+
1993
+ requests.length = 0
1994
+ responses = [
1995
+ [
1996
+ LLMEvent.stepStart({ index: 0 }),
1997
+ LLMEvent.stepFinish({ index: 0, reason: "stop" }),
1998
+ LLMEvent.finish({ reason: "stop" }),
1999
+ ],
2000
+ [
2001
+ LLMEvent.stepStart({ index: 0 }),
2002
+ LLMEvent.stepFinish({ index: 0, reason: "stop" }),
2003
+ LLMEvent.finish({ reason: "stop" }),
2004
+ ],
2005
+ [
2006
+ LLMEvent.stepStart({ index: 0 }),
2007
+ LLMEvent.stepFinish({ index: 0, reason: "stop" }),
2008
+ LLMEvent.finish({ reason: "stop" }),
2009
+ ],
2010
+ ]
2011
+ streamGate = yield* Deferred.make<void>()
2012
+ streamStarted = yield* Deferred.make<void>()
2013
+
2014
+ const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
2015
+ yield* Deferred.await(streamStarted)
2016
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Queue first" }), delivery: "queue" })
2017
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Queue second" }), delivery: "queue" })
2018
+ yield* Deferred.succeed(streamGate, undefined)
2019
+ yield* Fiber.join(first)
2020
+ streamGate = undefined
2021
+ streamStarted = undefined
2022
+
2023
+ expect(requests).toHaveLength(3)
2024
+ expect(userTexts(requests[0]!)).toEqual(["Start working"])
2025
+ expect(userTexts(requests[1]!)).toEqual(["Start working", "Queue first"])
2026
+ expect(userTexts(requests[2]!)).toEqual(["Start working", "Queue first", "Queue second"])
2027
+ }),
2028
+ )
2029
+
2030
+ it.effect("promotes queued input after steering continuation ends", () =>
2031
+ Effect.gen(function* () {
2032
+ yield* setup
2033
+ const session = yield* SessionV2.Service
2034
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start steering" }), resume: false })
2035
+ yield* session.prompt({
2036
+ sessionID,
2037
+ prompt: Prompt.make({ text: "Queue for later" }),
2038
+ delivery: "queue",
2039
+ resume: false,
2040
+ })
2041
+
2042
+ requests.length = 0
2043
+ responses = [
2044
+ [
2045
+ LLMEvent.stepStart({ index: 0 }),
2046
+ LLMEvent.stepFinish({ index: 0, reason: "stop" }),
2047
+ LLMEvent.finish({ reason: "stop" }),
2048
+ ],
2049
+ [
2050
+ LLMEvent.stepStart({ index: 0 }),
2051
+ LLMEvent.stepFinish({ index: 0, reason: "stop" }),
2052
+ LLMEvent.finish({ reason: "stop" }),
2053
+ ],
2054
+ ]
2055
+
2056
+ yield* session.resume(sessionID)
2057
+
2058
+ expect(requests).toHaveLength(2)
2059
+ expect(userTexts(requests[0]!)).toEqual(["Start steering"])
2060
+ expect(userTexts(requests[1]!)).toEqual(["Start steering", "Queue for later"])
2061
+ }),
2062
+ )
2063
+
2064
+ it.effect("promotes steers before the next queued input", () =>
2065
+ Effect.gen(function* () {
2066
+ yield* setup
2067
+ const session = yield* SessionV2.Service
2068
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start working" }), resume: false })
2069
+
2070
+ requests.length = 0
2071
+ responses = [
2072
+ [
2073
+ LLMEvent.stepStart({ index: 0 }),
2074
+ LLMEvent.stepFinish({ index: 0, reason: "stop" }),
2075
+ LLMEvent.finish({ reason: "stop" }),
2076
+ ],
2077
+ [
2078
+ LLMEvent.stepStart({ index: 0 }),
2079
+ LLMEvent.stepFinish({ index: 0, reason: "stop" }),
2080
+ LLMEvent.finish({ reason: "stop" }),
2081
+ ],
2082
+ [
2083
+ LLMEvent.stepStart({ index: 0 }),
2084
+ LLMEvent.stepFinish({ index: 0, reason: "stop" }),
2085
+ LLMEvent.finish({ reason: "stop" }),
2086
+ ],
2087
+ [
2088
+ LLMEvent.stepStart({ index: 0 }),
2089
+ LLMEvent.stepFinish({ index: 0, reason: "stop" }),
2090
+ LLMEvent.finish({ reason: "stop" }),
2091
+ ],
2092
+ ]
2093
+ const firstGate = yield* Deferred.make<void>()
2094
+ const secondGate = yield* Deferred.make<void>()
2095
+ streamGate = firstGate
2096
+
2097
+ const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
2098
+ while (requests.length < 1) yield* Effect.yieldNow
2099
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Queue first" }), delivery: "queue" })
2100
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Queue second" }), delivery: "queue" })
2101
+ streamGate = secondGate
2102
+ yield* Deferred.succeed(firstGate, undefined)
2103
+ while (requests.length < 2) yield* Effect.yieldNow
2104
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Steer before next queued input" }) })
2105
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Also steer before next queued input" }) })
2106
+ yield* Deferred.succeed(secondGate, undefined)
2107
+ yield* Fiber.join(first)
2108
+ streamGate = undefined
2109
+
2110
+ expect(requests).toHaveLength(4)
2111
+ expect(userTexts(requests[0]!)).toEqual(["Start working"])
2112
+ expect(userTexts(requests[1]!)).toEqual(["Start working", "Queue first"])
2113
+ expect(userTexts(requests[2]!)).toEqual([
2114
+ "Start working",
2115
+ "Queue first",
2116
+ "Steer before next queued input",
2117
+ "Also steer before next queued input",
2118
+ ])
2119
+ expect(userTexts(requests[3]!)).toEqual([
2120
+ "Start working",
2121
+ "Queue first",
2122
+ "Steer before next queued input",
2123
+ "Also steer before next queued input",
2124
+ "Queue second",
2125
+ ])
2126
+ }),
2127
+ )
2128
+
2129
+ it.effect("coalesces multiple active steering prompts into one continuation turn", () =>
2130
+ Effect.gen(function* () {
2131
+ yield* setup
2132
+ const session = yield* SessionV2.Service
2133
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start working" }), resume: false })
2134
+
2135
+ requests.length = 0
2136
+ responses = [
2137
+ [
2138
+ LLMEvent.stepStart({ index: 0 }),
2139
+ LLMEvent.stepFinish({ index: 0, reason: "stop" }),
2140
+ LLMEvent.finish({ reason: "stop" }),
2141
+ ],
2142
+ [
2143
+ LLMEvent.stepStart({ index: 0 }),
2144
+ LLMEvent.stepFinish({ index: 0, reason: "stop" }),
2145
+ LLMEvent.finish({ reason: "stop" }),
2146
+ ],
2147
+ ]
2148
+ streamGate = yield* Deferred.make<void>()
2149
+ streamStarted = yield* Deferred.make<void>()
2150
+
2151
+ const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
2152
+ yield* Deferred.await(streamStarted)
2153
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First steer" }) })
2154
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second steer" }) })
2155
+ yield* Deferred.succeed(streamGate, undefined)
2156
+ yield* Fiber.join(first)
2157
+ streamGate = undefined
2158
+ streamStarted = undefined
2159
+ yield* Effect.yieldNow
2160
+
2161
+ expect(requests).toHaveLength(2)
2162
+ expect(userTexts(requests[1]!)).toEqual(["Start working", "First steer", "Second steer"])
2163
+ yield* (yield* SessionExecution.Service).wake(sessionID)
2164
+ yield* Effect.yieldNow
2165
+ expect(requests).toHaveLength(2)
2166
+ }),
2167
+ )
2168
+
2169
+ it.effect("runs steering input accepted while the active provider turn fails", () =>
2170
+ Effect.gen(function* () {
2171
+ yield* setup
2172
+ const session = yield* SessionV2.Service
2173
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start working" }), resume: false })
2174
+
2175
+ requests.length = 0
2176
+ responses = undefined
2177
+ response = []
2178
+ streamFailure = providerUnavailable()
2179
+ streamGate = yield* Deferred.make<void>()
2180
+ streamStarted = yield* Deferred.make<void>()
2181
+
2182
+ const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
2183
+ yield* Deferred.await(streamStarted)
2184
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Recover with this" }) })
2185
+ yield* Deferred.succeed(streamGate, undefined)
2186
+ expect(yield* Fiber.join(first).pipe(Effect.flip)).toBe(streamFailure)
2187
+
2188
+ streamFailure = undefined
2189
+ streamGate = undefined
2190
+ streamStarted = undefined
2191
+ yield* Effect.yieldNow
2192
+
2193
+ expect(requests).toHaveLength(2)
2194
+ expect(userTexts(requests[1]!)).toEqual(["Start working", "Recover with this"])
2195
+ }),
2196
+ )
2197
+
2198
+ it.effect("durably fails local tools left running by a prior process before continuing", () =>
2199
+ Effect.gen(function* () {
2200
+ yield* setup
2201
+ const session = yield* SessionV2.Service
2202
+ const events = yield* EventV2.Service
2203
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Recover interrupted tool" }), resume: false })
2204
+ yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID, Number.MAX_SAFE_INTEGER)
2205
+ const assistantMessageID = SessionMessage.ID.create()
2206
+ yield* events.publish(SessionEvent.Step.Started, {
2207
+ sessionID,
2208
+ assistantMessageID,
2209
+ timestamp: yield* DateTime.now,
2210
+ agent: "build",
2211
+ model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") },
2212
+ })
2213
+ yield* events.publish(SessionEvent.Tool.Input.Started, {
2214
+ sessionID,
2215
+ timestamp: yield* DateTime.now,
2216
+ assistantMessageID,
2217
+ callID: "call-interrupted",
2218
+ name: "echo",
2219
+ })
2220
+ yield* events.publish(SessionEvent.Tool.Input.Ended, {
2221
+ sessionID,
2222
+ timestamp: yield* DateTime.now,
2223
+ assistantMessageID,
2224
+ callID: "call-interrupted",
2225
+ text: '{"text":"stale"}',
2226
+ })
2227
+ yield* events.publish(SessionEvent.Tool.Called, {
2228
+ sessionID,
2229
+ timestamp: yield* DateTime.now,
2230
+ assistantMessageID,
2231
+ callID: "call-interrupted",
2232
+ tool: "echo",
2233
+ input: { text: "stale" },
2234
+ provider: { executed: false },
2235
+ })
2236
+ requests.length = 0
2237
+ response = []
2238
+ yield* session.resume(sessionID)
2239
+
2240
+ expect(requests).toHaveLength(1)
2241
+ expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"])
2242
+ expect(yield* session.context(sessionID)).toMatchObject([
2243
+ { type: "user", text: "Recover interrupted tool" },
2244
+ {
2245
+ type: "assistant",
2246
+ content: [
2247
+ {
2248
+ type: "tool",
2249
+ id: "call-interrupted",
2250
+ state: { status: "error", error: { type: "unknown", message: "Tool execution interrupted" } },
2251
+ },
2252
+ ],
2253
+ },
2254
+ ])
2255
+ }),
2256
+ )
2257
+
2258
+ it.effect("durably fails hosted tools left running by a prior process before continuing inline", () =>
2259
+ Effect.gen(function* () {
2260
+ yield* setup
2261
+ const session = yield* SessionV2.Service
2262
+ const events = yield* EventV2.Service
2263
+ yield* session.prompt({
2264
+ sessionID,
2265
+ prompt: Prompt.make({ text: "Recover interrupted hosted tool" }),
2266
+ resume: false,
2267
+ })
2268
+ yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID, Number.MAX_SAFE_INTEGER)
2269
+ const assistantMessageID = SessionMessage.ID.create()
2270
+ yield* events.publish(SessionEvent.Step.Started, {
2271
+ sessionID,
2272
+ assistantMessageID,
2273
+ timestamp: yield* DateTime.now,
2274
+ agent: "build",
2275
+ model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") },
2276
+ })
2277
+ yield* events.publish(SessionEvent.Tool.Input.Started, {
2278
+ sessionID,
2279
+ timestamp: yield* DateTime.now,
2280
+ assistantMessageID,
2281
+ callID: "call-hosted-interrupted",
2282
+ name: "web_search",
2283
+ })
2284
+ yield* events.publish(SessionEvent.Tool.Input.Ended, {
2285
+ sessionID,
2286
+ timestamp: yield* DateTime.now,
2287
+ assistantMessageID,
2288
+ callID: "call-hosted-interrupted",
2289
+ text: '{"query":"stale"}',
2290
+ })
2291
+ yield* events.publish(SessionEvent.Tool.Called, {
2292
+ sessionID,
2293
+ timestamp: yield* DateTime.now,
2294
+ assistantMessageID,
2295
+ callID: "call-hosted-interrupted",
2296
+ tool: "web_search",
2297
+ input: { query: "stale" },
2298
+ provider: { executed: true, metadata: { openai: { itemId: "call-hosted-interrupted" } } },
2299
+ })
2300
+ requests.length = 0
2301
+ response = []
2302
+ yield* session.resume(sessionID)
2303
+
2304
+ expect(requests).toHaveLength(1)
2305
+ expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user", "assistant"])
2306
+ expect(requests[0]?.messages[1]?.content).toMatchObject([
2307
+ {
2308
+ type: "tool-call",
2309
+ id: "call-hosted-interrupted",
2310
+ providerExecuted: true,
2311
+ providerMetadata: { openai: { itemId: "call-hosted-interrupted" } },
2312
+ },
2313
+ { type: "tool-result", id: "call-hosted-interrupted", providerExecuted: true, result: { type: "error" } },
2314
+ ])
2315
+ }),
2316
+ )
2317
+
2318
+ it.effect("durably fails pending tool input left by a prior process before continuing", () =>
2319
+ Effect.gen(function* () {
2320
+ yield* setup
2321
+ const session = yield* SessionV2.Service
2322
+ const events = yield* EventV2.Service
2323
+ yield* session.prompt({
2324
+ sessionID,
2325
+ prompt: Prompt.make({ text: "Recover interrupted tool input" }),
2326
+ resume: false,
2327
+ })
2328
+ yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID, Number.MAX_SAFE_INTEGER)
2329
+ const assistantMessageID = SessionMessage.ID.create()
2330
+ yield* events.publish(SessionEvent.Step.Started, {
2331
+ sessionID,
2332
+ assistantMessageID,
2333
+ timestamp: yield* DateTime.now,
2334
+ agent: "build",
2335
+ model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") },
2336
+ })
2337
+ yield* events.publish(SessionEvent.Tool.Input.Started, {
2338
+ sessionID,
2339
+ timestamp: yield* DateTime.now,
2340
+ assistantMessageID,
2341
+ callID: "call-pending-interrupted",
2342
+ name: "echo",
2343
+ })
2344
+ requests.length = 0
2345
+ response = []
2346
+ yield* session.resume(sessionID)
2347
+
2348
+ expect(requests).toHaveLength(1)
2349
+ expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"])
2350
+ expect(yield* session.context(sessionID)).toMatchObject([
2351
+ { type: "user", text: "Recover interrupted tool input" },
2352
+ { type: "assistant", content: [{ type: "tool", id: "call-pending-interrupted", state: { status: "error" } }] },
2353
+ ])
2354
+ }),
2355
+ )
2356
+
2357
+ it.effect("promotes the first queued input when woken while idle", () =>
2358
+ Effect.gen(function* () {
2359
+ yield* setup
2360
+ const session = yield* SessionV2.Service
2361
+ yield* session.prompt({
2362
+ sessionID,
2363
+ prompt: Prompt.make({ text: "Wait in queue" }),
2364
+ delivery: "queue",
2365
+ resume: false,
2366
+ })
2367
+
2368
+ requests.length = 0
2369
+ yield* (yield* SessionExecution.Service).wake(sessionID)
2370
+ yield* Effect.yieldNow
2371
+
2372
+ expect(requests).toHaveLength(1)
2373
+ expect(userTexts(requests[0]!)).toEqual(["Wait in queue"])
2374
+ }),
2375
+ )
2376
+
2377
+ it.effect("retries inbox input after prompt projection rolls back", () =>
2378
+ Effect.gen(function* () {
2379
+ yield* setup
2380
+ const session = yield* SessionV2.Service
2381
+ const events = yield* EventV2.Service
2382
+ const defect = new Error("fail after prompt promotion")
2383
+ let fail = true
2384
+ yield* events.project(SessionEvent.Prompted, () => (fail ? Effect.die(defect) : Effect.void))
2385
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Recover promoted input" }), resume: false })
2386
+
2387
+ expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect)
2388
+ fail = false
2389
+ requests.length = 0
2390
+ response = [
2391
+ LLMEvent.stepStart({ index: 0 }),
2392
+ LLMEvent.stepFinish({ index: 0, reason: "stop" }),
2393
+ LLMEvent.finish({ reason: "stop" }),
2394
+ ]
2395
+
2396
+ yield* (yield* SessionExecution.Service).wake(sessionID)
2397
+ while (requests.length === 0) yield* Effect.yieldNow
2398
+
2399
+ expect(userTexts(requests[0]!)).toEqual(["Recover promoted input"])
2400
+ }),
2401
+ )
2402
+
2403
+ it.effect("does not strand a committed promotion when a post-commit listener defects", () =>
2404
+ Effect.gen(function* () {
2405
+ yield* setup
2406
+ const session = yield* SessionV2.Service
2407
+ const events = yield* EventV2.Service
2408
+ yield* events.listen((event) =>
2409
+ event.type === SessionEvent.Prompted.type ? Effect.die("fail after prompt promotion commits") : Effect.void,
2410
+ )
2411
+ yield* session.prompt({
2412
+ sessionID,
2413
+ prompt: Prompt.make({ text: "Run committed promotion" }),
2414
+ resume: false,
2415
+ })
2416
+
2417
+ requests.length = 0
2418
+ yield* session.resume(sessionID)
2419
+
2420
+ expect(requests).toHaveLength(1)
2421
+ expect(userTexts(requests[0]!)).toEqual(["Run committed promotion"])
2422
+ }),
2423
+ )
2424
+
2425
+ it.effect("runs different sessions concurrently", () =>
2426
+ Effect.gen(function* () {
2427
+ yield* setup
2428
+ yield* insertSession(otherSessionID)
2429
+ const session = yield* SessionV2.Service
2430
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Run first" }), resume: false })
2431
+ yield* session.prompt({ sessionID: otherSessionID, prompt: Prompt.make({ text: "Run second" }), resume: false })
2432
+
2433
+ requests.length = 0
2434
+ responses = undefined
2435
+ response = []
2436
+ streamGate = yield* Deferred.make<void>()
2437
+ streamStarted = yield* Deferred.make<void>()
2438
+
2439
+ const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
2440
+ yield* Deferred.await(streamStarted)
2441
+ const second = yield* session.resume(otherSessionID).pipe(Effect.forkChild)
2442
+ yield* Effect.yieldNow
2443
+
2444
+ expect(requests).toHaveLength(2)
2445
+ expect(requests.map((request) => request.providerOptions?.openai?.promptCacheKey)).toEqual([
2446
+ sessionID,
2447
+ otherSessionID,
2448
+ ])
2449
+ yield* Deferred.succeed(streamGate, undefined)
2450
+ yield* Fiber.join(first)
2451
+ yield* Fiber.join(second)
2452
+ streamGate = undefined
2453
+ streamStarted = undefined
2454
+ }),
2455
+ )
2456
+
2457
+ it.effect("bounds 64-character session prompt cache keys", () =>
2458
+ Effect.gen(function* () {
2459
+ yield* setup
2460
+ const longSessionID = SessionV2.ID.make(`ses_${"a".repeat(64)}`)
2461
+ const otherLongSessionID = SessionV2.ID.make(`ses_${"b".repeat(64)}`)
2462
+ yield* insertSession(longSessionID)
2463
+ yield* insertSession(otherLongSessionID)
2464
+ const session = yield* SessionV2.Service
2465
+ yield* session.prompt({
2466
+ sessionID: longSessionID,
2467
+ prompt: Prompt.make({ text: "Run long session" }),
2468
+ resume: false,
2469
+ })
2470
+ yield* session.prompt({
2471
+ sessionID: otherLongSessionID,
2472
+ prompt: Prompt.make({ text: "Run other long session" }),
2473
+ resume: false,
2474
+ })
2475
+
2476
+ requests.length = 0
2477
+ yield* session.resume(longSessionID)
2478
+ yield* session.resume(otherLongSessionID)
2479
+
2480
+ const keys = requests.map((request) => request.providerOptions?.openai?.promptCacheKey)
2481
+ expect(keys).toEqual([longSessionID.slice(4), otherLongSessionID.slice(4)])
2482
+ expect(keys.every((key) => typeof key === "string" && key.length === 64)).toBe(true)
2483
+ expect(keys[0]).not.toBe(keys[1])
2484
+ }),
2485
+ )
2486
+
2487
+ it.effect("fans out one failed run and allows a later retry", () =>
2488
+ Effect.gen(function* () {
2489
+ yield* setup
2490
+ const session = yield* SessionV2.Service
2491
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Retry after failure" }), resume: false })
2492
+
2493
+ requests.length = 0
2494
+ responses = undefined
2495
+ response = []
2496
+ streamFailure = providerUnavailable()
2497
+ streamGate = yield* Deferred.make<void>()
2498
+ streamStarted = yield* Deferred.make<void>()
2499
+
2500
+ const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
2501
+ yield* Deferred.await(streamStarted)
2502
+ const second = yield* session.resume(sessionID).pipe(Effect.forkChild)
2503
+ yield* Effect.yieldNow
2504
+
2505
+ expect(requests).toHaveLength(1)
2506
+ yield* Deferred.succeed(streamGate, undefined)
2507
+ const [firstExit, secondExit] = yield* Effect.all([Fiber.await(first), Fiber.await(second)])
2508
+ expect(secondExit).toEqual(firstExit)
2509
+
2510
+ streamFailure = undefined
2511
+ streamGate = undefined
2512
+ streamStarted = undefined
2513
+ yield* session.resume(sessionID)
2514
+ expect(requests).toHaveLength(2)
2515
+ }),
2516
+ )
2517
+
2518
+ it.effect("durably settles local tool failures before continuing", () =>
2519
+ Effect.gen(function* () {
2520
+ yield* setup
2521
+ const session = yield* SessionV2.Service
2522
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Call missing" }), resume: false })
2523
+
2524
+ requests.length = 0
2525
+ responses = [
2526
+ [
2527
+ LLMEvent.stepStart({ index: 0 }),
2528
+ LLMEvent.toolCall({ id: "call-missing", name: "missing", input: {} }),
2529
+ LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
2530
+ LLMEvent.finish({ reason: "tool-calls" }),
2531
+ ],
2532
+ [
2533
+ LLMEvent.stepStart({ index: 0 }),
2534
+ LLMEvent.textStart({ id: "text-after-error" }),
2535
+ LLMEvent.textDelta({ id: "text-after-error", text: "Recovered" }),
2536
+ LLMEvent.textEnd({ id: "text-after-error" }),
2537
+ LLMEvent.stepFinish({ index: 0, reason: "stop" }),
2538
+ LLMEvent.finish({ reason: "stop" }),
2539
+ ],
2540
+ ]
2541
+ streamGate = undefined
2542
+ streamStarted = undefined
2543
+
2544
+ yield* session.resume(sessionID)
2545
+
2546
+ expect(requests).toHaveLength(2)
2547
+ expect(yield* session.context(sessionID)).toMatchObject([
2548
+ { type: "user", text: "Call missing" },
2549
+ {
2550
+ type: "assistant",
2551
+ content: [
2552
+ {
2553
+ type: "tool",
2554
+ id: "call-missing",
2555
+ state: { status: "error", error: { message: "Unknown tool: missing" } },
2556
+ },
2557
+ ],
2558
+ },
2559
+ { type: "assistant", finish: "stop", content: [{ type: "text", id: "text-after-error", text: "Recovered" }] },
2560
+ ])
2561
+ }),
2562
+ )
2563
+
2564
+ it.effect("returns unexpected local tool defects to the model and continues", () =>
2565
+ Effect.gen(function* () {
2566
+ yield* setup
2567
+ const session = yield* SessionV2.Service
2568
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Call defect" }), resume: false })
2569
+
2570
+ requests.length = 0
2571
+ responses = [
2572
+ [
2573
+ LLMEvent.stepStart({ index: 0 }),
2574
+ LLMEvent.toolCall({ id: "call-defect", name: "defect", input: {} }),
2575
+ LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
2576
+ LLMEvent.finish({ reason: "tool-calls" }),
2577
+ ],
2578
+ [
2579
+ LLMEvent.stepStart({ index: 0 }),
2580
+ LLMEvent.textStart({ id: "text-after-defect" }),
2581
+ LLMEvent.textDelta({ id: "text-after-defect", text: "Recovered" }),
2582
+ LLMEvent.textEnd({ id: "text-after-defect" }),
2583
+ LLMEvent.stepFinish({ index: 0, reason: "stop" }),
2584
+ LLMEvent.finish({ reason: "stop" }),
2585
+ ],
2586
+ ]
2587
+
2588
+ yield* session.resume(sessionID)
2589
+
2590
+ expect(requests).toHaveLength(2)
2591
+ expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"])
2592
+ expect(yield* session.context(sessionID)).toMatchObject([
2593
+ { type: "user", text: "Call defect" },
2594
+ {
2595
+ type: "assistant",
2596
+ content: [
2597
+ {
2598
+ type: "tool",
2599
+ id: "call-defect",
2600
+ state: {
2601
+ status: "error",
2602
+ error: { type: "unknown", message: "Tool execution failed: unexpected tool defect" },
2603
+ },
2604
+ },
2605
+ ],
2606
+ },
2607
+ { type: "assistant", finish: "stop", content: [{ type: "text", text: "Recovered" }] },
2608
+ ])
2609
+ }),
2610
+ )
2611
+
2612
+ it.effect("returns policy-blocked tools to the model and continues", () =>
2613
+ Effect.gen(function* () {
2614
+ yield* setup
2615
+ const session = yield* SessionV2.Service
2616
+ const registry = yield* ToolRegistry.Service
2617
+ yield* registry.register({
2618
+ blocked: Tool.make({
2619
+ description: "Fail because policy blocked execution",
2620
+ input: Schema.Struct({}),
2621
+ output: Schema.Struct({}),
2622
+ execute: () =>
2623
+ Effect.fail(new PermissionV2.BlockedError({ rules: [] })).pipe(
2624
+ Effect.mapError(() => new Tool.Failure({ message: "Permission blocked" })),
2625
+ ),
2626
+ }),
2627
+ })
2628
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Call blocked" }), resume: false })
2629
+
2630
+ requests.length = 0
2631
+ responses = [
2632
+ [
2633
+ LLMEvent.stepStart({ index: 0 }),
2634
+ LLMEvent.toolCall({ id: "call-blocked", name: "blocked", input: {} }),
2635
+ LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
2636
+ LLMEvent.finish({ reason: "tool-calls" }),
2637
+ ],
2638
+ [
2639
+ LLMEvent.stepStart({ index: 0 }),
2640
+ LLMEvent.stepFinish({ index: 0, reason: "stop" }),
2641
+ LLMEvent.finish({ reason: "stop" }),
2642
+ ],
2643
+ ]
2644
+
2645
+ yield* session.resume(sessionID)
2646
+
2647
+ expect(requests).toHaveLength(2)
2648
+ expect(yield* session.context(sessionID)).toMatchObject([
2649
+ { type: "user", text: "Call blocked" },
2650
+ {
2651
+ type: "assistant",
2652
+ content: [
2653
+ { type: "tool", id: "call-blocked", state: { status: "error", error: { message: "Permission blocked" } } },
2654
+ ],
2655
+ },
2656
+ { type: "assistant", finish: "stop" },
2657
+ ])
2658
+ }),
2659
+ )
2660
+
2661
+ it.effect("interrupts runner continuation when permission approval is declined", () =>
2662
+ Effect.gen(function* () {
2663
+ yield* setup
2664
+ const session = yield* SessionV2.Service
2665
+ const registry = yield* ToolRegistry.Service
2666
+ yield* registry.register({
2667
+ declined: Tool.make({
2668
+ description: "Fail because the user declined approval",
2669
+ input: Schema.Struct({}),
2670
+ output: Schema.Struct({}),
2671
+ execute: () => Effect.die(new PermissionV2.DeclinedError()),
2672
+ }),
2673
+ })
2674
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Call declined" }), resume: false })
2675
+
2676
+ requests.length = 0
2677
+ response = [
2678
+ LLMEvent.stepStart({ index: 0 }),
2679
+ LLMEvent.toolCall({ id: "call-declined", name: "declined", input: {} }),
2680
+ LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
2681
+ LLMEvent.finish({ reason: "tool-calls" }),
2682
+ ]
2683
+
2684
+ const exit = yield* session.resume(sessionID).pipe(Effect.exit)
2685
+
2686
+ expect(exit._tag).toBe("Failure")
2687
+ if (exit._tag === "Failure") expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true)
2688
+ expect(requests).toHaveLength(1)
2689
+ expect(yield* session.context(sessionID)).toMatchObject([
2690
+ { type: "user", text: "Call declined" },
2691
+ {
2692
+ type: "assistant",
2693
+ content: [
2694
+ {
2695
+ type: "tool",
2696
+ id: "call-declined",
2697
+ state: { status: "error", error: { message: "Tool execution interrupted" } },
2698
+ },
2699
+ ],
2700
+ },
2701
+ ])
2702
+ }),
2703
+ )
2704
+
2705
+ it.effect("returns permission corrections to the model and continues", () =>
2706
+ Effect.gen(function* () {
2707
+ yield* setup
2708
+ const session = yield* SessionV2.Service
2709
+ const registry = yield* ToolRegistry.Service
2710
+ yield* registry.register({
2711
+ corrected: Tool.make({
2712
+ description: "Fail with user correction feedback",
2713
+ input: Schema.Struct({}),
2714
+ output: Schema.Struct({}),
2715
+ execute: () =>
2716
+ Effect.fail(new PermissionV2.CorrectedError({ feedback: "Use another tool" })).pipe(
2717
+ Effect.mapError(() => new Tool.Failure({ message: "Use another tool" })),
2718
+ ),
2719
+ }),
2720
+ })
2721
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Call corrected" }), resume: false })
2722
+
2723
+ requests.length = 0
2724
+ responses = [
2725
+ [
2726
+ LLMEvent.stepStart({ index: 0 }),
2727
+ LLMEvent.toolCall({ id: "call-corrected", name: "corrected", input: {} }),
2728
+ LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
2729
+ LLMEvent.finish({ reason: "tool-calls" }),
2730
+ ],
2731
+ [
2732
+ LLMEvent.stepStart({ index: 0 }),
2733
+ LLMEvent.stepFinish({ index: 0, reason: "stop" }),
2734
+ LLMEvent.finish({ reason: "stop" }),
2735
+ ],
2736
+ ]
2737
+
2738
+ yield* session.resume(sessionID)
2739
+
2740
+ expect(requests).toHaveLength(2)
2741
+ expect(yield* session.context(sessionID)).toMatchObject([
2742
+ { type: "user", text: "Call corrected" },
2743
+ {
2744
+ type: "assistant",
2745
+ content: [
2746
+ { type: "tool", id: "call-corrected", state: { status: "error", error: { message: "Use another tool" } } },
2747
+ ],
2748
+ },
2749
+ { type: "assistant", finish: "stop" },
2750
+ ])
2751
+ }),
2752
+ )
2753
+
2754
+ it.effect("interrupts runner continuation when a question is dismissed", () =>
2755
+ Effect.gen(function* () {
2756
+ yield* setup
2757
+ const session = yield* SessionV2.Service
2758
+ const registry = yield* ToolRegistry.Service
2759
+ const questions = yield* QuestionV2.Service
2760
+ yield* registry.register({
2761
+ question: Tool.make({
2762
+ description: "Ask the user",
2763
+ input: Schema.Struct({}),
2764
+ output: Schema.Struct({}),
2765
+ execute: (_, context) =>
2766
+ questions.ask({ sessionID: context.sessionID, questions: [] }).pipe(Effect.as({}), Effect.orDie),
2767
+ }),
2768
+ })
2769
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Ask then stop" }), resume: false })
2770
+
2771
+ requests.length = 0
2772
+ responses = [
2773
+ [
2774
+ LLMEvent.stepStart({ index: 0 }),
2775
+ LLMEvent.toolCall({ id: "call-question", name: "question", input: {} }),
2776
+ LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
2777
+ LLMEvent.finish({ reason: "tool-calls" }),
2778
+ ],
2779
+ [],
2780
+ ]
2781
+
2782
+ const run = yield* session.resume(sessionID).pipe(Effect.exit, Effect.forkChild)
2783
+ let pending = yield* questions.list()
2784
+ while (pending.length === 0) {
2785
+ yield* Effect.yieldNow
2786
+ pending = yield* questions.list()
2787
+ }
2788
+ yield* questions.reject(pending[0]!.id)
2789
+ const exit = yield* Fiber.join(run)
2790
+
2791
+ expect(exit._tag).toBe("Failure")
2792
+ if (exit._tag === "Failure") expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true)
2793
+ expect(requests).toHaveLength(1)
2794
+ expect(yield* session.context(sessionID)).toMatchObject([
2795
+ { type: "user", text: "Ask then stop" },
2796
+ {
2797
+ type: "assistant",
2798
+ content: [
2799
+ {
2800
+ type: "tool",
2801
+ id: "call-question",
2802
+ state: { status: "error", error: { type: "unknown", message: "Tool execution interrupted" } },
2803
+ },
2804
+ ],
2805
+ },
2806
+ ])
2807
+ }),
2808
+ )
2809
+
2810
+ it.effect("awaits started local tools before surfacing provider stream failure", () =>
2811
+ Effect.gen(function* () {
2812
+ yield* setup
2813
+ const session = yield* SessionV2.Service
2814
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Settle before failing" }), resume: false })
2815
+ const failure = providerUnavailable()
2816
+ toolExecutionGate = yield* Deferred.make<void>()
2817
+ responseStream = Stream.concat(
2818
+ Stream.fromIterable([
2819
+ LLMEvent.stepStart({ index: 0 }),
2820
+ LLMEvent.toolCall({ id: "call-before-failure", name: "echo", input: { text: "settle" } }),
2821
+ ]),
2822
+ Stream.fail(failure),
2823
+ )
2824
+
2825
+ const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
2826
+ while (executions.length === 0) yield* Effect.yieldNow
2827
+ yield* Effect.yieldNow
2828
+ yield* Deferred.succeed(toolExecutionGate, undefined)
2829
+ expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(failure)
2830
+ toolExecutionGate = undefined
2831
+
2832
+ expect(yield* session.context(sessionID)).toMatchObject([
2833
+ { type: "user", text: "Settle before failing" },
2834
+ {
2835
+ type: "assistant",
2836
+ content: [
2837
+ { type: "tool", id: "call-before-failure", state: { status: "completed", structured: { text: "settle" } } },
2838
+ ],
2839
+ },
2840
+ ])
2841
+ }),
2842
+ )
2843
+
2844
+ it.effect("durably fails blocked local tools when a provider turn is interrupted", () =>
2845
+ Effect.gen(function* () {
2846
+ yield* setup
2847
+ const session = yield* SessionV2.Service
2848
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Interrupt blocked tool" }), resume: false })
2849
+ executions.length = 0
2850
+ toolExecutionGate = yield* Deferred.make<void>()
2851
+ responseStream = Stream.concat(
2852
+ Stream.fromIterable([
2853
+ LLMEvent.stepStart({ index: 0 }),
2854
+ LLMEvent.toolCall({ id: "call-before-interrupt", name: "echo", input: { text: "blocked" } }),
2855
+ ]),
2856
+ Stream.never,
2857
+ )
2858
+
2859
+ const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
2860
+ while (executions.length === 0) yield* Effect.yieldNow
2861
+ yield* session.interrupt(sessionID)
2862
+ toolExecutionGate = undefined
2863
+
2864
+ expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
2865
+ yield* session.interrupt(sessionID)
2866
+ expect(yield* session.context(sessionID)).toMatchObject([
2867
+ { type: "user", text: "Interrupt blocked tool" },
2868
+ {
2869
+ type: "assistant",
2870
+ content: [
2871
+ {
2872
+ type: "tool",
2873
+ id: "call-before-interrupt",
2874
+ state: { status: "error", error: { type: "unknown", message: "Tool execution interrupted" } },
2875
+ },
2876
+ ],
2877
+ },
2878
+ ])
2879
+
2880
+ yield* replaySessionProjection(sessionID)
2881
+
2882
+ expect(yield* session.context(sessionID)).toMatchObject([
2883
+ { type: "user", text: "Interrupt blocked tool" },
2884
+ { type: "assistant", content: [{ type: "tool", id: "call-before-interrupt", state: { status: "error" } }] },
2885
+ ])
2886
+ requests.length = 0
2887
+ responseStream = undefined
2888
+ response = []
2889
+ yield* session.resume(sessionID)
2890
+ expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"])
2891
+ }),
2892
+ )
2893
+
2894
+ it.effect("interrupts a blocked provider turn without local tool execution", () =>
2895
+ Effect.gen(function* () {
2896
+ yield* setup
2897
+ const session = yield* SessionV2.Service
2898
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Interrupt provider" }), resume: false })
2899
+ requests.length = 0
2900
+ response = []
2901
+ streamGate = yield* Deferred.make<void>()
2902
+ streamStarted = yield* Deferred.make<void>()
2903
+
2904
+ const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
2905
+ yield* Deferred.await(streamStarted)
2906
+ yield* session.interrupt(sessionID)
2907
+ const exit = yield* Fiber.await(run)
2908
+ streamGate = undefined
2909
+ streamStarted = undefined
2910
+
2911
+ expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue()
2912
+ expect(requests).toHaveLength(1)
2913
+ yield* session.interrupt(sessionID)
2914
+ }),
2915
+ )
2916
+
2917
+ it.effect("durably fails blocked local tools when interrupted while awaiting settlement", () =>
2918
+ Effect.gen(function* () {
2919
+ yield* setup
2920
+ const session = yield* SessionV2.Service
2921
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Interrupt tool settlement" }), resume: false })
2922
+ executions.length = 0
2923
+ toolExecutionGate = yield* Deferred.make<void>()
2924
+ response = [
2925
+ LLMEvent.stepStart({ index: 0 }),
2926
+ LLMEvent.toolCall({ id: "call-await-interrupt", name: "echo", input: { text: "blocked" } }),
2927
+ LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
2928
+ LLMEvent.finish({ reason: "tool-calls" }),
2929
+ ]
2930
+
2931
+ const runner = yield* SessionRunner.Service
2932
+ const run = yield* runner.run({ sessionID, force: true }).pipe(Effect.forkChild)
2933
+ while (executions.length === 0) yield* Effect.yieldNow
2934
+ yield* Fiber.interrupt(run)
2935
+ toolExecutionGate = undefined
2936
+
2937
+ expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
2938
+ expect(yield* session.context(sessionID)).toMatchObject([
2939
+ { type: "user", text: "Interrupt tool settlement" },
2940
+ {
2941
+ type: "assistant",
2942
+ content: [
2943
+ {
2944
+ type: "tool",
2945
+ id: "call-await-interrupt",
2946
+ state: { status: "error", error: { type: "unknown", message: "Tool execution interrupted" } },
2947
+ },
2948
+ ],
2949
+ },
2950
+ ])
2951
+ }),
2952
+ )
2953
+
2954
+ it.effect("forces a text response on an agent's configured final step", () =>
2955
+ Effect.gen(function* () {
2956
+ yield* setup
2957
+ const agents = yield* AgentV2.Service
2958
+ yield* agents.transform((editor) =>
2959
+ editor.update(AgentV2.ID.make("build"), (agent) => {
2960
+ agent.steps = 2
2961
+ }),
2962
+ )
2963
+ const session = yield* SessionV2.Service
2964
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Finish at the limit" }), resume: false })
2965
+
2966
+ requests.length = 0
2967
+ executions.length = 0
2968
+ responses = [
2969
+ [
2970
+ LLMEvent.stepStart({ index: 0 }),
2971
+ LLMEvent.toolCall({ id: "call-terminal", name: "echo", input: { text: "done" } }),
2972
+ LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
2973
+ LLMEvent.finish({ reason: "tool-calls" }),
2974
+ ],
2975
+ [
2976
+ LLMEvent.stepStart({ index: 0 }),
2977
+ LLMEvent.toolCall({ id: "call-forbidden", name: "echo", input: { text: "forbidden" } }),
2978
+ LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
2979
+ LLMEvent.finish({ reason: "tool-calls" }),
2980
+ ],
2981
+ ]
2982
+
2983
+ yield* session.resume(sessionID)
2984
+
2985
+ expect(requests).toHaveLength(2)
2986
+ expect(requests[0]?.toolChoice).toBeUndefined()
2987
+ expect(requests[1]?.toolChoice).toMatchObject({ type: "none" })
2988
+ expect(requests[1]?.tools).toEqual([])
2989
+ expect(requests[1]?.messages.at(-1)).toMatchObject({
2990
+ role: "assistant",
2991
+ content: [{ type: "text", text: expect.stringContaining("MAXIMUM STEPS REACHED") }],
2992
+ })
2993
+ expect(executions).toEqual(["done"])
2994
+ expect(yield* session.context(sessionID)).toMatchObject([
2995
+ { type: "user", text: "Finish at the limit" },
2996
+ { type: "assistant", content: [{ type: "tool", id: "call-terminal", state: { status: "completed" } }] },
2997
+ { type: "assistant", content: [{ type: "tool", id: "call-forbidden", state: { status: "error" } }] },
2998
+ ])
2999
+ }),
3000
+ )
3001
+
3002
+ it.effect("resets the configured step allowance when steering input promotes", () =>
3003
+ Effect.gen(function* () {
3004
+ yield* setup
3005
+ const agents = yield* AgentV2.Service
3006
+ yield* agents.transform((editor) =>
3007
+ editor.update(AgentV2.ID.make("build"), (agent) => {
3008
+ agent.steps = 2
3009
+ }),
3010
+ )
3011
+ const session = yield* SessionV2.Service
3012
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start work" }), resume: false })
3013
+
3014
+ requests.length = 0
3015
+ executions.length = 0
3016
+ responses = [
3017
+ [
3018
+ LLMEvent.stepStart({ index: 0 }),
3019
+ LLMEvent.toolCall({ id: "call-before-steer", name: "echo", input: { text: "before" } }),
3020
+ LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
3021
+ LLMEvent.finish({ reason: "tool-calls" }),
3022
+ ],
3023
+ [
3024
+ LLMEvent.stepStart({ index: 0 }),
3025
+ LLMEvent.toolCall({ id: "call-after-steer", name: "echo", input: { text: "after" } }),
3026
+ LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
3027
+ LLMEvent.finish({ reason: "tool-calls" }),
3028
+ ],
3029
+ [
3030
+ LLMEvent.stepStart({ index: 0 }),
3031
+ LLMEvent.stepFinish({ index: 0, reason: "stop" }),
3032
+ LLMEvent.finish({ reason: "stop" }),
3033
+ ],
3034
+ ]
3035
+ streamGate = yield* Deferred.make<void>()
3036
+ streamStarted = yield* Deferred.make<void>()
3037
+
3038
+ const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
3039
+ yield* Deferred.await(streamStarted)
3040
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Change direction" }) })
3041
+ yield* Deferred.succeed(streamGate, undefined)
3042
+ yield* Fiber.join(run)
3043
+ streamGate = undefined
3044
+ streamStarted = undefined
3045
+
3046
+ expect(requests).toHaveLength(3)
3047
+ expect(requests[1]?.toolChoice).toBeUndefined()
3048
+ expect(requests[1]?.tools).not.toEqual([])
3049
+ expect(requests[2]?.toolChoice).toMatchObject({ type: "none" })
3050
+ expect(executions).toEqual(["before", "after"])
3051
+ }),
3052
+ )
3053
+
3054
+ it.effect("projects provider errors as terminal assistant step failures", () =>
3055
+ Effect.gen(function* () {
3056
+ yield* setup
3057
+ const session = yield* SessionV2.Service
3058
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail durably" }), resume: false })
3059
+
3060
+ requests.length = 0
3061
+ responses = undefined
3062
+ streamGate = undefined
3063
+ streamStarted = undefined
3064
+ response = [LLMEvent.stepStart({ index: 0 }), LLMEvent.providerError({ message: "Provider unavailable" })]
3065
+
3066
+ yield* session.resume(sessionID)
3067
+
3068
+ expect(requests).toHaveLength(1)
3069
+ expect(yield* session.context(sessionID)).toMatchObject([
3070
+ { type: "user", text: "Fail durably" },
3071
+ { type: "assistant", finish: "error", error: { type: "unknown", message: "Provider unavailable" } },
3072
+ ])
3073
+ }),
3074
+ )
3075
+
3076
+ it.effect("projects provider errors emitted before assistant step start", () =>
3077
+ Effect.gen(function* () {
3078
+ yield* setup
3079
+ const session = yield* SessionV2.Service
3080
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail before step" }), resume: false })
3081
+
3082
+ requests.length = 0
3083
+ response = [LLMEvent.providerError({ message: "Provider unavailable" })]
3084
+
3085
+ yield* session.resume(sessionID)
3086
+
3087
+ expect(requests).toHaveLength(1)
3088
+ expect(yield* session.context(sessionID)).toMatchObject([
3089
+ { type: "user", text: "Fail before step" },
3090
+ { type: "assistant", finish: "error", error: { type: "unknown", message: "Provider unavailable" } },
3091
+ ])
3092
+ }),
3093
+ )
3094
+
3095
+ it.effect("does not recover context overflow after durable assistant output", () =>
3096
+ Effect.gen(function* () {
3097
+ yield* setup
3098
+ const session = yield* SessionV2.Service
3099
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail after output" }), resume: false })
3100
+
3101
+ requests.length = 0
3102
+ response = [
3103
+ LLMEvent.stepStart({ index: 0 }),
3104
+ LLMEvent.textStart({ id: "text-partial" }),
3105
+ LLMEvent.textDelta({ id: "text-partial", text: "Partial" }),
3106
+ LLMEvent.textEnd({ id: "text-partial" }),
3107
+ LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }),
3108
+ ]
3109
+ yield* session.resume(sessionID)
3110
+
3111
+ expect(requests).toHaveLength(1)
3112
+ expect(yield* session.context(sessionID)).toMatchObject([
3113
+ { type: "user", text: "Fail after output" },
3114
+ {
3115
+ type: "assistant",
3116
+ finish: "error",
3117
+ error: { message: "prompt too long" },
3118
+ content: [{ type: "text", text: "Partial" }],
3119
+ },
3120
+ ])
3121
+ }),
3122
+ )
3123
+
3124
+ it.effect("projects raw provider stream failures as terminal assistant step failures", () =>
3125
+ Effect.gen(function* () {
3126
+ yield* setup
3127
+ const session = yield* SessionV2.Service
3128
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail raw stream durably" }), resume: false })
3129
+ const failure = providerUnavailable()
3130
+ responseStream = Stream.fail(failure)
3131
+
3132
+ expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure)
3133
+ yield* replaySessionProjection(sessionID)
3134
+ expect(yield* session.context(sessionID)).toMatchObject([
3135
+ { type: "user", text: "Fail raw stream durably" },
3136
+ { type: "assistant", finish: "error", error: { type: "unknown", message: "Provider unavailable" } },
3137
+ ])
3138
+ }),
3139
+ )
3140
+
3141
+ it.effect("does not continue automatically after a provider error follows a local tool call", () =>
3142
+ Effect.gen(function* () {
3143
+ yield* setup
3144
+ const session = yield* SessionV2.Service
3145
+ yield* session.prompt({
3146
+ sessionID,
3147
+ prompt: Prompt.make({ text: "Do not continue failed provider" }),
3148
+ resume: false,
3149
+ })
3150
+
3151
+ requests.length = 0
3152
+ const executionCount = executions.length
3153
+ response = [
3154
+ LLMEvent.stepStart({ index: 0 }),
3155
+ LLMEvent.toolCall({ id: "call-before-provider-error", name: "echo", input: { text: "settled" } }),
3156
+ LLMEvent.providerError({ message: "Provider unavailable" }),
3157
+ ]
3158
+
3159
+ yield* session.resume(sessionID)
3160
+
3161
+ expect(requests).toHaveLength(1)
3162
+ expect(executions.slice(executionCount)).toEqual(["settled"])
3163
+ }),
3164
+ )
3165
+
3166
+ it.effect("durably fails a hosted tool when its provider errors before returning a result", () =>
3167
+ Effect.gen(function* () {
3168
+ yield* setup
3169
+ const session = yield* SessionV2.Service
3170
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail hosted tool durably" }), resume: false })
3171
+
3172
+ requests.length = 0
3173
+ response = [
3174
+ LLMEvent.stepStart({ index: 0 }),
3175
+ LLMEvent.toolCall({
3176
+ id: "call-hosted-provider-error",
3177
+ name: "web_search",
3178
+ input: { query: "effect" },
3179
+ providerExecuted: true,
3180
+ }),
3181
+ LLMEvent.providerError({ message: "Provider unavailable" }),
3182
+ ]
3183
+
3184
+ yield* session.resume(sessionID)
3185
+
3186
+ expect(requests).toHaveLength(1)
3187
+ expect(yield* session.context(sessionID)).toMatchObject([
3188
+ { type: "user", text: "Fail hosted tool durably" },
3189
+ {
3190
+ type: "assistant",
3191
+ content: [{ type: "tool", id: "call-hosted-provider-error", state: { status: "error" } }],
3192
+ },
3193
+ ])
3194
+ }),
3195
+ )
3196
+
3197
+ it.effect("durably fails a hosted tool left unresolved at normal provider EOF", () =>
3198
+ Effect.gen(function* () {
3199
+ yield* setup
3200
+ const session = yield* SessionV2.Service
3201
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail hosted tool at EOF" }), resume: false })
3202
+ response = [
3203
+ LLMEvent.stepStart({ index: 0 }),
3204
+ LLMEvent.toolCall({
3205
+ id: "call-hosted-eof",
3206
+ name: "web_search",
3207
+ input: { query: "effect" },
3208
+ providerExecuted: true,
3209
+ }),
3210
+ ]
3211
+
3212
+ yield* session.resume(sessionID)
3213
+ yield* replaySessionProjection(sessionID)
3214
+
3215
+ expect(yield* session.context(sessionID)).toMatchObject([
3216
+ { type: "user", text: "Fail hosted tool at EOF" },
3217
+ { type: "assistant", content: [{ type: "tool", id: "call-hosted-eof", state: { status: "error" } }] },
3218
+ ])
3219
+ }),
3220
+ )
3221
+
3222
+ it.effect("durably fails a hosted tool left unresolved by a raw provider stream failure", () =>
3223
+ Effect.gen(function* () {
3224
+ yield* setup
3225
+ const session = yield* SessionV2.Service
3226
+ yield* session.prompt({
3227
+ sessionID,
3228
+ prompt: Prompt.make({ text: "Fail hosted tool on raw failure" }),
3229
+ resume: false,
3230
+ })
3231
+ const failure = providerUnavailable()
3232
+ responseStream = Stream.concat(
3233
+ Stream.fromIterable([
3234
+ LLMEvent.stepStart({ index: 0 }),
3235
+ LLMEvent.toolCall({
3236
+ id: "call-hosted-raw-failure",
3237
+ name: "web_search",
3238
+ input: { query: "effect" },
3239
+ providerExecuted: true,
3240
+ }),
3241
+ ]),
3242
+ Stream.fail(failure),
3243
+ )
3244
+
3245
+ expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure)
3246
+ yield* replaySessionProjection(sessionID)
3247
+ expect(yield* session.context(sessionID)).toMatchObject([
3248
+ { type: "user", text: "Fail hosted tool on raw failure" },
3249
+ {
3250
+ type: "assistant",
3251
+ finish: "error",
3252
+ error: { type: "unknown", message: "Provider unavailable" },
3253
+ content: [{ type: "tool", id: "call-hosted-raw-failure", state: { status: "error" } }],
3254
+ },
3255
+ ])
3256
+ }),
3257
+ )
3258
+
3259
+ it.effect("keeps interleaved assistant text blocks separate", () =>
3260
+ Effect.gen(function* () {
3261
+ yield* setup
3262
+ const session = yield* SessionV2.Service
3263
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Two blocks" }), resume: false })
3264
+
3265
+ responses = undefined
3266
+ streamGate = undefined
3267
+ streamStarted = undefined
3268
+ response = [
3269
+ LLMEvent.stepStart({ index: 0 }),
3270
+ LLMEvent.textStart({ id: "text-1" }),
3271
+ LLMEvent.textStart({ id: "text-2" }),
3272
+ LLMEvent.textDelta({ id: "text-1", text: "First" }),
3273
+ LLMEvent.textDelta({ id: "text-2", text: "Second" }),
3274
+ LLMEvent.textEnd({ id: "text-1" }),
3275
+ LLMEvent.textEnd({ id: "text-2" }),
3276
+ LLMEvent.stepFinish({ index: 0, reason: "stop" }),
3277
+ LLMEvent.finish({ reason: "stop" }),
3278
+ ]
3279
+
3280
+ yield* session.resume(sessionID)
3281
+
3282
+ expect(yield* session.context(sessionID)).toMatchObject([
3283
+ { type: "user", text: "Two blocks" },
3284
+ {
3285
+ type: "assistant",
3286
+ content: [
3287
+ { type: "text", id: "text-1", text: "First" },
3288
+ { type: "text", id: "text-2", text: "Second" },
3289
+ ],
3290
+ },
3291
+ ])
3292
+ }),
3293
+ )
3294
+
3295
+ for (const kind of fragmentKinds) {
3296
+ it.effect(`broadcasts provider ${kind} deltas without storing projection rewrites`, () =>
3297
+ verifyEphemeralDeltas(kind),
3298
+ )
3299
+
3300
+ it.effect(`durably closes partial ${kind} when the provider stream fails`, () => verifyPartialFlushOnFailure(kind))
3301
+
3302
+ it.effect(`durably closes partial ${kind} when the provider stream is interrupted`, () =>
3303
+ verifyPartialFlushOnInterruption(kind),
3304
+ )
3305
+ }
3306
+
3307
+ it.effect("rejects duplicate streamed text starts", () =>
3308
+ Effect.gen(function* () {
3309
+ yield* setup
3310
+ const session = yield* SessionV2.Service
3311
+ responses = undefined
3312
+ streamGate = undefined
3313
+ streamStarted = undefined
3314
+ response = [LLMEvent.textStart({ id: "text-1" }), LLMEvent.textStart({ id: "text-1" })]
3315
+
3316
+ expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe(
3317
+ "Duplicate text start: text-1",
3318
+ )
3319
+ }),
3320
+ )
3321
+
3322
+ it.effect("transitions streamed raw tool input to parsed called input", () =>
3323
+ Effect.gen(function* () {
3324
+ yield* setup
3325
+ const session = yield* SessionV2.Service
3326
+ yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Call provider tool" }), resume: false })
3327
+
3328
+ responses = undefined
3329
+ streamGate = undefined
3330
+ streamStarted = undefined
3331
+ response = [
3332
+ LLMEvent.stepStart({ index: 0 }),
3333
+ LLMEvent.toolInputStart({ id: "call-parsed", name: "web_search" }),
3334
+ LLMEvent.toolInputDelta({ id: "call-parsed", name: "web_search", text: '{"query":"hello"}' }),
3335
+ LLMEvent.toolInputEnd({ id: "call-parsed", name: "web_search" }),
3336
+ LLMEvent.toolCall({ id: "call-parsed", name: "web_search", input: { query: "hello" }, providerExecuted: true }),
3337
+ ]
3338
+
3339
+ yield* session.resume(sessionID)
3340
+
3341
+ expect(yield* session.context(sessionID)).toMatchObject([
3342
+ { type: "user", text: "Call provider tool" },
3343
+ {
3344
+ type: "assistant",
3345
+ content: [{ type: "tool", id: "call-parsed", state: { status: "error", input: { query: "hello" } } }],
3346
+ },
3347
+ ])
3348
+ }),
3349
+ )
3350
+
3351
+ it.effect("rejects malformed streamed tool input ordering", () =>
3352
+ Effect.gen(function* () {
3353
+ yield* setup
3354
+ const session = yield* SessionV2.Service
3355
+ responses = undefined
3356
+ streamGate = undefined
3357
+ streamStarted = undefined
3358
+ response = [LLMEvent.toolInputDelta({ id: "call-1", name: "read", text: "{}" })]
3359
+
3360
+ expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe(
3361
+ "Tool input delta before start: call-1",
3362
+ )
3363
+ }),
3364
+ )
3365
+ })