mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-18 02:22:32 +00:00
2236 lines
73 KiB
TypeScript
2236 lines
73 KiB
TypeScript
import { NodeFileSystem } from "@effect/platform-node"
|
|
import { FetchHttpClient } from "effect/unstable/http"
|
|
import { expect } from "bun:test"
|
|
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
|
|
import fs from "fs/promises"
|
|
import path from "path"
|
|
import { fileURLToPath, pathToFileURL } from "url"
|
|
import { NamedError } from "@opencode-ai/core/util/error"
|
|
import { Agent as AgentSvc } from "../../src/agent/agent"
|
|
import { 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 { Git } from "../../src/git"
|
|
import { Image } from "../../src/image/image"
|
|
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 { 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 { Reference } from "../../src/reference/reference"
|
|
import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture"
|
|
import { testEffect } from "../lib/effect"
|
|
import { reply, TestLLMServer } from "../lib/llm-server"
|
|
import { SyncEvent } from "@/sync"
|
|
|
|
void Log.init({ print: false })
|
|
|
|
const summary = Layer.succeed(
|
|
SessionSummary.Service,
|
|
SessionSummary.Service.of({
|
|
summarize: () => Effect.void,
|
|
diff: () => Effect.succeed([]),
|
|
computeDiff: () => Effect.succeed([]),
|
|
}),
|
|
)
|
|
|
|
const ref = {
|
|
providerID: ProviderID.make("test"),
|
|
modelID: ModelID.make("test-model"),
|
|
}
|
|
|
|
function defer<T>() {
|
|
let resolve!: (value: T | PromiseLike<T>) => void
|
|
const promise = new Promise<T>((done) => {
|
|
resolve = done
|
|
})
|
|
return { promise, resolve }
|
|
}
|
|
|
|
function withSh<A, E, R>(fx: () => Effect.Effect<A, E, R>) {
|
|
return Effect.acquireUseRelease(
|
|
Effect.sync(() => {
|
|
const prev = process.env.SHELL
|
|
process.env.SHELL = "/bin/sh"
|
|
Shell.preferred.reset()
|
|
return prev
|
|
}),
|
|
() => fx(),
|
|
(prev) =>
|
|
Effect.sync(() => {
|
|
if (prev === undefined) delete process.env.SHELL
|
|
else process.env.SHELL = prev
|
|
Shell.preferred.reset()
|
|
}),
|
|
)
|
|
}
|
|
|
|
function toolPart(parts: MessageV2.Part[]) {
|
|
return parts.find((part): part is MessageV2.ToolPart => part.type === "tool")
|
|
}
|
|
|
|
type CompletedToolPart = MessageV2.ToolPart & { state: MessageV2.ToolStateCompleted }
|
|
type ErrorToolPart = MessageV2.ToolPart & { state: MessageV2.ToolStateError }
|
|
|
|
function completedTool(parts: MessageV2.Part[]) {
|
|
const part = toolPart(parts)
|
|
expect(part?.state.status).toBe("completed")
|
|
return part?.state.status === "completed" ? (part as CompletedToolPart) : undefined
|
|
}
|
|
|
|
function errorTool(parts: MessageV2.Part[]) {
|
|
const part = toolPart(parts)
|
|
expect(part?.state.status).toBe("error")
|
|
return part?.state.status === "error" ? (part as ErrorToolPart) : undefined
|
|
}
|
|
|
|
const mcp = Layer.succeed(
|
|
MCP.Service,
|
|
MCP.Service.of({
|
|
status: () => Effect.succeed({}),
|
|
clients: () => Effect.succeed({}),
|
|
tools: () => Effect.succeed({}),
|
|
prompts: () => Effect.succeed({}),
|
|
resources: () => Effect.succeed({}),
|
|
add: () => Effect.succeed({ status: { status: "disabled" as const } }),
|
|
connect: () => Effect.void,
|
|
disconnect: () => Effect.void,
|
|
getPrompt: () => Effect.succeed(undefined),
|
|
readResource: () => Effect.succeed(undefined),
|
|
startAuth: () => Effect.die("unexpected MCP auth in prompt-effect tests"),
|
|
authenticate: () => Effect.die("unexpected MCP auth in prompt-effect tests"),
|
|
finishAuth: () => Effect.die("unexpected MCP auth in prompt-effect tests"),
|
|
removeAuth: () => Effect.void,
|
|
supportsOAuth: () => Effect.succeed(false),
|
|
hasStoredTokens: () => Effect.succeed(false),
|
|
getAuthStatus: () => Effect.succeed("not_authenticated" as const),
|
|
}),
|
|
)
|
|
|
|
const lsp = Layer.succeed(
|
|
LSP.Service,
|
|
LSP.Service.of({
|
|
init: () => Effect.void,
|
|
status: () => Effect.succeed([]),
|
|
hasClients: () => Effect.succeed(false),
|
|
touchFile: () => Effect.void,
|
|
diagnostics: () => Effect.succeed({}),
|
|
hover: () => Effect.succeed(undefined),
|
|
definition: () => Effect.succeed([]),
|
|
references: () => Effect.succeed([]),
|
|
implementation: () => Effect.succeed([]),
|
|
documentSymbol: () => Effect.succeed([]),
|
|
workspaceSymbol: () => Effect.succeed([]),
|
|
prepareCallHierarchy: () => Effect.succeed([]),
|
|
incomingCalls: () => Effect.succeed([]),
|
|
outgoingCalls: () => Effect.succeed([]),
|
|
}),
|
|
)
|
|
|
|
const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer))
|
|
const run = SessionRunState.layer.pipe(Layer.provide(status))
|
|
const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer)
|
|
function makeHttp() {
|
|
const deps = Layer.mergeAll(
|
|
Session.defaultLayer,
|
|
Snapshot.defaultLayer,
|
|
LLM.defaultLayer,
|
|
Env.defaultLayer,
|
|
AgentSvc.defaultLayer,
|
|
Command.defaultLayer,
|
|
Permission.defaultLayer,
|
|
Plugin.defaultLayer,
|
|
Config.defaultLayer,
|
|
ProviderSvc.defaultLayer,
|
|
lsp,
|
|
mcp,
|
|
AppFileSystem.defaultLayer,
|
|
status,
|
|
SyncEvent.defaultLayer,
|
|
).pipe(Layer.provideMerge(infra))
|
|
const question = Question.layer.pipe(Layer.provideMerge(deps))
|
|
const todo = Todo.layer.pipe(Layer.provideMerge(deps))
|
|
const registry = ToolRegistry.layer.pipe(
|
|
Layer.provide(Skill.defaultLayer),
|
|
Layer.provide(FetchHttpClient.layer),
|
|
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
|
Layer.provide(Git.defaultLayer),
|
|
Layer.provide(Reference.defaultLayer),
|
|
Layer.provide(Ripgrep.defaultLayer),
|
|
Layer.provide(Format.defaultLayer),
|
|
Layer.provideMerge(todo),
|
|
Layer.provideMerge(question),
|
|
Layer.provideMerge(deps),
|
|
)
|
|
const trunc = Truncate.layer.pipe(Layer.provideMerge(deps))
|
|
const proc = SessionProcessor.layer.pipe(
|
|
Layer.provide(summary),
|
|
Layer.provide(Image.defaultLayer),
|
|
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(Image.defaultLayer),
|
|
Layer.provide(Reference.defaultLayer),
|
|
Layer.provide(summary),
|
|
Layer.provideMerge(run),
|
|
Layer.provideMerge(compact),
|
|
Layer.provideMerge(proc),
|
|
Layer.provideMerge(registry),
|
|
Layer.provideMerge(trunc),
|
|
Layer.provide(Instruction.defaultLayer),
|
|
Layer.provide(SystemPrompt.defaultLayer),
|
|
Layer.provideMerge(deps),
|
|
),
|
|
).pipe(Layer.provide(summary))
|
|
}
|
|
|
|
const it = testEffect(makeHttp())
|
|
const unix = process.platform !== "win32" ? it.live : it.live.skip
|
|
|
|
// Config that registers a custom "test" provider with a "test-model" model
|
|
// so provider model lookup succeeds inside the loop.
|
|
const cfg = {
|
|
provider: {
|
|
test: {
|
|
name: "Test",
|
|
id: "test",
|
|
env: [],
|
|
npm: "@ai-sdk/openai-compatible",
|
|
models: {
|
|
"test-model": {
|
|
id: "test-model",
|
|
name: "Test Model",
|
|
attachment: false,
|
|
reasoning: false,
|
|
temperature: false,
|
|
tool_call: true,
|
|
release_date: "2025-01-01",
|
|
limit: { context: 100000, output: 10000 },
|
|
cost: { input: 0, output: 0 },
|
|
options: {},
|
|
},
|
|
},
|
|
options: {
|
|
apiKey: "test-key",
|
|
baseURL: "http://localhost:1/v1",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
function providerCfg(url: string) {
|
|
return {
|
|
...cfg,
|
|
provider: {
|
|
...cfg.provider,
|
|
test: {
|
|
...cfg.provider.test,
|
|
options: {
|
|
...cfg.provider.test.options,
|
|
baseURL: url,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
const user = Effect.fn("test.user")(function* (sessionID: SessionID, text: string) {
|
|
const session = yield* Session.Service
|
|
const msg = yield* session.updateMessage({
|
|
id: MessageID.ascending(),
|
|
role: "user",
|
|
sessionID,
|
|
agent: "build",
|
|
model: ref,
|
|
time: { created: Date.now() },
|
|
})
|
|
yield* session.updatePart({
|
|
id: PartID.ascending(),
|
|
messageID: msg.id,
|
|
sessionID,
|
|
type: "text",
|
|
text,
|
|
})
|
|
return msg
|
|
})
|
|
|
|
const seed = Effect.fn("test.seed")(function* (sessionID: SessionID, opts?: { finish?: string }) {
|
|
const session = yield* Session.Service
|
|
const msg = yield* user(sessionID, "hello")
|
|
const assistant: MessageV2.Assistant = {
|
|
id: MessageID.ascending(),
|
|
role: "assistant",
|
|
parentID: msg.id,
|
|
sessionID,
|
|
mode: "build",
|
|
agent: "build",
|
|
cost: 0,
|
|
path: { cwd: "/tmp", root: "/tmp" },
|
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
modelID: ref.modelID,
|
|
providerID: ref.providerID,
|
|
time: { created: Date.now() },
|
|
...(opts?.finish ? { finish: opts.finish } : {}),
|
|
}
|
|
yield* session.updateMessage(assistant)
|
|
yield* session.updatePart({
|
|
id: PartID.ascending(),
|
|
messageID: assistant.id,
|
|
sessionID,
|
|
type: "text",
|
|
text: "hi there",
|
|
})
|
|
return { user: msg, assistant }
|
|
})
|
|
|
|
const addSubtask = (sessionID: SessionID, messageID: MessageID, model = ref) =>
|
|
Effect.gen(function* () {
|
|
const session = yield* Session.Service
|
|
yield* session.updatePart({
|
|
id: PartID.ascending(),
|
|
messageID,
|
|
sessionID,
|
|
type: "subtask",
|
|
prompt: "look into the cache key path",
|
|
description: "inspect bug",
|
|
agent: "general",
|
|
model,
|
|
})
|
|
})
|
|
|
|
const boot = Effect.fn("test.boot")(function* (input?: { title?: string }) {
|
|
const 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 },
|
|
),
|
|
)
|
|
|
|
it.live("glob tool keeps instance context during prompt runs", () =>
|
|
provideTmpdirServer(
|
|
({ dir, llm }) =>
|
|
Effect.gen(function* () {
|
|
const prompt = yield* SessionPrompt.Service
|
|
const sessions = yield* Session.Service
|
|
const session = yield* sessions.create({
|
|
title: "Glob context",
|
|
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
|
})
|
|
const file = path.join(dir, "probe.txt")
|
|
yield* Effect.promise(() => Bun.write(file, "probe"))
|
|
|
|
yield* prompt.prompt({
|
|
sessionID: session.id,
|
|
agent: "build",
|
|
noReply: true,
|
|
parts: [{ type: "text", text: "find text files" }],
|
|
})
|
|
yield* llm.tool("glob", { pattern: "**/*.txt" })
|
|
yield* llm.text("done")
|
|
|
|
const result = yield* prompt.loop({ sessionID: session.id })
|
|
expect(result.info.role).toBe("assistant")
|
|
|
|
const msgs = yield* MessageV2.filterCompactedEffect(session.id)
|
|
const tool = msgs
|
|
.flatMap((msg) => msg.parts)
|
|
.find(
|
|
(part): part is CompletedToolPart =>
|
|
part.type === "tool" && part.tool === "glob" && part.state.status === "completed",
|
|
)
|
|
if (!tool) return
|
|
|
|
expect(tool.state.output).toContain(file)
|
|
expect(tool.state.output).not.toContain("No context found for instance")
|
|
expect(result.parts.some((part) => part.type === "text" && part.text === "done")).toBe(true)
|
|
}),
|
|
{ git: true, config: providerCfg },
|
|
),
|
|
)
|
|
|
|
it.live("loop continues when finish is stop but assistant has tool parts", () =>
|
|
provideTmpdirServer(
|
|
Effect.fnUntraced(function* ({ llm }) {
|
|
const prompt = yield* SessionPrompt.Service
|
|
const sessions = yield* Session.Service
|
|
const session = yield* sessions.create({
|
|
title: "Pinned",
|
|
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
|
})
|
|
yield* prompt.prompt({
|
|
sessionID: session.id,
|
|
agent: "build",
|
|
noReply: true,
|
|
parts: [{ type: "text", text: "hello" }],
|
|
})
|
|
yield* llm.push(reply().tool("first", { value: "first" }).stop())
|
|
yield* llm.text("second")
|
|
|
|
const result = yield* prompt.loop({ sessionID: session.id })
|
|
expect(yield* llm.calls).toBe(2)
|
|
expect(result.info.role).toBe("assistant")
|
|
if (result.info.role === "assistant") {
|
|
expect(result.parts.some((part) => part.type === "text" && part.text === "second")).toBe(true)
|
|
expect(result.info.finish).toBe("stop")
|
|
}
|
|
}),
|
|
{ git: true, config: providerCfg },
|
|
),
|
|
)
|
|
|
|
it.live("failed subtask preserves metadata on error tool state", () =>
|
|
provideTmpdirServer(
|
|
Effect.fnUntraced(function* ({ llm }) {
|
|
const prompt = yield* SessionPrompt.Service
|
|
const sessions = yield* Session.Service
|
|
const chat = yield* sessions.create({ title: "Pinned" })
|
|
yield* llm.tool("task", {
|
|
description: "inspect bug",
|
|
prompt: "look into the cache key path",
|
|
subagent_type: "general",
|
|
})
|
|
yield* llm.text("done")
|
|
const msg = yield* user(chat.id, "hello")
|
|
yield* addSubtask(chat.id, msg.id)
|
|
|
|
const result = yield* prompt.loop({ sessionID: chat.id })
|
|
expect(result.info.role).toBe("assistant")
|
|
expect(yield* llm.calls).toBe(2)
|
|
|
|
const msgs = yield* MessageV2.filterCompactedEffect(chat.id)
|
|
const taskMsg = msgs.find((item) => item.info.role === "assistant" && item.info.agent === "general")
|
|
expect(taskMsg?.info.role).toBe("assistant")
|
|
if (!taskMsg || taskMsg.info.role !== "assistant") return
|
|
|
|
const tool = errorTool(taskMsg.parts)
|
|
if (!tool) return
|
|
|
|
expect(tool.state.error).toContain("Tool execution failed")
|
|
expect(tool.state.metadata).toBeDefined()
|
|
expect(tool.state.metadata?.sessionId).toBeDefined()
|
|
expect(tool.state.metadata?.model).toEqual({
|
|
providerID: ProviderID.make("test"),
|
|
modelID: ModelID.make("missing-model"),
|
|
})
|
|
}),
|
|
{
|
|
git: true,
|
|
config: (url) => ({
|
|
...providerCfg(url),
|
|
agent: {
|
|
general: {
|
|
model: "test/missing-model",
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
),
|
|
)
|
|
|
|
it.live(
|
|
"running subtask preserves metadata after tool-call transition",
|
|
() =>
|
|
provideTmpdirServer(
|
|
Effect.fnUntraced(function* ({ llm }) {
|
|
const prompt = yield* SessionPrompt.Service
|
|
const sessions = yield* Session.Service
|
|
const chat = yield* sessions.create({ title: "Pinned" })
|
|
yield* llm.hang
|
|
const msg = yield* user(chat.id, "hello")
|
|
yield* addSubtask(chat.id, msg.id)
|
|
|
|
const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
|
|
|
|
const tool = yield* Effect.promise(async () => {
|
|
const end = Date.now() + 5_000
|
|
while (Date.now() < end) {
|
|
const msgs = await Effect.runPromise(MessageV2.filterCompactedEffect(chat.id))
|
|
const taskMsg = msgs.find((item) => item.info.role === "assistant" && item.info.agent === "general")
|
|
const tool = taskMsg?.parts.find((part): part is MessageV2.ToolPart => part.type === "tool")
|
|
if (tool?.state.status === "running" && tool.state.metadata?.sessionId) return tool
|
|
await new Promise((done) => setTimeout(done, 20))
|
|
}
|
|
throw new Error("timed out waiting for running subtask metadata")
|
|
})
|
|
|
|
if (tool.state.status !== "running") return
|
|
expect(typeof tool.state.metadata?.sessionId).toBe("string")
|
|
expect(tool.state.title).toBeDefined()
|
|
expect(tool.state.metadata?.model).toBeDefined()
|
|
|
|
yield* prompt.cancel(chat.id)
|
|
yield* Fiber.await(fiber)
|
|
}),
|
|
{ git: true, config: providerCfg },
|
|
),
|
|
5_000,
|
|
)
|
|
|
|
it.live(
|
|
"running task tool preserves metadata after tool-call transition",
|
|
() =>
|
|
provideTmpdirServer(
|
|
Effect.fnUntraced(function* ({ llm }) {
|
|
const prompt = yield* SessionPrompt.Service
|
|
const sessions = yield* Session.Service
|
|
const chat = yield* sessions.create({
|
|
title: "Pinned",
|
|
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
|
})
|
|
yield* llm.tool("task", {
|
|
description: "inspect bug",
|
|
prompt: "look into the cache key path",
|
|
subagent_type: "general",
|
|
})
|
|
yield* llm.hang
|
|
yield* user(chat.id, "hello")
|
|
|
|
const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
|
|
|
|
const tool = yield* Effect.promise(async () => {
|
|
const end = Date.now() + 5_000
|
|
while (Date.now() < end) {
|
|
const msgs = await Effect.runPromise(MessageV2.filterCompactedEffect(chat.id))
|
|
const assistant = msgs.findLast((item) => item.info.role === "assistant" && item.info.agent === "build")
|
|
const tool = assistant?.parts.find(
|
|
(part): part is MessageV2.ToolPart => part.type === "tool" && part.tool === "task",
|
|
)
|
|
if (tool?.state.status === "running" && tool.state.metadata?.sessionId) return tool
|
|
await new Promise((done) => setTimeout(done, 20))
|
|
}
|
|
throw new Error("timed out waiting for running task metadata")
|
|
})
|
|
|
|
if (tool.state.status !== "running") return
|
|
expect(typeof tool.state.metadata?.sessionId).toBe("string")
|
|
expect(tool.state.title).toBe("inspect bug")
|
|
expect(tool.state.metadata?.model).toBeDefined()
|
|
|
|
yield* prompt.cancel(chat.id)
|
|
yield* Fiber.await(fiber)
|
|
}),
|
|
{ git: true, config: providerCfg },
|
|
),
|
|
10_000,
|
|
)
|
|
|
|
it.live(
|
|
"loop sets status to busy then idle",
|
|
() =>
|
|
provideTmpdirServer(
|
|
Effect.fnUntraced(function* ({ llm }) {
|
|
const prompt = yield* SessionPrompt.Service
|
|
const sessions = yield* Session.Service
|
|
const status = yield* SessionStatus.Service
|
|
|
|
yield* llm.hang
|
|
|
|
const chat = yield* sessions.create({})
|
|
yield* user(chat.id, "hi")
|
|
|
|
const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
|
|
yield* llm.wait(1)
|
|
expect((yield* status.get(chat.id)).type).toBe("busy")
|
|
yield* prompt.cancel(chat.id)
|
|
yield* Fiber.await(fiber)
|
|
expect((yield* status.get(chat.id)).type).toBe("idle")
|
|
}),
|
|
{ git: true, config: providerCfg },
|
|
),
|
|
3_000,
|
|
)
|
|
|
|
// Cancel semantics
|
|
|
|
it.live(
|
|
"cancel interrupts loop and resolves with an assistant message",
|
|
() =>
|
|
provideTmpdirServer(
|
|
Effect.fnUntraced(function* ({ llm }) {
|
|
const prompt = yield* SessionPrompt.Service
|
|
const sessions = yield* Session.Service
|
|
const chat = yield* sessions.create({ title: "Pinned" })
|
|
yield* seed(chat.id)
|
|
|
|
yield* llm.hang
|
|
|
|
yield* user(chat.id, "more")
|
|
|
|
const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
|
|
yield* llm.wait(1)
|
|
yield* prompt.cancel(chat.id)
|
|
const exit = yield* Fiber.await(fiber)
|
|
expect(Exit.isSuccess(exit)).toBe(true)
|
|
if (Exit.isSuccess(exit)) {
|
|
expect(exit.value.info.role).toBe("assistant")
|
|
}
|
|
}),
|
|
{ git: true, config: providerCfg },
|
|
),
|
|
3_000,
|
|
)
|
|
|
|
it.live(
|
|
"cancel records MessageAbortedError on interrupted process",
|
|
() =>
|
|
provideTmpdirServer(
|
|
Effect.fnUntraced(function* ({ llm }) {
|
|
const prompt = yield* SessionPrompt.Service
|
|
const sessions = yield* Session.Service
|
|
const chat = yield* sessions.create({ title: "Pinned" })
|
|
yield* llm.hang
|
|
yield* user(chat.id, "hello")
|
|
|
|
const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
|
|
yield* llm.wait(1)
|
|
yield* prompt.cancel(chat.id)
|
|
const exit = yield* Fiber.await(fiber)
|
|
expect(Exit.isSuccess(exit)).toBe(true)
|
|
if (Exit.isSuccess(exit)) {
|
|
const info = exit.value.info
|
|
if (info.role === "assistant") {
|
|
expect(info.error?.name).toBe("MessageAbortedError")
|
|
}
|
|
}
|
|
}),
|
|
{ git: true, config: providerCfg },
|
|
),
|
|
3_000,
|
|
)
|
|
|
|
it.live(
|
|
"cancel finalizes subtask tool state",
|
|
() =>
|
|
provideTmpdirInstance(
|
|
() =>
|
|
Effect.gen(function* () {
|
|
const ready = defer<void>()
|
|
const aborted = defer<void>()
|
|
const registry = yield* ToolRegistry.Service
|
|
const { task } = yield* registry.named()
|
|
const original = task.execute
|
|
task.execute = (_args, ctx) =>
|
|
Effect.callback<never>((_resume) => {
|
|
ready.resolve()
|
|
ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true })
|
|
return Effect.sync(() => aborted.resolve())
|
|
})
|
|
yield* Effect.addFinalizer(() => Effect.sync(() => void (task.execute = original)))
|
|
|
|
const { prompt, chat } = yield* boot()
|
|
const msg = yield* user(chat.id, "hello")
|
|
yield* addSubtask(chat.id, msg.id)
|
|
|
|
const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
|
|
yield* Effect.promise(() => ready.promise)
|
|
yield* prompt.cancel(chat.id)
|
|
yield* Effect.promise(() => aborted.promise)
|
|
|
|
const exit = yield* Fiber.await(fiber)
|
|
expect(Exit.isSuccess(exit)).toBe(true)
|
|
|
|
const msgs = yield* MessageV2.filterCompactedEffect(chat.id)
|
|
const taskMsg = msgs.find((item) => item.info.role === "assistant" && item.info.agent === "general")
|
|
expect(taskMsg?.info.role).toBe("assistant")
|
|
if (!taskMsg || taskMsg.info.role !== "assistant") return
|
|
|
|
const tool = toolPart(taskMsg.parts)
|
|
expect(tool?.type).toBe("tool")
|
|
if (!tool) return
|
|
|
|
expect(tool.state.status).not.toBe("running")
|
|
expect(taskMsg.info.time.completed).toBeDefined()
|
|
expect(taskMsg.info.finish).toBeDefined()
|
|
}),
|
|
{ git: true, config: cfg },
|
|
),
|
|
30_000,
|
|
)
|
|
|
|
it.live(
|
|
"cancel 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(
|
|
Effect.fnUntraced(function* ({ llm }) {
|
|
const prompt = yield* SessionPrompt.Service
|
|
const sessions = yield* Session.Service
|
|
const chat = yield* sessions.create({ title: "Pinned" })
|
|
|
|
yield* llm.fail("boom")
|
|
yield* user(chat.id, "hello")
|
|
|
|
const [a, b] = yield* Effect.all([prompt.loop({ sessionID: chat.id }), prompt.loop({ sessionID: chat.id })], {
|
|
concurrency: "unbounded",
|
|
})
|
|
expect(a.info.id).toBe(b.info.id)
|
|
expect(a.info.role).toBe("assistant")
|
|
}),
|
|
{ git: true, config: providerCfg },
|
|
),
|
|
3_000,
|
|
)
|
|
|
|
it.live(
|
|
"prompt submitted during an active run is included in the next LLM input",
|
|
() =>
|
|
provideTmpdirServer(
|
|
Effect.fnUntraced(function* ({ llm }) {
|
|
const gate = defer<void>()
|
|
const prompt = yield* SessionPrompt.Service
|
|
const sessions = yield* Session.Service
|
|
const chat = yield* sessions.create({ title: "Pinned" })
|
|
|
|
yield* llm.hold("first", gate.promise)
|
|
yield* llm.text("second")
|
|
|
|
const a = yield* prompt
|
|
.prompt({
|
|
sessionID: chat.id,
|
|
agent: "build",
|
|
model: ref,
|
|
parts: [{ type: "text", text: "first" }],
|
|
})
|
|
.pipe(Effect.forkChild)
|
|
|
|
yield* llm.wait(1)
|
|
|
|
const id = MessageID.ascending()
|
|
const b = yield* prompt
|
|
.prompt({
|
|
sessionID: chat.id,
|
|
messageID: id,
|
|
agent: "build",
|
|
model: ref,
|
|
parts: [{ type: "text", text: "second" }],
|
|
})
|
|
.pipe(Effect.forkChild)
|
|
|
|
yield* Effect.promise(async () => {
|
|
const end = Date.now() + 5000
|
|
while (Date.now() < end) {
|
|
const msgs = await Effect.runPromise(sessions.messages({ sessionID: chat.id }))
|
|
if (msgs.some((msg) => msg.info.role === "user" && msg.info.id === id)) return
|
|
await new Promise((done) => setTimeout(done, 20))
|
|
}
|
|
throw new Error("timed out waiting for second prompt to save")
|
|
})
|
|
|
|
gate.resolve()
|
|
|
|
const [ea, eb] = yield* Effect.all([Fiber.await(a), Fiber.await(b)])
|
|
expect(Exit.isSuccess(ea)).toBe(true)
|
|
expect(Exit.isSuccess(eb)).toBe(true)
|
|
expect(yield* llm.calls).toBe(2)
|
|
|
|
const msgs = yield* sessions.messages({ sessionID: chat.id })
|
|
const assistants = msgs.filter((msg) => msg.info.role === "assistant")
|
|
expect(assistants).toHaveLength(2)
|
|
const last = assistants.at(-1)
|
|
if (!last || last.info.role !== "assistant") throw new Error("expected second assistant")
|
|
expect(last.info.parentID).toBe(id)
|
|
expect(last.parts.some((part) => part.type === "text" && part.text === "second")).toBe(true)
|
|
|
|
const inputs = yield* llm.inputs
|
|
expect(inputs).toHaveLength(2)
|
|
expect(JSON.stringify(inputs.at(-1)?.messages)).toContain("second")
|
|
}),
|
|
{ git: true, config: providerCfg },
|
|
),
|
|
3_000,
|
|
)
|
|
|
|
it.live(
|
|
"assertNotBusy throws BusyError when loop running",
|
|
() =>
|
|
provideTmpdirServer(
|
|
Effect.fnUntraced(function* ({ llm }) {
|
|
const prompt = yield* SessionPrompt.Service
|
|
const run = yield* SessionRunState.Service
|
|
const sessions = yield* Session.Service
|
|
yield* llm.hang
|
|
|
|
const chat = yield* sessions.create({})
|
|
yield* user(chat.id, "hi")
|
|
|
|
const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
|
|
yield* llm.wait(1)
|
|
|
|
const exit = yield* run.assertNotBusy(chat.id).pipe(Effect.exit)
|
|
expect(Exit.isFailure(exit)).toBe(true)
|
|
if (Exit.isFailure(exit)) {
|
|
expect(Cause.squash(exit.cause)).toBeInstanceOf(Session.BusyError)
|
|
}
|
|
|
|
yield* prompt.cancel(chat.id)
|
|
yield* Fiber.await(fiber)
|
|
}),
|
|
{ git: true, config: providerCfg },
|
|
),
|
|
3_000,
|
|
)
|
|
|
|
it.live("assertNotBusy succeeds when idle", () =>
|
|
provideTmpdirInstance(
|
|
(_dir) =>
|
|
Effect.gen(function* () {
|
|
const run = yield* SessionRunState.Service
|
|
const sessions = yield* Session.Service
|
|
|
|
const chat = yield* sessions.create({})
|
|
const exit = yield* run.assertNotBusy(chat.id).pipe(Effect.exit)
|
|
expect(Exit.isSuccess(exit)).toBe(true)
|
|
}),
|
|
{ git: true },
|
|
),
|
|
)
|
|
|
|
// Shell semantics
|
|
|
|
it.live(
|
|
"shell rejects with BusyError when loop running",
|
|
() =>
|
|
provideTmpdirServer(
|
|
Effect.fnUntraced(function* ({ llm }) {
|
|
const prompt = yield* SessionPrompt.Service
|
|
const sessions = yield* Session.Service
|
|
const chat = yield* sessions.create({ title: "Pinned" })
|
|
yield* llm.hang
|
|
yield* user(chat.id, "hi")
|
|
|
|
const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
|
|
yield* llm.wait(1)
|
|
|
|
const exit = yield* prompt.shell({ sessionID: chat.id, agent: "build", command: "echo hi" }).pipe(Effect.exit)
|
|
expect(Exit.isFailure(exit)).toBe(true)
|
|
if (Exit.isFailure(exit)) {
|
|
expect(Cause.squash(exit.cause)).toBeInstanceOf(Session.BusyError)
|
|
}
|
|
|
|
yield* prompt.cancel(chat.id)
|
|
yield* Fiber.await(fiber)
|
|
}),
|
|
{ git: true, config: providerCfg },
|
|
),
|
|
3_000,
|
|
)
|
|
|
|
unix("shell captures stdout and stderr in completed tool output", () =>
|
|
provideTmpdirInstance(
|
|
(_dir) =>
|
|
Effect.gen(function* () {
|
|
const { prompt, run, chat } = yield* boot()
|
|
const result = yield* prompt.shell({
|
|
sessionID: chat.id,
|
|
agent: "build",
|
|
command: "printf out && printf err >&2",
|
|
})
|
|
|
|
expect(result.info.role).toBe("assistant")
|
|
const tool = completedTool(result.parts)
|
|
if (!tool) return
|
|
|
|
expect(tool.state.output).toContain("out")
|
|
expect(tool.state.output).toContain("err")
|
|
expect(tool.state.metadata.output).toContain("out")
|
|
expect(tool.state.metadata.output).toContain("err")
|
|
yield* run.assertNotBusy(chat.id)
|
|
}),
|
|
{ git: true, config: cfg },
|
|
),
|
|
)
|
|
|
|
unix("shell completes a fast command on the preferred shell", () =>
|
|
provideTmpdirInstance(
|
|
(dir) =>
|
|
Effect.gen(function* () {
|
|
const { prompt, run, chat } = yield* boot()
|
|
const result = yield* prompt.shell({
|
|
sessionID: chat.id,
|
|
agent: "build",
|
|
command: "pwd",
|
|
})
|
|
|
|
expect(result.info.role).toBe("assistant")
|
|
const tool = completedTool(result.parts)
|
|
if (!tool) return
|
|
|
|
expect(tool.state.input.command).toBe("pwd")
|
|
expect(tool.state.output).toContain(dir)
|
|
expect(tool.state.metadata.output).toContain(dir)
|
|
yield* run.assertNotBusy(chat.id)
|
|
}),
|
|
{ git: true, config: cfg },
|
|
),
|
|
)
|
|
|
|
unix(
|
|
"shell 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 chat = yield* sessions.create({
|
|
title: "Pinned",
|
|
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
|
})
|
|
yield* llm.text("after-shell")
|
|
|
|
const sh = yield* prompt
|
|
.shell({ sessionID: chat.id, agent: "build", command: "sleep 0.2" })
|
|
.pipe(Effect.forkChild)
|
|
yield* Effect.sleep(50)
|
|
|
|
const loop = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
|
|
yield* Effect.sleep(50)
|
|
|
|
expect(yield* llm.calls).toBe(0)
|
|
|
|
yield* Fiber.await(sh)
|
|
const exit = yield* Fiber.await(loop)
|
|
|
|
expect(Exit.isSuccess(exit)).toBe(true)
|
|
if (Exit.isSuccess(exit)) {
|
|
expect(exit.value.info.role).toBe("assistant")
|
|
expect(exit.value.parts.some((part) => part.type === "text" && part.text === "after-shell")).toBe(true)
|
|
}
|
|
expect(yield* llm.calls).toBe(1)
|
|
}),
|
|
{ git: true, config: providerCfg },
|
|
),
|
|
3_000,
|
|
)
|
|
|
|
it.live(
|
|
"shell completion resumes queued loop callers",
|
|
() =>
|
|
provideTmpdirServer(
|
|
Effect.fnUntraced(function* ({ llm }) {
|
|
const prompt = yield* SessionPrompt.Service
|
|
const sessions = yield* Session.Service
|
|
const chat = yield* sessions.create({
|
|
title: "Pinned",
|
|
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
|
})
|
|
yield* llm.text("done")
|
|
|
|
const sh = yield* prompt
|
|
.shell({ sessionID: chat.id, agent: "build", command: "sleep 0.2" })
|
|
.pipe(Effect.forkChild)
|
|
yield* Effect.sleep(50)
|
|
|
|
const a = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
|
|
const b = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
|
|
yield* Effect.sleep(50)
|
|
|
|
expect(yield* llm.calls).toBe(0)
|
|
|
|
yield* Fiber.await(sh)
|
|
const [ea, eb] = yield* Effect.all([Fiber.await(a), Fiber.await(b)])
|
|
|
|
expect(Exit.isSuccess(ea)).toBe(true)
|
|
expect(Exit.isSuccess(eb)).toBe(true)
|
|
if (Exit.isSuccess(ea) && Exit.isSuccess(eb)) {
|
|
expect(ea.value.info.id).toBe(eb.value.info.id)
|
|
expect(ea.value.info.role).toBe("assistant")
|
|
}
|
|
expect(yield* llm.calls).toBe(1)
|
|
}),
|
|
{ git: true, config: providerCfg },
|
|
),
|
|
3_000,
|
|
)
|
|
|
|
unix(
|
|
"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,
|
|
)
|
|
|
|
unix(
|
|
"cancel persists aborted shell result when shell ignores TERM",
|
|
() =>
|
|
withSh(() =>
|
|
provideTmpdirInstance(
|
|
(_dir) =>
|
|
Effect.gen(function* () {
|
|
const { prompt, chat } = yield* boot()
|
|
|
|
const sh = yield* prompt
|
|
.shell({ sessionID: chat.id, agent: "build", command: "trap '' TERM; sleep 30" })
|
|
.pipe(Effect.forkChild)
|
|
yield* Effect.sleep(50)
|
|
|
|
yield* prompt.cancel(chat.id)
|
|
|
|
const exit = yield* Fiber.await(sh)
|
|
expect(Exit.isSuccess(exit)).toBe(true)
|
|
if (Exit.isSuccess(exit)) {
|
|
expect(exit.value.info.role).toBe("assistant")
|
|
const tool = completedTool(exit.value.parts)
|
|
if (tool) {
|
|
expect(tool.state.output).toContain("User aborted the command")
|
|
}
|
|
}
|
|
}),
|
|
{ git: true, config: cfg },
|
|
),
|
|
),
|
|
30_000,
|
|
)
|
|
|
|
unix(
|
|
"cancel finalizes interrupted bash tool output through normal truncation",
|
|
() =>
|
|
provideTmpdirServer(
|
|
({ dir, llm }) =>
|
|
Effect.gen(function* () {
|
|
const prompt = yield* SessionPrompt.Service
|
|
const sessions = yield* Session.Service
|
|
const chat = yield* sessions.create({
|
|
title: "Interrupted bash truncation",
|
|
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
|
})
|
|
|
|
yield* prompt.prompt({
|
|
sessionID: chat.id,
|
|
agent: "build",
|
|
noReply: true,
|
|
parts: [{ type: "text", text: "run bash" }],
|
|
})
|
|
|
|
yield* llm.tool("bash", {
|
|
command:
|
|
'i=0; while [ "$i" -lt 4000 ]; do printf "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx %05d\\n" "$i"; i=$((i + 1)); done; sleep 30',
|
|
description: "Print many lines",
|
|
timeout: 30_000,
|
|
workdir: path.resolve(dir),
|
|
})
|
|
|
|
const run = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
|
|
yield* llm.wait(1)
|
|
yield* Effect.sleep(150)
|
|
yield* prompt.cancel(chat.id)
|
|
|
|
const exit = yield* Fiber.await(run)
|
|
expect(Exit.isSuccess(exit)).toBe(true)
|
|
if (Exit.isFailure(exit)) return
|
|
|
|
const tool = completedTool(exit.value.parts)
|
|
if (!tool) return
|
|
|
|
expect(tool.state.metadata.truncated).toBe(true)
|
|
expect(typeof tool.state.metadata.outputPath).toBe("string")
|
|
expect(tool.state.output).toMatch(/\.\.\.output truncated\.\.\./)
|
|
expect(tool.state.output).toMatch(/Full output saved to:\s+\S+/)
|
|
expect(tool.state.output).not.toContain("Tool execution aborted")
|
|
}),
|
|
{ git: true, config: providerCfg },
|
|
),
|
|
30_000,
|
|
)
|
|
|
|
unix(
|
|
"cancel interrupts loop queued behind shell",
|
|
() =>
|
|
provideTmpdirInstance(
|
|
(_dir) =>
|
|
Effect.gen(function* () {
|
|
const { prompt, chat } = yield* boot()
|
|
|
|
const sh = yield* prompt
|
|
.shell({ sessionID: chat.id, agent: "build", command: "sleep 30" })
|
|
.pipe(Effect.forkChild)
|
|
yield* Effect.sleep(50)
|
|
|
|
const loop = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
|
|
yield* Effect.sleep(50)
|
|
|
|
yield* prompt.cancel(chat.id)
|
|
|
|
const exit = yield* Fiber.await(loop)
|
|
expect(Exit.isSuccess(exit)).toBe(true)
|
|
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 },
|
|
),
|
|
)
|
|
|
|
it.live("resolves configured reference mentions before workspace paths and agents", () =>
|
|
provideTmpdirInstance(
|
|
(dir) =>
|
|
Effect.gen(function* () {
|
|
const docs = path.join(dir, "external-docs")
|
|
yield* Effect.promise(() => fs.mkdir(path.join(docs, "guide"), { recursive: true }))
|
|
yield* Effect.promise(() => fs.mkdir(path.join(dir, "docs"), { recursive: true }))
|
|
yield* Effect.promise(() => Bun.write(path.join(docs, "README.md"), "reference readme"))
|
|
yield* Effect.promise(() => Bun.write(path.join(docs, "guide", "intro.md"), "reference intro"))
|
|
yield* Effect.promise(() => Bun.write(path.join(dir, "docs", "README.md"), "workspace readme"))
|
|
|
|
const prompt = yield* SessionPrompt.Service
|
|
const parts = yield* prompt.resolvePromptParts(
|
|
"Use @docs and @docs/README.md and @docs/guide and @docs/missing.md and @docs/README.md and @build",
|
|
)
|
|
const references = parts.filter(
|
|
(part): part is MessageV2.TextPartInput =>
|
|
part.type === "text" && part.synthetic === true && part.text.startsWith("Referenced configured reference "),
|
|
)
|
|
const files = parts.filter((part): part is MessageV2.FilePartInput => part.type === "file")
|
|
const agents = parts.filter((part): part is MessageV2.AgentPartInput => part.type === "agent")
|
|
const bare = references.find((part) => part.text.includes("@docs."))
|
|
const missing = references.find((part) => part.text.includes("@docs/missing.md"))
|
|
const guide = files.find((part) => part.filename === "docs/guide")
|
|
|
|
expect(references.length).toBe(2)
|
|
expect(bare?.metadata?.reference).toMatchObject({
|
|
name: "docs",
|
|
kind: "local",
|
|
path: docs,
|
|
})
|
|
expect(missing?.text).toContain("Path does not exist inside configured reference @docs")
|
|
expect(missing?.metadata?.reference).toMatchObject({
|
|
target: "missing.md",
|
|
targetPath: path.join(docs, "missing.md"),
|
|
})
|
|
|
|
expect(files.length).toBe(2)
|
|
expect(files.map((file) => fileURLToPath(file.url)).sort()).toEqual(
|
|
[path.join(docs, "README.md"), path.join(docs, "guide")].sort(),
|
|
)
|
|
expect(guide?.mime).toBe("application/x-directory")
|
|
expect(agents.map((agent) => agent.name)).toEqual(["build"])
|
|
}),
|
|
{
|
|
git: true,
|
|
config: {
|
|
...cfg,
|
|
reference: {
|
|
docs: "./external-docs",
|
|
},
|
|
},
|
|
},
|
|
),
|
|
)
|
|
|
|
it.live("injects metadata for bare configured reference mentions", () =>
|
|
provideTmpdirInstance(
|
|
(dir) =>
|
|
Effect.gen(function* () {
|
|
const docs = path.join(dir, "external-docs")
|
|
yield* Effect.promise(() => fs.mkdir(docs, { recursive: true }))
|
|
|
|
const prompt = yield* SessionPrompt.Service
|
|
const sessions = yield* Session.Service
|
|
const session = yield* sessions.create({})
|
|
const message = yield* prompt.prompt({
|
|
sessionID: session.id,
|
|
noReply: true,
|
|
parts: yield* prompt.resolvePromptParts("Use @docs for context"),
|
|
})
|
|
|
|
const stored = MessageV2.get({ sessionID: session.id, messageID: message.info.id })
|
|
const synthetic = stored.parts.filter(
|
|
(part): part is MessageV2.TextPart => part.type === "text" && part.synthetic === true,
|
|
)
|
|
const reference = synthetic.find((part) => part.text.startsWith("Referenced configured reference @docs."))
|
|
|
|
expect(reference?.metadata?.reference).toMatchObject({ name: "docs", kind: "local", path: docs })
|
|
expect(synthetic.some((part) => part.text.includes(`Reference root: ${docs}`))).toBe(true)
|
|
expect(synthetic.some((part) => part.text.includes("subagent scout"))).toBe(true)
|
|
|
|
yield* sessions.remove(session.id)
|
|
}),
|
|
{
|
|
git: true,
|
|
config: {
|
|
...cfg,
|
|
reference: {
|
|
docs: "./external-docs",
|
|
},
|
|
},
|
|
},
|
|
),
|
|
)
|
|
|
|
it.live("injects metadata for configured reference file attachments", () =>
|
|
provideTmpdirInstance(
|
|
(dir) =>
|
|
Effect.gen(function* () {
|
|
const docs = path.join(dir, "external-docs")
|
|
const readme = path.join(docs, "README.md")
|
|
yield* Effect.promise(() => fs.mkdir(docs, { recursive: true }))
|
|
yield* Effect.promise(() => Bun.write(readme, "reference readme"))
|
|
|
|
const prompt = yield* SessionPrompt.Service
|
|
const sessions = yield* Session.Service
|
|
const session = yield* sessions.create({})
|
|
const message = yield* prompt.prompt({
|
|
sessionID: session.id,
|
|
agent: "build",
|
|
noReply: true,
|
|
parts: [
|
|
{ type: "text", text: "Read @docs/README.md" },
|
|
{
|
|
type: "file",
|
|
mime: "text/plain",
|
|
filename: "docs/README.md",
|
|
url: pathToFileURL(readme).href,
|
|
source: {
|
|
type: "file",
|
|
path: "docs/README.md",
|
|
text: { value: "@docs/README.md", start: 5, end: 20 },
|
|
},
|
|
},
|
|
],
|
|
})
|
|
|
|
const stored = MessageV2.get({ sessionID: session.id, messageID: message.info.id })
|
|
const synthetic = stored.parts.filter(
|
|
(part): part is MessageV2.TextPart => part.type === "text" && part.synthetic === true,
|
|
)
|
|
const reference = synthetic.find((part) => part.text.startsWith("Referenced configured reference @docs/README.md."))
|
|
|
|
expect(reference?.metadata?.reference).toMatchObject({
|
|
name: "docs",
|
|
kind: "local",
|
|
path: docs,
|
|
target: "README.md",
|
|
targetPath: readme,
|
|
source: { value: "@docs/README.md", start: 5, end: 20 },
|
|
})
|
|
expect(synthetic.findIndex((part) => part === reference)).toBeLessThan(
|
|
synthetic.findIndex((part) => part.text.startsWith("Called the Read tool with the following input:")),
|
|
)
|
|
|
|
yield* sessions.remove(session.id)
|
|
}),
|
|
{
|
|
git: true,
|
|
config: {
|
|
...cfg,
|
|
reference: {
|
|
docs: "./external-docs",
|
|
},
|
|
},
|
|
},
|
|
),
|
|
)
|
|
|
|
// 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",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
),
|
|
)
|
|
|
|
// 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("build")
|
|
}
|
|
}
|
|
}),
|
|
{ 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,
|
|
)
|