| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495 |
- // kilocode_change - all agent: "build" references renamed to agent: "code"
- import { NodeFileSystem } from "@effect/platform-node"
- import { expect } from "bun:test"
- import { Cause, Effect, Exit, Fiber, Layer } from "effect"
- import path from "path"
- import z from "zod"
- import { Agent as AgentSvc } from "../../src/agent/agent"
- import { Bus } from "../../src/bus"
- import { Command } from "../../src/command"
- import { Config } from "../../src/config/config"
- import { FileTime } from "../../src/file/time"
- import { LSP } from "../../src/lsp"
- import { MCP } from "../../src/mcp"
- import { Permission } from "../../src/permission"
- import { Plugin } from "../../src/plugin"
- import { Provider as ProviderSvc } from "../../src/provider/provider"
- import type { Provider } from "../../src/provider/provider"
- import { ModelID, ProviderID } from "../../src/provider/schema"
- import { Question } from "../../src/question"
- import { Todo } from "../../src/session/todo"
- import { Session } from "../../src/session"
- import { LLM } from "../../src/session/llm"
- import { MessageV2 } from "../../src/session/message-v2"
- import { AppFileSystem } from "../../src/filesystem"
- import { SessionCompaction } from "../../src/session/compaction"
- import { Instruction } from "../../src/session/instruction"
- import { SessionProcessor } from "../../src/session/processor"
- import { SessionPrompt } from "../../src/session/prompt"
- import { SessionRunState } from "../../src/session/run-state"
- import { MessageID, PartID, SessionID } from "../../src/session/schema"
- import { SessionStatus } from "../../src/session/status"
- import { Shell } from "../../src/shell/shell"
- import { Snapshot } from "../../src/snapshot"
- import { ToolRegistry } from "../../src/tool/registry"
- import { Truncate } from "../../src/tool/truncate"
- import { Log } from "../../src/util/log"
- import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
- import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture"
- import { testEffect } from "../lib/effect"
- import { reply, TestLLMServer } from "../lib/llm-server"
- Log.init({ print: false })
- const ref = {
- providerID: ProviderID.make("test"),
- modelID: ModelID.make("test-model"),
- }
- function defer<T>() {
- let resolve!: (value: T | PromiseLike<T>) => void
- const promise = new Promise<T>((done) => {
- resolve = done
- })
- return { promise, resolve }
- }
- function withSh<A, E, R>(fx: () => Effect.Effect<A, E, R>) {
- return Effect.acquireUseRelease(
- Effect.sync(() => {
- const prev = process.env.SHELL
- process.env.SHELL = "/bin/sh"
- Shell.preferred.reset()
- return prev
- }),
- () => fx(),
- (prev) =>
- Effect.sync(() => {
- if (prev === undefined) delete process.env.SHELL
- else process.env.SHELL = prev
- Shell.preferred.reset()
- }),
- )
- }
- function toolPart(parts: MessageV2.Part[]) {
- return parts.find((part): part is MessageV2.ToolPart => part.type === "tool")
- }
- type CompletedToolPart = MessageV2.ToolPart & { state: MessageV2.ToolStateCompleted }
- type ErrorToolPart = MessageV2.ToolPart & { state: MessageV2.ToolStateError }
- function completedTool(parts: MessageV2.Part[]) {
- const part = toolPart(parts)
- expect(part?.state.status).toBe("completed")
- return part?.state.status === "completed" ? (part as CompletedToolPart) : undefined
- }
- function errorTool(parts: MessageV2.Part[]) {
- const part = toolPart(parts)
- expect(part?.state.status).toBe("error")
- return part?.state.status === "error" ? (part as ErrorToolPart) : undefined
- }
- const mcp = Layer.succeed(
- MCP.Service,
- MCP.Service.of({
- status: () => Effect.succeed({}),
- clients: () => Effect.succeed({}),
- tools: () => Effect.succeed({}),
- prompts: () => Effect.succeed({}),
- resources: () => Effect.succeed({}),
- add: () => Effect.succeed({ status: { status: "disabled" as const } }),
- connect: () => Effect.void,
- disconnect: () => Effect.void,
- getPrompt: () => Effect.succeed(undefined),
- readResource: () => Effect.succeed(undefined),
- startAuth: () => Effect.die("unexpected MCP auth in prompt-effect tests"),
- authenticate: () => Effect.die("unexpected MCP auth in prompt-effect tests"),
- finishAuth: () => Effect.die("unexpected MCP auth in prompt-effect tests"),
- removeAuth: () => Effect.void,
- supportsOAuth: () => Effect.succeed(false),
- hasStoredTokens: () => Effect.succeed(false),
- getAuthStatus: () => Effect.succeed("not_authenticated" as const),
- }),
- )
- const lsp = Layer.succeed(
- LSP.Service,
- LSP.Service.of({
- init: () => Effect.void,
- status: () => Effect.succeed([]),
- hasClients: () => Effect.succeed(false),
- touchFile: () => Effect.void,
- diagnostics: () => Effect.succeed({}),
- hover: () => Effect.succeed(undefined),
- definition: () => Effect.succeed([]),
- references: () => Effect.succeed([]),
- implementation: () => Effect.succeed([]),
- documentSymbol: () => Effect.succeed([]),
- workspaceSymbol: () => Effect.succeed([]),
- prepareCallHierarchy: () => Effect.succeed([]),
- incomingCalls: () => Effect.succeed([]),
- outgoingCalls: () => Effect.succeed([]),
- }),
- )
- const filetime = Layer.succeed(
- FileTime.Service,
- FileTime.Service.of({
- read: () => Effect.void,
- get: () => Effect.succeed(undefined),
- assert: () => Effect.void,
- withLock: (_filepath, fn) => Effect.promise(fn),
- }),
- )
- const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer))
- const run = SessionRunState.layer.pipe(Layer.provide(status))
- const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer)
- function makeHttp() {
- const deps = Layer.mergeAll(
- Session.defaultLayer,
- Snapshot.defaultLayer,
- LLM.defaultLayer,
- AgentSvc.defaultLayer,
- Command.defaultLayer,
- Permission.defaultLayer,
- Plugin.defaultLayer,
- Config.defaultLayer,
- ProviderSvc.defaultLayer,
- filetime,
- lsp,
- mcp,
- AppFileSystem.defaultLayer,
- status,
- ).pipe(Layer.provideMerge(infra))
- const question = Question.layer.pipe(Layer.provideMerge(deps))
- const todo = Todo.layer.pipe(Layer.provideMerge(deps))
- const registry = ToolRegistry.layer.pipe(
- Layer.provideMerge(todo),
- Layer.provideMerge(question),
- Layer.provideMerge(deps),
- )
- const trunc = Truncate.layer.pipe(Layer.provideMerge(deps))
- const proc = SessionProcessor.layer.pipe(Layer.provideMerge(deps))
- const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps))
- return Layer.mergeAll(
- TestLLMServer.layer,
- SessionPrompt.layer.pipe(
- Layer.provideMerge(run),
- Layer.provideMerge(compact),
- Layer.provideMerge(proc),
- Layer.provideMerge(registry),
- Layer.provideMerge(trunc),
- Layer.provide(Instruction.defaultLayer),
- Layer.provideMerge(deps),
- ),
- )
- }
- const it = testEffect(makeHttp())
- const unix = process.platform !== "win32" ? it.live : it.live.skip
- const unixSkip = it.live.skip // kilocode_change - TODO(#8990): skip flaky cancel tests on Linux CI
- // Config that registers a custom "test" provider with a "test-model" model
- // so Provider.getModel("test", "test-model") succeeds inside the loop.
- const cfg = {
- provider: {
- test: {
- name: "Test",
- id: "test",
- env: [],
- npm: "@ai-sdk/openai-compatible",
- models: {
- "test-model": {
- id: "test-model",
- name: "Test Model",
- attachment: false,
- reasoning: false,
- temperature: false,
- tool_call: true,
- release_date: "2025-01-01",
- limit: { context: 100000, output: 10000 },
- cost: { input: 0, output: 0 },
- options: {},
- },
- },
- options: {
- apiKey: "test-key",
- baseURL: "http://localhost:1/v1",
- },
- },
- },
- }
- function providerCfg(url: string) {
- return {
- ...cfg,
- provider: {
- ...cfg.provider,
- test: {
- ...cfg.provider.test,
- options: {
- ...cfg.provider.test.options,
- baseURL: url,
- },
- },
- },
- }
- }
- const user = Effect.fn("test.user")(function* (sessionID: SessionID, text: string) {
- const session = yield* Session.Service
- const msg = yield* session.updateMessage({
- id: MessageID.ascending(),
- role: "user",
- sessionID,
- agent: "code",
- model: ref,
- time: { created: Date.now() },
- })
- yield* session.updatePart({
- id: PartID.ascending(),
- messageID: msg.id,
- sessionID,
- type: "text",
- text,
- })
- return msg
- })
- const seed = Effect.fn("test.seed")(function* (sessionID: SessionID, opts?: { finish?: string }) {
- const session = yield* Session.Service
- const msg = yield* user(sessionID, "hello")
- const assistant: MessageV2.Assistant = {
- id: MessageID.ascending(),
- role: "assistant",
- parentID: msg.id,
- sessionID,
- mode: "build",
- agent: "code",
- cost: 0,
- path: { cwd: "/tmp", root: "/tmp" },
- tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
- modelID: ref.modelID,
- providerID: ref.providerID,
- time: { created: Date.now() },
- ...(opts?.finish ? { finish: opts.finish } : {}),
- }
- yield* session.updateMessage(assistant)
- yield* session.updatePart({
- id: PartID.ascending(),
- messageID: assistant.id,
- sessionID,
- type: "text",
- text: "hi there",
- })
- return { user: msg, assistant }
- })
- const addSubtask = (sessionID: SessionID, messageID: MessageID, model = ref) =>
- Effect.gen(function* () {
- const session = yield* Session.Service
- yield* session.updatePart({
- id: PartID.ascending(),
- messageID,
- sessionID,
- type: "subtask",
- prompt: "look into the cache key path",
- description: "inspect bug",
- agent: "general",
- model,
- })
- })
- const boot = Effect.fn("test.boot")(function* (input?: { title?: string }) {
- const prompt = yield* SessionPrompt.Service
- const run = yield* SessionRunState.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create(input ?? { title: "Pinned" })
- return { prompt, run, sessions, chat }
- })
- // Loop semantics
- it.live("loop exits immediately when last assistant has stop finish", () =>
- provideTmpdirServer(
- Effect.fnUntraced(function* ({ llm }) {
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({ title: "Pinned" })
- yield* seed(chat.id, { finish: "stop" })
- const result = yield* prompt.loop({ sessionID: chat.id })
- expect(result.info.role).toBe("assistant")
- if (result.info.role === "assistant") expect(result.info.finish).toBe("stop")
- expect(yield* llm.calls).toBe(0)
- }),
- { git: true, config: providerCfg },
- ),
- )
- it.live("loop calls LLM and returns assistant message", () =>
- provideTmpdirServer(
- Effect.fnUntraced(function* ({ llm }) {
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({
- title: "Pinned",
- permission: [{ permission: "*", pattern: "*", action: "allow" }],
- })
- yield* prompt.prompt({
- sessionID: chat.id,
- agent: "code",
- noReply: true,
- parts: [{ type: "text", text: "hello" }],
- })
- yield* llm.text("world")
- const result = yield* prompt.loop({ sessionID: chat.id })
- expect(result.info.role).toBe("assistant")
- const parts = result.parts.filter((p) => p.type === "text")
- expect(parts.some((p) => p.type === "text" && p.text === "world")).toBe(true)
- expect(yield* llm.hits).toHaveLength(1)
- }),
- { git: true, config: providerCfg },
- ),
- )
- it.live("static loop returns assistant text through local provider", () =>
- provideTmpdirServer(
- Effect.fnUntraced(function* ({ llm }) {
- const session = yield* Effect.promise(() =>
- Session.create({
- title: "Prompt provider",
- permission: [{ permission: "*", pattern: "*", action: "allow" }],
- }),
- )
- yield* Effect.promise(() =>
- SessionPrompt.prompt({
- sessionID: session.id,
- agent: "code",
- noReply: true,
- parts: [{ type: "text", text: "hello" }],
- }),
- )
- yield* llm.text("world")
- const result = yield* Effect.promise(() => SessionPrompt.loop({ sessionID: session.id }))
- expect(result.info.role).toBe("assistant")
- expect(result.parts.some((part) => part.type === "text" && part.text === "world")).toBe(true)
- expect(yield* llm.hits).toHaveLength(1)
- expect(yield* llm.pending).toBe(0)
- }),
- { git: true, config: providerCfg },
- ),
- )
- it.live("static loop consumes queued replies across turns", () =>
- provideTmpdirServer(
- Effect.fnUntraced(function* ({ llm }) {
- const session = yield* Effect.promise(() =>
- Session.create({
- title: "Prompt provider turns",
- permission: [{ permission: "*", pattern: "*", action: "allow" }],
- }),
- )
- yield* Effect.promise(() =>
- SessionPrompt.prompt({
- sessionID: session.id,
- agent: "code",
- noReply: true,
- parts: [{ type: "text", text: "hello one" }],
- }),
- )
- yield* llm.text("world one")
- const first = yield* Effect.promise(() => SessionPrompt.loop({ sessionID: session.id }))
- expect(first.info.role).toBe("assistant")
- expect(first.parts.some((part) => part.type === "text" && part.text === "world one")).toBe(true)
- yield* Effect.promise(() =>
- SessionPrompt.prompt({
- sessionID: session.id,
- agent: "code",
- noReply: true,
- parts: [{ type: "text", text: "hello two" }],
- }),
- )
- yield* llm.text("world two")
- const second = yield* Effect.promise(() => SessionPrompt.loop({ sessionID: session.id }))
- expect(second.info.role).toBe("assistant")
- expect(second.parts.some((part) => part.type === "text" && part.text === "world two")).toBe(true)
- expect(yield* llm.hits).toHaveLength(2)
- expect(yield* llm.pending).toBe(0)
- }),
- { git: true, config: providerCfg },
- ),
- )
- it.live("loop continues when finish is tool-calls", () =>
- provideTmpdirServer(
- Effect.fnUntraced(function* ({ llm }) {
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const session = yield* sessions.create({
- title: "Pinned",
- permission: [{ permission: "*", pattern: "*", action: "allow" }],
- })
- yield* prompt.prompt({
- sessionID: session.id,
- agent: "code",
- noReply: true,
- parts: [{ type: "text", text: "hello" }],
- })
- yield* llm.tool("first", { value: "first" })
- yield* llm.text("second")
- const result = yield* prompt.loop({ sessionID: session.id })
- expect(yield* llm.calls).toBe(2)
- expect(result.info.role).toBe("assistant")
- if (result.info.role === "assistant") {
- expect(result.parts.some((part) => part.type === "text" && part.text === "second")).toBe(true)
- expect(result.info.finish).toBe("stop")
- }
- }),
- { git: true, config: providerCfg },
- ),
- )
- it.live("loop continues when finish is stop but assistant has tool parts", () =>
- provideTmpdirServer(
- Effect.fnUntraced(function* ({ llm }) {
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const session = yield* sessions.create({
- title: "Pinned",
- permission: [{ permission: "*", pattern: "*", action: "allow" }],
- })
- yield* prompt.prompt({
- sessionID: session.id,
- agent: "code",
- noReply: true,
- parts: [{ type: "text", text: "hello" }],
- })
- yield* llm.push(reply().tool("first", { value: "first" }).stop())
- yield* llm.text("second")
- const result = yield* prompt.loop({ sessionID: session.id })
- expect(yield* llm.calls).toBe(2)
- expect(result.info.role).toBe("assistant")
- if (result.info.role === "assistant") {
- expect(result.parts.some((part) => part.type === "text" && part.text === "second")).toBe(true)
- expect(result.info.finish).toBe("stop")
- }
- }),
- { git: true, config: providerCfg },
- ),
- )
- it.live("failed subtask preserves metadata on error tool state", () =>
- provideTmpdirServer(
- Effect.fnUntraced(function* ({ llm }) {
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({ title: "Pinned" })
- yield* llm.tool("task", {
- description: "inspect bug",
- prompt: "look into the cache key path",
- subagent_type: "general",
- })
- yield* llm.text("done")
- const msg = yield* user(chat.id, "hello")
- yield* addSubtask(chat.id, msg.id)
- const result = yield* prompt.loop({ sessionID: chat.id })
- expect(result.info.role).toBe("assistant")
- expect(yield* llm.calls).toBe(2)
- const msgs = yield* MessageV2.filterCompactedEffect(chat.id)
- const taskMsg = msgs.find((item) => item.info.role === "assistant" && item.info.agent === "general")
- expect(taskMsg?.info.role).toBe("assistant")
- if (!taskMsg || taskMsg.info.role !== "assistant") return
- const tool = errorTool(taskMsg.parts)
- if (!tool) return
- expect(tool.state.error).toContain("Tool execution failed")
- expect(tool.state.metadata).toBeDefined()
- expect(tool.state.metadata?.sessionId).toBeDefined()
- expect(tool.state.metadata?.model).toEqual({
- providerID: ProviderID.make("test"),
- modelID: ModelID.make("missing-model"),
- })
- }),
- {
- git: true,
- config: (url) => ({
- ...providerCfg(url),
- agent: {
- general: {
- model: "test/missing-model",
- },
- },
- }),
- },
- ),
- )
- it.live(
- "running subtask preserves metadata after tool-call transition",
- () =>
- provideTmpdirServer(
- Effect.fnUntraced(function* ({ llm }) {
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({ title: "Pinned" })
- yield* llm.hang
- const msg = yield* user(chat.id, "hello")
- yield* addSubtask(chat.id, msg.id)
- const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- const tool = yield* Effect.promise(async () => {
- const end = Date.now() + 5_000
- while (Date.now() < end) {
- const msgs = await Effect.runPromise(MessageV2.filterCompactedEffect(chat.id))
- const taskMsg = msgs.find((item) => item.info.role === "assistant" && item.info.agent === "general")
- const tool = taskMsg?.parts.find((part): part is MessageV2.ToolPart => part.type === "tool")
- if (tool?.state.status === "running" && tool.state.metadata?.sessionId) return tool
- await new Promise((done) => setTimeout(done, 20))
- }
- throw new Error("timed out waiting for running subtask metadata")
- })
- if (tool.state.status !== "running") return
- expect(typeof tool.state.metadata?.sessionId).toBe("string")
- expect(tool.state.title).toBeDefined()
- expect(tool.state.metadata?.model).toBeDefined()
- yield* prompt.cancel(chat.id)
- yield* Fiber.await(fiber)
- }),
- { git: true, config: providerCfg },
- ),
- 5_000,
- )
- it.live(
- "running task tool preserves metadata after tool-call transition",
- () =>
- provideTmpdirServer(
- Effect.fnUntraced(function* ({ llm }) {
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({
- title: "Pinned",
- permission: [{ permission: "*", pattern: "*", action: "allow" }],
- })
- yield* llm.tool("task", {
- description: "inspect bug",
- prompt: "look into the cache key path",
- subagent_type: "general",
- })
- yield* llm.hang
- yield* user(chat.id, "hello")
- const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- const tool = yield* Effect.promise(async () => {
- const end = Date.now() + 5_000
- while (Date.now() < end) {
- const msgs = await Effect.runPromise(MessageV2.filterCompactedEffect(chat.id))
- const assistant = msgs.findLast((item) => item.info.role === "assistant" && item.info.agent === "code") // kilocode_change
- const tool = assistant?.parts.find(
- (part): part is MessageV2.ToolPart => part.type === "tool" && part.tool === "task",
- )
- if (tool?.state.status === "running" && tool.state.metadata?.sessionId) return tool
- await new Promise((done) => setTimeout(done, 20))
- }
- throw new Error("timed out waiting for running task metadata")
- })
- if (tool.state.status !== "running") return
- expect(typeof tool.state.metadata?.sessionId).toBe("string")
- expect(tool.state.title).toBe("inspect bug")
- expect(tool.state.metadata?.model).toBeDefined()
- yield* prompt.cancel(chat.id)
- yield* Fiber.await(fiber)
- }),
- { git: true, config: providerCfg },
- ),
- 10_000,
- )
- it.live(
- "loop sets status to busy then idle",
- () =>
- provideTmpdirServer(
- Effect.fnUntraced(function* ({ llm }) {
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const status = yield* SessionStatus.Service
- yield* llm.hang
- const chat = yield* sessions.create({})
- yield* user(chat.id, "hi")
- const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- yield* llm.wait(1)
- expect((yield* status.get(chat.id)).type).toBe("busy")
- yield* prompt.cancel(chat.id)
- yield* Fiber.await(fiber)
- expect((yield* status.get(chat.id)).type).toBe("idle")
- }),
- { git: true, config: providerCfg },
- ),
- 3_000,
- )
- // Cancel semantics
- it.live(
- "cancel interrupts loop and resolves with an assistant message",
- () =>
- provideTmpdirServer(
- Effect.fnUntraced(function* ({ llm }) {
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({ title: "Pinned" })
- yield* seed(chat.id)
- yield* llm.hang
- yield* user(chat.id, "more")
- const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- yield* llm.wait(1)
- yield* prompt.cancel(chat.id)
- const exit = yield* Fiber.await(fiber)
- expect(Exit.isSuccess(exit)).toBe(true)
- if (Exit.isSuccess(exit)) {
- expect(exit.value.info.role).toBe("assistant")
- }
- }),
- { git: true, config: providerCfg },
- ),
- 3_000,
- )
- it.live(
- "cancel records MessageAbortedError on interrupted process",
- () =>
- provideTmpdirServer(
- Effect.fnUntraced(function* ({ llm }) {
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({ title: "Pinned" })
- yield* llm.hang
- yield* user(chat.id, "hello")
- const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- yield* llm.wait(1)
- yield* prompt.cancel(chat.id)
- const exit = yield* Fiber.await(fiber)
- expect(Exit.isSuccess(exit)).toBe(true)
- if (Exit.isSuccess(exit)) {
- const info = exit.value.info
- if (info.role === "assistant") {
- expect(info.error?.name).toBe("MessageAbortedError")
- }
- }
- }),
- { git: true, config: providerCfg },
- ),
- 3_000,
- )
- it.live(
- "cancel finalizes subtask tool state",
- () =>
- provideTmpdirInstance(
- () =>
- Effect.gen(function* () {
- const ready = defer<void>()
- const aborted = defer<void>()
- const registry = yield* ToolRegistry.Service
- const { task } = yield* registry.named()
- const original = task.execute
- task.execute = async (_args, ctx) => {
- ready.resolve()
- ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true })
- await new Promise<void>(() => {})
- return {
- title: "",
- metadata: {
- sessionId: SessionID.make("task"),
- model: ref,
- },
- output: "",
- }
- }
- yield* Effect.addFinalizer(() => Effect.sync(() => void (task.execute = original)))
- const { prompt, chat } = yield* boot()
- const msg = yield* user(chat.id, "hello")
- yield* addSubtask(chat.id, msg.id)
- const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- yield* Effect.promise(() => ready.promise)
- yield* prompt.cancel(chat.id)
- yield* Effect.promise(() => aborted.promise)
- const exit = yield* Fiber.await(fiber)
- expect(Exit.isSuccess(exit)).toBe(true)
- const msgs = yield* MessageV2.filterCompactedEffect(chat.id)
- const taskMsg = msgs.find((item) => item.info.role === "assistant" && item.info.agent === "general")
- expect(taskMsg?.info.role).toBe("assistant")
- if (!taskMsg || taskMsg.info.role !== "assistant") return
- const tool = toolPart(taskMsg.parts)
- expect(tool?.type).toBe("tool")
- if (!tool) return
- expect(tool.state.status).not.toBe("running")
- expect(taskMsg.info.time.completed).toBeDefined()
- expect(taskMsg.info.finish).toBeDefined()
- }),
- { git: true, config: cfg },
- ),
- 30_000,
- )
- it.live(
- "cancel with queued callers resolves all cleanly",
- () =>
- provideTmpdirServer(
- Effect.fnUntraced(function* ({ llm }) {
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({ title: "Pinned" })
- yield* llm.hang
- yield* user(chat.id, "hello")
- const a = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- yield* llm.wait(1)
- const b = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- yield* Effect.sleep(50)
- yield* prompt.cancel(chat.id)
- const [exitA, exitB] = yield* Effect.all([Fiber.await(a), Fiber.await(b)])
- expect(Exit.isSuccess(exitA)).toBe(true)
- expect(Exit.isSuccess(exitB)).toBe(true)
- if (Exit.isSuccess(exitA) && Exit.isSuccess(exitB)) {
- expect(exitA.value.info.id).toBe(exitB.value.info.id)
- }
- }),
- { git: true, config: providerCfg },
- ),
- 3_000,
- )
- // Queue semantics
- it.live("concurrent loop callers get same result", () =>
- provideTmpdirInstance(
- (dir) =>
- Effect.gen(function* () {
- const { prompt, run, chat } = yield* boot()
- yield* seed(chat.id, { finish: "stop" })
- const [a, b] = yield* Effect.all([prompt.loop({ sessionID: chat.id }), prompt.loop({ sessionID: chat.id })], {
- concurrency: "unbounded",
- })
- expect(a.info.id).toBe(b.info.id)
- expect(a.info.role).toBe("assistant")
- yield* run.assertNotBusy(chat.id)
- }),
- { git: true },
- ),
- )
- it.live(
- "concurrent loop callers all receive same error result",
- () =>
- provideTmpdirServer(
- Effect.fnUntraced(function* ({ llm }) {
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({ title: "Pinned" })
- yield* llm.fail("boom")
- yield* user(chat.id, "hello")
- const [a, b] = yield* Effect.all([prompt.loop({ sessionID: chat.id }), prompt.loop({ sessionID: chat.id })], {
- concurrency: "unbounded",
- })
- expect(a.info.id).toBe(b.info.id)
- expect(a.info.role).toBe("assistant")
- }),
- { git: true, config: providerCfg },
- ),
- 3_000,
- )
- // kilocode_change start - skip flaky test, tracked in #8990
- it.live.skip(
- "prompt submitted during an active run is included in the next LLM input",
- // kilocode_change end
- () =>
- provideTmpdirServer(
- Effect.fnUntraced(function* ({ llm }) {
- const gate = defer<void>()
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({ title: "Pinned" })
- yield* llm.hold("first", gate.promise)
- yield* llm.text("second")
- const a = yield* prompt
- .prompt({
- sessionID: chat.id,
- agent: "code",
- model: ref,
- parts: [{ type: "text", text: "first" }],
- })
- .pipe(Effect.forkChild)
- yield* llm.wait(1)
- const id = MessageID.ascending()
- const b = yield* prompt
- .prompt({
- sessionID: chat.id,
- messageID: id,
- agent: "code",
- model: ref,
- parts: [{ type: "text", text: "second" }],
- })
- .pipe(Effect.forkChild)
- yield* Effect.promise(async () => {
- const end = Date.now() + 5000
- while (Date.now() < end) {
- const msgs = await Effect.runPromise(sessions.messages({ sessionID: chat.id }))
- if (msgs.some((msg) => msg.info.role === "user" && msg.info.id === id)) return
- await new Promise((done) => setTimeout(done, 20))
- }
- throw new Error("timed out waiting for second prompt to save")
- })
- gate.resolve()
- const [ea, eb] = yield* Effect.all([Fiber.await(a), Fiber.await(b)])
- expect(Exit.isSuccess(ea)).toBe(true)
- expect(Exit.isSuccess(eb)).toBe(true)
- expect(yield* llm.calls).toBe(2)
- const msgs = yield* sessions.messages({ sessionID: chat.id })
- const assistants = msgs.filter((msg) => msg.info.role === "assistant")
- expect(assistants).toHaveLength(2)
- const last = assistants.at(-1)
- if (!last || last.info.role !== "assistant") throw new Error("expected second assistant")
- expect(last.info.parentID).toBe(id)
- expect(last.parts.some((part) => part.type === "text" && part.text === "second")).toBe(true)
- const inputs = yield* llm.inputs
- expect(inputs).toHaveLength(2)
- expect(JSON.stringify(inputs.at(-1)?.messages)).toContain("second")
- }),
- { git: true, config: providerCfg },
- ),
- 3_000,
- )
- it.live(
- "assertNotBusy throws BusyError when loop running",
- () =>
- provideTmpdirServer(
- Effect.fnUntraced(function* ({ llm }) {
- const prompt = yield* SessionPrompt.Service
- const run = yield* SessionRunState.Service
- const sessions = yield* Session.Service
- yield* llm.hang
- const chat = yield* sessions.create({})
- yield* user(chat.id, "hi")
- const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- yield* llm.wait(1)
- const exit = yield* run.assertNotBusy(chat.id).pipe(Effect.exit)
- expect(Exit.isFailure(exit)).toBe(true)
- if (Exit.isFailure(exit)) {
- expect(Cause.squash(exit.cause)).toBeInstanceOf(Session.BusyError)
- }
- yield* prompt.cancel(chat.id)
- yield* Fiber.await(fiber)
- }),
- { git: true, config: providerCfg },
- ),
- 3_000,
- )
- it.live("assertNotBusy succeeds when idle", () =>
- provideTmpdirInstance(
- (dir) =>
- Effect.gen(function* () {
- const run = yield* SessionRunState.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({})
- const exit = yield* run.assertNotBusy(chat.id).pipe(Effect.exit)
- expect(Exit.isSuccess(exit)).toBe(true)
- }),
- { git: true },
- ),
- )
- // Shell semantics
- it.live(
- "shell rejects with BusyError when loop running",
- () =>
- provideTmpdirServer(
- Effect.fnUntraced(function* ({ llm }) {
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({ title: "Pinned" })
- yield* llm.hang
- yield* user(chat.id, "hi")
- const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- yield* llm.wait(1)
- const exit = yield* prompt.shell({ sessionID: chat.id, agent: "code", command: "echo hi" }).pipe(Effect.exit)
- expect(Exit.isFailure(exit)).toBe(true)
- if (Exit.isFailure(exit)) {
- expect(Cause.squash(exit.cause)).toBeInstanceOf(Session.BusyError)
- }
- yield* prompt.cancel(chat.id)
- yield* Fiber.await(fiber)
- }),
- { git: true, config: providerCfg },
- ),
- 3_000,
- )
- unix("shell captures stdout and stderr in completed tool output", () =>
- provideTmpdirInstance(
- (dir) =>
- Effect.gen(function* () {
- const { prompt, run, chat } = yield* boot()
- const result = yield* prompt.shell({
- sessionID: chat.id,
- agent: "code",
- command: "printf out && printf err >&2",
- })
- expect(result.info.role).toBe("assistant")
- const tool = completedTool(result.parts)
- if (!tool) return
- expect(tool.state.output).toContain("out")
- expect(tool.state.output).toContain("err")
- expect(tool.state.metadata.output).toContain("out")
- expect(tool.state.metadata.output).toContain("err")
- yield* run.assertNotBusy(chat.id)
- }),
- { git: true, config: cfg },
- ),
- )
- unix("shell completes a fast command on the preferred shell", () =>
- provideTmpdirInstance(
- (dir) =>
- Effect.gen(function* () {
- const { prompt, run, chat } = yield* boot()
- const result = yield* prompt.shell({
- sessionID: chat.id,
- agent: "code",
- command: "pwd",
- })
- expect(result.info.role).toBe("assistant")
- const tool = completedTool(result.parts)
- if (!tool) return
- expect(tool.state.input.command).toBe("pwd")
- expect(tool.state.output).toContain(dir)
- expect(tool.state.metadata.output).toContain(dir)
- yield* run.assertNotBusy(chat.id)
- }),
- { git: true, config: cfg },
- ),
- )
- unix("shell lists files from the project directory", () =>
- provideTmpdirInstance(
- (dir) =>
- Effect.gen(function* () {
- const { prompt, run, chat } = yield* boot()
- yield* Effect.promise(() => Bun.write(path.join(dir, "README.md"), "# e2e\n"))
- const result = yield* prompt.shell({
- sessionID: chat.id,
- agent: "code",
- command: "command ls",
- })
- expect(result.info.role).toBe("assistant")
- const tool = completedTool(result.parts)
- if (!tool) return
- expect(tool.state.input.command).toBe("command ls")
- expect(tool.state.output).toContain("README.md")
- expect(tool.state.metadata.output).toContain("README.md")
- yield* run.assertNotBusy(chat.id)
- }),
- { git: true, config: cfg },
- ),
- )
- unix("shell captures stderr from a failing command", () =>
- provideTmpdirInstance(
- (dir) =>
- Effect.gen(function* () {
- const { prompt, run, chat } = yield* boot()
- const result = yield* prompt.shell({
- sessionID: chat.id,
- agent: "code",
- command: "command -v __nonexistent_cmd_e2e__ || echo 'not found' >&2; exit 1",
- })
- expect(result.info.role).toBe("assistant")
- const tool = completedTool(result.parts)
- if (!tool) return
- expect(tool.state.output).toContain("not found")
- expect(tool.state.metadata.output).toContain("not found")
- yield* run.assertNotBusy(chat.id)
- }),
- { git: true, config: cfg },
- ),
- )
- unix(
- "shell updates running metadata before process exit",
- () =>
- withSh(() =>
- provideTmpdirInstance(
- (dir) =>
- Effect.gen(function* () {
- const { prompt, chat } = yield* boot()
- const fiber = yield* prompt
- .shell({ sessionID: chat.id, agent: "code", command: "printf first && sleep 0.2 && printf second" })
- .pipe(Effect.forkChild)
- yield* Effect.promise(async () => {
- const start = Date.now()
- while (Date.now() - start < 5000) {
- const msgs = await MessageV2.filterCompacted(MessageV2.stream(chat.id))
- const taskMsg = msgs.find((item) => item.info.role === "assistant")
- const tool = taskMsg ? toolPart(taskMsg.parts) : undefined
- if (tool?.state.status === "running" && tool.state.metadata?.output.includes("first")) return
- await new Promise((done) => setTimeout(done, 20))
- }
- throw new Error("timed out waiting for running shell metadata")
- })
- const exit = yield* Fiber.await(fiber)
- expect(Exit.isSuccess(exit)).toBe(true)
- }),
- { git: true, config: cfg },
- ),
- ),
- 30_000,
- )
- it.live(
- "loop waits while shell runs and starts after shell exits",
- () =>
- provideTmpdirServer(
- Effect.fnUntraced(function* ({ llm }) {
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({
- title: "Pinned",
- permission: [{ permission: "*", pattern: "*", action: "allow" }],
- })
- yield* llm.text("after-shell")
- const sh = yield* prompt
- .shell({ sessionID: chat.id, agent: "code", command: "sleep 0.2" })
- .pipe(Effect.forkChild)
- yield* Effect.sleep(50)
- const loop = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- yield* Effect.sleep(50)
- expect(yield* llm.calls).toBe(0)
- yield* Fiber.await(sh)
- const exit = yield* Fiber.await(loop)
- expect(Exit.isSuccess(exit)).toBe(true)
- if (Exit.isSuccess(exit)) {
- expect(exit.value.info.role).toBe("assistant")
- expect(exit.value.parts.some((part) => part.type === "text" && part.text === "after-shell")).toBe(true)
- }
- expect(yield* llm.calls).toBe(1)
- }),
- { git: true, config: providerCfg },
- ),
- 3_000,
- )
- // kilocode_change start - shell process timing is unreliable on Windows CI;
- // aligns with every other shell-* test in this file that uses `unix(...)`.
- unix(
- // kilocode_change end
- "shell completion resumes queued loop callers",
- () =>
- provideTmpdirServer(
- Effect.fnUntraced(function* ({ llm }) {
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({
- title: "Pinned",
- permission: [{ permission: "*", pattern: "*", action: "allow" }],
- })
- yield* llm.text("done")
- const sh = yield* prompt
- .shell({ sessionID: chat.id, agent: "code", command: "sleep 0.2" })
- .pipe(Effect.forkChild)
- yield* Effect.sleep(50)
- const a = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- const b = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- yield* Effect.sleep(50)
- expect(yield* llm.calls).toBe(0)
- yield* Fiber.await(sh)
- const [ea, eb] = yield* Effect.all([Fiber.await(a), Fiber.await(b)])
- expect(Exit.isSuccess(ea)).toBe(true)
- expect(Exit.isSuccess(eb)).toBe(true)
- if (Exit.isSuccess(ea) && Exit.isSuccess(eb)) {
- expect(ea.value.info.id).toBe(eb.value.info.id)
- expect(ea.value.info.role).toBe("assistant")
- }
- expect(yield* llm.calls).toBe(1)
- }),
- { git: true, config: providerCfg },
- ),
- 3_000,
- )
- // kilocode_change start - TODO(#8990): flaky on Linux CI
- unixSkip(
- "cancel interrupts shell and resolves cleanly",
- () =>
- withSh(() =>
- provideTmpdirInstance(
- (dir) =>
- Effect.gen(function* () {
- const { prompt, run, chat } = yield* boot()
- const sh = yield* prompt
- .shell({ sessionID: chat.id, agent: "code", command: "sleep 30" })
- .pipe(Effect.forkChild)
- yield* Effect.sleep(50)
- yield* prompt.cancel(chat.id)
- const status = yield* SessionStatus.Service
- expect((yield* status.get(chat.id)).type).toBe("idle")
- const busy = yield* run.assertNotBusy(chat.id).pipe(Effect.exit)
- expect(Exit.isSuccess(busy)).toBe(true)
- const exit = yield* Fiber.await(sh)
- expect(Exit.isSuccess(exit)).toBe(true)
- if (Exit.isSuccess(exit)) {
- expect(exit.value.info.role).toBe("assistant")
- const tool = completedTool(exit.value.parts)
- if (tool) {
- expect(tool.state.output).toContain("User aborted the command")
- }
- }
- }),
- { git: true, config: cfg },
- ),
- ),
- 30_000,
- )
- // kilocode_change end
- // kilocode_change start - TODO(#8990): flaky on Linux CI
- unixSkip(
- "cancel persists aborted shell result when shell ignores TERM",
- () =>
- withSh(() =>
- provideTmpdirInstance(
- (dir) =>
- Effect.gen(function* () {
- const { prompt, chat } = yield* boot()
- const sh = yield* prompt
- .shell({ sessionID: chat.id, agent: "code", command: "trap '' TERM; sleep 30" })
- .pipe(Effect.forkChild)
- yield* Effect.sleep(50)
- yield* prompt.cancel(chat.id)
- const exit = yield* Fiber.await(sh)
- expect(Exit.isSuccess(exit)).toBe(true)
- if (Exit.isSuccess(exit)) {
- expect(exit.value.info.role).toBe("assistant")
- const tool = completedTool(exit.value.parts)
- if (tool) {
- expect(tool.state.output).toContain("User aborted the command")
- }
- }
- }),
- { git: true, config: cfg },
- ),
- ),
- 30_000,
- )
- // kilocode_change end
- unix(
- "cancel finalizes interrupted bash tool output through normal truncation",
- () =>
- provideTmpdirServer(
- ({ dir, llm }) =>
- Effect.gen(function* () {
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({
- title: "Interrupted bash truncation",
- permission: [{ permission: "*", pattern: "*", action: "allow" }],
- })
- yield* prompt.prompt({
- sessionID: chat.id,
- agent: "build",
- noReply: true,
- parts: [{ type: "text", text: "run bash" }],
- })
- yield* llm.tool("bash", {
- command:
- 'i=0; while [ "$i" -lt 4000 ]; do printf "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx %05d\\n" "$i"; i=$((i + 1)); done; sleep 30',
- description: "Print many lines",
- timeout: 30_000,
- workdir: path.resolve(dir),
- })
- const run = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- yield* llm.wait(1)
- yield* Effect.sleep(150)
- yield* prompt.cancel(chat.id)
- const exit = yield* Fiber.await(run)
- expect(Exit.isSuccess(exit)).toBe(true)
- if (Exit.isFailure(exit)) return
- const tool = completedTool(exit.value.parts)
- if (!tool) return
- expect(tool.state.metadata.truncated).toBe(true)
- expect(typeof tool.state.metadata.outputPath).toBe("string")
- expect(tool.state.output).toContain("The tool call succeeded but the output was truncated.")
- expect(tool.state.output).toContain("Full output saved to:")
- expect(tool.state.output).not.toContain("Tool execution aborted")
- }),
- { git: true, config: providerCfg },
- ),
- 30_000,
- )
- // kilocode_change start - TODO(#8990): flaky on Linux CI
- unixSkip(
- "cancel interrupts loop queued behind shell",
- () =>
- provideTmpdirInstance(
- (dir) =>
- Effect.gen(function* () {
- const { prompt, chat } = yield* boot()
- const sh = yield* prompt
- .shell({ sessionID: chat.id, agent: "code", command: "sleep 30" })
- .pipe(Effect.forkChild)
- yield* Effect.sleep(50)
- const loop = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- yield* Effect.sleep(50)
- yield* prompt.cancel(chat.id)
- const exit = yield* Fiber.await(loop)
- expect(Exit.isSuccess(exit)).toBe(true)
- yield* Fiber.await(sh)
- }),
- { git: true, config: cfg },
- ),
- 30_000,
- )
- // kilocode_change end
- unix(
- "shell rejects when another shell is already running",
- () =>
- withSh(() =>
- provideTmpdirInstance(
- (dir) =>
- Effect.gen(function* () {
- const { prompt, chat } = yield* boot()
- const a = yield* prompt
- .shell({ sessionID: chat.id, agent: "code", command: "sleep 30" })
- .pipe(Effect.forkChild)
- yield* Effect.sleep(50)
- const exit = yield* prompt
- .shell({ sessionID: chat.id, agent: "code", command: "echo hi" })
- .pipe(Effect.exit)
- expect(Exit.isFailure(exit)).toBe(true)
- if (Exit.isFailure(exit)) {
- expect(Cause.squash(exit.cause)).toBeInstanceOf(Session.BusyError)
- }
- yield* prompt.cancel(chat.id)
- yield* Fiber.await(a)
- }),
- { git: true, config: cfg },
- ),
- ),
- 30_000,
- )
- // Abort signal propagation tests for inline tool execution
- /** Override a tool's execute to hang until aborted. Returns ready/aborted defers and a finalizer. */
- function hangUntilAborted(tool: { execute: (...args: any[]) => any }) {
- const ready = defer<void>()
- const aborted = defer<void>()
- const original = tool.execute
- tool.execute = async (_args: any, ctx: any) => {
- ready.resolve()
- ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true })
- await new Promise<void>(() => {})
- return { title: "", metadata: {}, output: "" }
- }
- const restore = Effect.addFinalizer(() => Effect.sync(() => void (tool.execute = original)))
- return { ready, aborted, restore }
- }
- it.live(
- "interrupt propagates abort signal to read tool via file part (text/plain)",
- () =>
- provideTmpdirInstance(
- (dir) =>
- Effect.gen(function* () {
- const registry = yield* ToolRegistry.Service
- const { read } = yield* registry.named()
- const { ready, aborted, restore } = hangUntilAborted(read)
- yield* restore
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({ title: "Abort Test" })
- const testFile = path.join(dir, "test.txt")
- yield* Effect.promise(() => Bun.write(testFile, "hello world"))
- const fiber = yield* prompt
- .prompt({
- sessionID: chat.id,
- agent: "build",
- parts: [
- { type: "text", text: "read this" },
- { type: "file", url: `file://${testFile}`, filename: "test.txt", mime: "text/plain" },
- ],
- })
- .pipe(Effect.forkChild)
- yield* Effect.promise(() => ready.promise)
- yield* Fiber.interrupt(fiber)
- yield* Effect.promise(() =>
- Promise.race([
- aborted.promise,
- new Promise<void>((_, reject) =>
- setTimeout(() => reject(new Error("abort signal not propagated within 2s")), 2_000),
- ),
- ]),
- )
- }),
- { git: true, config: cfg },
- ),
- 30_000,
- )
- it.live(
- "interrupt propagates abort signal to read tool via file part (directory)",
- () =>
- provideTmpdirInstance(
- (dir) =>
- Effect.gen(function* () {
- const registry = yield* ToolRegistry.Service
- const { read } = yield* registry.named()
- const { ready, aborted, restore } = hangUntilAborted(read)
- yield* restore
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({ title: "Abort Test" })
- const fiber = yield* prompt
- .prompt({
- sessionID: chat.id,
- agent: "build",
- parts: [
- { type: "text", text: "read this" },
- { type: "file", url: `file://${dir}`, filename: "dir", mime: "application/x-directory" },
- ],
- })
- .pipe(Effect.forkChild)
- yield* Effect.promise(() => ready.promise)
- yield* Fiber.interrupt(fiber)
- yield* Effect.promise(() =>
- Promise.race([
- aborted.promise,
- new Promise<void>((_, reject) =>
- setTimeout(() => reject(new Error("abort signal not propagated within 2s")), 2_000),
- ),
- ]),
- )
- }),
- { git: true, config: cfg },
- ),
- 30_000,
- )
|