prompt.test.ts 79 KB

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