Files
kilocode/packages/opencode/test/session/prompt.test.ts
T
Imanol Maiztegui bacfa4ae62 Effect Migration for Kilo callsites (#10572)
* refactor(opencode): migrate agent promise helpers to kilocode module and adopt Effect-based tests

Remove legacy promise-based helpers (`get`, `list`, `defaultAgent`, `remove`)
from the core agent module and update callsites to use the dedicated
`@/kilocode/agent` module directly. Migrate kilocode-specific tests from
`WithInstance.provide` patterns to the `testEffect` helper with Effect
generators for cleaner, more idiomatic test code.

- Remove `makeRuntime` import and exported promise helpers from agent.ts
- Update HTTP API handlers to import from `@/kilocode/agent` instead of
  re-exported `Agent` namespace
- Rewrite agent tests to use `load()` helper or Effect service access
- Convert agent-global-config-dirs and agent-skill-permissions tests to
  `testEffect` pattern
- Reorder SDK type definitions (BackgroundProcessLogs/WorkspaceWarpError)

* chore(auth): remove legacy promise helpers and replace with direct Effect service access

Eliminate the exported promise-based convenience functions (`get`, `all`,
`set`, `remove`) from the Auth module and replace all callsites with
explicit `AppRuntime.runPromise` or `makeRuntime` invocations that access
`Auth.Service` directly through Effect's service pattern.

- Delete `makeRuntime`-backed promise helpers from auth/index.ts
- Update CLI entrypoint, kilo-sessions, indexing, and server instance to
  use `AppRuntime.runPromise(Auth.Service.use(...))` for auth access
- Inject `Auth.Service` as a dependency into the ModelsDev layer and
  consume it via Effect generator instead of promise wrapper
- Create a local `makeRuntime` instance in model-cache.ts for non-Effect
  callsites that still need promise-based auth access
- Rewrite tests to manipulate auth.json directly on disk with proper
  save/restore semantics instead of relying on removed helpers

* refactor(suggestion): convert suggest tool to Effect-native with injected Command dependency

Transform the suggestion tool from async/promise-based implementation to
idiomatic Effect generators with explicit dependency injection of the
Command service rather than importing and calling module-level helpers.

- Convert `resolvePrompt` from async function to Effect generator that
  accepts a `Command.Interface` parameter
- Refactor `SuggestTool` definition to yield `Command.Service` from the
  Effect context and thread it through to `resolvePrompt`
- Add `Command.Service` as a dependency to the tool registry layer and
  provide `Command.defaultLayer` in both production and test wiring
- Remove unused `makeRuntime` import and exported `get` helper from
  command/index.ts
- Add explicit type annotations to Auth delegate in server instance
- Rewrite suggestion tests to use `testEffect` helper with a mock
  `Command.Service` layer instead of spying on module exports

* feat(git): migrate WorktreeFamily to Effect service and wire Git.Service as dependency

Convert WorktreeFamily.list from an async function using legacy promise
helpers to an Effect generator that yields Git.Service from context,
eliminating the need for the removed `run` promise wrapper in git/index.

- Replace `WorktreeFamily.list()` async function with Effect.fn generator
  that obtains Git.Service and InstanceState from the Effect context
- Remove legacy `makeRuntime`/`run`/`runPromise` exports from git module
- Update RecallTool to thread Git.Service through to WorktreeFamily calls
  via EffectBridge
- Add Git.Service as a required dependency in tool registry and HTTP
  server route layers
- Update all test layers to provide Git.defaultLayer

* chore(mcp): replace legacy promise helpers with Effect-native AppRuntime calls

Remove exported promise-based `status`, `connect`, and `disconnect`
helpers from MCP module and convert the network recovery callsite in
SessionNetwork to use AppRuntime.runPromise with Effect.gen directly.

* refactor(auth): adopt makeRuntime helper for Auth service resolution in kilo modules

Replace AppRuntime.runPromise with locally scoped makeRuntime instances
in kilo-sessions and kilocode/indexing modules, removing the dependency
on the global AppRuntime singleton for Auth service access.

* fix(test): simplify cleanup error handling in provider test

Replace try-catch block with promise .catch() for file unlink operation
during test teardown.
2026-05-26 12:51:21 +02:00

2347 lines
79 KiB
TypeScript

import { NodeFileSystem } from "@effect/platform-node"
import { FetchHttpClient } from "effect/unstable/http"
import { afterEach, expect, mock, spyOn } from "bun:test" // kilocode_change - spy on review telemetry
import { Telemetry } from "@kilocode/kilo-telemetry" // kilocode_change - assert review command telemetry
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
import path from "path"
import { fileURLToPath } from "url"
import { NamedError } from "@opencode-ai/core/util/error"
import { Agent as AgentSvc } from "../../src/agent/agent"
import { Bus } from "../../src/bus"
import { Command } from "../../src/command"
import { Config } from "@/config/config"
import { LSP } from "@/lsp/lsp"
import { MCP } from "../../src/mcp"
import { Permission } from "../../src/permission"
import { Plugin } from "../../src/plugin"
import { Provider as ProviderSvc } from "@/provider/provider"
import { Env } from "../../src/env"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { Question } from "../../src/question"
import { Todo } from "../../src/session/todo"
import { Session } from "@/session/session"
import { SessionMessageTable } from "../../src/session/session.sql"
import { LLM } from "../../src/session/llm"
import { MessageV2 } from "../../src/session/message-v2"
import { AppFileSystem } from "@opencode-ai/core/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 { Suggestion } from "../../src/kilocode/suggestion" // kilocode_change - accept suggestion in telemetry test
import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { SessionStatus } from "../../src/session/status"
import { SessionV2 } from "../../src/v2/session"
import { Skill } from "../../src/skill"
import { SystemPrompt } from "../../src/session/system"
import { Shell } from "../../src/shell/shell"
import { Snapshot } from "../../src/snapshot"
import { ToolRegistry } from "@/tool/registry"
import { Truncate } from "@/tool/truncate"
import * as Log from "@opencode-ai/core/util/log"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import * as Database from "../../src/storage/db"
import { Ripgrep } from "../../src/file/ripgrep"
import { Format } from "../../src/format"
import { Git } from "../../src/git" // kilocode_change
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 }
}
// kilocode_change start - make timing-sensitive prompt tests wait on observable state
const waitFor = <A, E, R>(label: string, run: Effect.Effect<A | undefined, E, R>) =>
Effect.gen(function* () {
const end = Date.now() + 5_000
while (Date.now() < end) {
const result = yield* run
if (result !== undefined) return result
yield* Effect.sleep(20)
}
throw new Error(`timed out waiting for ${label}`)
})
// kilocode_change end
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.provide(Git.defaultLayer), // kilocode_change
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
// kilocode_change start - restore any spies between tests so review telemetry spy never leaks
afterEach(() => {
mock.restore()
})
// kilocode_change end
// 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 config = yield* Config.Service
const prompt = yield* SessionPrompt.Service
const run = yield* SessionRunState.Service
const sessions = yield* Session.Service
yield* config.get()
const chat = yield* sessions.create(input ?? { title: "Pinned" })
return { prompt, run, sessions, chat }
})
// Loop semantics
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("prompt emits v2 prompted and synthetic events", () =>
provideTmpdirServer(
Effect.fnUntraced(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const chat = yield* sessions.create({ title: "Pinned" })
yield* prompt.prompt({
sessionID: chat.id,
agent: "build",
noReply: true,
parts: [
{ type: "text", text: "hello v2" },
{
type: "file",
mime: "text/plain",
filename: "note.txt",
url: "data:text/plain;base64,bm90ZSBjb250ZW50",
},
],
})
const messages = yield* SessionV2.Service.use((session) => session.messages({ sessionID: chat.id })).pipe(
Effect.provide(SessionV2.layer),
)
const row = Database.use((db) =>
db.select().from(SessionMessageTable).where(Database.eq(SessionMessageTable.session_id, chat.id)).get(),
)
expect(messages.find((message) => message.type === "user")).toMatchObject({ type: "user", text: "hello v2" })
expect(typeof row?.data.time.created).toBe("number")
expect(messages).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: "synthetic", text: expect.stringContaining("Called the Read tool") }),
expect.objectContaining({ type: "synthetic", text: "note content" }),
]),
)
}),
{ 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 },
),
)
// kilocode_change - skipped: tracked in #9958
it.live.skip("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 === "code") // kilocode_change
const tool = assistant?.parts.find(
(part): part is MessageV2.ToolPart => part.type === "tool" && part.tool === "task",
)
if (tool?.state.status === "running" && tool.state.metadata?.sessionId) return tool
await new Promise((done) => setTimeout(done, 20))
}
throw new Error("timed out waiting for running task metadata")
})
if (tool.state.status !== "running") return
expect(typeof tool.state.metadata?.sessionId).toBe("string")
expect(tool.state.title).toBe("inspect bug")
expect(tool.state.metadata?.model).toBeDefined()
yield* prompt.cancel(chat.id)
yield* Fiber.await(fiber)
}),
{ git: true, config: providerCfg },
),
10_000, // kilocode_change
)
// kilocode_change start - child task failures stay tool errors so the parent can recover
it.live("failed task tool preserves metadata and lets the parent follow up", () =>
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.error(400, { error: { message: "child prompt failed" } })
yield* llm.text("parent recovered")
yield* user(chat.id, "hello")
const result = yield* prompt.loop({ sessionID: chat.id })
expect(yield* llm.calls).toBe(3)
expect(result.parts.some((part) => part.type === "text" && part.text === "parent recovered")).toBe(true)
const msgs = yield* MessageV2.filterCompactedEffect(chat.id)
const part = msgs
.flatMap((msg) => msg.parts)
.find(
(part): part is ErrorToolPart =>
part.type === "tool" && part.tool === "task" && part.state.status === "error",
)
expect(part).toBeDefined()
if (!part) return
expect(part.state.error).toContain("child prompt failed")
expect(part.state.metadata?.sessionId).toBeDefined()
const hits = yield* llm.hits
expect(hits).toHaveLength(3)
expect(JSON.stringify(hits.at(-1)?.body)).toContain("child prompt failed")
}),
{ git: true, config: providerCfg },
),
)
// kilocode_change end
it.live(
"loop sets status to busy then idle",
() =>
provideTmpdirServer(
// kilocode_change start - hold the model response instead of cancelling an infinite stream
Effect.fnUntraced(function* ({ llm }) {
const gate = defer<void>()
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const status = yield* SessionStatus.Service
yield* llm.push(reply().wait(gate.promise).text("done").stop())
const chat = yield* sessions.create({ title: "Pinned" })
yield* user(chat.id, "hi")
const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
yield* waitFor(
"busy status",
status.get(chat.id).pipe(Effect.map((item) => (item.type === "busy" ? item : undefined))),
)
gate.resolve()
yield* Fiber.await(fiber)
yield* waitFor(
"idle status",
status.get(chat.id).pipe(Effect.map((item) => (item.type === "idle" ? item : undefined))),
)
}),
// kilocode_change end
{ git: true, config: providerCfg },
),
10_000, // kilocode_change
)
// 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,
)
unix(
// kilocode_change - skip flaky cancel test on Windows CI
"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,
)
// kilocode_change start - handleSubtask propagates child session cost to wrapper (#6321)
it.live(
"handleSubtask propagates subagent cost to wrapper message",
() =>
provideTmpdirServer(
Effect.fnUntraced(function* ({ llm }) {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const registry = yield* ToolRegistry.Service
const { task } = yield* registry.named()
const original = task.execute
// Simulate task tool: create a child session, persist an assistant with cost, return metadata.
task.execute = (_args, ctx) =>
Effect.gen(function* () {
const child = yield* sessions.create({ parentID: ctx.sessionID, title: "subagent" })
const childAssistant: MessageV2.Assistant = {
id: MessageID.ascending(),
role: "assistant",
parentID: ctx.messageID,
sessionID: child.id,
mode: "general",
agent: "general",
cost: 0.42,
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(), completed: Date.now() },
finish: "stop",
}
yield* sessions.updateMessage(childAssistant)
yield* ctx.metadata({
title: "done",
metadata: { sessionId: child.id, model: ref, variant: undefined },
})
return { title: "done", metadata: { sessionId: child.id, model: ref, variant: undefined }, output: "done" }
})
yield* Effect.addFinalizer(() => Effect.sync(() => void (task.execute = original)))
const chat = yield* sessions.create({ title: "Pinned" })
const msg = yield* user(chat.id, "hello")
yield* addSubtask(chat.id, msg.id)
// The loop continues past handleSubtask into a normal LLM step; provide one response to exit.
yield* llm.text("wrapped")
yield* prompt.loop({ sessionID: chat.id })
const msgs = yield* MessageV2.filterCompactedEffect(chat.id)
const wrapper = msgs.find((item) => item.info.role === "assistant" && item.info.agent === "general")
expect(wrapper?.info.role).toBe("assistant")
if (!wrapper || wrapper.info.role !== "assistant") return
expect(wrapper.info.cost).toBeCloseTo(0.42, 6)
}),
{ git: true, config: providerCfg },
),
30_000,
)
// kilocode_change end
it.live(
"cancel propagates from slash command subtask to child session",
() =>
provideTmpdirServer(
Effect.fnUntraced(function* ({ llm }) {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const status = yield* SessionStatus.Service
const chat = yield* sessions.create({ title: "Pinned" })
yield* llm.hang
const msg = yield* user(chat.id, "hello")
yield* addSubtask(chat.id, msg.id)
const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
yield* llm.wait(1)
const msgs = yield* MessageV2.filterCompactedEffect(chat.id)
const taskMsg = msgs.find((item) => item.info.role === "assistant" && item.info.agent === "general")
const tool = taskMsg ? toolPart(taskMsg.parts) : undefined
const sessionID = tool?.state.status === "running" ? tool.state.metadata?.sessionId : undefined
expect(typeof sessionID).toBe("string")
if (typeof sessionID !== "string") throw new Error("missing child session id")
const childID = SessionID.make(sessionID)
expect((yield* status.get(childID)).type).toBe("busy")
yield* prompt.cancel(chat.id)
const exit = yield* Fiber.await(fiber)
expect(Exit.isSuccess(exit)).toBe(true)
expect((yield* status.get(chat.id)).type).toBe("idle")
expect((yield* status.get(childID)).type).toBe("idle")
}),
{ git: true, config: providerCfg },
),
10_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(
// kilocode_change start - gate the failing stream so both callers join the same run
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.push(reply().wait(gate.promise).streamError("boom"))
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) // kilocode_change - let b attach to a's done deferred before gate resolves
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)
if (!Exit.isSuccess(ea) || !Exit.isSuccess(eb)) return
const [first, second] = [ea.value, eb.value]
expect(first.info.id).toBe(second.info.id)
expect(first.info.role).toBe("assistant")
}),
// kilocode_change end
{ git: true, config: providerCfg },
),
10_000, // kilocode_change
)
// kilocode_change start - #9492: the upstream fork-based shape of this test
// loses Instance AsyncLocalStorage context in the second forked prompt, which
// surfaces as a "No context found for instance" die before the queue behavior
// can be exercised. The Kilo queue semantics (in-flight stream drains, second
// LLM request ends with the queued user message) are covered end-to-end in
// packages/opencode/test/kilocode/session-prompt-queue.test.ts — keep this
// upstream scaffold skipped so future OpenCode merges remain friction-free.
it.live.skip(
"prompt submitted during an active run is included in the next LLM input",
// kilocode_change end
() =>
provideTmpdirServer(
Effect.fnUntraced(function* ({ llm }) {
const gate = defer<void>()
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const chat = yield* sessions.create({ title: "Pinned" })
yield* llm.hold("first", gate.promise)
yield* llm.text("second")
const a = yield* prompt
.prompt({
sessionID: chat.id,
agent: "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 uses configured shell over env shell",
() =>
withSh(() =>
provideTmpdirInstance(
(_dir) =>
Effect.gen(function* () {
if (!Bun.which("bash")) return
const { prompt, chat } = yield* boot()
const result = yield* prompt.shell({
sessionID: chat.id,
agent: "build",
command: "[[ 1 -eq 1 ]] && printf configured",
})
const tool = completedTool(result.parts)
if (!tool) return
expect(tool.state.output).toContain("configured")
}),
{ git: true, config: { ...cfg, shell: "bash" } },
),
),
30_000,
)
unix("shell commands can change directory after startup", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const { prompt, run, chat } = yield* boot()
const parent = path.dirname(dir)
const result = yield* prompt.shell({
sessionID: chat.id,
agent: "build",
command: "cd .. && pwd",
})
expect(result.info.role).toBe("assistant")
const tool = completedTool(result.parts)
if (!tool) return
expect(tool.state.output).toContain(parent)
expect(tool.state.metadata.output).toContain(parent)
yield* run.assertNotBusy(chat.id)
}),
{ 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 status = yield* SessionStatus.Service // kilocode_change
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)
// kilocode_change start - wait for shell to actually be running before forking loop
yield* waitFor("shell busy", status.get(chat.id).pipe(Effect.map((s) => (s.type === "busy" ? s : undefined))))
// kilocode_change end
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 },
),
30_000, // kilocode_change - Windows CI process startup can exceed 3s
)
it.live(
"shell completion resumes queued loop callers",
() =>
provideTmpdirServer(
Effect.fnUntraced(function* ({ llm }) {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const status = yield* SessionStatus.Service // kilocode_change
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)
// kilocode_change start - wait for shell to actually be running before forking loop callers
yield* waitFor("shell busy", status.get(chat.id).pipe(Effect.map((s) => (s.type === "busy" ? s : undefined))))
// kilocode_change end
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 },
),
30_000, // kilocode_change - Windows CI process startup can exceed 3s
)
unix(
"command ! expansion uses configured shell over env shell",
() =>
withSh(() =>
provideTmpdirServer(
({ llm }) =>
Effect.gen(function* () {
if (!Bun.which("bash")) return
const { prompt, chat } = yield* boot()
yield* llm.text("done")
const result = yield* prompt.command({
sessionID: chat.id,
command: "probe",
arguments: "",
})
expect(result.info.role).toBe("assistant")
const inputs = yield* llm.inputs
expect(JSON.stringify(inputs.at(-1)?.messages)).toContain("configured")
}),
{
git: true,
config: (url) => ({
...providerCfg(url),
shell: "bash",
command: {
probe: {
template: "Probe: !`[[ 1 -eq 1 ]] && printf configured`",
},
},
}),
},
),
),
30_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,
)
// kilocode_change start - cancel persists aborted shell result when shell ignores TERM
unix(
"cancel persists aborted shell result when shell ignores TERM",
() =>
withSh(() =>
provideTmpdirInstance(
(_dir) =>
Effect.gen(function* () {
const { prompt, chat, sessions } = yield* boot()
const sh = yield* prompt
.shell({
sessionID: chat.id,
agent: "build",
command: "trap '' TERM; printf started; sleep 10; printf not-killed",
})
.pipe(Effect.forkChild)
yield* waitFor(
"shell start output",
sessions
.messages({ sessionID: chat.id })
.pipe(
Effect.map((msgs) =>
msgs
.flatMap((msg) => msg.parts)
.find(
(part) =>
part.type === "tool" &&
part.state.status === "running" &&
(part.state.metadata?.output ?? "").includes("started"),
),
),
),
)
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("started")
expect(tool.state.output).toContain("User aborted the command")
expect(tool.state.output).not.toContain("not-killed")
}
}
}),
{ git: true, config: cfg },
),
),
30_000,
)
// kilocode_change end
unix(
"cancel finalizes interrupted bash tool output through normal truncation",
() =>
provideTmpdirServer(
({ dir, llm }) =>
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const chat = yield* sessions.create({
title: "Interrupted bash truncation",
permission: [{ permission: "*", pattern: "*", action: "allow" }],
})
yield* prompt.prompt({
sessionID: chat.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "run bash" }],
})
yield* llm.tool("bash", {
command:
'i=0; while [ "$i" -lt 4000 ]; do printf "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx %05d\\n" "$i"; i=$((i + 1)); done; sleep 30',
description: "Print many lines",
timeout: 30_000,
workdir: path.resolve(dir),
})
const run = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
yield* llm.wait(1)
// kilocode_change start
yield* waitFor(
"large bash output",
sessions.messages({ sessionID: chat.id }).pipe(
Effect.map((msgs) => {
const part = msgs.flatMap((msg) => msg.parts).find((part) => part.type === "tool")
if (part?.type !== "tool") return
if (part.state.status !== "running") return
if (!String(part.state.metadata?.output ?? "").includes("03999")) return
return part
}),
),
)
// kilocode_change end
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)
if (Exit.isSuccess(exit)) {
const tool = completedTool(exit.value.parts)
expect(tool?.state.output).toContain("User aborted the command")
}
yield* Fiber.await(sh)
}),
{ git: true, config: cfg },
),
30_000,
)
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,
)
// Missing file handling
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({})
const missing = path.join(dir, "does-not-exist.ts")
const msg = yield* prompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [
{ type: "text", text: "please review @does-not-exist.ts" },
{
type: "file",
mime: "text/plain",
url: `file://${missing}`,
filename: "does-not-exist.ts",
},
],
})
if (msg.info.role !== "user") throw new Error("expected user message")
const hasFailure = msg.parts.some(
(part) => part.type === "text" && part.synthetic && part.text.includes("Read tool failed to read"),
)
expect(hasFailure).toBe(true)
yield* sessions.remove(session.id)
}),
{ git: true, config: cfg },
),
)
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({})
const missing = path.join(dir, "still-missing.ts")
const msg = yield* prompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [
{
type: "file",
mime: "text/plain",
url: `file://${missing}`,
filename: "still-missing.ts",
},
{ type: "text", text: "after-file" },
],
})
if (msg.info.role !== "user") throw new Error("expected user message")
const stored = 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")
const result = yield* prompt.prompt({
sessionID: session.id,
agent: "build",
parts: [{ type: "text", text: "Where is SessionProcessor?" }],
})
expect(result.info.role).toBe("assistant")
expect(result.parts.some((part) => part.type === "text" && part.text.includes("processor.ts"))).toBe(true)
const msgs = yield* sessions.messages({ sessionID: session.id })
expect(msgs.filter((msg) => msg.info.role === "assistant")).toHaveLength(1)
expect(yield* llm.calls).toBe(1)
}),
{ 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")
}
}
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,
)
// Agent variant
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()
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",
},
},
},
},
),
)
// kilocode_change start - /review subtask path tags child completions for telemetry
it.live(
"review command marks child completions with review telemetry",
() =>
provideTmpdirServer(
Effect.fnUntraced(function* ({ llm }) {
const trackSpy = spyOn(Telemetry, "trackLlmCompletion")
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const chat = yield* sessions.create({
title: "Review telemetry",
permission: [{ permission: "*", pattern: "*", action: "allow" }],
})
// child subagent's first LLM step needs non-zero usage so trackStep fires
yield* llm.text("review done", { usage: { input: 100, output: 50 } })
yield* prompt.command({
sessionID: chat.id,
command: "review",
arguments: "",
agent: "general",
})
const tagged = trackSpy.mock.calls
.map((args) => args[0] as Parameters<typeof Telemetry.trackLlmCompletion>[0])
.find((p) => p.mode === "review" && p.feature === "code_reviews" && p.command === "review")
expect(tagged).toBeDefined()
}),
{ git: true, config: providerCfg },
),
30_000,
)
it.live(
"accepted suggest tool marks following completion with review telemetry",
() =>
provideTmpdirServer(
Effect.fnUntraced(function* ({ llm }) {
const trackSpy = spyOn(Telemetry, "trackLlmCompletion")
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const chat = yield* sessions.create({
title: "Suggest telemetry",
permission: [{ permission: "*", pattern: "*", action: "allow" }],
})
yield* llm.tool("suggest", {
suggest: "Run a local review?",
actions: [{ label: "Review", prompt: "/local-review-uncommitted --focus telemetry" }],
})
yield* llm.text("review done", { usage: { input: 100, output: 50 } })
const fiber = yield* prompt
.prompt({
sessionID: chat.id,
agent: "build",
model: ref,
parts: [{ type: "text", text: "Suggest a review action." }],
})
.pipe(Effect.forkChild)
const request = yield* waitFor(
"suggestion request",
Effect.promise(() => Suggestion.list()).pipe(
Effect.map((items) => items.find((item) => item.sessionID === chat.id)),
),
)
yield* Effect.promise(() => Suggestion.accept({ requestID: request.id, index: 0 }))
yield* Fiber.join(fiber)
const tagged = trackSpy.mock.calls
.map((args) => args[0] as Parameters<typeof Telemetry.trackLlmCompletion>[0])
.find(
(p) =>
p.mode === "review" &&
p.feature === "code_reviews" &&
p.command === "local-review-uncommitted" &&
p.tool === "suggest",
)
expect(tagged).toBeDefined()
}),
{ git: true, config: providerCfg },
),
30_000,
)
// kilocode_change end
// Agent / command resolution errors
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"')
}
}
}),
{ 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("code") // kilocode_change - "build" renamed to "code"
}
}
}),
{ 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")
}
}
}),
{ git: true },
),
30_000,
)