mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-21 14:07:20 +08:00
* 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.
2347 lines
79 KiB
TypeScript
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,
|
|
)
|