prompt.test.ts 73 KB

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