فهرست منبع

test(opencode): consolidate session prompt tests into Effect style (#23710)

Kit Langton 3 ماه پیش
والد
کامیت
9579429276
2فایلهای تغییر یافته به همراه1797 افزوده شده و 2023 حذف شده
  1. 0 1522
      packages/opencode/test/session/prompt-effect.test.ts
  2. 1797 501
      packages/opencode/test/session/prompt.test.ts

+ 0 - 1522
packages/opencode/test/session/prompt-effect.test.ts

@@ -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,
-)

+ 1797 - 501
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,
+)