prompt-effect.test.ts 50 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535
  1. import { NodeFileSystem } from "@effect/platform-node"
  2. import { FetchHttpClient } from "effect/unstable/http"
  3. import { expect } from "bun:test"
  4. import { Cause, Effect, Exit, Fiber, Layer } from "effect"
  5. import path from "path"
  6. import { Agent as AgentSvc } from "../../src/agent/agent"
  7. import { Bus } from "../../src/bus"
  8. import { Command } from "../../src/command"
  9. import { Config } from "../../src/config"
  10. import { FileTime } from "../../src/file/time"
  11. import { LSP } from "../../src/lsp"
  12. import { MCP } from "../../src/mcp"
  13. import { Permission } from "../../src/permission"
  14. import { Plugin } from "../../src/plugin"
  15. import { Provider as ProviderSvc } from "../../src/provider/provider"
  16. import { Env } from "../../src/env"
  17. import type { Provider } from "../../src/provider/provider"
  18. import { ModelID, ProviderID } from "../../src/provider/schema"
  19. import { Question } from "../../src/question"
  20. import { Todo } from "../../src/session/todo"
  21. import { Session } from "../../src/session"
  22. import { LLM } from "../../src/session/llm"
  23. import { MessageV2 } from "../../src/session/message-v2"
  24. import { AppFileSystem } from "@opencode-ai/shared/filesystem"
  25. import { SessionCompaction } from "../../src/session/compaction"
  26. import { SessionSummary } from "../../src/session/summary"
  27. import { Instruction } from "../../src/session/instruction"
  28. import { SessionProcessor } from "../../src/session/processor"
  29. import { SessionPrompt } from "../../src/session/prompt"
  30. import { SessionRevert } from "../../src/session/revert"
  31. import { SessionRunState } from "../../src/session/run-state"
  32. import { MessageID, PartID, SessionID } from "../../src/session/schema"
  33. import { SessionStatus } from "../../src/session/status"
  34. import { Skill } from "../../src/skill"
  35. import { SystemPrompt } from "../../src/session/system"
  36. import { Shell } from "../../src/shell/shell"
  37. import { Snapshot } from "../../src/snapshot"
  38. import { ToolRegistry } from "../../src/tool/registry"
  39. import { Truncate } from "../../src/tool/truncate"
  40. import { Log } from "../../src/util/log"
  41. import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
  42. import { Ripgrep } from "../../src/file/ripgrep"
  43. import { Format } from "../../src/format"
  44. import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture"
  45. import { testEffect } from "../lib/effect"
  46. import { reply, TestLLMServer } from "../lib/llm-server"
  47. Log.init({ print: false })
  48. const summary = Layer.succeed(
  49. SessionSummary.Service,
  50. SessionSummary.Service.of({
  51. summarize: () => Effect.void,
  52. diff: () => Effect.succeed([]),
  53. computeDiff: () => Effect.succeed([]),
  54. }),
  55. )
  56. const ref = {
  57. providerID: ProviderID.make("test"),
  58. modelID: ModelID.make("test-model"),
  59. }
  60. function defer<T>() {
  61. let resolve!: (value: T | PromiseLike<T>) => void
  62. const promise = new Promise<T>((done) => {
  63. resolve = done
  64. })
  65. return { promise, resolve }
  66. }
  67. function withSh<A, E, R>(fx: () => Effect.Effect<A, E, R>) {
  68. return Effect.acquireUseRelease(
  69. Effect.sync(() => {
  70. const prev = process.env.SHELL
  71. process.env.SHELL = "/bin/sh"
  72. Shell.preferred.reset()
  73. return prev
  74. }),
  75. () => fx(),
  76. (prev) =>
  77. Effect.sync(() => {
  78. if (prev === undefined) delete process.env.SHELL
  79. else process.env.SHELL = prev
  80. Shell.preferred.reset()
  81. }),
  82. )
  83. }
  84. function toolPart(parts: MessageV2.Part[]) {
  85. return parts.find((part): part is MessageV2.ToolPart => part.type === "tool")
  86. }
  87. type CompletedToolPart = MessageV2.ToolPart & { state: MessageV2.ToolStateCompleted }
  88. type ErrorToolPart = MessageV2.ToolPart & { state: MessageV2.ToolStateError }
  89. function completedTool(parts: MessageV2.Part[]) {
  90. const part = toolPart(parts)
  91. expect(part?.state.status).toBe("completed")
  92. return part?.state.status === "completed" ? (part as CompletedToolPart) : undefined
  93. }
  94. function errorTool(parts: MessageV2.Part[]) {
  95. const part = toolPart(parts)
  96. expect(part?.state.status).toBe("error")
  97. return part?.state.status === "error" ? (part as ErrorToolPart) : undefined
  98. }
  99. const mcp = Layer.succeed(
  100. MCP.Service,
  101. MCP.Service.of({
  102. status: () => Effect.succeed({}),
  103. clients: () => Effect.succeed({}),
  104. tools: () => Effect.succeed({}),
  105. prompts: () => Effect.succeed({}),
  106. resources: () => Effect.succeed({}),
  107. add: () => Effect.succeed({ status: { status: "disabled" as const } }),
  108. connect: () => Effect.void,
  109. disconnect: () => Effect.void,
  110. getPrompt: () => Effect.succeed(undefined),
  111. readResource: () => Effect.succeed(undefined),
  112. startAuth: () => Effect.die("unexpected MCP auth in prompt-effect tests"),
  113. authenticate: () => Effect.die("unexpected MCP auth in prompt-effect tests"),
  114. finishAuth: () => Effect.die("unexpected MCP auth in prompt-effect tests"),
  115. removeAuth: () => Effect.void,
  116. supportsOAuth: () => Effect.succeed(false),
  117. hasStoredTokens: () => Effect.succeed(false),
  118. getAuthStatus: () => Effect.succeed("not_authenticated" as const),
  119. }),
  120. )
  121. const lsp = Layer.succeed(
  122. LSP.Service,
  123. LSP.Service.of({
  124. init: () => Effect.void,
  125. status: () => Effect.succeed([]),
  126. hasClients: () => Effect.succeed(false),
  127. touchFile: () => Effect.void,
  128. diagnostics: () => Effect.succeed({}),
  129. hover: () => Effect.succeed(undefined),
  130. definition: () => Effect.succeed([]),
  131. references: () => Effect.succeed([]),
  132. implementation: () => Effect.succeed([]),
  133. documentSymbol: () => Effect.succeed([]),
  134. workspaceSymbol: () => Effect.succeed([]),
  135. prepareCallHierarchy: () => Effect.succeed([]),
  136. incomingCalls: () => Effect.succeed([]),
  137. outgoingCalls: () => Effect.succeed([]),
  138. }),
  139. )
  140. const filetime = Layer.succeed(
  141. FileTime.Service,
  142. FileTime.Service.of({
  143. read: () => Effect.void,
  144. get: () => Effect.succeed(undefined),
  145. assert: () => Effect.void,
  146. withLock: (_filepath, fn) => fn(),
  147. }),
  148. )
  149. const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer))
  150. const run = SessionRunState.layer.pipe(Layer.provide(status))
  151. const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer)
  152. function makeHttp() {
  153. const deps = Layer.mergeAll(
  154. Session.defaultLayer,
  155. Snapshot.defaultLayer,
  156. LLM.defaultLayer,
  157. Env.defaultLayer,
  158. AgentSvc.defaultLayer,
  159. Command.defaultLayer,
  160. Permission.defaultLayer,
  161. Plugin.defaultLayer,
  162. Config.defaultLayer,
  163. ProviderSvc.defaultLayer,
  164. filetime,
  165. lsp,
  166. mcp,
  167. AppFileSystem.defaultLayer,
  168. status,
  169. ).pipe(Layer.provideMerge(infra))
  170. const question = Question.layer.pipe(Layer.provideMerge(deps))
  171. const todo = Todo.layer.pipe(Layer.provideMerge(deps))
  172. const registry = ToolRegistry.layer.pipe(
  173. Layer.provide(Skill.defaultLayer),
  174. Layer.provide(FetchHttpClient.layer),
  175. Layer.provide(CrossSpawnSpawner.defaultLayer),
  176. Layer.provide(Ripgrep.defaultLayer),
  177. Layer.provide(Format.defaultLayer),
  178. Layer.provideMerge(todo),
  179. Layer.provideMerge(question),
  180. Layer.provideMerge(deps),
  181. )
  182. const trunc = Truncate.layer.pipe(Layer.provideMerge(deps))
  183. const proc = SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provideMerge(deps))
  184. const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps))
  185. return Layer.mergeAll(
  186. TestLLMServer.layer,
  187. SessionPrompt.layer.pipe(
  188. Layer.provide(SessionRevert.defaultLayer),
  189. Layer.provide(summary),
  190. Layer.provideMerge(run),
  191. Layer.provideMerge(compact),
  192. Layer.provideMerge(proc),
  193. Layer.provideMerge(registry),
  194. Layer.provideMerge(trunc),
  195. Layer.provide(Instruction.defaultLayer),
  196. Layer.provide(SystemPrompt.defaultLayer),
  197. Layer.provideMerge(deps),
  198. ),
  199. ).pipe(Layer.provide(summary))
  200. }
  201. const it = testEffect(makeHttp())
  202. const unix = process.platform !== "win32" ? it.live : it.live.skip
  203. // Config that registers a custom "test" provider with a "test-model" model
  204. // so provider model lookup succeeds inside the loop.
  205. const cfg = {
  206. provider: {
  207. test: {
  208. name: "Test",
  209. id: "test",
  210. env: [],
  211. npm: "@ai-sdk/openai-compatible",
  212. models: {
  213. "test-model": {
  214. id: "test-model",
  215. name: "Test Model",
  216. attachment: false,
  217. reasoning: false,
  218. temperature: false,
  219. tool_call: true,
  220. release_date: "2025-01-01",
  221. limit: { context: 100000, output: 10000 },
  222. cost: { input: 0, output: 0 },
  223. options: {},
  224. },
  225. },
  226. options: {
  227. apiKey: "test-key",
  228. baseURL: "http://localhost:1/v1",
  229. },
  230. },
  231. },
  232. }
  233. function providerCfg(url: string) {
  234. return {
  235. ...cfg,
  236. provider: {
  237. ...cfg.provider,
  238. test: {
  239. ...cfg.provider.test,
  240. options: {
  241. ...cfg.provider.test.options,
  242. baseURL: url,
  243. },
  244. },
  245. },
  246. }
  247. }
  248. const user = Effect.fn("test.user")(function* (sessionID: SessionID, text: string) {
  249. const session = yield* Session.Service
  250. const msg = yield* session.updateMessage({
  251. id: MessageID.ascending(),
  252. role: "user",
  253. sessionID,
  254. agent: "build",
  255. model: ref,
  256. time: { created: Date.now() },
  257. })
  258. yield* session.updatePart({
  259. id: PartID.ascending(),
  260. messageID: msg.id,
  261. sessionID,
  262. type: "text",
  263. text,
  264. })
  265. return msg
  266. })
  267. const seed = Effect.fn("test.seed")(function* (sessionID: SessionID, opts?: { finish?: string }) {
  268. const session = yield* Session.Service
  269. const msg = yield* user(sessionID, "hello")
  270. const assistant: MessageV2.Assistant = {
  271. id: MessageID.ascending(),
  272. role: "assistant",
  273. parentID: msg.id,
  274. sessionID,
  275. mode: "build",
  276. agent: "build",
  277. cost: 0,
  278. path: { cwd: "/tmp", root: "/tmp" },
  279. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  280. modelID: ref.modelID,
  281. providerID: ref.providerID,
  282. time: { created: Date.now() },
  283. ...(opts?.finish ? { finish: opts.finish } : {}),
  284. }
  285. yield* session.updateMessage(assistant)
  286. yield* session.updatePart({
  287. id: PartID.ascending(),
  288. messageID: assistant.id,
  289. sessionID,
  290. type: "text",
  291. text: "hi there",
  292. })
  293. return { user: msg, assistant }
  294. })
  295. const addSubtask = (sessionID: SessionID, messageID: MessageID, model = ref) =>
  296. Effect.gen(function* () {
  297. const session = yield* Session.Service
  298. yield* session.updatePart({
  299. id: PartID.ascending(),
  300. messageID,
  301. sessionID,
  302. type: "subtask",
  303. prompt: "look into the cache key path",
  304. description: "inspect bug",
  305. agent: "general",
  306. model,
  307. })
  308. })
  309. const boot = Effect.fn("test.boot")(function* (input?: { title?: string }) {
  310. const prompt = yield* SessionPrompt.Service
  311. const run = yield* SessionRunState.Service
  312. const sessions = yield* Session.Service
  313. const chat = yield* sessions.create(input ?? { title: "Pinned" })
  314. return { prompt, run, sessions, chat }
  315. })
  316. // Loop semantics
  317. it.live("loop exits immediately when last assistant has stop finish", () =>
  318. provideTmpdirServer(
  319. Effect.fnUntraced(function* ({ llm }) {
  320. const prompt = yield* SessionPrompt.Service
  321. const sessions = yield* Session.Service
  322. const chat = yield* sessions.create({ title: "Pinned" })
  323. yield* seed(chat.id, { finish: "stop" })
  324. const result = yield* prompt.loop({ sessionID: chat.id })
  325. expect(result.info.role).toBe("assistant")
  326. if (result.info.role === "assistant") expect(result.info.finish).toBe("stop")
  327. expect(yield* llm.calls).toBe(0)
  328. }),
  329. { git: true, config: providerCfg },
  330. ),
  331. )
  332. it.live("loop calls LLM and returns assistant message", () =>
  333. provideTmpdirServer(
  334. Effect.fnUntraced(function* ({ llm }) {
  335. const prompt = yield* SessionPrompt.Service
  336. const sessions = yield* Session.Service
  337. const chat = yield* sessions.create({
  338. title: "Pinned",
  339. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  340. })
  341. yield* prompt.prompt({
  342. sessionID: chat.id,
  343. agent: "build",
  344. noReply: true,
  345. parts: [{ type: "text", text: "hello" }],
  346. })
  347. yield* llm.text("world")
  348. const result = yield* prompt.loop({ sessionID: chat.id })
  349. expect(result.info.role).toBe("assistant")
  350. const parts = result.parts.filter((p) => p.type === "text")
  351. expect(parts.some((p) => p.type === "text" && p.text === "world")).toBe(true)
  352. expect(yield* llm.hits).toHaveLength(1)
  353. }),
  354. { git: true, config: providerCfg },
  355. ),
  356. )
  357. it.live("static loop returns assistant text through local provider", () =>
  358. provideTmpdirServer(
  359. Effect.fnUntraced(function* ({ llm }) {
  360. const prompt = yield* SessionPrompt.Service
  361. const sessions = yield* Session.Service
  362. const session = yield* sessions.create({
  363. title: "Prompt provider",
  364. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  365. })
  366. yield* prompt.prompt({
  367. sessionID: session.id,
  368. agent: "build",
  369. noReply: true,
  370. parts: [{ type: "text", text: "hello" }],
  371. })
  372. yield* llm.text("world")
  373. const result = yield* prompt.loop({ sessionID: session.id })
  374. expect(result.info.role).toBe("assistant")
  375. expect(result.parts.some((part) => part.type === "text" && part.text === "world")).toBe(true)
  376. expect(yield* llm.hits).toHaveLength(1)
  377. expect(yield* llm.pending).toBe(0)
  378. }),
  379. { git: true, config: providerCfg },
  380. ),
  381. )
  382. it.live("static loop consumes queued replies across turns", () =>
  383. provideTmpdirServer(
  384. Effect.fnUntraced(function* ({ llm }) {
  385. const prompt = yield* SessionPrompt.Service
  386. const sessions = yield* Session.Service
  387. const session = yield* sessions.create({
  388. title: "Prompt provider turns",
  389. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  390. })
  391. yield* prompt.prompt({
  392. sessionID: session.id,
  393. agent: "build",
  394. noReply: true,
  395. parts: [{ type: "text", text: "hello one" }],
  396. })
  397. yield* llm.text("world one")
  398. const first = yield* prompt.loop({ sessionID: session.id })
  399. expect(first.info.role).toBe("assistant")
  400. expect(first.parts.some((part) => part.type === "text" && part.text === "world one")).toBe(true)
  401. yield* prompt.prompt({
  402. sessionID: session.id,
  403. agent: "build",
  404. noReply: true,
  405. parts: [{ type: "text", text: "hello two" }],
  406. })
  407. yield* llm.text("world two")
  408. const second = yield* prompt.loop({ sessionID: session.id })
  409. expect(second.info.role).toBe("assistant")
  410. expect(second.parts.some((part) => part.type === "text" && part.text === "world two")).toBe(true)
  411. expect(yield* llm.hits).toHaveLength(2)
  412. expect(yield* llm.pending).toBe(0)
  413. }),
  414. { git: true, config: providerCfg },
  415. ),
  416. )
  417. it.live("loop continues when finish is tool-calls", () =>
  418. provideTmpdirServer(
  419. Effect.fnUntraced(function* ({ llm }) {
  420. const prompt = yield* SessionPrompt.Service
  421. const sessions = yield* Session.Service
  422. const session = yield* sessions.create({
  423. title: "Pinned",
  424. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  425. })
  426. yield* prompt.prompt({
  427. sessionID: session.id,
  428. agent: "build",
  429. noReply: true,
  430. parts: [{ type: "text", text: "hello" }],
  431. })
  432. yield* llm.tool("first", { value: "first" })
  433. yield* llm.text("second")
  434. const result = yield* prompt.loop({ sessionID: session.id })
  435. expect(yield* llm.calls).toBe(2)
  436. expect(result.info.role).toBe("assistant")
  437. if (result.info.role === "assistant") {
  438. expect(result.parts.some((part) => part.type === "text" && part.text === "second")).toBe(true)
  439. expect(result.info.finish).toBe("stop")
  440. }
  441. }),
  442. { git: true, config: providerCfg },
  443. ),
  444. )
  445. it.live("glob tool keeps instance context during prompt runs", () =>
  446. provideTmpdirServer(
  447. ({ dir, llm }) =>
  448. Effect.gen(function* () {
  449. const prompt = yield* SessionPrompt.Service
  450. const sessions = yield* Session.Service
  451. const session = yield* sessions.create({
  452. title: "Glob context",
  453. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  454. })
  455. const file = path.join(dir, "probe.txt")
  456. yield* Effect.promise(() => Bun.write(file, "probe"))
  457. yield* prompt.prompt({
  458. sessionID: session.id,
  459. agent: "build",
  460. noReply: true,
  461. parts: [{ type: "text", text: "find text files" }],
  462. })
  463. yield* llm.tool("glob", { pattern: "**/*.txt" })
  464. yield* llm.text("done")
  465. const result = yield* prompt.loop({ sessionID: session.id })
  466. expect(result.info.role).toBe("assistant")
  467. const msgs = yield* MessageV2.filterCompactedEffect(session.id)
  468. const tool = msgs
  469. .flatMap((msg) => msg.parts)
  470. .find(
  471. (part): part is CompletedToolPart =>
  472. part.type === "tool" && part.tool === "glob" && part.state.status === "completed",
  473. )
  474. if (!tool) return
  475. expect(tool.state.output).toContain(file)
  476. expect(tool.state.output).not.toContain("No context found for instance")
  477. expect(result.parts.some((part) => part.type === "text" && part.text === "done")).toBe(true)
  478. }),
  479. { git: true, config: providerCfg },
  480. ),
  481. )
  482. it.live("loop continues when finish is stop but assistant has tool parts", () =>
  483. provideTmpdirServer(
  484. Effect.fnUntraced(function* ({ llm }) {
  485. const prompt = yield* SessionPrompt.Service
  486. const sessions = yield* Session.Service
  487. const session = yield* sessions.create({
  488. title: "Pinned",
  489. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  490. })
  491. yield* prompt.prompt({
  492. sessionID: session.id,
  493. agent: "build",
  494. noReply: true,
  495. parts: [{ type: "text", text: "hello" }],
  496. })
  497. yield* llm.push(reply().tool("first", { value: "first" }).stop())
  498. yield* llm.text("second")
  499. const result = yield* prompt.loop({ sessionID: session.id })
  500. expect(yield* llm.calls).toBe(2)
  501. expect(result.info.role).toBe("assistant")
  502. if (result.info.role === "assistant") {
  503. expect(result.parts.some((part) => part.type === "text" && part.text === "second")).toBe(true)
  504. expect(result.info.finish).toBe("stop")
  505. }
  506. }),
  507. { git: true, config: providerCfg },
  508. ),
  509. )
  510. it.live("failed subtask preserves metadata on error tool state", () =>
  511. provideTmpdirServer(
  512. Effect.fnUntraced(function* ({ llm }) {
  513. const prompt = yield* SessionPrompt.Service
  514. const sessions = yield* Session.Service
  515. const chat = yield* sessions.create({ title: "Pinned" })
  516. yield* llm.tool("task", {
  517. description: "inspect bug",
  518. prompt: "look into the cache key path",
  519. subagent_type: "general",
  520. })
  521. yield* llm.text("done")
  522. const msg = yield* user(chat.id, "hello")
  523. yield* addSubtask(chat.id, msg.id)
  524. const result = yield* prompt.loop({ sessionID: chat.id })
  525. expect(result.info.role).toBe("assistant")
  526. expect(yield* llm.calls).toBe(2)
  527. const msgs = yield* MessageV2.filterCompactedEffect(chat.id)
  528. const taskMsg = msgs.find((item) => item.info.role === "assistant" && item.info.agent === "general")
  529. expect(taskMsg?.info.role).toBe("assistant")
  530. if (!taskMsg || taskMsg.info.role !== "assistant") return
  531. const tool = errorTool(taskMsg.parts)
  532. if (!tool) return
  533. expect(tool.state.error).toContain("Tool execution failed")
  534. expect(tool.state.metadata).toBeDefined()
  535. expect(tool.state.metadata?.sessionId).toBeDefined()
  536. expect(tool.state.metadata?.model).toEqual({
  537. providerID: ProviderID.make("test"),
  538. modelID: ModelID.make("missing-model"),
  539. })
  540. }),
  541. {
  542. git: true,
  543. config: (url) => ({
  544. ...providerCfg(url),
  545. agent: {
  546. general: {
  547. model: "test/missing-model",
  548. },
  549. },
  550. }),
  551. },
  552. ),
  553. )
  554. it.live(
  555. "running subtask preserves metadata after tool-call transition",
  556. () =>
  557. provideTmpdirServer(
  558. Effect.fnUntraced(function* ({ llm }) {
  559. const prompt = yield* SessionPrompt.Service
  560. const sessions = yield* Session.Service
  561. const chat = yield* sessions.create({ title: "Pinned" })
  562. yield* llm.hang
  563. const msg = yield* user(chat.id, "hello")
  564. yield* addSubtask(chat.id, msg.id)
  565. const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
  566. const tool = yield* Effect.promise(async () => {
  567. const end = Date.now() + 5_000
  568. while (Date.now() < end) {
  569. const msgs = await Effect.runPromise(MessageV2.filterCompactedEffect(chat.id))
  570. const taskMsg = msgs.find((item) => item.info.role === "assistant" && item.info.agent === "general")
  571. const tool = taskMsg?.parts.find((part): part is MessageV2.ToolPart => part.type === "tool")
  572. if (tool?.state.status === "running" && tool.state.metadata?.sessionId) return tool
  573. await new Promise((done) => setTimeout(done, 20))
  574. }
  575. throw new Error("timed out waiting for running subtask metadata")
  576. })
  577. if (tool.state.status !== "running") return
  578. expect(typeof tool.state.metadata?.sessionId).toBe("string")
  579. expect(tool.state.title).toBeDefined()
  580. expect(tool.state.metadata?.model).toBeDefined()
  581. yield* prompt.cancel(chat.id)
  582. yield* Fiber.await(fiber)
  583. }),
  584. { git: true, config: providerCfg },
  585. ),
  586. 5_000,
  587. )
  588. it.live(
  589. "running task tool preserves metadata after tool-call transition",
  590. () =>
  591. provideTmpdirServer(
  592. Effect.fnUntraced(function* ({ llm }) {
  593. const prompt = yield* SessionPrompt.Service
  594. const sessions = yield* Session.Service
  595. const chat = yield* sessions.create({
  596. title: "Pinned",
  597. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  598. })
  599. yield* llm.tool("task", {
  600. description: "inspect bug",
  601. prompt: "look into the cache key path",
  602. subagent_type: "general",
  603. })
  604. yield* llm.hang
  605. yield* user(chat.id, "hello")
  606. const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
  607. const tool = yield* Effect.promise(async () => {
  608. const end = Date.now() + 5_000
  609. while (Date.now() < end) {
  610. const msgs = await Effect.runPromise(MessageV2.filterCompactedEffect(chat.id))
  611. const assistant = msgs.findLast((item) => item.info.role === "assistant" && item.info.agent === "build")
  612. const tool = assistant?.parts.find(
  613. (part): part is MessageV2.ToolPart => part.type === "tool" && part.tool === "task",
  614. )
  615. if (tool?.state.status === "running" && tool.state.metadata?.sessionId) return tool
  616. await new Promise((done) => setTimeout(done, 20))
  617. }
  618. throw new Error("timed out waiting for running task metadata")
  619. })
  620. if (tool.state.status !== "running") return
  621. expect(typeof tool.state.metadata?.sessionId).toBe("string")
  622. expect(tool.state.title).toBe("inspect bug")
  623. expect(tool.state.metadata?.model).toBeDefined()
  624. yield* prompt.cancel(chat.id)
  625. yield* Fiber.await(fiber)
  626. }),
  627. { git: true, config: providerCfg },
  628. ),
  629. 10_000,
  630. )
  631. it.live(
  632. "loop sets status to busy then idle",
  633. () =>
  634. provideTmpdirServer(
  635. Effect.fnUntraced(function* ({ llm }) {
  636. const prompt = yield* SessionPrompt.Service
  637. const sessions = yield* Session.Service
  638. const status = yield* SessionStatus.Service
  639. yield* llm.hang
  640. const chat = yield* sessions.create({})
  641. yield* user(chat.id, "hi")
  642. const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
  643. yield* llm.wait(1)
  644. expect((yield* status.get(chat.id)).type).toBe("busy")
  645. yield* prompt.cancel(chat.id)
  646. yield* Fiber.await(fiber)
  647. expect((yield* status.get(chat.id)).type).toBe("idle")
  648. }),
  649. { git: true, config: providerCfg },
  650. ),
  651. 3_000,
  652. )
  653. // Cancel semantics
  654. it.live(
  655. "cancel interrupts loop and resolves with an assistant message",
  656. () =>
  657. provideTmpdirServer(
  658. Effect.fnUntraced(function* ({ llm }) {
  659. const prompt = yield* SessionPrompt.Service
  660. const sessions = yield* Session.Service
  661. const chat = yield* sessions.create({ title: "Pinned" })
  662. yield* seed(chat.id)
  663. yield* llm.hang
  664. yield* user(chat.id, "more")
  665. const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
  666. yield* llm.wait(1)
  667. yield* prompt.cancel(chat.id)
  668. const exit = yield* Fiber.await(fiber)
  669. expect(Exit.isSuccess(exit)).toBe(true)
  670. if (Exit.isSuccess(exit)) {
  671. expect(exit.value.info.role).toBe("assistant")
  672. }
  673. }),
  674. { git: true, config: providerCfg },
  675. ),
  676. 3_000,
  677. )
  678. it.live(
  679. "cancel records MessageAbortedError on interrupted process",
  680. () =>
  681. provideTmpdirServer(
  682. Effect.fnUntraced(function* ({ llm }) {
  683. const prompt = yield* SessionPrompt.Service
  684. const sessions = yield* Session.Service
  685. const chat = yield* sessions.create({ title: "Pinned" })
  686. yield* llm.hang
  687. yield* user(chat.id, "hello")
  688. const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
  689. yield* llm.wait(1)
  690. yield* prompt.cancel(chat.id)
  691. const exit = yield* Fiber.await(fiber)
  692. expect(Exit.isSuccess(exit)).toBe(true)
  693. if (Exit.isSuccess(exit)) {
  694. const info = exit.value.info
  695. if (info.role === "assistant") {
  696. expect(info.error?.name).toBe("MessageAbortedError")
  697. }
  698. }
  699. }),
  700. { git: true, config: providerCfg },
  701. ),
  702. 3_000,
  703. )
  704. it.live(
  705. "cancel finalizes subtask tool state",
  706. () =>
  707. provideTmpdirInstance(
  708. () =>
  709. Effect.gen(function* () {
  710. const ready = defer<void>()
  711. const aborted = defer<void>()
  712. const registry = yield* ToolRegistry.Service
  713. const { task } = yield* registry.named()
  714. const original = task.execute
  715. task.execute = (_args, ctx) =>
  716. Effect.callback<never>((resume) => {
  717. ready.resolve()
  718. ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true })
  719. return Effect.sync(() => aborted.resolve())
  720. })
  721. yield* Effect.addFinalizer(() => Effect.sync(() => void (task.execute = original)))
  722. const { prompt, chat } = yield* boot()
  723. const msg = yield* user(chat.id, "hello")
  724. yield* addSubtask(chat.id, msg.id)
  725. const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
  726. yield* Effect.promise(() => ready.promise)
  727. yield* prompt.cancel(chat.id)
  728. yield* Effect.promise(() => aborted.promise)
  729. const exit = yield* Fiber.await(fiber)
  730. expect(Exit.isSuccess(exit)).toBe(true)
  731. const msgs = yield* MessageV2.filterCompactedEffect(chat.id)
  732. const taskMsg = msgs.find((item) => item.info.role === "assistant" && item.info.agent === "general")
  733. expect(taskMsg?.info.role).toBe("assistant")
  734. if (!taskMsg || taskMsg.info.role !== "assistant") return
  735. const tool = toolPart(taskMsg.parts)
  736. expect(tool?.type).toBe("tool")
  737. if (!tool) return
  738. expect(tool.state.status).not.toBe("running")
  739. expect(taskMsg.info.time.completed).toBeDefined()
  740. expect(taskMsg.info.finish).toBeDefined()
  741. }),
  742. { git: true, config: cfg },
  743. ),
  744. 30_000,
  745. )
  746. it.live(
  747. "cancel with queued callers resolves all cleanly",
  748. () =>
  749. provideTmpdirServer(
  750. Effect.fnUntraced(function* ({ llm }) {
  751. const prompt = yield* SessionPrompt.Service
  752. const sessions = yield* Session.Service
  753. const chat = yield* sessions.create({ title: "Pinned" })
  754. yield* llm.hang
  755. yield* user(chat.id, "hello")
  756. const a = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
  757. yield* llm.wait(1)
  758. const b = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
  759. yield* Effect.sleep(50)
  760. yield* prompt.cancel(chat.id)
  761. const [exitA, exitB] = yield* Effect.all([Fiber.await(a), Fiber.await(b)])
  762. expect(Exit.isSuccess(exitA)).toBe(true)
  763. expect(Exit.isSuccess(exitB)).toBe(true)
  764. if (Exit.isSuccess(exitA) && Exit.isSuccess(exitB)) {
  765. expect(exitA.value.info.id).toBe(exitB.value.info.id)
  766. }
  767. }),
  768. { git: true, config: providerCfg },
  769. ),
  770. 3_000,
  771. )
  772. // Queue semantics
  773. it.live("concurrent loop callers get same result", () =>
  774. provideTmpdirInstance(
  775. (dir) =>
  776. Effect.gen(function* () {
  777. const { prompt, run, chat } = yield* boot()
  778. yield* seed(chat.id, { finish: "stop" })
  779. const [a, b] = yield* Effect.all([prompt.loop({ sessionID: chat.id }), prompt.loop({ sessionID: chat.id })], {
  780. concurrency: "unbounded",
  781. })
  782. expect(a.info.id).toBe(b.info.id)
  783. expect(a.info.role).toBe("assistant")
  784. yield* run.assertNotBusy(chat.id)
  785. }),
  786. { git: true },
  787. ),
  788. )
  789. it.live(
  790. "concurrent loop callers all receive same error result",
  791. () =>
  792. provideTmpdirServer(
  793. Effect.fnUntraced(function* ({ llm }) {
  794. const prompt = yield* SessionPrompt.Service
  795. const sessions = yield* Session.Service
  796. const chat = yield* sessions.create({ title: "Pinned" })
  797. yield* llm.fail("boom")
  798. yield* user(chat.id, "hello")
  799. const [a, b] = yield* Effect.all([prompt.loop({ sessionID: chat.id }), prompt.loop({ sessionID: chat.id })], {
  800. concurrency: "unbounded",
  801. })
  802. expect(a.info.id).toBe(b.info.id)
  803. expect(a.info.role).toBe("assistant")
  804. }),
  805. { git: true, config: providerCfg },
  806. ),
  807. 3_000,
  808. )
  809. it.live(
  810. "prompt submitted during an active run is included in the next LLM input",
  811. () =>
  812. provideTmpdirServer(
  813. Effect.fnUntraced(function* ({ llm }) {
  814. const gate = defer<void>()
  815. const prompt = yield* SessionPrompt.Service
  816. const sessions = yield* Session.Service
  817. const chat = yield* sessions.create({ title: "Pinned" })
  818. yield* llm.hold("first", gate.promise)
  819. yield* llm.text("second")
  820. const a = yield* prompt
  821. .prompt({
  822. sessionID: chat.id,
  823. agent: "build",
  824. model: ref,
  825. parts: [{ type: "text", text: "first" }],
  826. })
  827. .pipe(Effect.forkChild)
  828. yield* llm.wait(1)
  829. const id = MessageID.ascending()
  830. const b = yield* prompt
  831. .prompt({
  832. sessionID: chat.id,
  833. messageID: id,
  834. agent: "build",
  835. model: ref,
  836. parts: [{ type: "text", text: "second" }],
  837. })
  838. .pipe(Effect.forkChild)
  839. yield* Effect.promise(async () => {
  840. const end = Date.now() + 5000
  841. while (Date.now() < end) {
  842. const msgs = await Effect.runPromise(sessions.messages({ sessionID: chat.id }))
  843. if (msgs.some((msg) => msg.info.role === "user" && msg.info.id === id)) return
  844. await new Promise((done) => setTimeout(done, 20))
  845. }
  846. throw new Error("timed out waiting for second prompt to save")
  847. })
  848. gate.resolve()
  849. const [ea, eb] = yield* Effect.all([Fiber.await(a), Fiber.await(b)])
  850. expect(Exit.isSuccess(ea)).toBe(true)
  851. expect(Exit.isSuccess(eb)).toBe(true)
  852. expect(yield* llm.calls).toBe(2)
  853. const msgs = yield* sessions.messages({ sessionID: chat.id })
  854. const assistants = msgs.filter((msg) => msg.info.role === "assistant")
  855. expect(assistants).toHaveLength(2)
  856. const last = assistants.at(-1)
  857. if (!last || last.info.role !== "assistant") throw new Error("expected second assistant")
  858. expect(last.info.parentID).toBe(id)
  859. expect(last.parts.some((part) => part.type === "text" && part.text === "second")).toBe(true)
  860. const inputs = yield* llm.inputs
  861. expect(inputs).toHaveLength(2)
  862. expect(JSON.stringify(inputs.at(-1)?.messages)).toContain("second")
  863. }),
  864. { git: true, config: providerCfg },
  865. ),
  866. 3_000,
  867. )
  868. it.live(
  869. "assertNotBusy throws BusyError when loop running",
  870. () =>
  871. provideTmpdirServer(
  872. Effect.fnUntraced(function* ({ llm }) {
  873. const prompt = yield* SessionPrompt.Service
  874. const run = yield* SessionRunState.Service
  875. const sessions = yield* Session.Service
  876. yield* llm.hang
  877. const chat = yield* sessions.create({})
  878. yield* user(chat.id, "hi")
  879. const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
  880. yield* llm.wait(1)
  881. const exit = yield* run.assertNotBusy(chat.id).pipe(Effect.exit)
  882. expect(Exit.isFailure(exit)).toBe(true)
  883. if (Exit.isFailure(exit)) {
  884. expect(Cause.squash(exit.cause)).toBeInstanceOf(Session.BusyError)
  885. }
  886. yield* prompt.cancel(chat.id)
  887. yield* Fiber.await(fiber)
  888. }),
  889. { git: true, config: providerCfg },
  890. ),
  891. 3_000,
  892. )
  893. it.live("assertNotBusy succeeds when idle", () =>
  894. provideTmpdirInstance(
  895. (dir) =>
  896. Effect.gen(function* () {
  897. const run = yield* SessionRunState.Service
  898. const sessions = yield* Session.Service
  899. const chat = yield* sessions.create({})
  900. const exit = yield* run.assertNotBusy(chat.id).pipe(Effect.exit)
  901. expect(Exit.isSuccess(exit)).toBe(true)
  902. }),
  903. { git: true },
  904. ),
  905. )
  906. // Shell semantics
  907. it.live(
  908. "shell rejects with BusyError when loop running",
  909. () =>
  910. provideTmpdirServer(
  911. Effect.fnUntraced(function* ({ llm }) {
  912. const prompt = yield* SessionPrompt.Service
  913. const sessions = yield* Session.Service
  914. const chat = yield* sessions.create({ title: "Pinned" })
  915. yield* llm.hang
  916. yield* user(chat.id, "hi")
  917. const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
  918. yield* llm.wait(1)
  919. const exit = yield* prompt.shell({ sessionID: chat.id, agent: "build", command: "echo hi" }).pipe(Effect.exit)
  920. expect(Exit.isFailure(exit)).toBe(true)
  921. if (Exit.isFailure(exit)) {
  922. expect(Cause.squash(exit.cause)).toBeInstanceOf(Session.BusyError)
  923. }
  924. yield* prompt.cancel(chat.id)
  925. yield* Fiber.await(fiber)
  926. }),
  927. { git: true, config: providerCfg },
  928. ),
  929. 3_000,
  930. )
  931. unix("shell captures stdout and stderr in completed tool output", () =>
  932. provideTmpdirInstance(
  933. (dir) =>
  934. Effect.gen(function* () {
  935. const { prompt, run, chat } = yield* boot()
  936. const result = yield* prompt.shell({
  937. sessionID: chat.id,
  938. agent: "build",
  939. command: "printf out && printf err >&2",
  940. })
  941. expect(result.info.role).toBe("assistant")
  942. const tool = completedTool(result.parts)
  943. if (!tool) return
  944. expect(tool.state.output).toContain("out")
  945. expect(tool.state.output).toContain("err")
  946. expect(tool.state.metadata.output).toContain("out")
  947. expect(tool.state.metadata.output).toContain("err")
  948. yield* run.assertNotBusy(chat.id)
  949. }),
  950. { git: true, config: cfg },
  951. ),
  952. )
  953. unix("shell completes a fast command on the preferred shell", () =>
  954. provideTmpdirInstance(
  955. (dir) =>
  956. Effect.gen(function* () {
  957. const { prompt, run, chat } = yield* boot()
  958. const result = yield* prompt.shell({
  959. sessionID: chat.id,
  960. agent: "build",
  961. command: "pwd",
  962. })
  963. expect(result.info.role).toBe("assistant")
  964. const tool = completedTool(result.parts)
  965. if (!tool) return
  966. expect(tool.state.input.command).toBe("pwd")
  967. expect(tool.state.output).toContain(dir)
  968. expect(tool.state.metadata.output).toContain(dir)
  969. yield* run.assertNotBusy(chat.id)
  970. }),
  971. { git: true, config: cfg },
  972. ),
  973. )
  974. unix("shell lists files from the project directory", () =>
  975. provideTmpdirInstance(
  976. (dir) =>
  977. Effect.gen(function* () {
  978. const { prompt, run, chat } = yield* boot()
  979. yield* Effect.promise(() => Bun.write(path.join(dir, "README.md"), "# e2e\n"))
  980. const result = yield* prompt.shell({
  981. sessionID: chat.id,
  982. agent: "build",
  983. command: "command ls",
  984. })
  985. expect(result.info.role).toBe("assistant")
  986. const tool = completedTool(result.parts)
  987. if (!tool) return
  988. expect(tool.state.input.command).toBe("command ls")
  989. expect(tool.state.output).toContain("README.md")
  990. expect(tool.state.metadata.output).toContain("README.md")
  991. yield* run.assertNotBusy(chat.id)
  992. }),
  993. { git: true, config: cfg },
  994. ),
  995. )
  996. unix("shell captures stderr from a failing command", () =>
  997. provideTmpdirInstance(
  998. (dir) =>
  999. Effect.gen(function* () {
  1000. const { prompt, run, chat } = yield* boot()
  1001. const result = yield* prompt.shell({
  1002. sessionID: chat.id,
  1003. agent: "build",
  1004. command: "command -v __nonexistent_cmd_e2e__ || echo 'not found' >&2; exit 1",
  1005. })
  1006. expect(result.info.role).toBe("assistant")
  1007. const tool = completedTool(result.parts)
  1008. if (!tool) return
  1009. expect(tool.state.output).toContain("not found")
  1010. expect(tool.state.metadata.output).toContain("not found")
  1011. yield* run.assertNotBusy(chat.id)
  1012. }),
  1013. { git: true, config: cfg },
  1014. ),
  1015. )
  1016. unix(
  1017. "shell updates running metadata before process exit",
  1018. () =>
  1019. withSh(() =>
  1020. provideTmpdirInstance(
  1021. (dir) =>
  1022. Effect.gen(function* () {
  1023. const { prompt, chat } = yield* boot()
  1024. const fiber = yield* prompt
  1025. .shell({ sessionID: chat.id, agent: "build", command: "printf first && sleep 0.2 && printf second" })
  1026. .pipe(Effect.forkChild)
  1027. yield* Effect.promise(async () => {
  1028. const start = Date.now()
  1029. while (Date.now() - start < 5000) {
  1030. const msgs = await MessageV2.filterCompacted(MessageV2.stream(chat.id))
  1031. const taskMsg = msgs.find((item) => item.info.role === "assistant")
  1032. const tool = taskMsg ? toolPart(taskMsg.parts) : undefined
  1033. if (tool?.state.status === "running" && tool.state.metadata?.output.includes("first")) return
  1034. await new Promise((done) => setTimeout(done, 20))
  1035. }
  1036. throw new Error("timed out waiting for running shell metadata")
  1037. })
  1038. const exit = yield* Fiber.await(fiber)
  1039. expect(Exit.isSuccess(exit)).toBe(true)
  1040. }),
  1041. { git: true, config: cfg },
  1042. ),
  1043. ),
  1044. 30_000,
  1045. )
  1046. it.live(
  1047. "loop waits while shell runs and starts after shell exits",
  1048. () =>
  1049. provideTmpdirServer(
  1050. Effect.fnUntraced(function* ({ llm }) {
  1051. const prompt = yield* SessionPrompt.Service
  1052. const sessions = yield* Session.Service
  1053. const chat = yield* sessions.create({
  1054. title: "Pinned",
  1055. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  1056. })
  1057. yield* llm.text("after-shell")
  1058. const sh = yield* prompt
  1059. .shell({ sessionID: chat.id, agent: "build", command: "sleep 0.2" })
  1060. .pipe(Effect.forkChild)
  1061. yield* Effect.sleep(50)
  1062. const loop = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
  1063. yield* Effect.sleep(50)
  1064. expect(yield* llm.calls).toBe(0)
  1065. yield* Fiber.await(sh)
  1066. const exit = yield* Fiber.await(loop)
  1067. expect(Exit.isSuccess(exit)).toBe(true)
  1068. if (Exit.isSuccess(exit)) {
  1069. expect(exit.value.info.role).toBe("assistant")
  1070. expect(exit.value.parts.some((part) => part.type === "text" && part.text === "after-shell")).toBe(true)
  1071. }
  1072. expect(yield* llm.calls).toBe(1)
  1073. }),
  1074. { git: true, config: providerCfg },
  1075. ),
  1076. 3_000,
  1077. )
  1078. it.live(
  1079. "shell completion resumes queued loop callers",
  1080. () =>
  1081. provideTmpdirServer(
  1082. Effect.fnUntraced(function* ({ llm }) {
  1083. const prompt = yield* SessionPrompt.Service
  1084. const sessions = yield* Session.Service
  1085. const chat = yield* sessions.create({
  1086. title: "Pinned",
  1087. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  1088. })
  1089. yield* llm.text("done")
  1090. const sh = yield* prompt
  1091. .shell({ sessionID: chat.id, agent: "build", command: "sleep 0.2" })
  1092. .pipe(Effect.forkChild)
  1093. yield* Effect.sleep(50)
  1094. const a = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
  1095. const b = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
  1096. yield* Effect.sleep(50)
  1097. expect(yield* llm.calls).toBe(0)
  1098. yield* Fiber.await(sh)
  1099. const [ea, eb] = yield* Effect.all([Fiber.await(a), Fiber.await(b)])
  1100. expect(Exit.isSuccess(ea)).toBe(true)
  1101. expect(Exit.isSuccess(eb)).toBe(true)
  1102. if (Exit.isSuccess(ea) && Exit.isSuccess(eb)) {
  1103. expect(ea.value.info.id).toBe(eb.value.info.id)
  1104. expect(ea.value.info.role).toBe("assistant")
  1105. }
  1106. expect(yield* llm.calls).toBe(1)
  1107. }),
  1108. { git: true, config: providerCfg },
  1109. ),
  1110. 3_000,
  1111. )
  1112. unix(
  1113. "cancel interrupts shell and resolves cleanly",
  1114. () =>
  1115. withSh(() =>
  1116. provideTmpdirInstance(
  1117. (dir) =>
  1118. Effect.gen(function* () {
  1119. const { prompt, run, chat } = yield* boot()
  1120. const sh = yield* prompt
  1121. .shell({ sessionID: chat.id, agent: "build", command: "sleep 30" })
  1122. .pipe(Effect.forkChild)
  1123. yield* Effect.sleep(50)
  1124. yield* prompt.cancel(chat.id)
  1125. const status = yield* SessionStatus.Service
  1126. expect((yield* status.get(chat.id)).type).toBe("idle")
  1127. const busy = yield* run.assertNotBusy(chat.id).pipe(Effect.exit)
  1128. expect(Exit.isSuccess(busy)).toBe(true)
  1129. const exit = yield* Fiber.await(sh)
  1130. expect(Exit.isSuccess(exit)).toBe(true)
  1131. if (Exit.isSuccess(exit)) {
  1132. expect(exit.value.info.role).toBe("assistant")
  1133. const tool = completedTool(exit.value.parts)
  1134. if (tool) {
  1135. expect(tool.state.output).toContain("User aborted the command")
  1136. }
  1137. }
  1138. }),
  1139. { git: true, config: cfg },
  1140. ),
  1141. ),
  1142. 30_000,
  1143. )
  1144. unix(
  1145. "cancel persists aborted shell result when shell ignores TERM",
  1146. () =>
  1147. withSh(() =>
  1148. provideTmpdirInstance(
  1149. (dir) =>
  1150. Effect.gen(function* () {
  1151. const { prompt, chat } = yield* boot()
  1152. const sh = yield* prompt
  1153. .shell({ sessionID: chat.id, agent: "build", command: "trap '' TERM; sleep 30" })
  1154. .pipe(Effect.forkChild)
  1155. yield* Effect.sleep(50)
  1156. yield* prompt.cancel(chat.id)
  1157. const exit = yield* Fiber.await(sh)
  1158. expect(Exit.isSuccess(exit)).toBe(true)
  1159. if (Exit.isSuccess(exit)) {
  1160. expect(exit.value.info.role).toBe("assistant")
  1161. const tool = completedTool(exit.value.parts)
  1162. if (tool) {
  1163. expect(tool.state.output).toContain("User aborted the command")
  1164. }
  1165. }
  1166. }),
  1167. { git: true, config: cfg },
  1168. ),
  1169. ),
  1170. 30_000,
  1171. )
  1172. unix(
  1173. "cancel finalizes interrupted bash tool output through normal truncation",
  1174. () =>
  1175. provideTmpdirServer(
  1176. ({ dir, llm }) =>
  1177. Effect.gen(function* () {
  1178. const prompt = yield* SessionPrompt.Service
  1179. const sessions = yield* Session.Service
  1180. const chat = yield* sessions.create({
  1181. title: "Interrupted bash truncation",
  1182. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  1183. })
  1184. yield* prompt.prompt({
  1185. sessionID: chat.id,
  1186. agent: "build",
  1187. noReply: true,
  1188. parts: [{ type: "text", text: "run bash" }],
  1189. })
  1190. yield* llm.tool("bash", {
  1191. command:
  1192. 'i=0; while [ "$i" -lt 4000 ]; do printf "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx %05d\\n" "$i"; i=$((i + 1)); done; sleep 30',
  1193. description: "Print many lines",
  1194. timeout: 30_000,
  1195. workdir: path.resolve(dir),
  1196. })
  1197. const run = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
  1198. yield* llm.wait(1)
  1199. yield* Effect.sleep(150)
  1200. yield* prompt.cancel(chat.id)
  1201. const exit = yield* Fiber.await(run)
  1202. expect(Exit.isSuccess(exit)).toBe(true)
  1203. if (Exit.isFailure(exit)) return
  1204. const tool = completedTool(exit.value.parts)
  1205. if (!tool) return
  1206. expect(tool.state.metadata.truncated).toBe(true)
  1207. expect(typeof tool.state.metadata.outputPath).toBe("string")
  1208. expect(tool.state.output).toMatch(/\.\.\.output truncated\.\.\./)
  1209. expect(tool.state.output).toMatch(/Full output saved to:\s+\S+/)
  1210. expect(tool.state.output).not.toContain("Tool execution aborted")
  1211. }),
  1212. { git: true, config: providerCfg },
  1213. ),
  1214. 30_000,
  1215. )
  1216. unix(
  1217. "cancel interrupts loop queued behind shell",
  1218. () =>
  1219. provideTmpdirInstance(
  1220. (dir) =>
  1221. Effect.gen(function* () {
  1222. const { prompt, chat } = yield* boot()
  1223. const sh = yield* prompt
  1224. .shell({ sessionID: chat.id, agent: "build", command: "sleep 30" })
  1225. .pipe(Effect.forkChild)
  1226. yield* Effect.sleep(50)
  1227. const loop = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
  1228. yield* Effect.sleep(50)
  1229. yield* prompt.cancel(chat.id)
  1230. const exit = yield* Fiber.await(loop)
  1231. expect(Exit.isSuccess(exit)).toBe(true)
  1232. yield* Fiber.await(sh)
  1233. }),
  1234. { git: true, config: cfg },
  1235. ),
  1236. 30_000,
  1237. )
  1238. unix(
  1239. "shell rejects when another shell is already running",
  1240. () =>
  1241. withSh(() =>
  1242. provideTmpdirInstance(
  1243. (dir) =>
  1244. Effect.gen(function* () {
  1245. const { prompt, chat } = yield* boot()
  1246. const a = yield* prompt
  1247. .shell({ sessionID: chat.id, agent: "build", command: "sleep 30" })
  1248. .pipe(Effect.forkChild)
  1249. yield* Effect.sleep(50)
  1250. const exit = yield* prompt
  1251. .shell({ sessionID: chat.id, agent: "build", command: "echo hi" })
  1252. .pipe(Effect.exit)
  1253. expect(Exit.isFailure(exit)).toBe(true)
  1254. if (Exit.isFailure(exit)) {
  1255. expect(Cause.squash(exit.cause)).toBeInstanceOf(Session.BusyError)
  1256. }
  1257. yield* prompt.cancel(chat.id)
  1258. yield* Fiber.await(a)
  1259. }),
  1260. { git: true, config: cfg },
  1261. ),
  1262. ),
  1263. 30_000,
  1264. )
  1265. // Abort signal propagation tests for inline tool execution
  1266. /** Override a tool's execute to hang until aborted. Returns ready/aborted defers and a finalizer. */
  1267. function hangUntilAborted(tool: { execute: (...args: any[]) => any }) {
  1268. const ready = defer<void>()
  1269. const aborted = defer<void>()
  1270. const original = tool.execute
  1271. tool.execute = (_args: any, ctx: any) => {
  1272. ready.resolve()
  1273. ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true })
  1274. return Effect.callback<never>(() => {})
  1275. }
  1276. const restore = Effect.addFinalizer(() => Effect.sync(() => void (tool.execute = original)))
  1277. return { ready, aborted, restore }
  1278. }
  1279. it.live(
  1280. "interrupt propagates abort signal to read tool via file part (text/plain)",
  1281. () =>
  1282. provideTmpdirInstance(
  1283. (dir) =>
  1284. Effect.gen(function* () {
  1285. const registry = yield* ToolRegistry.Service
  1286. const { read } = yield* registry.named()
  1287. const { ready, aborted, restore } = hangUntilAborted(read)
  1288. yield* restore
  1289. const prompt = yield* SessionPrompt.Service
  1290. const sessions = yield* Session.Service
  1291. const chat = yield* sessions.create({ title: "Abort Test" })
  1292. const testFile = path.join(dir, "test.txt")
  1293. yield* Effect.promise(() => Bun.write(testFile, "hello world"))
  1294. const fiber = yield* prompt
  1295. .prompt({
  1296. sessionID: chat.id,
  1297. agent: "build",
  1298. parts: [
  1299. { type: "text", text: "read this" },
  1300. { type: "file", url: `file://${testFile}`, filename: "test.txt", mime: "text/plain" },
  1301. ],
  1302. })
  1303. .pipe(Effect.forkChild)
  1304. yield* Effect.promise(() => ready.promise)
  1305. yield* Fiber.interrupt(fiber)
  1306. yield* Effect.promise(() =>
  1307. Promise.race([
  1308. aborted.promise,
  1309. new Promise<void>((_, reject) =>
  1310. setTimeout(() => reject(new Error("abort signal not propagated within 2s")), 2_000),
  1311. ),
  1312. ]),
  1313. )
  1314. }),
  1315. { git: true, config: cfg },
  1316. ),
  1317. 30_000,
  1318. )
  1319. it.live(
  1320. "interrupt propagates abort signal to read tool via file part (directory)",
  1321. () =>
  1322. provideTmpdirInstance(
  1323. (dir) =>
  1324. Effect.gen(function* () {
  1325. const registry = yield* ToolRegistry.Service
  1326. const { read } = yield* registry.named()
  1327. const { ready, aborted, restore } = hangUntilAborted(read)
  1328. yield* restore
  1329. const prompt = yield* SessionPrompt.Service
  1330. const sessions = yield* Session.Service
  1331. const chat = yield* sessions.create({ title: "Abort Test" })
  1332. const fiber = yield* prompt
  1333. .prompt({
  1334. sessionID: chat.id,
  1335. agent: "build",
  1336. parts: [
  1337. { type: "text", text: "read this" },
  1338. { type: "file", url: `file://${dir}`, filename: "dir", mime: "application/x-directory" },
  1339. ],
  1340. })
  1341. .pipe(Effect.forkChild)
  1342. yield* Effect.promise(() => ready.promise)
  1343. yield* Fiber.interrupt(fiber)
  1344. yield* Effect.promise(() =>
  1345. Promise.race([
  1346. aborted.promise,
  1347. new Promise<void>((_, reject) =>
  1348. setTimeout(() => reject(new Error("abort signal not propagated within 2s")), 2_000),
  1349. ),
  1350. ]),
  1351. )
  1352. }),
  1353. { git: true, config: cfg },
  1354. ),
  1355. 30_000,
  1356. )