prompt.test.ts 67 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061
  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 { fileURLToPath } from "url"
  7. import { NamedError } from "@opencode-ai/core/util/error"
  8. import { Agent as AgentSvc } from "../../src/agent/agent"
  9. import { Bus } from "../../src/bus"
  10. import { Command } from "../../src/command"
  11. import { Config } from "@/config/config"
  12. import { LSP } from "@/lsp/lsp"
  13. import { MCP } from "../../src/mcp"
  14. import { Permission } from "../../src/permission"
  15. import { Plugin } from "../../src/plugin"
  16. import { Provider as ProviderSvc } from "@/provider/provider"
  17. import { Env } from "../../src/env"
  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 "@/session/session"
  22. import { SessionMessageTable } from "../../src/session/session.sql"
  23. import { LLM } from "../../src/session/llm"
  24. import { MessageV2 } from "../../src/session/message-v2"
  25. import { AppFileSystem } from "@opencode-ai/core/filesystem"
  26. import { SessionCompaction } from "../../src/session/compaction"
  27. import { SessionSummary } from "../../src/session/summary"
  28. import { Instruction } from "../../src/session/instruction"
  29. import { SessionProcessor } from "../../src/session/processor"
  30. import { SessionPrompt } from "../../src/session/prompt"
  31. import { SessionRevert } from "../../src/session/revert"
  32. import { SessionRunState } from "../../src/session/run-state"
  33. import { MessageID, PartID, SessionID } from "../../src/session/schema"
  34. import { SessionStatus } from "../../src/session/status"
  35. import { SessionV2 } from "../../src/v2/session"
  36. import { Skill } from "../../src/skill"
  37. import { SystemPrompt } from "../../src/session/system"
  38. import { Shell } from "../../src/shell/shell"
  39. import { Snapshot } from "../../src/snapshot"
  40. import { ToolRegistry } from "@/tool/registry"
  41. import { Truncate } from "@/tool/truncate"
  42. import * as Log from "@opencode-ai/core/util/log"
  43. import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
  44. import * as Database from "../../src/storage/db"
  45. import { Ripgrep } from "../../src/file/ripgrep"
  46. import { Format } from "../../src/format"
  47. import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture"
  48. import { testEffect } from "../lib/effect"
  49. import { reply, TestLLMServer } from "../lib/llm-server"
  50. void Log.init({ print: false })
  51. const summary = Layer.succeed(
  52. SessionSummary.Service,
  53. SessionSummary.Service.of({
  54. summarize: () => Effect.void,
  55. diff: () => Effect.succeed([]),
  56. computeDiff: () => Effect.succeed([]),
  57. }),
  58. )
  59. const ref = {
  60. providerID: ProviderID.make("test"),
  61. modelID: ModelID.make("test-model"),
  62. }
  63. function defer<T>() {
  64. let resolve!: (value: T | PromiseLike<T>) => void
  65. const promise = new Promise<T>((done) => {
  66. resolve = done
  67. })
  68. return { promise, resolve }
  69. }
  70. function withSh<A, E, R>(fx: () => Effect.Effect<A, E, R>) {
  71. return Effect.acquireUseRelease(
  72. Effect.sync(() => {
  73. const prev = process.env.SHELL
  74. process.env.SHELL = "/bin/sh"
  75. Shell.preferred.reset()
  76. return prev
  77. }),
  78. () => fx(),
  79. (prev) =>
  80. Effect.sync(() => {
  81. if (prev === undefined) delete process.env.SHELL
  82. else process.env.SHELL = prev
  83. Shell.preferred.reset()
  84. }),
  85. )
  86. }
  87. function toolPart(parts: MessageV2.Part[]) {
  88. return parts.find((part): part is MessageV2.ToolPart => part.type === "tool")
  89. }
  90. type CompletedToolPart = MessageV2.ToolPart & { state: MessageV2.ToolStateCompleted }
  91. type ErrorToolPart = MessageV2.ToolPart & { state: MessageV2.ToolStateError }
  92. function completedTool(parts: MessageV2.Part[]) {
  93. const part = toolPart(parts)
  94. expect(part?.state.status).toBe("completed")
  95. return part?.state.status === "completed" ? (part as CompletedToolPart) : undefined
  96. }
  97. function errorTool(parts: MessageV2.Part[]) {
  98. const part = toolPart(parts)
  99. expect(part?.state.status).toBe("error")
  100. return part?.state.status === "error" ? (part as ErrorToolPart) : undefined
  101. }
  102. const mcp = Layer.succeed(
  103. MCP.Service,
  104. MCP.Service.of({
  105. status: () => Effect.succeed({}),
  106. clients: () => Effect.succeed({}),
  107. tools: () => Effect.succeed({}),
  108. prompts: () => Effect.succeed({}),
  109. resources: () => Effect.succeed({}),
  110. add: () => Effect.succeed({ status: { status: "disabled" as const } }),
  111. connect: () => Effect.void,
  112. disconnect: () => Effect.void,
  113. getPrompt: () => Effect.succeed(undefined),
  114. readResource: () => Effect.succeed(undefined),
  115. startAuth: () => Effect.die("unexpected MCP auth in prompt-effect tests"),
  116. authenticate: () => Effect.die("unexpected MCP auth in prompt-effect tests"),
  117. finishAuth: () => Effect.die("unexpected MCP auth in prompt-effect tests"),
  118. removeAuth: () => Effect.void,
  119. supportsOAuth: () => Effect.succeed(false),
  120. hasStoredTokens: () => Effect.succeed(false),
  121. getAuthStatus: () => Effect.succeed("not_authenticated" as const),
  122. }),
  123. )
  124. const lsp = Layer.succeed(
  125. LSP.Service,
  126. LSP.Service.of({
  127. init: () => Effect.void,
  128. status: () => Effect.succeed([]),
  129. hasClients: () => Effect.succeed(false),
  130. touchFile: () => Effect.void,
  131. diagnostics: () => Effect.succeed({}),
  132. hover: () => Effect.succeed(undefined),
  133. definition: () => Effect.succeed([]),
  134. references: () => Effect.succeed([]),
  135. implementation: () => Effect.succeed([]),
  136. documentSymbol: () => Effect.succeed([]),
  137. workspaceSymbol: () => Effect.succeed([]),
  138. prepareCallHierarchy: () => Effect.succeed([]),
  139. incomingCalls: () => Effect.succeed([]),
  140. outgoingCalls: () => Effect.succeed([]),
  141. }),
  142. )
  143. const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer))
  144. const run = SessionRunState.layer.pipe(Layer.provide(status))
  145. const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer)
  146. function makeHttp() {
  147. const deps = Layer.mergeAll(
  148. Session.defaultLayer,
  149. Snapshot.defaultLayer,
  150. LLM.defaultLayer,
  151. Env.defaultLayer,
  152. AgentSvc.defaultLayer,
  153. Command.defaultLayer,
  154. Permission.defaultLayer,
  155. Plugin.defaultLayer,
  156. Config.defaultLayer,
  157. ProviderSvc.defaultLayer,
  158. lsp,
  159. mcp,
  160. AppFileSystem.defaultLayer,
  161. status,
  162. ).pipe(Layer.provideMerge(infra))
  163. const question = Question.layer.pipe(Layer.provideMerge(deps))
  164. const todo = Todo.layer.pipe(Layer.provideMerge(deps))
  165. const registry = ToolRegistry.layer.pipe(
  166. Layer.provide(Skill.defaultLayer),
  167. Layer.provide(FetchHttpClient.layer),
  168. Layer.provide(CrossSpawnSpawner.defaultLayer),
  169. Layer.provide(Ripgrep.defaultLayer),
  170. Layer.provide(Format.defaultLayer),
  171. Layer.provideMerge(todo),
  172. Layer.provideMerge(question),
  173. Layer.provideMerge(deps),
  174. )
  175. const trunc = Truncate.layer.pipe(Layer.provideMerge(deps))
  176. const proc = SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provideMerge(deps))
  177. const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps))
  178. return Layer.mergeAll(
  179. TestLLMServer.layer,
  180. SessionPrompt.layer.pipe(
  181. Layer.provide(SessionRevert.defaultLayer),
  182. Layer.provide(summary),
  183. Layer.provideMerge(run),
  184. Layer.provideMerge(compact),
  185. Layer.provideMerge(proc),
  186. Layer.provideMerge(registry),
  187. Layer.provideMerge(trunc),
  188. Layer.provide(Instruction.defaultLayer),
  189. Layer.provide(SystemPrompt.defaultLayer),
  190. Layer.provideMerge(deps),
  191. ),
  192. ).pipe(Layer.provide(summary))
  193. }
  194. const it = testEffect(makeHttp())
  195. const unix = process.platform !== "win32" ? it.live : it.live.skip
  196. // Config that registers a custom "test" provider with a "test-model" model
  197. // so provider model lookup succeeds inside the loop.
  198. const cfg = {
  199. provider: {
  200. test: {
  201. name: "Test",
  202. id: "test",
  203. env: [],
  204. npm: "@ai-sdk/openai-compatible",
  205. models: {
  206. "test-model": {
  207. id: "test-model",
  208. name: "Test Model",
  209. attachment: false,
  210. reasoning: false,
  211. temperature: false,
  212. tool_call: true,
  213. release_date: "2025-01-01",
  214. limit: { context: 100000, output: 10000 },
  215. cost: { input: 0, output: 0 },
  216. options: {},
  217. },
  218. },
  219. options: {
  220. apiKey: "test-key",
  221. baseURL: "http://localhost:1/v1",
  222. },
  223. },
  224. },
  225. }
  226. function providerCfg(url: string) {
  227. return {
  228. ...cfg,
  229. provider: {
  230. ...cfg.provider,
  231. test: {
  232. ...cfg.provider.test,
  233. options: {
  234. ...cfg.provider.test.options,
  235. baseURL: url,
  236. },
  237. },
  238. },
  239. }
  240. }
  241. const user = Effect.fn("test.user")(function* (sessionID: SessionID, text: string) {
  242. const session = yield* Session.Service
  243. const msg = yield* session.updateMessage({
  244. id: MessageID.ascending(),
  245. role: "user",
  246. sessionID,
  247. agent: "build",
  248. model: ref,
  249. time: { created: Date.now() },
  250. })
  251. yield* session.updatePart({
  252. id: PartID.ascending(),
  253. messageID: msg.id,
  254. sessionID,
  255. type: "text",
  256. text,
  257. })
  258. return msg
  259. })
  260. const seed = Effect.fn("test.seed")(function* (sessionID: SessionID, opts?: { finish?: string }) {
  261. const session = yield* Session.Service
  262. const msg = yield* user(sessionID, "hello")
  263. const assistant: MessageV2.Assistant = {
  264. id: MessageID.ascending(),
  265. role: "assistant",
  266. parentID: msg.id,
  267. sessionID,
  268. mode: "build",
  269. agent: "build",
  270. cost: 0,
  271. path: { cwd: "/tmp", root: "/tmp" },
  272. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  273. modelID: ref.modelID,
  274. providerID: ref.providerID,
  275. time: { created: Date.now() },
  276. ...(opts?.finish ? { finish: opts.finish } : {}),
  277. }
  278. yield* session.updateMessage(assistant)
  279. yield* session.updatePart({
  280. id: PartID.ascending(),
  281. messageID: assistant.id,
  282. sessionID,
  283. type: "text",
  284. text: "hi there",
  285. })
  286. return { user: msg, assistant }
  287. })
  288. const addSubtask = (sessionID: SessionID, messageID: MessageID, model = ref) =>
  289. Effect.gen(function* () {
  290. const session = yield* Session.Service
  291. yield* session.updatePart({
  292. id: PartID.ascending(),
  293. messageID,
  294. sessionID,
  295. type: "subtask",
  296. prompt: "look into the cache key path",
  297. description: "inspect bug",
  298. agent: "general",
  299. model,
  300. })
  301. })
  302. const boot = Effect.fn("test.boot")(function* (input?: { title?: string }) {
  303. const config = yield* Config.Service
  304. const prompt = yield* SessionPrompt.Service
  305. const run = yield* SessionRunState.Service
  306. const sessions = yield* Session.Service
  307. yield* config.get()
  308. const chat = yield* sessions.create(input ?? { title: "Pinned" })
  309. return { prompt, run, sessions, chat }
  310. })
  311. // Loop semantics
  312. it.live("loop exits immediately when last assistant has stop finish", () =>
  313. provideTmpdirServer(
  314. Effect.fnUntraced(function* ({ llm }) {
  315. const prompt = yield* SessionPrompt.Service
  316. const sessions = yield* Session.Service
  317. const chat = yield* sessions.create({ title: "Pinned" })
  318. yield* seed(chat.id, { finish: "stop" })
  319. const result = yield* prompt.loop({ sessionID: chat.id })
  320. expect(result.info.role).toBe("assistant")
  321. if (result.info.role === "assistant") expect(result.info.finish).toBe("stop")
  322. expect(yield* llm.calls).toBe(0)
  323. }),
  324. { git: true, config: providerCfg },
  325. ),
  326. )
  327. it.live("loop calls LLM and returns assistant message", () =>
  328. provideTmpdirServer(
  329. Effect.fnUntraced(function* ({ llm }) {
  330. const prompt = yield* SessionPrompt.Service
  331. const sessions = yield* Session.Service
  332. const chat = yield* sessions.create({
  333. title: "Pinned",
  334. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  335. })
  336. yield* prompt.prompt({
  337. sessionID: chat.id,
  338. agent: "build",
  339. noReply: true,
  340. parts: [{ type: "text", text: "hello" }],
  341. })
  342. yield* llm.text("world")
  343. const result = yield* prompt.loop({ sessionID: chat.id })
  344. expect(result.info.role).toBe("assistant")
  345. const parts = result.parts.filter((p) => p.type === "text")
  346. expect(parts.some((p) => p.type === "text" && p.text === "world")).toBe(true)
  347. expect(yield* llm.hits).toHaveLength(1)
  348. }),
  349. { git: true, config: providerCfg },
  350. ),
  351. )
  352. it.live("prompt emits v2 prompted and synthetic events", () =>
  353. provideTmpdirServer(
  354. Effect.fnUntraced(function* () {
  355. const prompt = yield* SessionPrompt.Service
  356. const sessions = yield* Session.Service
  357. const chat = yield* sessions.create({ title: "Pinned" })
  358. yield* prompt.prompt({
  359. sessionID: chat.id,
  360. agent: "build",
  361. noReply: true,
  362. parts: [
  363. { type: "text", text: "hello v2" },
  364. {
  365. type: "file",
  366. mime: "text/plain",
  367. filename: "note.txt",
  368. url: "data:text/plain;base64,bm90ZSBjb250ZW50",
  369. },
  370. ],
  371. })
  372. const messages = yield* SessionV2.Service.use((session) => session.messages({ sessionID: chat.id })).pipe(
  373. Effect.provide(SessionV2.layer),
  374. )
  375. const row = Database.use((db) =>
  376. db.select().from(SessionMessageTable).where(Database.eq(SessionMessageTable.session_id, chat.id)).get(),
  377. )
  378. expect(messages.find((message) => message.type === "user")).toMatchObject({ type: "user", text: "hello v2" })
  379. expect(typeof row?.data.time.created).toBe("number")
  380. expect(messages).toEqual(
  381. expect.arrayContaining([
  382. expect.objectContaining({ type: "synthetic", text: expect.stringContaining("Called the Read tool") }),
  383. expect.objectContaining({ type: "synthetic", text: "note content" }),
  384. ]),
  385. )
  386. }),
  387. { git: true, config: providerCfg },
  388. ),
  389. )
  390. it.live("static loop returns assistant text through local provider", () =>
  391. provideTmpdirServer(
  392. Effect.fnUntraced(function* ({ llm }) {
  393. const prompt = yield* SessionPrompt.Service
  394. const sessions = yield* Session.Service
  395. const session = yield* sessions.create({
  396. title: "Prompt provider",
  397. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  398. })
  399. yield* prompt.prompt({
  400. sessionID: session.id,
  401. agent: "build",
  402. noReply: true,
  403. parts: [{ type: "text", text: "hello" }],
  404. })
  405. yield* llm.text("world")
  406. const result = yield* prompt.loop({ sessionID: session.id })
  407. expect(result.info.role).toBe("assistant")
  408. expect(result.parts.some((part) => part.type === "text" && part.text === "world")).toBe(true)
  409. expect(yield* llm.hits).toHaveLength(1)
  410. expect(yield* llm.pending).toBe(0)
  411. }),
  412. { git: true, config: providerCfg },
  413. ),
  414. )
  415. it.live("static loop consumes queued replies across turns", () =>
  416. provideTmpdirServer(
  417. Effect.fnUntraced(function* ({ llm }) {
  418. const prompt = yield* SessionPrompt.Service
  419. const sessions = yield* Session.Service
  420. const session = yield* sessions.create({
  421. title: "Prompt provider turns",
  422. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  423. })
  424. yield* prompt.prompt({
  425. sessionID: session.id,
  426. agent: "build",
  427. noReply: true,
  428. parts: [{ type: "text", text: "hello one" }],
  429. })
  430. yield* llm.text("world one")
  431. const first = yield* prompt.loop({ sessionID: session.id })
  432. expect(first.info.role).toBe("assistant")
  433. expect(first.parts.some((part) => part.type === "text" && part.text === "world one")).toBe(true)
  434. yield* prompt.prompt({
  435. sessionID: session.id,
  436. agent: "build",
  437. noReply: true,
  438. parts: [{ type: "text", text: "hello two" }],
  439. })
  440. yield* llm.text("world two")
  441. const second = yield* prompt.loop({ sessionID: session.id })
  442. expect(second.info.role).toBe("assistant")
  443. expect(second.parts.some((part) => part.type === "text" && part.text === "world two")).toBe(true)
  444. expect(yield* llm.hits).toHaveLength(2)
  445. expect(yield* llm.pending).toBe(0)
  446. }),
  447. { git: true, config: providerCfg },
  448. ),
  449. )
  450. it.live("loop continues when finish is tool-calls", () =>
  451. provideTmpdirServer(
  452. Effect.fnUntraced(function* ({ llm }) {
  453. const prompt = yield* SessionPrompt.Service
  454. const sessions = yield* Session.Service
  455. const session = yield* sessions.create({
  456. title: "Pinned",
  457. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  458. })
  459. yield* prompt.prompt({
  460. sessionID: session.id,
  461. agent: "build",
  462. noReply: true,
  463. parts: [{ type: "text", text: "hello" }],
  464. })
  465. yield* llm.tool("first", { value: "first" })
  466. yield* llm.text("second")
  467. const result = yield* prompt.loop({ sessionID: session.id })
  468. expect(yield* llm.calls).toBe(2)
  469. expect(result.info.role).toBe("assistant")
  470. if (result.info.role === "assistant") {
  471. expect(result.parts.some((part) => part.type === "text" && part.text === "second")).toBe(true)
  472. expect(result.info.finish).toBe("stop")
  473. }
  474. }),
  475. { git: true, config: providerCfg },
  476. ),
  477. )
  478. it.live("glob tool keeps instance context during prompt runs", () =>
  479. provideTmpdirServer(
  480. ({ dir, llm }) =>
  481. Effect.gen(function* () {
  482. const prompt = yield* SessionPrompt.Service
  483. const sessions = yield* Session.Service
  484. const session = yield* sessions.create({
  485. title: "Glob context",
  486. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  487. })
  488. const file = path.join(dir, "probe.txt")
  489. yield* Effect.promise(() => Bun.write(file, "probe"))
  490. yield* prompt.prompt({
  491. sessionID: session.id,
  492. agent: "build",
  493. noReply: true,
  494. parts: [{ type: "text", text: "find text files" }],
  495. })
  496. yield* llm.tool("glob", { pattern: "**/*.txt" })
  497. yield* llm.text("done")
  498. const result = yield* prompt.loop({ sessionID: session.id })
  499. expect(result.info.role).toBe("assistant")
  500. const msgs = yield* MessageV2.filterCompactedEffect(session.id)
  501. const tool = msgs
  502. .flatMap((msg) => msg.parts)
  503. .find(
  504. (part): part is CompletedToolPart =>
  505. part.type === "tool" && part.tool === "glob" && part.state.status === "completed",
  506. )
  507. if (!tool) return
  508. expect(tool.state.output).toContain(file)
  509. expect(tool.state.output).not.toContain("No context found for instance")
  510. expect(result.parts.some((part) => part.type === "text" && part.text === "done")).toBe(true)
  511. }),
  512. { git: true, config: providerCfg },
  513. ),
  514. )
  515. it.live("loop continues when finish is stop but assistant has tool parts", () =>
  516. provideTmpdirServer(
  517. Effect.fnUntraced(function* ({ llm }) {
  518. const prompt = yield* SessionPrompt.Service
  519. const sessions = yield* Session.Service
  520. const session = yield* sessions.create({
  521. title: "Pinned",
  522. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  523. })
  524. yield* prompt.prompt({
  525. sessionID: session.id,
  526. agent: "build",
  527. noReply: true,
  528. parts: [{ type: "text", text: "hello" }],
  529. })
  530. yield* llm.push(reply().tool("first", { value: "first" }).stop())
  531. yield* llm.text("second")
  532. const result = yield* prompt.loop({ sessionID: session.id })
  533. expect(yield* llm.calls).toBe(2)
  534. expect(result.info.role).toBe("assistant")
  535. if (result.info.role === "assistant") {
  536. expect(result.parts.some((part) => part.type === "text" && part.text === "second")).toBe(true)
  537. expect(result.info.finish).toBe("stop")
  538. }
  539. }),
  540. { git: true, config: providerCfg },
  541. ),
  542. )
  543. it.live("failed subtask preserves metadata on error tool state", () =>
  544. provideTmpdirServer(
  545. Effect.fnUntraced(function* ({ llm }) {
  546. const prompt = yield* SessionPrompt.Service
  547. const sessions = yield* Session.Service
  548. const chat = yield* sessions.create({ title: "Pinned" })
  549. yield* llm.tool("task", {
  550. description: "inspect bug",
  551. prompt: "look into the cache key path",
  552. subagent_type: "general",
  553. })
  554. yield* llm.text("done")
  555. const msg = yield* user(chat.id, "hello")
  556. yield* addSubtask(chat.id, msg.id)
  557. const result = yield* prompt.loop({ sessionID: chat.id })
  558. expect(result.info.role).toBe("assistant")
  559. expect(yield* llm.calls).toBe(2)
  560. const msgs = yield* MessageV2.filterCompactedEffect(chat.id)
  561. const taskMsg = msgs.find((item) => item.info.role === "assistant" && item.info.agent === "general")
  562. expect(taskMsg?.info.role).toBe("assistant")
  563. if (!taskMsg || taskMsg.info.role !== "assistant") return
  564. const tool = errorTool(taskMsg.parts)
  565. if (!tool) return
  566. expect(tool.state.error).toContain("Tool execution failed")
  567. expect(tool.state.metadata).toBeDefined()
  568. expect(tool.state.metadata?.sessionId).toBeDefined()
  569. expect(tool.state.metadata?.model).toEqual({
  570. providerID: ProviderID.make("test"),
  571. modelID: ModelID.make("missing-model"),
  572. })
  573. }),
  574. {
  575. git: true,
  576. config: (url) => ({
  577. ...providerCfg(url),
  578. agent: {
  579. general: {
  580. model: "test/missing-model",
  581. },
  582. },
  583. }),
  584. },
  585. ),
  586. )
  587. it.live(
  588. "running subtask preserves metadata after tool-call transition",
  589. () =>
  590. provideTmpdirServer(
  591. Effect.fnUntraced(function* ({ llm }) {
  592. const prompt = yield* SessionPrompt.Service
  593. const sessions = yield* Session.Service
  594. const chat = yield* sessions.create({ title: "Pinned" })
  595. yield* llm.hang
  596. const msg = yield* user(chat.id, "hello")
  597. yield* addSubtask(chat.id, msg.id)
  598. const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
  599. const tool = yield* Effect.promise(async () => {
  600. const end = Date.now() + 5_000
  601. while (Date.now() < end) {
  602. const msgs = await Effect.runPromise(MessageV2.filterCompactedEffect(chat.id))
  603. const taskMsg = msgs.find((item) => item.info.role === "assistant" && item.info.agent === "general")
  604. const tool = taskMsg?.parts.find((part): part is MessageV2.ToolPart => part.type === "tool")
  605. if (tool?.state.status === "running" && tool.state.metadata?.sessionId) return tool
  606. await new Promise((done) => setTimeout(done, 20))
  607. }
  608. throw new Error("timed out waiting for running subtask metadata")
  609. })
  610. if (tool.state.status !== "running") return
  611. expect(typeof tool.state.metadata?.sessionId).toBe("string")
  612. expect(tool.state.title).toBeDefined()
  613. expect(tool.state.metadata?.model).toBeDefined()
  614. yield* prompt.cancel(chat.id)
  615. yield* Fiber.await(fiber)
  616. }),
  617. { git: true, config: providerCfg },
  618. ),
  619. 5_000,
  620. )
  621. it.live(
  622. "running task tool preserves metadata after tool-call transition",
  623. () =>
  624. provideTmpdirServer(
  625. Effect.fnUntraced(function* ({ llm }) {
  626. const prompt = yield* SessionPrompt.Service
  627. const sessions = yield* Session.Service
  628. const chat = yield* sessions.create({
  629. title: "Pinned",
  630. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  631. })
  632. yield* llm.tool("task", {
  633. description: "inspect bug",
  634. prompt: "look into the cache key path",
  635. subagent_type: "general",
  636. })
  637. yield* llm.hang
  638. yield* user(chat.id, "hello")
  639. const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
  640. const tool = yield* Effect.promise(async () => {
  641. const end = Date.now() + 5_000
  642. while (Date.now() < end) {
  643. const msgs = await Effect.runPromise(MessageV2.filterCompactedEffect(chat.id))
  644. const assistant = msgs.findLast((item) => item.info.role === "assistant" && item.info.agent === "build")
  645. const tool = assistant?.parts.find(
  646. (part): part is MessageV2.ToolPart => part.type === "tool" && part.tool === "task",
  647. )
  648. if (tool?.state.status === "running" && tool.state.metadata?.sessionId) return tool
  649. await new Promise((done) => setTimeout(done, 20))
  650. }
  651. throw new Error("timed out waiting for running task metadata")
  652. })
  653. if (tool.state.status !== "running") return
  654. expect(typeof tool.state.metadata?.sessionId).toBe("string")
  655. expect(tool.state.title).toBe("inspect bug")
  656. expect(tool.state.metadata?.model).toBeDefined()
  657. yield* prompt.cancel(chat.id)
  658. yield* Fiber.await(fiber)
  659. }),
  660. { git: true, config: providerCfg },
  661. ),
  662. 10_000,
  663. )
  664. it.live(
  665. "loop sets status to busy then idle",
  666. () =>
  667. provideTmpdirServer(
  668. Effect.fnUntraced(function* ({ llm }) {
  669. const prompt = yield* SessionPrompt.Service
  670. const sessions = yield* Session.Service
  671. const status = yield* SessionStatus.Service
  672. yield* llm.hang
  673. const chat = yield* sessions.create({})
  674. yield* user(chat.id, "hi")
  675. const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
  676. yield* llm.wait(1)
  677. expect((yield* status.get(chat.id)).type).toBe("busy")
  678. yield* prompt.cancel(chat.id)
  679. yield* Fiber.await(fiber)
  680. expect((yield* status.get(chat.id)).type).toBe("idle")
  681. }),
  682. { git: true, config: providerCfg },
  683. ),
  684. 3_000,
  685. )
  686. // Cancel semantics
  687. it.live(
  688. "cancel interrupts loop and resolves with an assistant message",
  689. () =>
  690. provideTmpdirServer(
  691. Effect.fnUntraced(function* ({ llm }) {
  692. const prompt = yield* SessionPrompt.Service
  693. const sessions = yield* Session.Service
  694. const chat = yield* sessions.create({ title: "Pinned" })
  695. yield* seed(chat.id)
  696. yield* llm.hang
  697. yield* user(chat.id, "more")
  698. const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
  699. yield* llm.wait(1)
  700. yield* prompt.cancel(chat.id)
  701. const exit = yield* Fiber.await(fiber)
  702. expect(Exit.isSuccess(exit)).toBe(true)
  703. if (Exit.isSuccess(exit)) {
  704. expect(exit.value.info.role).toBe("assistant")
  705. }
  706. }),
  707. { git: true, config: providerCfg },
  708. ),
  709. 3_000,
  710. )
  711. it.live(
  712. "cancel records MessageAbortedError on interrupted process",
  713. () =>
  714. provideTmpdirServer(
  715. Effect.fnUntraced(function* ({ llm }) {
  716. const prompt = yield* SessionPrompt.Service
  717. const sessions = yield* Session.Service
  718. const chat = yield* sessions.create({ title: "Pinned" })
  719. yield* llm.hang
  720. yield* user(chat.id, "hello")
  721. const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
  722. yield* llm.wait(1)
  723. yield* prompt.cancel(chat.id)
  724. const exit = yield* Fiber.await(fiber)
  725. expect(Exit.isSuccess(exit)).toBe(true)
  726. if (Exit.isSuccess(exit)) {
  727. const info = exit.value.info
  728. if (info.role === "assistant") {
  729. expect(info.error?.name).toBe("MessageAbortedError")
  730. }
  731. }
  732. }),
  733. { git: true, config: providerCfg },
  734. ),
  735. 3_000,
  736. )
  737. it.live(
  738. "cancel finalizes subtask tool state",
  739. () =>
  740. provideTmpdirInstance(
  741. () =>
  742. Effect.gen(function* () {
  743. const ready = defer<void>()
  744. const aborted = defer<void>()
  745. const registry = yield* ToolRegistry.Service
  746. const { task } = yield* registry.named()
  747. const original = task.execute
  748. task.execute = (_args, ctx) =>
  749. Effect.callback<never>((_resume) => {
  750. ready.resolve()
  751. ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true })
  752. return Effect.sync(() => aborted.resolve())
  753. })
  754. yield* Effect.addFinalizer(() => Effect.sync(() => void (task.execute = original)))
  755. const { prompt, chat } = yield* boot()
  756. const msg = yield* user(chat.id, "hello")
  757. yield* addSubtask(chat.id, msg.id)
  758. const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
  759. yield* Effect.promise(() => ready.promise)
  760. yield* prompt.cancel(chat.id)
  761. yield* Effect.promise(() => aborted.promise)
  762. const exit = yield* Fiber.await(fiber)
  763. expect(Exit.isSuccess(exit)).toBe(true)
  764. const msgs = yield* MessageV2.filterCompactedEffect(chat.id)
  765. const taskMsg = msgs.find((item) => item.info.role === "assistant" && item.info.agent === "general")
  766. expect(taskMsg?.info.role).toBe("assistant")
  767. if (!taskMsg || taskMsg.info.role !== "assistant") return
  768. const tool = toolPart(taskMsg.parts)
  769. expect(tool?.type).toBe("tool")
  770. if (!tool) return
  771. expect(tool.state.status).not.toBe("running")
  772. expect(taskMsg.info.time.completed).toBeDefined()
  773. expect(taskMsg.info.finish).toBeDefined()
  774. }),
  775. { git: true, config: cfg },
  776. ),
  777. 30_000,
  778. )
  779. it.live(
  780. "cancel propagates from slash command subtask to child session",
  781. () =>
  782. provideTmpdirServer(
  783. Effect.fnUntraced(function* ({ llm }) {
  784. const prompt = yield* SessionPrompt.Service
  785. const sessions = yield* Session.Service
  786. const status = yield* SessionStatus.Service
  787. const chat = yield* sessions.create({ title: "Pinned" })
  788. yield* llm.hang
  789. const msg = yield* user(chat.id, "hello")
  790. yield* addSubtask(chat.id, msg.id)
  791. const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
  792. yield* llm.wait(1)
  793. const msgs = yield* MessageV2.filterCompactedEffect(chat.id)
  794. const taskMsg = msgs.find((item) => item.info.role === "assistant" && item.info.agent === "general")
  795. const tool = taskMsg ? toolPart(taskMsg.parts) : undefined
  796. const sessionID = tool?.state.status === "running" ? tool.state.metadata?.sessionId : undefined
  797. expect(typeof sessionID).toBe("string")
  798. if (typeof sessionID !== "string") throw new Error("missing child session id")
  799. const childID = SessionID.make(sessionID)
  800. expect((yield* status.get(childID)).type).toBe("busy")
  801. yield* prompt.cancel(chat.id)
  802. const exit = yield* Fiber.await(fiber)
  803. expect(Exit.isSuccess(exit)).toBe(true)
  804. expect((yield* status.get(chat.id)).type).toBe("idle")
  805. expect((yield* status.get(childID)).type).toBe("idle")
  806. }),
  807. { git: true, config: providerCfg },
  808. ),
  809. 10_000,
  810. )
  811. it.live(
  812. "cancel with queued callers resolves all cleanly",
  813. () =>
  814. provideTmpdirServer(
  815. Effect.fnUntraced(function* ({ llm }) {
  816. const prompt = yield* SessionPrompt.Service
  817. const sessions = yield* Session.Service
  818. const chat = yield* sessions.create({ title: "Pinned" })
  819. yield* llm.hang
  820. yield* user(chat.id, "hello")
  821. const a = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
  822. yield* llm.wait(1)
  823. const b = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
  824. yield* Effect.sleep(50)
  825. yield* prompt.cancel(chat.id)
  826. const [exitA, exitB] = yield* Effect.all([Fiber.await(a), Fiber.await(b)])
  827. expect(Exit.isSuccess(exitA)).toBe(true)
  828. expect(Exit.isSuccess(exitB)).toBe(true)
  829. if (Exit.isSuccess(exitA) && Exit.isSuccess(exitB)) {
  830. expect(exitA.value.info.id).toBe(exitB.value.info.id)
  831. }
  832. }),
  833. { git: true, config: providerCfg },
  834. ),
  835. 3_000,
  836. )
  837. // Queue semantics
  838. it.live("concurrent loop callers get same result", () =>
  839. provideTmpdirInstance(
  840. (_dir) =>
  841. Effect.gen(function* () {
  842. const { prompt, run, chat } = yield* boot()
  843. yield* seed(chat.id, { finish: "stop" })
  844. const [a, b] = yield* Effect.all([prompt.loop({ sessionID: chat.id }), prompt.loop({ sessionID: chat.id })], {
  845. concurrency: "unbounded",
  846. })
  847. expect(a.info.id).toBe(b.info.id)
  848. expect(a.info.role).toBe("assistant")
  849. yield* run.assertNotBusy(chat.id)
  850. }),
  851. { git: true },
  852. ),
  853. )
  854. it.live(
  855. "concurrent loop callers all receive same error result",
  856. () =>
  857. provideTmpdirServer(
  858. Effect.fnUntraced(function* ({ llm }) {
  859. const prompt = yield* SessionPrompt.Service
  860. const sessions = yield* Session.Service
  861. const chat = yield* sessions.create({ title: "Pinned" })
  862. yield* llm.fail("boom")
  863. yield* user(chat.id, "hello")
  864. const [a, b] = yield* Effect.all([prompt.loop({ sessionID: chat.id }), prompt.loop({ sessionID: chat.id })], {
  865. concurrency: "unbounded",
  866. })
  867. expect(a.info.id).toBe(b.info.id)
  868. expect(a.info.role).toBe("assistant")
  869. }),
  870. { git: true, config: providerCfg },
  871. ),
  872. 3_000,
  873. )
  874. it.live(
  875. "prompt submitted during an active run is included in the next LLM input",
  876. () =>
  877. provideTmpdirServer(
  878. Effect.fnUntraced(function* ({ llm }) {
  879. const gate = defer<void>()
  880. const prompt = yield* SessionPrompt.Service
  881. const sessions = yield* Session.Service
  882. const chat = yield* sessions.create({ title: "Pinned" })
  883. yield* llm.hold("first", gate.promise)
  884. yield* llm.text("second")
  885. const a = yield* prompt
  886. .prompt({
  887. sessionID: chat.id,
  888. agent: "build",
  889. model: ref,
  890. parts: [{ type: "text", text: "first" }],
  891. })
  892. .pipe(Effect.forkChild)
  893. yield* llm.wait(1)
  894. const id = MessageID.ascending()
  895. const b = yield* prompt
  896. .prompt({
  897. sessionID: chat.id,
  898. messageID: id,
  899. agent: "build",
  900. model: ref,
  901. parts: [{ type: "text", text: "second" }],
  902. })
  903. .pipe(Effect.forkChild)
  904. yield* Effect.promise(async () => {
  905. const end = Date.now() + 5000
  906. while (Date.now() < end) {
  907. const msgs = await Effect.runPromise(sessions.messages({ sessionID: chat.id }))
  908. if (msgs.some((msg) => msg.info.role === "user" && msg.info.id === id)) return
  909. await new Promise((done) => setTimeout(done, 20))
  910. }
  911. throw new Error("timed out waiting for second prompt to save")
  912. })
  913. gate.resolve()
  914. const [ea, eb] = yield* Effect.all([Fiber.await(a), Fiber.await(b)])
  915. expect(Exit.isSuccess(ea)).toBe(true)
  916. expect(Exit.isSuccess(eb)).toBe(true)
  917. expect(yield* llm.calls).toBe(2)
  918. const msgs = yield* sessions.messages({ sessionID: chat.id })
  919. const assistants = msgs.filter((msg) => msg.info.role === "assistant")
  920. expect(assistants).toHaveLength(2)
  921. const last = assistants.at(-1)
  922. if (!last || last.info.role !== "assistant") throw new Error("expected second assistant")
  923. expect(last.info.parentID).toBe(id)
  924. expect(last.parts.some((part) => part.type === "text" && part.text === "second")).toBe(true)
  925. const inputs = yield* llm.inputs
  926. expect(inputs).toHaveLength(2)
  927. expect(JSON.stringify(inputs.at(-1)?.messages)).toContain("second")
  928. }),
  929. { git: true, config: providerCfg },
  930. ),
  931. 3_000,
  932. )
  933. it.live(
  934. "assertNotBusy throws BusyError when loop running",
  935. () =>
  936. provideTmpdirServer(
  937. Effect.fnUntraced(function* ({ llm }) {
  938. const prompt = yield* SessionPrompt.Service
  939. const run = yield* SessionRunState.Service
  940. const sessions = yield* Session.Service
  941. yield* llm.hang
  942. const chat = yield* sessions.create({})
  943. yield* user(chat.id, "hi")
  944. const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
  945. yield* llm.wait(1)
  946. const exit = yield* run.assertNotBusy(chat.id).pipe(Effect.exit)
  947. expect(Exit.isFailure(exit)).toBe(true)
  948. if (Exit.isFailure(exit)) {
  949. expect(Cause.squash(exit.cause)).toBeInstanceOf(Session.BusyError)
  950. }
  951. yield* prompt.cancel(chat.id)
  952. yield* Fiber.await(fiber)
  953. }),
  954. { git: true, config: providerCfg },
  955. ),
  956. 3_000,
  957. )
  958. it.live("assertNotBusy succeeds when idle", () =>
  959. provideTmpdirInstance(
  960. (_dir) =>
  961. Effect.gen(function* () {
  962. const run = yield* SessionRunState.Service
  963. const sessions = yield* Session.Service
  964. const chat = yield* sessions.create({})
  965. const exit = yield* run.assertNotBusy(chat.id).pipe(Effect.exit)
  966. expect(Exit.isSuccess(exit)).toBe(true)
  967. }),
  968. { git: true },
  969. ),
  970. )
  971. // Shell semantics
  972. it.live(
  973. "shell rejects with BusyError when loop running",
  974. () =>
  975. provideTmpdirServer(
  976. Effect.fnUntraced(function* ({ llm }) {
  977. const prompt = yield* SessionPrompt.Service
  978. const sessions = yield* Session.Service
  979. const chat = yield* sessions.create({ title: "Pinned" })
  980. yield* llm.hang
  981. yield* user(chat.id, "hi")
  982. const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
  983. yield* llm.wait(1)
  984. const exit = yield* prompt.shell({ sessionID: chat.id, agent: "build", command: "echo hi" }).pipe(Effect.exit)
  985. expect(Exit.isFailure(exit)).toBe(true)
  986. if (Exit.isFailure(exit)) {
  987. expect(Cause.squash(exit.cause)).toBeInstanceOf(Session.BusyError)
  988. }
  989. yield* prompt.cancel(chat.id)
  990. yield* Fiber.await(fiber)
  991. }),
  992. { git: true, config: providerCfg },
  993. ),
  994. 3_000,
  995. )
  996. unix("shell captures stdout and stderr in completed tool output", () =>
  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: "printf out && printf err >&2",
  1005. })
  1006. expect(result.info.role).toBe("assistant")
  1007. const tool = completedTool(result.parts)
  1008. if (!tool) return
  1009. expect(tool.state.output).toContain("out")
  1010. expect(tool.state.output).toContain("err")
  1011. expect(tool.state.metadata.output).toContain("out")
  1012. expect(tool.state.metadata.output).toContain("err")
  1013. yield* run.assertNotBusy(chat.id)
  1014. }),
  1015. { git: true, config: cfg },
  1016. ),
  1017. )
  1018. unix("shell completes a fast command on the preferred shell", () =>
  1019. provideTmpdirInstance(
  1020. (dir) =>
  1021. Effect.gen(function* () {
  1022. const { prompt, run, chat } = yield* boot()
  1023. const result = yield* prompt.shell({
  1024. sessionID: chat.id,
  1025. agent: "build",
  1026. command: "pwd",
  1027. })
  1028. expect(result.info.role).toBe("assistant")
  1029. const tool = completedTool(result.parts)
  1030. if (!tool) return
  1031. expect(tool.state.input.command).toBe("pwd")
  1032. expect(tool.state.output).toContain(dir)
  1033. expect(tool.state.metadata.output).toContain(dir)
  1034. yield* run.assertNotBusy(chat.id)
  1035. }),
  1036. { git: true, config: cfg },
  1037. ),
  1038. )
  1039. unix(
  1040. "shell uses configured shell over env shell",
  1041. () =>
  1042. withSh(() =>
  1043. provideTmpdirInstance(
  1044. (_dir) =>
  1045. Effect.gen(function* () {
  1046. if (!Bun.which("bash")) return
  1047. const { prompt, chat } = yield* boot()
  1048. const result = yield* prompt.shell({
  1049. sessionID: chat.id,
  1050. agent: "build",
  1051. command: "[[ 1 -eq 1 ]] && printf configured",
  1052. })
  1053. const tool = completedTool(result.parts)
  1054. if (!tool) return
  1055. expect(tool.state.output).toContain("configured")
  1056. }),
  1057. { git: true, config: { ...cfg, shell: "bash" } },
  1058. ),
  1059. ),
  1060. 30_000,
  1061. )
  1062. unix("shell commands can change directory after startup", () =>
  1063. provideTmpdirInstance(
  1064. (dir) =>
  1065. Effect.gen(function* () {
  1066. const { prompt, run, chat } = yield* boot()
  1067. const parent = path.dirname(dir)
  1068. const result = yield* prompt.shell({
  1069. sessionID: chat.id,
  1070. agent: "build",
  1071. command: "cd .. && pwd",
  1072. })
  1073. expect(result.info.role).toBe("assistant")
  1074. const tool = completedTool(result.parts)
  1075. if (!tool) return
  1076. expect(tool.state.output).toContain(parent)
  1077. expect(tool.state.metadata.output).toContain(parent)
  1078. yield* run.assertNotBusy(chat.id)
  1079. }),
  1080. { git: true, config: cfg },
  1081. ),
  1082. )
  1083. unix("shell lists files from the project directory", () =>
  1084. provideTmpdirInstance(
  1085. (dir) =>
  1086. Effect.gen(function* () {
  1087. const { prompt, run, chat } = yield* boot()
  1088. yield* Effect.promise(() => Bun.write(path.join(dir, "README.md"), "# e2e\n"))
  1089. const result = yield* prompt.shell({
  1090. sessionID: chat.id,
  1091. agent: "build",
  1092. command: "command ls",
  1093. })
  1094. expect(result.info.role).toBe("assistant")
  1095. const tool = completedTool(result.parts)
  1096. if (!tool) return
  1097. expect(tool.state.input.command).toBe("command ls")
  1098. expect(tool.state.output).toContain("README.md")
  1099. expect(tool.state.metadata.output).toContain("README.md")
  1100. yield* run.assertNotBusy(chat.id)
  1101. }),
  1102. { git: true, config: cfg },
  1103. ),
  1104. )
  1105. unix("shell captures stderr from a failing command", () =>
  1106. provideTmpdirInstance(
  1107. (_dir) =>
  1108. Effect.gen(function* () {
  1109. const { prompt, run, chat } = yield* boot()
  1110. const result = yield* prompt.shell({
  1111. sessionID: chat.id,
  1112. agent: "build",
  1113. command: "command -v __nonexistent_cmd_e2e__ || echo 'not found' >&2; exit 1",
  1114. })
  1115. expect(result.info.role).toBe("assistant")
  1116. const tool = completedTool(result.parts)
  1117. if (!tool) return
  1118. expect(tool.state.output).toContain("not found")
  1119. expect(tool.state.metadata.output).toContain("not found")
  1120. yield* run.assertNotBusy(chat.id)
  1121. }),
  1122. { git: true, config: cfg },
  1123. ),
  1124. )
  1125. unix(
  1126. "shell updates running metadata before process exit",
  1127. () =>
  1128. withSh(() =>
  1129. provideTmpdirInstance(
  1130. (_dir) =>
  1131. Effect.gen(function* () {
  1132. const { prompt, chat } = yield* boot()
  1133. const fiber = yield* prompt
  1134. .shell({ sessionID: chat.id, agent: "build", command: "printf first && sleep 0.2 && printf second" })
  1135. .pipe(Effect.forkChild)
  1136. yield* Effect.promise(async () => {
  1137. const start = Date.now()
  1138. while (Date.now() - start < 5000) {
  1139. const msgs = await MessageV2.filterCompacted(MessageV2.stream(chat.id))
  1140. const taskMsg = msgs.find((item) => item.info.role === "assistant")
  1141. const tool = taskMsg ? toolPart(taskMsg.parts) : undefined
  1142. if (tool?.state.status === "running" && tool.state.metadata?.output.includes("first")) return
  1143. await new Promise((done) => setTimeout(done, 20))
  1144. }
  1145. throw new Error("timed out waiting for running shell metadata")
  1146. })
  1147. const exit = yield* Fiber.await(fiber)
  1148. expect(Exit.isSuccess(exit)).toBe(true)
  1149. }),
  1150. { git: true, config: cfg },
  1151. ),
  1152. ),
  1153. 30_000,
  1154. )
  1155. it.live(
  1156. "loop waits while shell runs and starts after shell exits",
  1157. () =>
  1158. provideTmpdirServer(
  1159. Effect.fnUntraced(function* ({ llm }) {
  1160. const prompt = yield* SessionPrompt.Service
  1161. const sessions = yield* Session.Service
  1162. const chat = yield* sessions.create({
  1163. title: "Pinned",
  1164. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  1165. })
  1166. yield* llm.text("after-shell")
  1167. const sh = yield* prompt
  1168. .shell({ sessionID: chat.id, agent: "build", command: "sleep 0.2" })
  1169. .pipe(Effect.forkChild)
  1170. yield* Effect.sleep(50)
  1171. const loop = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
  1172. yield* Effect.sleep(50)
  1173. expect(yield* llm.calls).toBe(0)
  1174. yield* Fiber.await(sh)
  1175. const exit = yield* Fiber.await(loop)
  1176. expect(Exit.isSuccess(exit)).toBe(true)
  1177. if (Exit.isSuccess(exit)) {
  1178. expect(exit.value.info.role).toBe("assistant")
  1179. expect(exit.value.parts.some((part) => part.type === "text" && part.text === "after-shell")).toBe(true)
  1180. }
  1181. expect(yield* llm.calls).toBe(1)
  1182. }),
  1183. { git: true, config: providerCfg },
  1184. ),
  1185. 3_000,
  1186. )
  1187. it.live(
  1188. "shell completion resumes queued loop callers",
  1189. () =>
  1190. provideTmpdirServer(
  1191. Effect.fnUntraced(function* ({ llm }) {
  1192. const prompt = yield* SessionPrompt.Service
  1193. const sessions = yield* Session.Service
  1194. const chat = yield* sessions.create({
  1195. title: "Pinned",
  1196. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  1197. })
  1198. yield* llm.text("done")
  1199. const sh = yield* prompt
  1200. .shell({ sessionID: chat.id, agent: "build", command: "sleep 0.2" })
  1201. .pipe(Effect.forkChild)
  1202. yield* Effect.sleep(50)
  1203. const a = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
  1204. const b = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
  1205. yield* Effect.sleep(50)
  1206. expect(yield* llm.calls).toBe(0)
  1207. yield* Fiber.await(sh)
  1208. const [ea, eb] = yield* Effect.all([Fiber.await(a), Fiber.await(b)])
  1209. expect(Exit.isSuccess(ea)).toBe(true)
  1210. expect(Exit.isSuccess(eb)).toBe(true)
  1211. if (Exit.isSuccess(ea) && Exit.isSuccess(eb)) {
  1212. expect(ea.value.info.id).toBe(eb.value.info.id)
  1213. expect(ea.value.info.role).toBe("assistant")
  1214. }
  1215. expect(yield* llm.calls).toBe(1)
  1216. }),
  1217. { git: true, config: providerCfg },
  1218. ),
  1219. 3_000,
  1220. )
  1221. unix(
  1222. "command ! expansion uses configured shell over env shell",
  1223. () =>
  1224. withSh(() =>
  1225. provideTmpdirServer(
  1226. ({ llm }) =>
  1227. Effect.gen(function* () {
  1228. if (!Bun.which("bash")) return
  1229. const { prompt, chat } = yield* boot()
  1230. yield* llm.text("done")
  1231. const result = yield* prompt.command({
  1232. sessionID: chat.id,
  1233. command: "probe",
  1234. arguments: "",
  1235. })
  1236. expect(result.info.role).toBe("assistant")
  1237. const inputs = yield* llm.inputs
  1238. expect(JSON.stringify(inputs.at(-1)?.messages)).toContain("configured")
  1239. }),
  1240. {
  1241. git: true,
  1242. config: (url) => ({
  1243. ...providerCfg(url),
  1244. shell: "bash",
  1245. command: {
  1246. probe: {
  1247. template: "Probe: !`[[ 1 -eq 1 ]] && printf configured`",
  1248. },
  1249. },
  1250. }),
  1251. },
  1252. ),
  1253. ),
  1254. 30_000,
  1255. )
  1256. unix(
  1257. "cancel interrupts shell and resolves cleanly",
  1258. () =>
  1259. withSh(() =>
  1260. provideTmpdirInstance(
  1261. (_dir) =>
  1262. Effect.gen(function* () {
  1263. const { prompt, run, chat } = yield* boot()
  1264. const sh = yield* prompt
  1265. .shell({ sessionID: chat.id, agent: "build", command: "sleep 30" })
  1266. .pipe(Effect.forkChild)
  1267. yield* Effect.sleep(50)
  1268. yield* prompt.cancel(chat.id)
  1269. const status = yield* SessionStatus.Service
  1270. expect((yield* status.get(chat.id)).type).toBe("idle")
  1271. const busy = yield* run.assertNotBusy(chat.id).pipe(Effect.exit)
  1272. expect(Exit.isSuccess(busy)).toBe(true)
  1273. const exit = yield* Fiber.await(sh)
  1274. expect(Exit.isSuccess(exit)).toBe(true)
  1275. if (Exit.isSuccess(exit)) {
  1276. expect(exit.value.info.role).toBe("assistant")
  1277. const tool = completedTool(exit.value.parts)
  1278. if (tool) {
  1279. expect(tool.state.output).toContain("User aborted the command")
  1280. }
  1281. }
  1282. }),
  1283. { git: true, config: cfg },
  1284. ),
  1285. ),
  1286. 30_000,
  1287. )
  1288. unix(
  1289. "cancel persists aborted shell result when shell ignores TERM",
  1290. () =>
  1291. withSh(() =>
  1292. provideTmpdirInstance(
  1293. (_dir) =>
  1294. Effect.gen(function* () {
  1295. const { prompt, chat } = yield* boot()
  1296. const sh = yield* prompt
  1297. .shell({ sessionID: chat.id, agent: "build", command: "trap '' TERM; sleep 30" })
  1298. .pipe(Effect.forkChild)
  1299. yield* Effect.sleep(50)
  1300. yield* prompt.cancel(chat.id)
  1301. const exit = yield* Fiber.await(sh)
  1302. expect(Exit.isSuccess(exit)).toBe(true)
  1303. if (Exit.isSuccess(exit)) {
  1304. expect(exit.value.info.role).toBe("assistant")
  1305. const tool = completedTool(exit.value.parts)
  1306. if (tool) {
  1307. expect(tool.state.output).toContain("User aborted the command")
  1308. }
  1309. }
  1310. }),
  1311. { git: true, config: cfg },
  1312. ),
  1313. ),
  1314. 30_000,
  1315. )
  1316. unix(
  1317. "cancel finalizes interrupted bash tool output through normal truncation",
  1318. () =>
  1319. provideTmpdirServer(
  1320. ({ dir, llm }) =>
  1321. Effect.gen(function* () {
  1322. const prompt = yield* SessionPrompt.Service
  1323. const sessions = yield* Session.Service
  1324. const chat = yield* sessions.create({
  1325. title: "Interrupted bash truncation",
  1326. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  1327. })
  1328. yield* prompt.prompt({
  1329. sessionID: chat.id,
  1330. agent: "build",
  1331. noReply: true,
  1332. parts: [{ type: "text", text: "run bash" }],
  1333. })
  1334. yield* llm.tool("bash", {
  1335. command:
  1336. 'i=0; while [ "$i" -lt 4000 ]; do printf "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx %05d\\n" "$i"; i=$((i + 1)); done; sleep 30',
  1337. description: "Print many lines",
  1338. timeout: 30_000,
  1339. workdir: path.resolve(dir),
  1340. })
  1341. const run = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
  1342. yield* llm.wait(1)
  1343. yield* Effect.sleep(150)
  1344. yield* prompt.cancel(chat.id)
  1345. const exit = yield* Fiber.await(run)
  1346. expect(Exit.isSuccess(exit)).toBe(true)
  1347. if (Exit.isFailure(exit)) return
  1348. const tool = completedTool(exit.value.parts)
  1349. if (!tool) return
  1350. expect(tool.state.metadata.truncated).toBe(true)
  1351. expect(typeof tool.state.metadata.outputPath).toBe("string")
  1352. expect(tool.state.output).toMatch(/\.\.\.output truncated\.\.\./)
  1353. expect(tool.state.output).toMatch(/Full output saved to:\s+\S+/)
  1354. expect(tool.state.output).not.toContain("Tool execution aborted")
  1355. }),
  1356. { git: true, config: providerCfg },
  1357. ),
  1358. 30_000,
  1359. )
  1360. unix(
  1361. "cancel interrupts loop queued behind shell",
  1362. () =>
  1363. provideTmpdirInstance(
  1364. (_dir) =>
  1365. Effect.gen(function* () {
  1366. const { prompt, chat } = yield* boot()
  1367. const sh = yield* prompt
  1368. .shell({ sessionID: chat.id, agent: "build", command: "sleep 30" })
  1369. .pipe(Effect.forkChild)
  1370. yield* Effect.sleep(50)
  1371. const loop = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
  1372. yield* Effect.sleep(50)
  1373. yield* prompt.cancel(chat.id)
  1374. const exit = yield* Fiber.await(loop)
  1375. expect(Exit.isSuccess(exit)).toBe(true)
  1376. if (Exit.isSuccess(exit)) {
  1377. const tool = completedTool(exit.value.parts)
  1378. expect(tool?.state.output).toContain("User aborted the command")
  1379. }
  1380. yield* Fiber.await(sh)
  1381. }),
  1382. { git: true, config: cfg },
  1383. ),
  1384. 30_000,
  1385. )
  1386. unix(
  1387. "shell rejects when another shell is already running",
  1388. () =>
  1389. withSh(() =>
  1390. provideTmpdirInstance(
  1391. (_dir) =>
  1392. Effect.gen(function* () {
  1393. const { prompt, chat } = yield* boot()
  1394. const a = yield* prompt
  1395. .shell({ sessionID: chat.id, agent: "build", command: "sleep 30" })
  1396. .pipe(Effect.forkChild)
  1397. yield* Effect.sleep(50)
  1398. const exit = yield* prompt
  1399. .shell({ sessionID: chat.id, agent: "build", command: "echo hi" })
  1400. .pipe(Effect.exit)
  1401. expect(Exit.isFailure(exit)).toBe(true)
  1402. if (Exit.isFailure(exit)) {
  1403. expect(Cause.squash(exit.cause)).toBeInstanceOf(Session.BusyError)
  1404. }
  1405. yield* prompt.cancel(chat.id)
  1406. yield* Fiber.await(a)
  1407. }),
  1408. { git: true, config: cfg },
  1409. ),
  1410. ),
  1411. 30_000,
  1412. )
  1413. // Abort signal propagation tests for inline tool execution
  1414. /** Override a tool's execute to hang until aborted. Returns ready/aborted defers and a finalizer. */
  1415. function hangUntilAborted(tool: { execute: (...args: any[]) => any }) {
  1416. const ready = defer<void>()
  1417. const aborted = defer<void>()
  1418. const original = tool.execute
  1419. tool.execute = (_args: any, ctx: any) => {
  1420. ready.resolve()
  1421. ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true })
  1422. return Effect.callback<never>(() => {})
  1423. }
  1424. const restore = Effect.addFinalizer(() => Effect.sync(() => void (tool.execute = original)))
  1425. return { ready, aborted, restore }
  1426. }
  1427. it.live(
  1428. "interrupt propagates abort signal to read tool via file part (text/plain)",
  1429. () =>
  1430. provideTmpdirInstance(
  1431. (dir) =>
  1432. Effect.gen(function* () {
  1433. const registry = yield* ToolRegistry.Service
  1434. const { read } = yield* registry.named()
  1435. const { ready, aborted, restore } = hangUntilAborted(read)
  1436. yield* restore
  1437. const prompt = yield* SessionPrompt.Service
  1438. const sessions = yield* Session.Service
  1439. const chat = yield* sessions.create({ title: "Abort Test" })
  1440. const testFile = path.join(dir, "test.txt")
  1441. yield* Effect.promise(() => Bun.write(testFile, "hello world"))
  1442. const fiber = yield* prompt
  1443. .prompt({
  1444. sessionID: chat.id,
  1445. agent: "build",
  1446. parts: [
  1447. { type: "text", text: "read this" },
  1448. { type: "file", url: `file://${testFile}`, filename: "test.txt", mime: "text/plain" },
  1449. ],
  1450. })
  1451. .pipe(Effect.forkChild)
  1452. yield* Effect.promise(() => ready.promise)
  1453. yield* Fiber.interrupt(fiber)
  1454. yield* Effect.promise(() =>
  1455. Promise.race([
  1456. aborted.promise,
  1457. new Promise<void>((_, reject) =>
  1458. setTimeout(() => reject(new Error("abort signal not propagated within 2s")), 2_000),
  1459. ),
  1460. ]),
  1461. )
  1462. }),
  1463. { git: true, config: cfg },
  1464. ),
  1465. 30_000,
  1466. )
  1467. it.live(
  1468. "interrupt propagates abort signal to read tool via file part (directory)",
  1469. () =>
  1470. provideTmpdirInstance(
  1471. (dir) =>
  1472. Effect.gen(function* () {
  1473. const registry = yield* ToolRegistry.Service
  1474. const { read } = yield* registry.named()
  1475. const { ready, aborted, restore } = hangUntilAborted(read)
  1476. yield* restore
  1477. const prompt = yield* SessionPrompt.Service
  1478. const sessions = yield* Session.Service
  1479. const chat = yield* sessions.create({ title: "Abort Test" })
  1480. const fiber = yield* prompt
  1481. .prompt({
  1482. sessionID: chat.id,
  1483. agent: "build",
  1484. parts: [
  1485. { type: "text", text: "read this" },
  1486. { type: "file", url: `file://${dir}`, filename: "dir", mime: "application/x-directory" },
  1487. ],
  1488. })
  1489. .pipe(Effect.forkChild)
  1490. yield* Effect.promise(() => ready.promise)
  1491. yield* Fiber.interrupt(fiber)
  1492. yield* Effect.promise(() =>
  1493. Promise.race([
  1494. aborted.promise,
  1495. new Promise<void>((_, reject) =>
  1496. setTimeout(() => reject(new Error("abort signal not propagated within 2s")), 2_000),
  1497. ),
  1498. ]),
  1499. )
  1500. }),
  1501. { git: true, config: cfg },
  1502. ),
  1503. 30_000,
  1504. )
  1505. // Missing file handling
  1506. it.live("does not fail the prompt when a file part is missing", () =>
  1507. provideTmpdirInstance(
  1508. (dir) =>
  1509. Effect.gen(function* () {
  1510. const prompt = yield* SessionPrompt.Service
  1511. const sessions = yield* Session.Service
  1512. const session = yield* sessions.create({})
  1513. const missing = path.join(dir, "does-not-exist.ts")
  1514. const msg = yield* prompt.prompt({
  1515. sessionID: session.id,
  1516. agent: "build",
  1517. noReply: true,
  1518. parts: [
  1519. { type: "text", text: "please review @does-not-exist.ts" },
  1520. {
  1521. type: "file",
  1522. mime: "text/plain",
  1523. url: `file://${missing}`,
  1524. filename: "does-not-exist.ts",
  1525. },
  1526. ],
  1527. })
  1528. if (msg.info.role !== "user") throw new Error("expected user message")
  1529. const hasFailure = msg.parts.some(
  1530. (part) => part.type === "text" && part.synthetic && part.text.includes("Read tool failed to read"),
  1531. )
  1532. expect(hasFailure).toBe(true)
  1533. yield* sessions.remove(session.id)
  1534. }),
  1535. { git: true, config: cfg },
  1536. ),
  1537. )
  1538. it.live("keeps stored part order stable when file resolution is async", () =>
  1539. provideTmpdirInstance(
  1540. (dir) =>
  1541. Effect.gen(function* () {
  1542. const prompt = yield* SessionPrompt.Service
  1543. const sessions = yield* Session.Service
  1544. const session = yield* sessions.create({})
  1545. const missing = path.join(dir, "still-missing.ts")
  1546. const msg = yield* prompt.prompt({
  1547. sessionID: session.id,
  1548. agent: "build",
  1549. noReply: true,
  1550. parts: [
  1551. {
  1552. type: "file",
  1553. mime: "text/plain",
  1554. url: `file://${missing}`,
  1555. filename: "still-missing.ts",
  1556. },
  1557. { type: "text", text: "after-file" },
  1558. ],
  1559. })
  1560. if (msg.info.role !== "user") throw new Error("expected user message")
  1561. const stored = MessageV2.get({
  1562. sessionID: session.id,
  1563. messageID: msg.info.id,
  1564. })
  1565. const text = stored.parts.filter((part) => part.type === "text").map((part) => part.text)
  1566. expect(text[0]?.startsWith("Called the Read tool with the following input:")).toBe(true)
  1567. expect(text[1]?.includes("Read tool failed to read")).toBe(true)
  1568. expect(text[2]).toBe("after-file")
  1569. yield* sessions.remove(session.id)
  1570. }),
  1571. { git: true, config: cfg },
  1572. ),
  1573. )
  1574. // Special characters in filenames
  1575. it.live("handles filenames with # character", () =>
  1576. provideTmpdirInstance(
  1577. (dir) =>
  1578. Effect.gen(function* () {
  1579. yield* Effect.promise(() => Bun.write(path.join(dir, "file#name.txt"), "special content\n"))
  1580. const prompt = yield* SessionPrompt.Service
  1581. const sessions = yield* Session.Service
  1582. const session = yield* sessions.create({})
  1583. const parts = yield* prompt.resolvePromptParts("Read @file#name.txt")
  1584. const fileParts = parts.filter((part) => part.type === "file")
  1585. expect(fileParts.length).toBe(1)
  1586. expect(fileParts[0].filename).toBe("file#name.txt")
  1587. expect(fileParts[0].url).toContain("%23")
  1588. const decodedPath = fileURLToPath(fileParts[0].url)
  1589. expect(decodedPath).toBe(path.join(dir, "file#name.txt"))
  1590. const message = yield* prompt.prompt({
  1591. sessionID: session.id,
  1592. parts,
  1593. noReply: true,
  1594. })
  1595. const stored = MessageV2.get({ sessionID: session.id, messageID: message.info.id })
  1596. const textParts = stored.parts.filter((part) => part.type === "text")
  1597. const hasContent = textParts.some((part) => part.text.includes("special content"))
  1598. expect(hasContent).toBe(true)
  1599. yield* sessions.remove(session.id)
  1600. }),
  1601. { git: true, config: cfg },
  1602. ),
  1603. )
  1604. // Regression: empty assistant turn loop
  1605. it.live("does not loop empty assistant turns for a simple reply", () =>
  1606. provideTmpdirServer(
  1607. Effect.fnUntraced(function* ({ llm }) {
  1608. const prompt = yield* SessionPrompt.Service
  1609. const sessions = yield* Session.Service
  1610. const session = yield* sessions.create({ title: "Prompt regression" })
  1611. yield* llm.text("packages/opencode/src/session/processor.ts")
  1612. const result = yield* prompt.prompt({
  1613. sessionID: session.id,
  1614. agent: "build",
  1615. parts: [{ type: "text", text: "Where is SessionProcessor?" }],
  1616. })
  1617. expect(result.info.role).toBe("assistant")
  1618. expect(result.parts.some((part) => part.type === "text" && part.text.includes("processor.ts"))).toBe(true)
  1619. const msgs = yield* sessions.messages({ sessionID: session.id })
  1620. expect(msgs.filter((msg) => msg.info.role === "assistant")).toHaveLength(1)
  1621. expect(yield* llm.calls).toBe(1)
  1622. }),
  1623. { git: true, config: providerCfg },
  1624. ),
  1625. )
  1626. it.live(
  1627. "records aborted errors when prompt is cancelled mid-stream",
  1628. () =>
  1629. provideTmpdirServer(
  1630. Effect.fnUntraced(function* ({ llm }) {
  1631. const prompt = yield* SessionPrompt.Service
  1632. const sessions = yield* Session.Service
  1633. const session = yield* sessions.create({ title: "Prompt cancel regression" })
  1634. yield* llm.hang
  1635. const fiber = yield* prompt
  1636. .prompt({
  1637. sessionID: session.id,
  1638. agent: "build",
  1639. parts: [{ type: "text", text: "Cancel me" }],
  1640. })
  1641. .pipe(Effect.forkChild)
  1642. yield* llm.wait(1)
  1643. yield* prompt.cancel(session.id)
  1644. const exit = yield* Fiber.await(fiber)
  1645. expect(Exit.isSuccess(exit)).toBe(true)
  1646. if (Exit.isSuccess(exit)) {
  1647. expect(exit.value.info.role).toBe("assistant")
  1648. if (exit.value.info.role === "assistant") {
  1649. expect(exit.value.info.error?.name).toBe("MessageAbortedError")
  1650. }
  1651. }
  1652. const msgs = yield* sessions.messages({ sessionID: session.id })
  1653. const last = msgs.findLast((msg) => msg.info.role === "assistant")
  1654. expect(last?.info.role).toBe("assistant")
  1655. if (last?.info.role === "assistant") {
  1656. expect(last.info.error?.name).toBe("MessageAbortedError")
  1657. }
  1658. }),
  1659. { git: true, config: providerCfg },
  1660. ),
  1661. 3_000,
  1662. )
  1663. // Agent variant
  1664. it.live("applies agent variant only when using agent model", () =>
  1665. provideTmpdirInstance(
  1666. (_dir) =>
  1667. Effect.gen(function* () {
  1668. const prompt = yield* SessionPrompt.Service
  1669. const sessions = yield* Session.Service
  1670. const session = yield* sessions.create({})
  1671. const other = yield* prompt.prompt({
  1672. sessionID: session.id,
  1673. agent: "build",
  1674. model: { providerID: ProviderID.make("opencode"), modelID: ModelID.make("kimi-k2.5-free") },
  1675. noReply: true,
  1676. parts: [{ type: "text", text: "hello" }],
  1677. })
  1678. if (other.info.role !== "user") throw new Error("expected user message")
  1679. expect(other.info.model.variant).toBeUndefined()
  1680. const match = yield* prompt.prompt({
  1681. sessionID: session.id,
  1682. agent: "build",
  1683. noReply: true,
  1684. parts: [{ type: "text", text: "hello again" }],
  1685. })
  1686. if (match.info.role !== "user") throw new Error("expected user message")
  1687. expect(match.info.model).toEqual({
  1688. providerID: ProviderID.make("test"),
  1689. modelID: ModelID.make("test-model"),
  1690. variant: "xhigh",
  1691. })
  1692. expect(match.info.model.variant).toBe("xhigh")
  1693. const override = yield* prompt.prompt({
  1694. sessionID: session.id,
  1695. agent: "build",
  1696. noReply: true,
  1697. variant: "high",
  1698. parts: [{ type: "text", text: "hello third" }],
  1699. })
  1700. if (override.info.role !== "user") throw new Error("expected user message")
  1701. expect(override.info.model.variant).toBe("high")
  1702. yield* sessions.remove(session.id)
  1703. }),
  1704. {
  1705. git: true,
  1706. config: {
  1707. ...cfg,
  1708. provider: {
  1709. ...cfg.provider,
  1710. test: {
  1711. ...cfg.provider.test,
  1712. models: {
  1713. "test-model": {
  1714. ...cfg.provider.test.models["test-model"],
  1715. variants: { xhigh: {}, high: {} },
  1716. },
  1717. },
  1718. },
  1719. },
  1720. agent: {
  1721. build: {
  1722. model: "test/test-model",
  1723. variant: "xhigh",
  1724. },
  1725. },
  1726. },
  1727. },
  1728. ),
  1729. )
  1730. // Agent / command resolution errors
  1731. it.live(
  1732. "unknown agent throws typed error",
  1733. () =>
  1734. provideTmpdirInstance(
  1735. (_dir) =>
  1736. Effect.gen(function* () {
  1737. const prompt = yield* SessionPrompt.Service
  1738. const sessions = yield* Session.Service
  1739. const session = yield* sessions.create({})
  1740. const exit = yield* prompt
  1741. .prompt({
  1742. sessionID: session.id,
  1743. agent: "nonexistent-agent-xyz",
  1744. noReply: true,
  1745. parts: [{ type: "text", text: "hello" }],
  1746. })
  1747. .pipe(Effect.exit)
  1748. expect(Exit.isFailure(exit)).toBe(true)
  1749. if (Exit.isFailure(exit)) {
  1750. const err = Cause.squash(exit.cause)
  1751. expect(err).not.toBeInstanceOf(TypeError)
  1752. expect(NamedError.Unknown.isInstance(err)).toBe(true)
  1753. if (NamedError.Unknown.isInstance(err)) {
  1754. expect(err.data.message).toContain('Agent not found: "nonexistent-agent-xyz"')
  1755. }
  1756. }
  1757. }),
  1758. { git: true },
  1759. ),
  1760. 30_000,
  1761. )
  1762. it.live(
  1763. "unknown agent error includes available agent names",
  1764. () =>
  1765. provideTmpdirInstance(
  1766. (_dir) =>
  1767. Effect.gen(function* () {
  1768. const prompt = yield* SessionPrompt.Service
  1769. const sessions = yield* Session.Service
  1770. const session = yield* sessions.create({})
  1771. const exit = yield* prompt
  1772. .prompt({
  1773. sessionID: session.id,
  1774. agent: "nonexistent-agent-xyz",
  1775. noReply: true,
  1776. parts: [{ type: "text", text: "hello" }],
  1777. })
  1778. .pipe(Effect.exit)
  1779. expect(Exit.isFailure(exit)).toBe(true)
  1780. if (Exit.isFailure(exit)) {
  1781. const err = Cause.squash(exit.cause)
  1782. expect(NamedError.Unknown.isInstance(err)).toBe(true)
  1783. if (NamedError.Unknown.isInstance(err)) {
  1784. expect(err.data.message).toContain("build")
  1785. }
  1786. }
  1787. }),
  1788. { git: true },
  1789. ),
  1790. 30_000,
  1791. )
  1792. it.live(
  1793. "unknown command throws typed error with available names",
  1794. () =>
  1795. provideTmpdirInstance(
  1796. (_dir) =>
  1797. Effect.gen(function* () {
  1798. const prompt = yield* SessionPrompt.Service
  1799. const sessions = yield* Session.Service
  1800. const session = yield* sessions.create({})
  1801. const exit = yield* prompt
  1802. .command({
  1803. sessionID: session.id,
  1804. command: "nonexistent-command-xyz",
  1805. arguments: "",
  1806. })
  1807. .pipe(Effect.exit)
  1808. expect(Exit.isFailure(exit)).toBe(true)
  1809. if (Exit.isFailure(exit)) {
  1810. const err = Cause.squash(exit.cause)
  1811. expect(err).not.toBeInstanceOf(TypeError)
  1812. expect(NamedError.Unknown.isInstance(err)).toBe(true)
  1813. if (NamedError.Unknown.isInstance(err)) {
  1814. expect(err.data.message).toContain('Command not found: "nonexistent-command-xyz"')
  1815. expect(err.data.message).toContain("init")
  1816. }
  1817. }
  1818. }),
  1819. { git: true },
  1820. ),
  1821. 30_000,
  1822. )