summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-04-21 11:54:40 -0400
committerGitHub <[email protected]>2026-04-21 15:54:40 +0000
commit95794292762caff2700128388e2672e4a5f5ab07 (patch)
tree686bde4bef915b5b4d695049f3e1e1a1f0ae1e96
parent2486621ca1b9d35ed15ee6c2ff2a04ba46c8e02a (diff)
downloadopencode-95794292762caff2700128388e2672e4a5f5ab07.tar.gz
opencode-95794292762caff2700128388e2672e4a5f5ab07.zip
test(opencode): consolidate session prompt tests into Effect style (#23710)
-rw-r--r--packages/opencode/test/session/prompt-effect.test.ts1522
-rw-r--r--packages/opencode/test/session/prompt.test.ts2298
2 files changed, 1797 insertions, 2023 deletions
diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts
deleted file mode 100644
index 2f5904684..000000000
--- a/packages/opencode/test/session/prompt-effect.test.ts
+++ /dev/null
@@ -1,1522 +0,0 @@
-import { NodeFileSystem } from "@effect/platform-node"
-import { FetchHttpClient } from "effect/unstable/http"
-import { expect } from "bun:test"
-import { Cause, Effect, Exit, Fiber, Layer } from "effect"
-import path from "path"
-import { Agent as AgentSvc } from "../../src/agent/agent"
-import { Bus } from "../../src/bus"
-import { Command } from "../../src/command"
-import { Config } from "../../src/config"
-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"
-import { Env } from "../../src/env"
-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 "@opencode-ai/shared/filesystem"
-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 { Skill } from "../../src/skill"
-import { SystemPrompt } from "../../src/session/system"
-import { Shell } from "../../src/shell/shell"
-import { Snapshot } from "../../src/snapshot"
-import { ToolRegistry } from "../../src/tool"
-import { Truncate } from "../../src/tool"
-import { Log } from "../../src/util"
-import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
-import { Ripgrep } from "../../src/file/ripgrep"
-import { Format } from "../../src/format"
-import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture"
-import { testEffect } from "../lib/effect"
-import { reply, TestLLMServer } from "../lib/llm-server"
-
-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: 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 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,
- Env.defaultLayer,
- AgentSvc.defaultLayer,
- Command.defaultLayer,
- Permission.defaultLayer,
- Plugin.defaultLayer,
- Config.defaultLayer,
- ProviderSvc.defaultLayer,
- 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.provide(Skill.defaultLayer),
- Layer.provide(FetchHttpClient.layer),
- Layer.provide(CrossSpawnSpawner.defaultLayer),
- Layer.provide(Ripgrep.defaultLayer),
- Layer.provide(Format.defaultLayer),
- Layer.provideMerge(todo),
- Layer.provideMerge(question),
- Layer.provideMerge(deps),
- )
- const trunc = Truncate.layer.pipe(Layer.provideMerge(deps))
- const proc = SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provideMerge(deps))
- const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps))
- return Layer.mergeAll(
- TestLLMServer.layer,
- SessionPrompt.layer.pipe(
- Layer.provide(SessionRevert.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.provideMerge(deps),
- ),
- ).pipe(Layer.provide(summary))
-}
-
-const it = testEffect(makeHttp())
-const unix = process.platform !== "win32" ? it.live : it.live.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 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: MessageV2.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 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: "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)
- }),
- { git: true, config: providerCfg },
- ),
-)
-
-it.live("static loop returns assistant text through local provider", () =>
- provideTmpdirServer(
- Effect.fnUntraced(function* ({ llm }) {
- 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)
- }),
- { git: true, config: providerCfg },
- ),
-)
-
-it.live("static loop consumes queued replies across turns", () =>
- provideTmpdirServer(
- Effect.fnUntraced(function* ({ llm }) {
- 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)
- }),
- { 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: "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")
- }
- }),
- { git: true, config: providerCfg },
- ),
-)
-
-it.live("glob tool keeps instance context during prompt runs", () =>
- provideTmpdirServer(
- ({ dir, llm }) =>
- Effect.gen(function* () {
- 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* Effect.promise(() => Bun.write(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)
- }),
- { 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: "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")
- }
- }),
- { 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 === "build")
- 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 = (_args, ctx) =>
- Effect.callback<never>((_resume) => {
- ready.resolve()
- ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true })
- return Effect.sync(() => aborted.resolve())
- })
- 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,
-)
-
-it.live(
- "prompt submitted during an active run is included in the next LLM input",
- () =>
- 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: "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* 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: "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(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: "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)
- }),
- { 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: "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)
- }),
- { 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: "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)
- }),
- { 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: "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)
- }),
- { 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: "build", 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: "build", 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,
-)
-
-it.live(
- "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: "build", 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,
-)
-
-unix(
- "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: "build", 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,
-)
-
-unix(
- "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: "build", 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,
-)
-
-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).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, config: providerCfg },
- ),
- 30_000,
-)
-
-unix(
- "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: "build", 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,
-)
-
-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: "build", command: "sleep 30" })
- .pipe(Effect.forkChild)
- yield* Effect.sleep(50)
-
- 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
-
-/** 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 = (_args: any, ctx: any) => {
- ready.resolve()
- ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true })
- return Effect.callback<never>(() => {})
- }
- 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,
-)
diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts
index 2b489da9e..8ffb20f15 100644
--- a/packages/opencode/test/session/prompt.test.ts
+++ b/packages/opencode/test/session/prompt.test.ts
@@ -1,22 +1,64 @@
+import { NodeFileSystem } from "@effect/platform-node"
+import { FetchHttpClient } from "effect/unstable/http"
+import { expect } from "bun:test"
+import { Cause, Effect, Exit, Fiber, Layer } from "effect"
import path from "path"
-import { describe, expect, test } from "bun:test"
-import { NamedError } from "@opencode-ai/shared/util/error"
import { fileURLToPath } from "url"
-import { Effect, Layer } from "effect"
-import { Instance } from "../../src/project/instance"
+import { NamedError } from "@opencode-ai/shared/util/error"
+import { Agent as AgentSvc } from "../../src/agent/agent"
+import { Bus } from "../../src/bus"
+import { Command } from "../../src/command"
+import { Config } from "../../src/config"
+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"
+import { Env } from "../../src/env"
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 "@opencode-ai/shared/filesystem"
+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 { Skill } from "../../src/skill"
+import { SystemPrompt } from "../../src/session/system"
+import { Shell } from "../../src/shell/shell"
+import { Snapshot } from "../../src/snapshot"
+import { ToolRegistry } from "../../src/tool"
+import { Truncate } from "../../src/tool"
import { Log } from "../../src/util"
-import { tmpdir } from "../fixture/fixture"
+import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
+import { Ripgrep } from "../../src/file/ripgrep"
+import { Format } from "../../src/format"
+import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture"
+import { testEffect } from "../lib/effect"
+import { reply, TestLLMServer } from "../lib/llm-server"
void Log.init({ print: false })
-function run<A, E>(fx: Effect.Effect<A, E, SessionPrompt.Service | Session.Service>) {
- return Effect.runPromise(
- fx.pipe(Effect.scoped, Effect.provide(Layer.mergeAll(SessionPrompt.defaultLayer, Session.defaultLayer))),
- )
+const summary = Layer.succeed(
+ SessionSummary.Service,
+ SessionSummary.Service.of({
+ summarize: () => Effect.void,
+ diff: () => Effect.succeed([]),
+ computeDiff: () => Effect.succeed([]),
+ }),
+)
+
+const ref = {
+ providerID: ProviderID.make("test"),
+ modelID: ModelID.make("test-model"),
}
function defer<T>() {
@@ -27,563 +69,1817 @@ function defer<T>() {
return { promise, resolve }
}
-function chat(text: string) {
- const payload =
- [
- `data: ${JSON.stringify({
- id: "chatcmpl-1",
- object: "chat.completion.chunk",
- choices: [{ delta: { role: "assistant" } }],
- })}`,
- `data: ${JSON.stringify({
- id: "chatcmpl-1",
- object: "chat.completion.chunk",
- choices: [{ delta: { content: text } }],
- })}`,
- `data: ${JSON.stringify({
- id: "chatcmpl-1",
- object: "chat.completion.chunk",
- choices: [{ delta: {}, finish_reason: "stop" }],
- })}`,
- "data: [DONE]",
- ].join("\n\n") + "\n\n"
-
- const encoder = new TextEncoder()
- return new ReadableStream<Uint8Array>({
- start(ctrl) {
- ctrl.enqueue(encoder.encode(payload))
- ctrl.close()
- },
- })
+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 hanging(ready: () => void) {
- const encoder = new TextEncoder()
- let timer: ReturnType<typeof setTimeout> | undefined
- const first = `data: ${JSON.stringify({
- id: "chatcmpl-1",
- object: "chat.completion.chunk",
- choices: [{ delta: { role: "assistant" } }],
- })}\n\n`
- const rest =
- [
- `data: ${JSON.stringify({
- id: "chatcmpl-1",
- object: "chat.completion.chunk",
- choices: [{ delta: { content: "late" } }],
- })}`,
- `data: ${JSON.stringify({
- id: "chatcmpl-1",
- object: "chat.completion.chunk",
- choices: [{ delta: {}, finish_reason: "stop" }],
- })}`,
- "data: [DONE]",
- ].join("\n\n") + "\n\n"
-
- return new ReadableStream<Uint8Array>({
- start(ctrl) {
- ctrl.enqueue(encoder.encode(first))
- ready()
- timer = setTimeout(() => {
- ctrl.enqueue(encoder.encode(rest))
- ctrl.close()
- }, 10000)
+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 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,
+ Env.defaultLayer,
+ AgentSvc.defaultLayer,
+ Command.defaultLayer,
+ Permission.defaultLayer,
+ Plugin.defaultLayer,
+ Config.defaultLayer,
+ ProviderSvc.defaultLayer,
+ 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.provide(Skill.defaultLayer),
+ Layer.provide(FetchHttpClient.layer),
+ Layer.provide(CrossSpawnSpawner.defaultLayer),
+ Layer.provide(Ripgrep.defaultLayer),
+ Layer.provide(Format.defaultLayer),
+ Layer.provideMerge(todo),
+ Layer.provideMerge(question),
+ Layer.provideMerge(deps),
+ )
+ const trunc = Truncate.layer.pipe(Layer.provideMerge(deps))
+ const proc = SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provideMerge(deps))
+ const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps))
+ return Layer.mergeAll(
+ TestLLMServer.layer,
+ SessionPrompt.layer.pipe(
+ Layer.provide(SessionRevert.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.provideMerge(deps),
+ ),
+ ).pipe(Layer.provide(summary))
+}
+
+const it = testEffect(makeHttp())
+const unix = process.platform !== "win32" ? it.live : it.live.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",
+ },
},
- cancel() {
- if (timer) clearTimeout(timer)
+ },
+}
+
+function providerCfg(url: string) {
+ return {
+ ...cfg,
+ provider: {
+ ...cfg.provider,
+ test: {
+ ...cfg.provider.test,
+ options: {
+ ...cfg.provider.test.options,
+ baseURL: url,
+ },
+ },
},
- })
+ }
}
-describe("session.prompt missing file", () => {
- test("does not fail the prompt when a file part is missing", async () => {
- await using tmp = await tmpdir({
+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: MessageV2.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 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: "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)
+ }),
+ { git: true, config: providerCfg },
+ ),
+)
+
+it.live("static loop returns assistant text through local provider", () =>
+ provideTmpdirServer(
+ Effect.fnUntraced(function* ({ llm }) {
+ 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)
+ }),
+ { git: true, config: providerCfg },
+ ),
+)
+
+it.live("static loop consumes queued replies across turns", () =>
+ provideTmpdirServer(
+ Effect.fnUntraced(function* ({ llm }) {
+ 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)
+ }),
+ { 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: "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")
+ }
+ }),
+ { git: true, config: providerCfg },
+ ),
+)
+
+it.live("glob tool keeps instance context during prompt runs", () =>
+ provideTmpdirServer(
+ ({ dir, llm }) =>
+ Effect.gen(function* () {
+ 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* Effect.promise(() => Bun.write(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)
+ }),
+ { 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: "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")
+ }
+ }),
+ { 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: {
+ config: (url) => ({
+ ...providerCfg(url),
agent: {
- build: {
- model: "openai/gpt-5.2",
+ 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")
+ })
- await Instance.provide({
- directory: tmp.path,
- fn: () =>
- run(
+ 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 === "build")
+ 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 = (_args, ctx) =>
+ Effect.callback<never>((_resume) => {
+ ready.resolve()
+ ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true })
+ return Effect.sync(() => aborted.resolve())
+ })
+ 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,
+)
+
+it.live(
+ "prompt submitted during an active run is included in the next LLM input",
+ () =>
+ 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: "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* 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: "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(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: "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)
+ }),
+ { 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: "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)
+ }),
+ { 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: "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)
+ }),
+ { 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: "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)
+ }),
+ { git: true, config: cfg },
+ ),
+)
+
+unix(
+ "shell updates running metadata before process exit",
+ () =>
+ withSh(() =>
+ provideTmpdirInstance(
+ (_dir) =>
Effect.gen(function* () {
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const session = yield* sessions.create({})
+ const { prompt, chat } = yield* boot()
- const missing = path.join(tmp.path, "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",
- },
- ],
+ const fiber = yield* prompt
+ .shell({ sessionID: chat.id, agent: "build", 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")
})
- if (msg.info.role !== "user") throw new Error("expected user message")
+ 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 hasFailure = msg.parts.some(
- (part) => part.type === "text" && part.synthetic && part.text.includes("Read tool failed to read"),
- )
- expect(hasFailure).toBe(true)
+ const sh = yield* prompt
+ .shell({ sessionID: chat.id, agent: "build", 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,
+)
+
+it.live(
+ "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: "build", 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)
- yield* sessions.remove(session.id)
+ 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,
+)
+
+unix(
+ "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: "build", 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,
+)
- test("keeps stored part order stable when file resolution is async", async () => {
- await using tmp = await tmpdir({
- git: true,
- config: {
- agent: {
- build: {
- model: "openai/gpt-5.2",
- },
- },
- },
- })
+unix(
+ "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: "build", 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,
+)
+
+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).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, config: providerCfg },
+ ),
+ 30_000,
+)
+
+unix(
+ "cancel interrupts loop queued behind shell",
+ () =>
+ provideTmpdirInstance(
+ (_dir) =>
+ Effect.gen(function* () {
+ const { prompt, chat } = yield* boot()
- await Instance.provide({
- directory: tmp.path,
- fn: () =>
- run(
+ const sh = yield* prompt
+ .shell({ sessionID: chat.id, agent: "build", 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,
+)
+
+unix(
+ "shell rejects when another shell is already running",
+ () =>
+ withSh(() =>
+ provideTmpdirInstance(
+ (_dir) =>
Effect.gen(function* () {
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const session = yield* sessions.create({})
+ const { prompt, chat } = yield* boot()
- const missing = path.join(tmp.path, "still-missing.ts")
- const msg = yield* prompt.prompt({
- sessionID: session.id,
+ const a = yield* prompt
+ .shell({ sessionID: chat.id, agent: "build", command: "sleep 30" })
+ .pipe(Effect.forkChild)
+ yield* Effect.sleep(50)
+
+ 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
+
+/** 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 = (_args: any, ctx: any) => {
+ ready.resolve()
+ ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true })
+ return Effect.callback<never>(() => {})
+ }
+ 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",
- noReply: true,
parts: [
- {
- type: "file",
- mime: "text/plain",
- url: `file://${missing}`,
- filename: "still-missing.ts",
- },
- { type: "text", text: "after-file" },
+ { type: "text", text: "read this" },
+ { type: "file", url: `file://${testFile}`, filename: "test.txt", mime: "text/plain" },
],
})
+ .pipe(Effect.forkChild)
- if (msg.info.role !== "user") throw new Error("expected user message")
+ yield* Effect.promise(() => ready.promise)
+ yield* Fiber.interrupt(fiber)
- const stored = MessageV2.get({
- sessionID: session.id,
- messageID: msg.info.id,
+ 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" },
+ ],
})
- const text = stored.parts.filter((part) => part.type === "text").map((part) => part.text)
+ .pipe(Effect.forkChild)
- 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* Effect.promise(() => ready.promise)
+ yield* Fiber.interrupt(fiber)
- yield* sessions.remove(session.id)
- }),
- ),
- })
- })
-})
+ 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,
+)
-describe("session.prompt special characters", () => {
- test("handles filenames with # character", async () => {
- await using tmp = await tmpdir({
- git: true,
- init: async (dir) => {
- await Bun.write(path.join(dir, "file#name.txt"), "special content\n")
- },
- })
+// Missing file handling
- await Instance.provide({
- directory: tmp.path,
- fn: () =>
- run(
- Effect.gen(function* () {
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const session = yield* sessions.create({})
- const template = "Read @file#name.txt"
- const parts = yield* prompt.resolvePromptParts(template)
- const fileParts = parts.filter((part) => part.type === "file")
+it.live("does not fail the prompt when a file part is missing", () =>
+ provideTmpdirInstance(
+ (dir) =>
+ Effect.gen(function* () {
+ const prompt = yield* SessionPrompt.Service
+ const sessions = yield* Session.Service
+ const session = yield* sessions.create({})
- expect(fileParts.length).toBe(1)
- expect(fileParts[0].filename).toBe("file#name.txt")
- expect(fileParts[0].url).toContain("%23")
+ 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",
+ },
+ ],
+ })
- const decodedPath = fileURLToPath(fileParts[0].url)
- expect(decodedPath).toBe(path.join(tmp.path, "file#name.txt"))
+ 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)
- const message = yield* prompt.prompt({
- sessionID: session.id,
- parts,
- noReply: true,
- })
- const stored = 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 },
+ ),
+)
- yield* sessions.remove(session.id)
- }),
- ),
- })
- })
-})
+it.live("keeps stored part order stable when file resolution is async", () =>
+ provideTmpdirInstance(
+ (dir) =>
+ Effect.gen(function* () {
+ const prompt = yield* SessionPrompt.Service
+ const sessions = yield* Session.Service
+ const session = yield* sessions.create({})
-describe("session.prompt regression", () => {
- test("does not loop empty assistant turns for a simple reply", async () => {
- let calls = 0
- const server = Bun.serve({
- port: 0,
- fetch(req) {
- const url = new URL(req.url)
- if (!url.pathname.endsWith("/chat/completions")) {
- return new Response("not found", { status: 404 })
- }
- calls++
- return new Response(chat("packages/opencode/src/session/processor.ts"), {
- status: 200,
- headers: { "Content-Type": "text/event-stream" },
+ 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" },
+ ],
})
- },
- })
- try {
- await using tmp = await tmpdir({
- git: true,
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- enabled_providers: ["alibaba"],
- provider: {
- alibaba: {
- options: {
- apiKey: "test-key",
- baseURL: `${server.url.origin}/v1`,
- },
- },
- },
- agent: {
- build: {
- model: "alibaba/qwen-plus",
- },
- },
- }),
- )
- },
- })
+ if (msg.info.role !== "user") throw new Error("expected user message")
+
+ const stored = 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)
+ }),
+ { git: true, config: cfg },
+ ),
+)
+
+// Special characters in filenames
+
+it.live("handles filenames with # character", () =>
+ provideTmpdirInstance(
+ (dir) =>
+ Effect.gen(function* () {
+ yield* Effect.promise(() => Bun.write(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 = 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.live("does not loop empty assistant turns for a simple reply", () =>
+ provideTmpdirServer(
+ Effect.fnUntraced(function* ({ llm }) {
+ 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")
- await Instance.provide({
- directory: tmp.path,
- fn: () =>
- run(
- Effect.gen(function* () {
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const session = yield* sessions.create({ title: "Prompt regression" })
- 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(calls).toBe(1)
- }),
- ),
+ const result = yield* prompt.prompt({
+ sessionID: session.id,
+ agent: "build",
+ parts: [{ type: "text", text: "Where is SessionProcessor?" }],
})
- } finally {
- void server.stop(true)
- }
- })
- test("records aborted errors when prompt is cancelled mid-stream", async () => {
- const ready = defer<void>()
- const server = Bun.serve({
- port: 0,
- fetch(req) {
- const url = new URL(req.url)
- if (!url.pathname.endsWith("/chat/completions")) {
- return new Response("not found", { status: 404 })
+ 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)
+ }),
+ { git: true, config: providerCfg },
+ ),
+)
+
+it.live(
+ "records aborted errors when prompt is cancelled mid-stream",
+ () =>
+ provideTmpdirServer(
+ Effect.fnUntraced(function* ({ llm }) {
+ 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")
+ }
}
- return new Response(
- hanging(() => ready.resolve()),
- {
- status: 200,
- headers: { "Content-Type": "text/event-stream" },
- },
- )
- },
- })
- try {
- await using tmp = await tmpdir({
- git: true,
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- enabled_providers: ["alibaba"],
- provider: {
- alibaba: {
- options: {
- apiKey: "test-key",
- baseURL: `${server.url.origin}/v1`,
- },
- },
- },
- agent: {
- build: {
- model: "alibaba/qwen-plus",
- },
- },
- }),
- )
- },
- })
+ 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")
+ }
+ }),
+ { git: true, config: providerCfg },
+ ),
+ 3_000,
+)
- await Instance.provide({
- directory: tmp.path,
- fn: () =>
- run(
- Effect.gen(function* () {
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const session = yield* sessions.create({ title: "Prompt cancel regression" })
- const task = Effect.runPromise(
- prompt.prompt({
- sessionID: session.id,
- agent: "build",
- parts: [{ type: "text", text: "Cancel me" }],
- }),
- )
-
- yield* Effect.promise(() => ready.promise)
- yield* prompt.cancel(session.id)
-
- const result = yield* Effect.promise(() =>
- Promise.race([
- task,
- new Promise<never>((_, reject) =>
- setTimeout(() => reject(new Error("timed out waiting for cancel")), 1000),
- ),
- ]),
- )
-
- expect(result.info.role).toBe("assistant")
- if (result.info.role === "assistant") {
- expect(result.info.error?.name).toBe("MessageAbortedError")
- }
+// Agent variant
- 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")
- }
- }),
- ),
- })
- } finally {
- void server.stop(true)
- }
- })
-})
+it.live("applies agent variant only when using agent model", () =>
+ provideTmpdirInstance(
+ (_dir) =>
+ 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: ProviderID.make("opencode"), modelID: 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()
-describe("session.prompt agent variant", () => {
- test("applies agent variant only when using agent model", async () => {
- const prev = process.env.OPENAI_API_KEY
- process.env.OPENAI_API_KEY = "test-openai-key"
-
- try {
- await using tmp = await tmpdir({
- git: true,
- config: {
- agent: {
- build: {
- model: "openai/gpt-5.2",
- variant: "xhigh",
+ 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: ProviderID.make("test"),
+ modelID: 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)
+ }),
+ {
+ git: true,
+ 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",
+ },
+ },
+ },
+ },
+ ),
+)
- await Instance.provide({
- directory: tmp.path,
- fn: () =>
- run(
- 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: ProviderID.make("opencode"), modelID: 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: ProviderID.make("openai"),
- modelID: ModelID.make("gpt-5.2"),
- 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)
- }),
- ),
- })
- } finally {
- if (prev === undefined) delete process.env.OPENAI_API_KEY
- else process.env.OPENAI_API_KEY = prev
- }
- })
-})
+// Agent / command resolution errors
-describe("session.agent-resolution", () => {
- test("unknown agent throws typed error", async () => {
- await using tmp = await tmpdir({ git: true })
- await Instance.provide({
- directory: tmp.path,
- fn: () =>
- run(
- Effect.gen(function* () {
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const session = yield* sessions.create({})
- const err = yield* Effect.promise(() =>
- Effect.runPromise(
- prompt.prompt({
- sessionID: session.id,
- agent: "nonexistent-agent-xyz",
- noReply: true,
- parts: [{ type: "text", text: "hello" }],
- }),
- ).then(
- () => undefined,
- (e) => e,
- ),
- )
- expect(err).toBeDefined()
+it.live(
+ "unknown agent throws typed error",
+ () =>
+ provideTmpdirInstance(
+ (_dir) =>
+ 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"')
}
- }),
- ),
- })
- }, 30000)
-
- test("unknown agent error includes available agent names", async () => {
- await using tmp = await tmpdir({ git: true })
- await Instance.provide({
- directory: tmp.path,
- fn: () =>
- run(
- Effect.gen(function* () {
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const session = yield* sessions.create({})
- const err = yield* Effect.promise(() =>
- Effect.runPromise(
- prompt.prompt({
- sessionID: session.id,
- agent: "nonexistent-agent-xyz",
- noReply: true,
- parts: [{ type: "text", text: "hello" }],
- }),
- ).then(
- () => undefined,
- (e) => e,
- ),
- )
+ }
+ }),
+ { git: true },
+ ),
+ 30_000,
+)
+
+it.live(
+ "unknown agent error includes available agent names",
+ () =>
+ provideTmpdirInstance(
+ (_dir) =>
+ 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")
}
- }),
- ),
- })
- }, 30000)
-
- test("unknown command throws typed error with available names", async () => {
- await using tmp = await tmpdir({ git: true })
- await Instance.provide({
- directory: tmp.path,
- fn: () =>
- run(
- Effect.gen(function* () {
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const session = yield* sessions.create({})
- const err = yield* Effect.promise(() =>
- Effect.runPromise(
- prompt.command({
- sessionID: session.id,
- command: "nonexistent-command-xyz",
- arguments: "",
- }),
- ).then(
- () => undefined,
- (e) => e,
- ),
- )
- expect(err).toBeDefined()
+ }
+ }),
+ { git: true },
+ ),
+ 30_000,
+)
+
+it.live(
+ "unknown command throws typed error with available names",
+ () =>
+ provideTmpdirInstance(
+ (_dir) =>
+ 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")
}
- }),
- ),
- })
- }, 30000)
-})
+ }
+ }),
+ { git: true },
+ ),
+ 30_000,
+)