prompt-effect.test.ts 49 KB

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