import { NodeFileSystem } from "@effect/platform-node" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" import { SessionV1 } from "@opencode-ai/core/v1/session" import { Database } from "@opencode-ai/core/database/database" import { eq } from "drizzle-orm" import { EventV2Bridge } from "@/event-v2-bridge" import { FetchHttpClient } from "effect/unstable/http" import { expect } from "bun:test" import { Cause, Deferred, Duration, Effect, Exit, Fiber, Layer } from "effect" import path from "path" import { fileURLToPath, pathToFileURL } from "url" import { NamedError } from "@opencode-ai/core/util/error" import { Agent as AgentSvc } from "../../src/agent/agent" import { BackgroundJob } from "@/background/job" import { Command } from "../../src/command" import { Config } from "@/config/config" import { LSP } from "@/lsp/lsp" import { MCP } from "../../src/mcp" import { Permission } from "../../src/permission" import { Plugin } from "../../src/plugin" import { Provider as ProviderSvc } from "@/provider/provider" import { Env } from "../../src/env" import { Git } from "../../src/git" import { Image } from "../../src/image/image" import { Question } from "../../src/question" import { Todo } from "../../src/session/todo" import { Session } from "@/session/session" import { SessionMessageTable } from "@opencode-ai/core/session/sql" import { LLM } from "../../src/session/llm" import { MessageV2 } from "../../src/session/message-v2" import { FSUtil } from "@opencode-ai/core/fs-util" import { SessionCompaction } from "../../src/session/compaction" import { SessionSummary } from "../../src/session/summary" import { Instruction } from "../../src/session/instruction" import { SessionProcessor } from "../../src/session/processor" import { SessionPrompt } from "../../src/session/prompt" import { SessionRevert } from "../../src/session/revert" import { SessionRunState } from "../../src/session/run-state" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { SessionStatus } from "../../src/session/status" import { SessionV2 } from "@opencode-ai/core/session" import { Skill } from "../../src/skill" import { SystemPrompt } from "../../src/session/system" import { Shell } from "../../src/shell/shell" import { Snapshot } from "../../src/snapshot" import { ToolRegistry } from "@/tool/registry" import { Truncate } from "@/tool/truncate" import * as Log from "@opencode-ai/core/util/log" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep" import { Format } from "../../src/format" import { Reference } from "../../src/reference/reference" import { RepositoryCache } from "../../src/reference/repository-cache" import { TestInstance } from "../fixture/fixture" import { awaitWithTimeout, pollWithTimeout, testEffect } from "../lib/effect" import { reply, TestLLMServer } from "../lib/llm-server" import { RuntimeFlags } from "@/effect/runtime-flags" import { ProviderV2 } from "@opencode-ai/core/provider" void Log.init({ print: false }) const summary = Layer.succeed( SessionSummary.Service, SessionSummary.Service.of({ summarize: () => Effect.void, diff: () => Effect.succeed([]), computeDiff: () => Effect.succeed([]), }), ) const ref = { providerID: ProviderV2.ID.make("test"), modelID: ProviderV2.ModelID.make("test-model"), } function withSh(fx: () => Effect.Effect) { 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: SessionV1.Part[]) { return parts.find((part): part is SessionV1.ToolPart => part.type === "tool") } type CompletedToolPart = SessionV1.ToolPart & { state: SessionV1.ToolStateCompleted } type ErrorToolPart = SessionV1.ToolPart & { state: SessionV1.ToolStateError } function completedTool(parts: SessionV1.Part[]) { const part = toolPart(parts) expect(part?.state.status).toBe("completed") return part?.state.status === "completed" ? (part as CompletedToolPart) : undefined } function errorTool(parts: SessionV1.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 status = SessionStatus.layer.pipe(Layer.provideMerge(EventV2Bridge.defaultLayer)) const run = SessionRunState.layer.pipe(Layer.provide(status)) const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer) const processorCreateStarted: Array<() => void> = [] const blockingProcessor = Layer.succeed( SessionProcessor.Service, SessionProcessor.Service.of({ create: () => Effect.sync(() => processorCreateStarted.shift()?.()).pipe(Effect.andThen(Effect.never)), }), ) function makePrompt(input?: { processor?: "blocking" }) { const deps = Layer.mergeAll( Session.defaultLayer, Snapshot.defaultLayer, LLM.defaultLayer, Env.defaultLayer, AgentSvc.defaultLayer, Command.defaultLayer, Permission.defaultLayer, Plugin.defaultLayer, Config.defaultLayer, ProviderSvc.defaultLayer, lsp, mcp, FSUtil.defaultLayer, BackgroundJob.defaultLayer, status, Database.defaultLayer, EventV2Bridge.defaultLayer, ).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.provide(Skill.defaultLayer), Layer.provide(FetchHttpClient.layer), Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(RepositoryCache.defaultLayer), Layer.provide(Git.defaultLayer), Layer.provide(Reference.defaultLayer), Layer.provide(Ripgrep.defaultLayer), Layer.provide(Format.defaultLayer), Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), Layer.provideMerge(todo), Layer.provideMerge(question), Layer.provideMerge(deps), ) const trunc = Truncate.layer.pipe(Layer.provideMerge(deps)) const proc = input?.processor === "blocking" ? blockingProcessor : SessionProcessor.layer.pipe( Layer.provide(summary), Layer.provide(Image.defaultLayer), Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), Layer.provideMerge(deps), ) const compact = SessionCompaction.layer.pipe( Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), Layer.provideMerge(proc), Layer.provideMerge(deps), ) return SessionPrompt.layer.pipe( Layer.provide(SessionRevert.defaultLayer), Layer.provide(Image.defaultLayer), Layer.provide(Reference.defaultLayer), Layer.provide(summary), Layer.provideMerge(run), Layer.provideMerge(compact), Layer.provideMerge(proc), Layer.provideMerge(registry), Layer.provideMerge(trunc), Layer.provide(Instruction.defaultLayer), Layer.provide(SystemPrompt.defaultLayer), Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), Layer.provideMerge(deps), Layer.provide(summary), ) } function makeHttp(input?: { processor?: "blocking" }) { return Layer.mergeAll(TestLLMServer.layer, makePrompt(input)) } function makeHttpNoLLMServer(input?: { processor?: "blocking" }) { return makePrompt(input) } const it = testEffect(makeHttp()) const noLLMServer = testEffect(makeHttpNoLLMServer()) const raceNoLLMServer = testEffect(makeHttpNoLLMServer({ processor: "blocking" })) const unix = process.platform !== "win32" ? it.instance : it.instance.skip const unixNoLLMServer = process.platform !== "win32" ? noLLMServer.instance : noLLMServer.instance.skip // Config that registers a custom "test" provider with a "test-model" model // so provider model lookup 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 writeText = Effect.fn("test.writeText")(function* (file: string, text: string) { const fs = yield* FSUtil.Service yield* fs.writeWithDirs(file, text) }) const ensureDir = Effect.fn("test.ensureDir")(function* (dir: string) { const fs = yield* FSUtil.Service yield* fs.ensureDir(dir) }) const writeConfig = Effect.fn("test.writeConfig")(function* (dir: string, config: Partial) { yield* writeText( path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", ...config }), ) }) const useServerConfig = Effect.fn("test.useServerConfig")(function* (config: (url: string) => Partial) { const { directory: dir } = yield* TestInstance const llm = yield* TestLLMServer yield* writeConfig(dir, config(llm.url)) return { dir, llm } }) // Wait for a session's runner to enter a busy state. SessionStatus is flipped // inside Runner.startShell's serialized transition, so cancel can't no-op once // we observe it. const waitForBusy = (sessionID: SessionID, duration: Duration.Input = "2 seconds") => pollWithTimeout( Effect.gen(function* () { const status = yield* SessionStatus.Service const s = yield* status.get(sessionID) return s.type === "busy" ? (true as const) : undefined }), `session ${sessionID} never became busy`, duration, ) const hasBash = Effect.sync(() => Bun.which("bash") !== null) const deferredAsPromise = (deferred: Deferred.Deferred): PromiseLike => ({ then: (onfulfilled, onrejected) => { Effect.runFork( Deferred.await(deferred).pipe( Effect.match({ onFailure: (error) => { onrejected?.(error) }, onSuccess: (value) => { onfulfilled?.(value) }, }), ), ) return deferredAsPromise(deferred) as PromiseLike }, }) function defer() { let resolve!: (value: T | PromiseLike) => void const promise = new Promise((done) => { resolve = done }) return { promise, resolve } } const succeedVoid = (deferred: Deferred.Deferred) => { Effect.runSync(Deferred.succeed(deferred, void 0).pipe(Effect.ignore)) } 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: "build", 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: SessionV1.Assistant = { id: MessageID.ascending(), role: "assistant", parentID: msg.id, sessionID, mode: "build", agent: "build", 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 config = yield* Config.Service const prompt = yield* SessionPrompt.Service const run = yield* SessionRunState.Service const sessions = yield* Session.Service yield* config.get() const chat = yield* sessions.create(input ?? { title: "Pinned" }) return { prompt, run, sessions, chat } }) // Loop semantics noLLMServer.instance( "loop exits immediately when last assistant has stop finish", () => Effect.gen(function* () { 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") }), { config: cfg }, ) it.instance("loop exits without an LLM request for interrupted orphan tool calls", () => Effect.gen(function* () { const { llm } = yield* useServerConfig(providerCfg) const prompt = yield* SessionPrompt.Service const sessions = yield* Session.Service const chat = yield* sessions.create({ title: "Pinned" }) const seeded = yield* seed(chat.id, { finish: "stop" }) yield* sessions.updatePart({ id: PartID.ascending(), messageID: seeded.assistant.id, sessionID: chat.id, type: "tool", callID: "interrupted-call", tool: "edit", state: { status: "error", input: {}, error: "Tool execution aborted", metadata: { interrupted: true }, time: { start: 1, end: 2 }, }, }) const result = yield* prompt.loop({ sessionID: chat.id }) expect(result.info.id).toBe(seeded.assistant.id) expect(yield* llm.hits).toHaveLength(0) }), ) it.instance("loop calls LLM and returns assistant message", () => Effect.gen(function* () { const { llm } = yield* useServerConfig(providerCfg) 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: "build", 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) }), ) noLLMServer.instance.skip( "prompt emits v2 prompted and synthetic events (v2 projector disabled)", () => Effect.gen(function* () { const prompt = yield* SessionPrompt.Service const sessions = yield* Session.Service const chat = yield* sessions.create({ title: "Pinned" }) yield* prompt.prompt({ sessionID: chat.id, agent: "build", noReply: true, parts: [ { type: "text", text: "hello v2" }, { type: "file", mime: "text/plain", filename: "note.txt", url: "data:text/plain;base64,bm90ZSBjb250ZW50", }, ], }) const messages = yield* SessionV2.Service.use((session) => session.messages({ sessionID: chat.id })).pipe( Effect.provide(SessionV2.defaultLayer), ) const { db } = yield* Database.Service const row = yield* db .select() .from(SessionMessageTable) .where(eq(SessionMessageTable.session_id, chat.id)) .get() .pipe(Effect.orDie) expect(messages.find((message) => message.type === "user")).toMatchObject({ type: "user", text: "hello v2" }) expect(typeof row?.data.time.created).toBe("number") expect(messages).toEqual( expect.arrayContaining([ expect.objectContaining({ type: "synthetic", text: expect.stringContaining("Called the Read tool") }), expect.objectContaining({ type: "synthetic", text: "note content" }), ]), ) }), { config: cfg }, ) it.instance("static loop returns assistant text through local provider", () => Effect.gen(function* () { const { llm } = yield* useServerConfig(providerCfg) const prompt = yield* SessionPrompt.Service const sessions = yield* Session.Service const session = yield* sessions.create({ title: "Prompt provider", permission: [{ permission: "*", pattern: "*", action: "allow" }], }) yield* prompt.prompt({ sessionID: session.id, agent: "build", noReply: true, parts: [{ type: "text", text: "hello" }], }) yield* llm.text("world") const result = yield* prompt.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) }), ) it.instance("static loop consumes queued replies across turns", () => Effect.gen(function* () { const { llm } = yield* useServerConfig(providerCfg) const prompt = yield* SessionPrompt.Service const sessions = yield* Session.Service const session = yield* sessions.create({ title: "Prompt provider turns", permission: [{ permission: "*", pattern: "*", action: "allow" }], }) yield* prompt.prompt({ sessionID: session.id, agent: "build", noReply: true, parts: [{ type: "text", text: "hello one" }], }) yield* llm.text("world one") const first = yield* prompt.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* prompt.prompt({ sessionID: session.id, agent: "build", noReply: true, parts: [{ type: "text", text: "hello two" }], }) yield* llm.text("world two") const second = yield* prompt.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) }), ) it.instance("loop continues when finish is tool-calls", () => Effect.gen(function* () { const { llm } = yield* useServerConfig(providerCfg) 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: "build", 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") } }), ) it.instance("glob tool keeps instance context during prompt runs", () => Effect.gen(function* () { const { dir, llm } = yield* useServerConfig(providerCfg) const prompt = yield* SessionPrompt.Service const sessions = yield* Session.Service const session = yield* sessions.create({ title: "Glob context", permission: [{ permission: "*", pattern: "*", action: "allow" }], }) const file = path.join(dir, "probe.txt") yield* writeText(file, "probe") yield* prompt.prompt({ sessionID: session.id, agent: "build", noReply: true, parts: [{ type: "text", text: "find text files" }], }) yield* llm.tool("glob", { pattern: "**/*.txt" }) yield* llm.text("done") const result = yield* prompt.loop({ sessionID: session.id }) expect(result.info.role).toBe("assistant") const msgs = yield* MessageV2.filterCompactedEffect(session.id) const tool = msgs .flatMap((msg) => msg.parts) .find( (part): part is CompletedToolPart => part.type === "tool" && part.tool === "glob" && part.state.status === "completed", ) if (!tool) return expect(tool.state.output).toContain(file) expect(tool.state.output).not.toContain("No context found for instance") expect(result.parts.some((part) => part.type === "text" && part.text === "done")).toBe(true) }), ) it.instance("loop continues when finish is stop but assistant has tool parts", () => Effect.gen(function* () { const { llm } = yield* useServerConfig(providerCfg) 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: "build", 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") } }), ) it.instance("failed subtask preserves metadata on error tool state", () => Effect.gen(function* () { const { llm } = yield* useServerConfig((url) => ({ ...providerCfg(url), agent: { general: { model: "test/missing-model", }, }, })) 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: ProviderV2.ID.make("test"), modelID: ProviderV2.ModelID.make("missing-model"), }) }), ) it.instance( "running subtask preserves metadata after tool-call transition", () => Effect.gen(function* () { const { llm } = yield* useServerConfig(providerCfg) 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* pollWithTimeout( Effect.gen(function* () { const msgs = yield* 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 SessionV1.ToolPart => part.type === "tool") if (tool?.state.status === "running" && tool.state.metadata?.sessionId) return tool }), "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) }), 5_000, ) it.instance( "running task tool preserves metadata after tool-call transition", () => Effect.gen(function* () { const { llm } = yield* useServerConfig(providerCfg) 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* pollWithTimeout( Effect.gen(function* () { const msgs = yield* MessageV2.filterCompactedEffect(chat.id) const assistant = msgs.findLast((item) => item.info.role === "assistant" && item.info.agent === "build") const tool = assistant?.parts.find( (part): part is SessionV1.ToolPart => part.type === "tool" && part.tool === "task", ) if (tool?.state.status === "running" && tool.state.metadata?.sessionId) return tool }), "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) }), 10_000, ) it.instance( "loop sets status to busy then idle", () => Effect.gen(function* () { const { llm } = yield* useServerConfig(providerCfg) 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") }), 3_000, ) // Cancel semantics it.instance( "cancel interrupts loop and resolves with an assistant message", () => Effect.gen(function* () { const { llm } = yield* useServerConfig(providerCfg) 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") } }), 3_000, ) it.instance( "cancel records MessageAbortedError on interrupted process", () => Effect.gen(function* () { const { llm } = yield* useServerConfig(providerCfg) 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") } } }), 3_000, ) raceNoLLMServer.instance( "finalizes assistant when cancelled before processor creation completes", () => Effect.gen(function* () { processorCreateStarted.length = 0 yield* Effect.addFinalizer(() => Effect.sync(() => { processorCreateStarted.length = 0 }), ) const prompt = yield* SessionPrompt.Service const sessions = yield* Session.Service const chat = yield* sessions.create({ title: "Processor creation race" }) yield* prompt.prompt({ sessionID: chat.id, agent: "build", noReply: true, parts: [{ type: "text", text: "first" }], }) const firstCreate = defer() processorCreateStarted.push(firstCreate.resolve) const first = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) yield* Effect.promise(() => firstCreate.promise) yield* prompt.cancel(chat.id) const firstExit = yield* Fiber.await(first) expect(Exit.isSuccess(firstExit)).toBe(true) let messages = yield* sessions.messages({ sessionID: chat.id }) const firstInterrupted = messages.at(-1) expect(firstInterrupted?.info.role).toBe("assistant") expect(firstInterrupted?.parts).toHaveLength(0) if (firstInterrupted?.info.role === "assistant") { expect(firstInterrupted.info.finish).toBeUndefined() expect(firstInterrupted.info.time.completed).toBeNumber() expect(firstInterrupted.info.error?.name).toBe("MessageAbortedError") } yield* prompt.prompt({ sessionID: chat.id, agent: "build", noReply: true, parts: [{ type: "text", text: "second" }], }) const secondCreate = defer() processorCreateStarted.push(secondCreate.resolve) const second = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) yield* Effect.promise(() => secondCreate.promise) yield* prompt.cancel(chat.id) const secondExit = yield* Fiber.await(second) expect(Exit.isSuccess(secondExit)).toBe(true) messages = yield* sessions.messages({ sessionID: chat.id }) const poisonMessages = messages.filter( (message) => message.info.role === "assistant" && message.parts.length === 0 && !message.info.finish && !message.info.time.completed && !message.info.error, ) expect(poisonMessages).toHaveLength(0) const interruptedMessages = messages.filter( (message) => message.info.role === "assistant" && message.parts.length === 0 && message.info.time.completed && message.info.error?.name === "MessageAbortedError", ) expect(interruptedMessages).toHaveLength(2) const lastUser = messages.at(-2) const lastAssistant = messages.at(-1) expect(lastUser?.info.role).toBe("user") expect(lastAssistant?.info.role).toBe("assistant") if (lastUser?.info.role === "user" && lastAssistant?.info.role === "assistant") { expect(lastAssistant.info.parentID).toBe(lastUser?.info.id) } }), { config: cfg }, 3_000, ) noLLMServer.instance( "cancel finalizes subtask tool state", () => Effect.gen(function* () { const ready = yield* Deferred.make() const aborted = yield* Deferred.make() const registry = yield* ToolRegistry.Service const { task } = yield* registry.named() const original = task.execute task.execute = (_args, ctx) => Effect.callback((_resume) => { ctx.abort.addEventListener("abort", () => succeedVoid(aborted), { once: true }) if (ctx.abort.aborted) succeedVoid(aborted) succeedVoid(ready) return Effect.sync(() => succeedVoid(aborted)) }) 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* awaitWithTimeout(Deferred.await(ready), "timed out waiting for task tool to start", "10 seconds") yield* prompt.cancel(chat.id) const exit = yield* Fiber.await(fiber) expect(Exit.isSuccess(exit)).toBe(true) yield* awaitWithTimeout(Deferred.await(aborted), "timed out waiting for task tool abort", "10 seconds") 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() }), { config: cfg }, 30_000, ) it.instance( "cancel propagates from slash command subtask to child session", () => Effect.gen(function* () { const { llm } = yield* useServerConfig(providerCfg) const prompt = yield* SessionPrompt.Service const sessions = yield* Session.Service const status = yield* SessionStatus.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) yield* llm.wait(1) const msgs = yield* MessageV2.filterCompactedEffect(chat.id) const taskMsg = msgs.find((item) => item.info.role === "assistant" && item.info.agent === "general") const tool = taskMsg ? toolPart(taskMsg.parts) : undefined const sessionID = tool?.state.status === "running" ? tool.state.metadata?.sessionId : undefined expect(typeof sessionID).toBe("string") if (typeof sessionID !== "string") throw new Error("missing child session id") const childID = SessionID.make(sessionID) expect((yield* status.get(childID)).type).toBe("busy") yield* prompt.cancel(chat.id) const exit = yield* Fiber.await(fiber) expect(Exit.isSuccess(exit)).toBe(true) expect((yield* status.get(chat.id)).type).toBe("idle") expect((yield* status.get(childID)).type).toBe("idle") }), 10_000, ) it.instance( "cancel with queued callers resolves all cleanly", () => Effect.gen(function* () { const { llm } = yield* useServerConfig(providerCfg) 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 }, 3_000, ) // Queue semantics noLLMServer.instance("concurrent loop callers get same result", () => 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) }), ) it.instance( "concurrent loop callers all receive same error result", () => Effect.gen(function* () { const { llm } = yield* useServerConfig(providerCfg) 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") }), 3_000, ) it.instance( "prompt submitted during an active run is included in the next LLM input", () => Effect.gen(function* () { const { llm } = yield* useServerConfig(providerCfg) const gate = yield* Deferred.make() const prompt = yield* SessionPrompt.Service const sessions = yield* Session.Service const chat = yield* sessions.create({ title: "Pinned" }) yield* llm.hold("first", deferredAsPromise(gate)) yield* llm.text("second") const a = yield* prompt .prompt({ sessionID: chat.id, agent: "build", 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: "build", model: ref, parts: [{ type: "text", text: "second" }], }) .pipe(Effect.forkChild) yield* pollWithTimeout( sessions .messages({ sessionID: chat.id }) .pipe( Effect.map((msgs) => msgs.some((msg) => msg.info.role === "user" && msg.info.id === id) ? true : undefined, ), ), "timed out waiting for second prompt to save", ) yield* Deferred.succeed(gate, void 0) 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") }), 3_000, ) it.instance( "assertNotBusy fails with BusyError when loop running", () => Effect.gen(function* () { const { llm } = yield* useServerConfig(providerCfg) 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) expect(Cause.squash(exit.cause)).toMatchObject({ _tag: "SessionBusyError", sessionID: chat.id }) } yield* prompt.cancel(chat.id) yield* Fiber.await(fiber) }), 3_000, ) noLLMServer.instance("assertNotBusy succeeds when idle", () => 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) }), ) // Shell semantics it.instance( "shell rejects with BusyError when loop running", () => Effect.gen(function* () { const { llm } = yield* useServerConfig(providerCfg) 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: "build", command: "echo hi" }).pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) { expect(Cause.squash(exit.cause)).toBeInstanceOf(Session.BusyError) expect(Cause.squash(exit.cause)).toMatchObject({ _tag: "SessionBusyError", sessionID: chat.id }) } yield* prompt.cancel(chat.id) yield* Fiber.await(fiber) }), 3_000, ) unixNoLLMServer( "shell captures stdout and stderr in completed tool output", () => Effect.gen(function* () { const { prompt, run, chat } = yield* boot() const result = yield* prompt.shell({ sessionID: chat.id, agent: "build", 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) }), { config: cfg }, ) unixNoLLMServer( "shell completes a fast command on the preferred shell", () => Effect.gen(function* () { const { directory: dir } = yield* TestInstance const { prompt, run, chat } = yield* boot() const result = yield* prompt.shell({ sessionID: chat.id, agent: "build", 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) }), { config: cfg }, ) unixNoLLMServer( "shell uses configured shell over env shell", () => withSh(() => Effect.gen(function* () { if (!(yield* hasBash)) return const { prompt, chat } = yield* boot() const result = yield* prompt.shell({ sessionID: chat.id, agent: "build", command: "[[ 1 -eq 1 ]] && printf configured", }) const tool = completedTool(result.parts) if (!tool) return expect(tool.state.output).toContain("configured") }), ), { config: { ...cfg, shell: "bash" } }, 30_000, ) unixNoLLMServer( "shell commands can change directory after startup", () => withSh(() => Effect.gen(function* () { const { directory: dir } = yield* TestInstance const { prompt, run, chat } = yield* boot() const parent = path.dirname(dir) const result = yield* prompt.shell({ sessionID: chat.id, agent: "build", command: "cd .. && pwd", }) expect(result.info.role).toBe("assistant") const tool = completedTool(result.parts) if (!tool) return expect(tool.state.output).toContain(parent) expect(tool.state.metadata.output).toContain(parent) yield* run.assertNotBusy(chat.id) }), ), { config: cfg }, ) unixNoLLMServer( "shell lists files from the project directory", () => Effect.gen(function* () { const { directory: dir } = yield* TestInstance const { prompt, run, chat } = yield* boot() yield* writeText(path.join(dir, "README.md"), "# e2e\n") const result = yield* prompt.shell({ sessionID: chat.id, agent: "build", 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) }), { config: cfg }, ) unixNoLLMServer( "shell captures stderr from a failing command", () => Effect.gen(function* () { const { prompt, run, chat } = yield* boot() const result = yield* prompt.shell({ sessionID: chat.id, agent: "build", 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) }), { config: cfg }, ) unixNoLLMServer( "shell updates running metadata before process exit", () => withSh(() => Effect.gen(function* () { const { prompt, chat } = yield* boot() const fiber = yield* prompt .shell({ sessionID: chat.id, agent: "build", command: "printf first && sleep 0.2 && printf second" }) .pipe(Effect.forkChild) yield* pollWithTimeout( Effect.gen(function* () { const msgs = yield* MessageV2.filterCompactedEffect(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 true }), "timed out waiting for running shell metadata", ) const exit = yield* Fiber.await(fiber) expect(Exit.isSuccess(exit)).toBe(true) }), ), { config: cfg }, 30_000, ) it.instance( "loop waits while shell runs and starts after shell exits", () => Effect.gen(function* () { const { llm } = yield* useServerConfig(providerCfg) 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: "build", command: "sleep 0.2" }) .pipe(Effect.forkChild) yield* waitForBusy(chat.id) 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 }, 3_000, ) it.instance( "shell completion resumes queued loop callers", () => Effect.gen(function* () { const { llm } = yield* useServerConfig(providerCfg) 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: "build", command: "sleep 0.2" }) .pipe(Effect.forkChild) yield* waitForBusy(chat.id) 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 }, 3_000, ) unix( "command ! expansion uses configured shell over env shell", () => withSh(() => Effect.gen(function* () { if (!(yield* hasBash)) return const { llm } = yield* useServerConfig((url) => ({ ...providerCfg(url), shell: "bash", command: { probe: { template: "Probe: !`[[ 1 -eq 1 ]] && printf configured`", }, }, })) const { prompt, chat } = yield* boot() yield* llm.text("done") const result = yield* prompt.command({ sessionID: chat.id, command: "probe", arguments: "", }) expect(result.info.role).toBe("assistant") const inputs = yield* llm.inputs expect(JSON.stringify(inputs.at(-1)?.messages)).toContain("configured") }), ), 30_000, ) unixNoLLMServer( "cancel interrupts shell and resolves cleanly", () => withSh(() => Effect.gen(function* () { const { prompt, run, chat } = yield* boot() const sh = yield* prompt .shell({ sessionID: chat.id, agent: "build", command: "sleep 30" }) .pipe(Effect.forkChild) yield* waitForBusy(chat.id) 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, ) unixNoLLMServer( "cancel persists aborted shell result when shell ignores TERM", () => withSh(() => Effect.gen(function* () { const { prompt, chat } = yield* boot() const { directory: dir } = yield* TestInstance const afs = yield* FSUtil.Service const ready = path.join(dir, ".trap-ready") const sh = yield* prompt .shell({ sessionID: chat.id, agent: "build", // Touch marker AFTER trap installs so the test waits for the actual // ignore-TERM state before cancelling; otherwise SIGTERM can arrive // before `trap` runs and the escalation path is never exercised. command: `trap '' TERM; touch "${ready}"; sleep 30`, }) .pipe(Effect.forkChild) yield* Effect.gen(function* () { while (!(yield* afs.existsSafe(ready))) { yield* Effect.sleep(Duration.millis(10)) } }).pipe(Effect.timeout(Duration.seconds(5))) 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, ) unix( "cancel finalizes interrupted bash tool output through normal truncation", () => Effect.gen(function* () { const { dir, llm } = yield* useServerConfig(providerCfg) 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; printf truncation-ready; 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* pollWithTimeout( Effect.gen(function* () { const msgs = yield* MessageV2.filterCompactedEffect(chat.id) const assistant = msgs.findLast((item) => item.info.role === "assistant") const tool = assistant ? toolPart(assistant.parts) : undefined if (tool?.state.status === "running" && tool.state.metadata?.output.includes("truncation-ready")) return true }), "timed out waiting for truncated shell output", ) 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).toMatch(/\.\.\.output truncated\.\.\./) expect(tool.state.output).toMatch(/Full output saved to:\s+\S+/) expect(tool.state.output).not.toContain("Tool execution aborted") }), { git: true }, 30_000, ) unixNoLLMServer( "cancel interrupts loop queued behind shell", () => Effect.gen(function* () { const { prompt, chat } = yield* boot() const sh = yield* prompt.shell({ sessionID: chat.id, agent: "build", command: "sleep 30" }).pipe(Effect.forkChild) yield* waitForBusy(chat.id) 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) if (Exit.isSuccess(exit)) { const tool = completedTool(exit.value.parts) expect(tool?.state.output).toContain("User aborted the command") } yield* Fiber.await(sh) }), { git: true, config: cfg }, 30_000, ) unixNoLLMServer( "shell rejects when another shell is already running", () => withSh(() => Effect.gen(function* () { const { prompt, chat } = yield* boot() const a = yield* prompt .shell({ sessionID: chat.id, agent: "build", command: "sleep 30" }) .pipe(Effect.forkChild) yield* waitForBusy(chat.id) const exit = yield* prompt.shell({ sessionID: chat.id, agent: "build", 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 function hangUntilAborted(tool: { execute: (...args: any[]) => any }) { return Effect.gen(function* () { const ready = yield* Deferred.make() const aborted = yield* Deferred.make() const original = tool.execute tool.execute = (_args: any, ctx: any) => { ctx.abort.addEventListener("abort", () => succeedVoid(aborted), { once: true }) if (ctx.abort.aborted) succeedVoid(aborted) succeedVoid(ready) return Effect.callback(() => Effect.sync(() => succeedVoid(aborted))) } const restore = Effect.addFinalizer(() => Effect.sync(() => void (tool.execute = original))) return { ready, aborted, restore } }) } noLLMServer.instance( "interrupt propagates abort signal to read tool via file part (text/plain)", () => Effect.gen(function* () { const { directory: dir } = yield* TestInstance const registry = yield* ToolRegistry.Service const { read } = yield* registry.named() const { ready, restore } = yield* 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* writeText(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* awaitWithTimeout(Deferred.await(ready), "timed out waiting for read tool to start", "10 seconds") yield* prompt.cancel(chat.id) yield* Fiber.interrupt(fiber) const exit = yield* Fiber.await(fiber) expect(Exit.isFailure(exit)).toBe(true) }), { config: cfg }, 30_000, ) noLLMServer.instance( "interrupt propagates abort signal to read tool via file part (directory)", () => Effect.gen(function* () { const { directory: dir } = yield* TestInstance const registry = yield* ToolRegistry.Service const { read } = yield* registry.named() const { ready, restore } = yield* 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* awaitWithTimeout(Deferred.await(ready), "timed out waiting for read tool to start", "10 seconds") yield* prompt.cancel(chat.id) yield* Fiber.interrupt(fiber) const exit = yield* Fiber.await(fiber) expect(Exit.isFailure(exit)).toBe(true) }), { config: cfg }, 30_000, ) // Missing file handling noLLMServer.instance( "does not fail the prompt when a file part is missing", () => Effect.gen(function* () { const { directory: dir } = yield* TestInstance const prompt = yield* SessionPrompt.Service const sessions = yield* Session.Service const session = yield* sessions.create({}) const missing = path.join(dir, "does-not-exist.ts") const msg = yield* prompt.prompt({ sessionID: session.id, agent: "build", noReply: true, parts: [ { type: "text", text: "please review @does-not-exist.ts" }, { type: "file", mime: "text/plain", url: `file://${missing}`, filename: "does-not-exist.ts", }, ], }) if (msg.info.role !== "user") throw new Error("expected user message") const hasFailure = msg.parts.some( (part) => part.type === "text" && part.synthetic && part.text.includes("Read tool failed to read"), ) expect(hasFailure).toBe(true) yield* sessions.remove(session.id) }), { config: cfg }, ) noLLMServer.instance( "keeps stored part order stable when file resolution is async", () => Effect.gen(function* () { const { directory: dir } = yield* TestInstance const prompt = yield* SessionPrompt.Service const sessions = yield* Session.Service const session = yield* sessions.create({}) const missing = path.join(dir, "still-missing.ts") const msg = yield* prompt.prompt({ sessionID: session.id, agent: "build", noReply: true, parts: [ { type: "file", mime: "text/plain", url: `file://${missing}`, filename: "still-missing.ts", }, { type: "text", text: "after-file" }, ], }) if (msg.info.role !== "user") throw new Error("expected user message") const stored = yield* MessageV2.get({ sessionID: session.id, messageID: msg.info.id, }) const text = stored.parts.filter((part) => part.type === "text").map((part) => part.text) expect(text[0]?.startsWith("Called the Read tool with the following input:")).toBe(true) expect(text[1]?.includes("Read tool failed to read")).toBe(true) expect(text[2]).toBe("after-file") yield* sessions.remove(session.id) }), { config: cfg }, ) noLLMServer.instance( "resolves configured reference mentions before workspace paths and agents", () => Effect.gen(function* () { const { directory: dir } = yield* TestInstance const docs = path.join(dir, "external-docs") yield* ensureDir(path.join(docs, "guide")) yield* ensureDir(path.join(dir, "docs")) yield* writeText(path.join(docs, "README.md"), "reference readme") yield* writeText(path.join(docs, "guide", "intro.md"), "reference intro") yield* writeText(path.join(dir, "docs", "README.md"), "workspace readme") const prompt = yield* SessionPrompt.Service const parts = yield* prompt.resolvePromptParts( "Use @docs and @docs/README.md and @docs/guide and @docs/missing.md and @docs/README.md and @build", ) const references = parts.filter( (part): part is SessionV1.TextPartInput => part.type === "text" && part.synthetic === true && part.text.startsWith("Referenced configured reference "), ) const files = parts.filter((part): part is SessionV1.FilePartInput => part.type === "file") const agents = parts.filter((part): part is SessionV1.AgentPartInput => part.type === "agent") const bare = references.find((part) => part.text.includes("@docs.")) const missing = references.find((part) => part.text.includes("@docs/missing.md")) const guide = files.find((part) => part.filename === "docs/guide") expect(references.length).toBe(2) expect(bare?.metadata?.reference).toMatchObject({ name: "docs", kind: "local", path: docs, }) expect(missing?.text).toContain("Path does not exist inside configured reference @docs") expect(missing?.metadata?.reference).toMatchObject({ target: "missing.md", targetPath: path.join(docs, "missing.md"), }) expect(files.length).toBe(2) expect(files.map((file) => fileURLToPath(file.url)).sort()).toEqual( [path.join(docs, "README.md"), path.join(docs, "guide")].sort(), ) expect(guide?.mime).toBe("application/x-directory") expect(agents.map((agent) => agent.name)).toEqual(["build"]) }), { config: { ...cfg, reference: { docs: "./external-docs", }, }, }, ) noLLMServer.instance( "injects metadata for bare configured reference mentions", () => Effect.gen(function* () { const { directory: dir } = yield* TestInstance const docs = path.join(dir, "external-docs") yield* ensureDir(docs) const prompt = yield* SessionPrompt.Service const sessions = yield* Session.Service const session = yield* sessions.create({}) const message = yield* prompt.prompt({ sessionID: session.id, noReply: true, parts: yield* prompt.resolvePromptParts("Use @docs for context"), }) const stored = yield* MessageV2.get({ sessionID: session.id, messageID: message.info.id }) const synthetic = stored.parts.filter( (part): part is SessionV1.TextPart => part.type === "text" && part.synthetic === true, ) const reference = synthetic.find((part) => part.text.startsWith("Referenced configured reference @docs.")) expect(reference?.metadata?.reference).toMatchObject({ name: "docs", kind: "local", path: docs }) expect(synthetic.some((part) => part.text.includes(`Reference root: ${docs}`))).toBe(true) expect(synthetic.some((part) => part.text.includes("Inspect the configured reference"))).toBe(true) yield* sessions.remove(session.id) }), { config: { ...cfg, reference: { docs: "./external-docs", }, }, }, ) noLLMServer.instance( "injects metadata for configured reference file attachments", () => Effect.gen(function* () { const { directory: dir } = yield* TestInstance const docs = path.join(dir, "external-docs") const readme = path.join(docs, "README.md") yield* ensureDir(docs) yield* writeText(readme, "reference readme") const prompt = yield* SessionPrompt.Service const sessions = yield* Session.Service const session = yield* sessions.create({}) const message = yield* prompt.prompt({ sessionID: session.id, agent: "build", noReply: true, parts: [ { type: "text", text: "Read @docs/README.md" }, { type: "file", mime: "text/plain", filename: "docs/README.md", url: pathToFileURL(readme).href, source: { type: "file", path: "docs/README.md", text: { value: "@docs/README.md", start: 5, end: 20 }, }, }, ], }) const stored = yield* MessageV2.get({ sessionID: session.id, messageID: message.info.id }) const synthetic = stored.parts.filter( (part): part is SessionV1.TextPart => part.type === "text" && part.synthetic === true, ) const reference = synthetic.find((part) => part.text.startsWith("Referenced configured reference @docs/README.md."), ) expect(reference?.metadata?.reference).toMatchObject({ name: "docs", kind: "local", path: docs, target: "README.md", targetPath: readme, source: { value: "@docs/README.md", start: 5, end: 20 }, }) expect(synthetic.findIndex((part) => part === reference)).toBeLessThan( synthetic.findIndex((part) => part.text.startsWith("Called the Read tool with the following input:")), ) yield* sessions.remove(session.id) }), { config: { ...cfg, reference: { docs: "./external-docs", }, }, }, ) // Special characters in filenames noLLMServer.instance( "handles filenames with # character", () => Effect.gen(function* () { const { directory: dir } = yield* TestInstance yield* writeText(path.join(dir, "file#name.txt"), "special content\n") const prompt = yield* SessionPrompt.Service const sessions = yield* Session.Service const session = yield* sessions.create({}) const parts = yield* prompt.resolvePromptParts("Read @file#name.txt") const fileParts = parts.filter((part) => part.type === "file") expect(fileParts.length).toBe(1) expect(fileParts[0].filename).toBe("file#name.txt") expect(fileParts[0].url).toContain("%23") const decodedPath = fileURLToPath(fileParts[0].url) expect(decodedPath).toBe(path.join(dir, "file#name.txt")) const message = yield* prompt.prompt({ sessionID: session.id, parts, noReply: true, }) const stored = yield* MessageV2.get({ sessionID: session.id, messageID: message.info.id }) const textParts = stored.parts.filter((part) => part.type === "text") const hasContent = textParts.some((part) => part.text.includes("special content")) expect(hasContent).toBe(true) yield* sessions.remove(session.id) }), { git: true, config: cfg }, ) // Regression: empty assistant turn loop it.instance("does not loop empty assistant turns for a simple reply", () => Effect.gen(function* () { const { llm } = yield* useServerConfig(providerCfg) const prompt = yield* SessionPrompt.Service const sessions = yield* Session.Service const session = yield* sessions.create({ title: "Prompt regression" }) yield* llm.text("packages/opencode/src/session/processor.ts") const result = yield* prompt.prompt({ sessionID: session.id, agent: "build", parts: [{ type: "text", text: "Where is SessionProcessor?" }], }) expect(result.info.role).toBe("assistant") expect(result.parts.some((part) => part.type === "text" && part.text.includes("processor.ts"))).toBe(true) const msgs = yield* sessions.messages({ sessionID: session.id }) expect(msgs.filter((msg) => msg.info.role === "assistant")).toHaveLength(1) expect(yield* llm.calls).toBe(1) }), ) it.instance( "records aborted errors when prompt is cancelled mid-stream", () => Effect.gen(function* () { const { llm } = yield* useServerConfig(providerCfg) const prompt = yield* SessionPrompt.Service const sessions = yield* Session.Service const session = yield* sessions.create({ title: "Prompt cancel regression" }) yield* llm.hang const fiber = yield* prompt .prompt({ sessionID: session.id, agent: "build", parts: [{ type: "text", text: "Cancel me" }], }) .pipe(Effect.forkChild) yield* llm.wait(1) yield* prompt.cancel(session.id) const exit = yield* Fiber.await(fiber) expect(Exit.isSuccess(exit)).toBe(true) if (Exit.isSuccess(exit)) { expect(exit.value.info.role).toBe("assistant") if (exit.value.info.role === "assistant") { expect(exit.value.info.error?.name).toBe("MessageAbortedError") } } const msgs = yield* sessions.messages({ sessionID: session.id }) const last = msgs.findLast((msg) => msg.info.role === "assistant") expect(last?.info.role).toBe("assistant") if (last?.info.role === "assistant") { expect(last.info.error?.name).toBe("MessageAbortedError") } }), 3_000, ) // Agent variant noLLMServer.instance( "applies agent variant only when using agent model", () => Effect.gen(function* () { const prompt = yield* SessionPrompt.Service const sessions = yield* Session.Service const session = yield* sessions.create({}) const other = yield* prompt.prompt({ sessionID: session.id, agent: "build", model: { providerID: ProviderV2.ID.make("opencode"), modelID: ProviderV2.ModelID.make("kimi-k2.5-free") }, noReply: true, parts: [{ type: "text", text: "hello" }], }) if (other.info.role !== "user") throw new Error("expected user message") expect(other.info.model.variant).toBeUndefined() const match = yield* prompt.prompt({ sessionID: session.id, agent: "build", noReply: true, parts: [{ type: "text", text: "hello again" }], }) if (match.info.role !== "user") throw new Error("expected user message") expect(match.info.model).toEqual({ providerID: ProviderV2.ID.make("test"), modelID: ProviderV2.ModelID.make("test-model"), variant: "xhigh", }) expect(match.info.model.variant).toBe("xhigh") const override = yield* prompt.prompt({ sessionID: session.id, agent: "build", noReply: true, variant: "high", parts: [{ type: "text", text: "hello third" }], }) if (override.info.role !== "user") throw new Error("expected user message") expect(override.info.model.variant).toBe("high") yield* sessions.remove(session.id) }), { config: { ...cfg, provider: { ...cfg.provider, test: { ...cfg.provider.test, models: { "test-model": { ...cfg.provider.test.models["test-model"], variants: { xhigh: {}, high: {} }, }, }, }, }, agent: { build: { model: "test/test-model", variant: "xhigh", }, }, }, }, ) // Agent / command resolution errors noLLMServer.instance( "unknown agent throws typed error", () => Effect.gen(function* () { const prompt = yield* SessionPrompt.Service const sessions = yield* Session.Service const session = yield* sessions.create({}) const exit = yield* prompt .prompt({ sessionID: session.id, agent: "nonexistent-agent-xyz", noReply: true, parts: [{ type: "text", text: "hello" }], }) .pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) { const err = Cause.squash(exit.cause) expect(err).not.toBeInstanceOf(TypeError) expect(NamedError.Unknown.isInstance(err)).toBe(true) if (NamedError.Unknown.isInstance(err)) { expect(err.data.message).toContain('Agent not found: "nonexistent-agent-xyz"') } } }), 30_000, ) noLLMServer.instance( "unknown agent error includes available agent names", () => Effect.gen(function* () { const prompt = yield* SessionPrompt.Service const sessions = yield* Session.Service const session = yield* sessions.create({}) const exit = yield* prompt .prompt({ sessionID: session.id, agent: "nonexistent-agent-xyz", noReply: true, parts: [{ type: "text", text: "hello" }], }) .pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) { const err = Cause.squash(exit.cause) expect(NamedError.Unknown.isInstance(err)).toBe(true) if (NamedError.Unknown.isInstance(err)) { expect(err.data.message).toContain("build") } } }), 30_000, ) noLLMServer.instance( "unknown command throws typed error with available names", () => Effect.gen(function* () { const prompt = yield* SessionPrompt.Service const sessions = yield* Session.Service const session = yield* sessions.create({}) const exit = yield* prompt .command({ sessionID: session.id, command: "nonexistent-command-xyz", arguments: "", }) .pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) { const err = Cause.squash(exit.cause) expect(err).not.toBeInstanceOf(TypeError) expect(NamedError.Unknown.isInstance(err)).toBe(true) if (NamedError.Unknown.isInstance(err)) { expect(err.data.message).toContain('Command not found: "nonexistent-command-xyz"') expect(err.data.message).toContain("init") } } }), 30_000, )