| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140 |
- import { NodeFileSystem } from "@effect/platform-node"
- import { expect } from "bun:test"
- import { Cause, Effect, Exit, Fiber, Layer, ServiceMap } from "effect"
- import * as Stream from "effect/Stream"
- import type { Agent } from "../../src/agent/agent"
- import { Agent as AgentSvc } from "../../src/agent/agent"
- import { Bus } from "../../src/bus"
- import { Command } from "../../src/command"
- import { Config } from "../../src/config/config"
- import { FileTime } from "../../src/file/time"
- import { LSP } from "../../src/lsp"
- import { MCP } from "../../src/mcp"
- import { Permission } from "../../src/permission"
- import { Plugin } from "../../src/plugin"
- import type { Provider } from "../../src/provider/provider"
- import { ModelID, ProviderID } from "../../src/provider/schema"
- import { Session } from "../../src/session"
- import { LLM } from "../../src/session/llm"
- import { MessageV2 } from "../../src/session/message-v2"
- import { AppFileSystem } from "../../src/filesystem"
- import { SessionCompaction } from "../../src/session/compaction"
- import { SessionProcessor } from "../../src/session/processor"
- import { SessionPrompt } from "../../src/session/prompt"
- import { MessageID, PartID, SessionID } from "../../src/session/schema"
- import { SessionStatus } from "../../src/session/status"
- import { Shell } from "../../src/shell/shell"
- import { Snapshot } from "../../src/snapshot"
- import { ToolRegistry } from "../../src/tool/registry"
- import { Truncate } from "../../src/tool/truncate"
- import { Log } from "../../src/util/log"
- import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
- import { provideTmpdirInstance } from "../fixture/fixture"
- import { testEffect } from "../lib/effect"
- Log.init({ print: false })
- const ref = {
- providerID: ProviderID.make("test"),
- modelID: ModelID.make("test-model"),
- }
- type Script = Stream.Stream<LLM.Event, unknown> | ((input: LLM.StreamInput) => Stream.Stream<LLM.Event, unknown>)
- class TestLLM extends ServiceMap.Service<
- TestLLM,
- {
- readonly push: (stream: Script) => Effect.Effect<void>
- readonly reply: (...items: LLM.Event[]) => Effect.Effect<void>
- readonly calls: Effect.Effect<number>
- readonly inputs: Effect.Effect<LLM.StreamInput[]>
- }
- >()("@test/PromptLLM") {}
- function stream(...items: LLM.Event[]) {
- return Stream.make(...items)
- }
- function usage(input = 1, output = 1, total = input + output) {
- return {
- inputTokens: input,
- outputTokens: output,
- totalTokens: total,
- inputTokenDetails: {
- noCacheTokens: undefined,
- cacheReadTokens: undefined,
- cacheWriteTokens: undefined,
- },
- outputTokenDetails: {
- textTokens: undefined,
- reasoningTokens: undefined,
- },
- }
- }
- function start(): LLM.Event {
- return { type: "start" }
- }
- function textStart(id = "t"): LLM.Event {
- return { type: "text-start", id }
- }
- function textDelta(id: string, text: string): LLM.Event {
- return { type: "text-delta", id, text }
- }
- function textEnd(id = "t"): LLM.Event {
- return { type: "text-end", id }
- }
- function finishStep(): LLM.Event {
- return {
- type: "finish-step",
- finishReason: "stop",
- rawFinishReason: "stop",
- response: { id: "res", modelId: "test-model", timestamp: new Date() },
- providerMetadata: undefined,
- usage: usage(),
- }
- }
- function finish(): LLM.Event {
- return { type: "finish", finishReason: "stop", rawFinishReason: "stop", totalUsage: usage() }
- }
- function finishToolCallsStep(): LLM.Event {
- return {
- type: "finish-step",
- finishReason: "tool-calls",
- rawFinishReason: "tool_calls",
- response: { id: "res", modelId: "test-model", timestamp: new Date() },
- providerMetadata: undefined,
- usage: usage(),
- }
- }
- function finishToolCalls(): LLM.Event {
- return { type: "finish", finishReason: "tool-calls", rawFinishReason: "tool_calls", totalUsage: usage() }
- }
- function replyStop(text: string, id = "t") {
- return [start(), textStart(id), textDelta(id, text), textEnd(id), finishStep(), finish()] as const
- }
- function replyToolCalls(text: string, id = "t") {
- return [start(), textStart(id), textDelta(id, text), textEnd(id), finishToolCallsStep(), finishToolCalls()] as const
- }
- function toolInputStart(id: string, toolName: string): LLM.Event {
- return { type: "tool-input-start", id, toolName }
- }
- function toolCall(toolCallId: string, toolName: string, input: unknown): LLM.Event {
- return { type: "tool-call", toolCallId, toolName, input }
- }
- function hang(_input: LLM.StreamInput, ...items: LLM.Event[]) {
- return stream(...items).pipe(Stream.concat(Stream.fromEffect(Effect.never)))
- }
- function defer<T>() {
- let resolve!: (value: T | PromiseLike<T>) => void
- const promise = new Promise<T>((done) => {
- resolve = done
- })
- return { promise, resolve }
- }
- function waitMs(ms: number) {
- return Effect.promise(() => new Promise<void>((done) => setTimeout(done, ms)))
- }
- 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 llm = Layer.unwrap(
- Effect.gen(function* () {
- const queue: Script[] = []
- const inputs: LLM.StreamInput[] = []
- let calls = 0
- const push = Effect.fn("TestLLM.push")((item: Script) => {
- queue.push(item)
- return Effect.void
- })
- const reply = Effect.fn("TestLLM.reply")((...items: LLM.Event[]) => push(stream(...items)))
- return Layer.mergeAll(
- Layer.succeed(
- LLM.Service,
- LLM.Service.of({
- stream: (input) => {
- calls += 1
- inputs.push(input)
- const item = queue.shift() ?? Stream.empty
- return typeof item === "function" ? item(input) : item
- },
- }),
- ),
- Layer.succeed(
- TestLLM,
- TestLLM.of({
- push,
- reply,
- calls: Effect.sync(() => calls),
- inputs: Effect.sync(() => [...inputs]),
- }),
- ),
- )
- }),
- )
- 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 filetime = Layer.succeed(
- FileTime.Service,
- FileTime.Service.of({
- read: () => Effect.void,
- get: () => Effect.succeed(undefined),
- assert: () => Effect.void,
- withLock: (_filepath, fn) => Effect.promise(fn),
- }),
- )
- const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer))
- const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer)
- const deps = Layer.mergeAll(
- Session.defaultLayer,
- Snapshot.defaultLayer,
- AgentSvc.defaultLayer,
- Command.defaultLayer,
- Permission.layer,
- Plugin.defaultLayer,
- Config.defaultLayer,
- filetime,
- lsp,
- mcp,
- AppFileSystem.defaultLayer,
- status,
- llm,
- ).pipe(Layer.provideMerge(infra))
- const registry = ToolRegistry.layer.pipe(Layer.provideMerge(deps))
- const trunc = Truncate.layer.pipe(Layer.provideMerge(deps))
- const proc = SessionProcessor.layer.pipe(Layer.provideMerge(deps))
- const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps))
- const env = SessionPrompt.layer.pipe(
- Layer.provideMerge(compact),
- Layer.provideMerge(proc),
- Layer.provideMerge(registry),
- Layer.provideMerge(trunc),
- Layer.provideMerge(deps),
- )
- const it = testEffect(env)
- const unix = process.platform !== "win32" ? it.effect : it.effect.skip
- // Config that registers a custom "test" provider with a "test-model" model
- // so Provider.getModel("test", "test-model") 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",
- },
- },
- },
- }
- 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 test = yield* TestLLM
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create(input ?? { title: "Pinned" })
- return { test, prompt, sessions, chat }
- })
- // Loop semantics
- it.effect("loop exits immediately when last assistant has stop finish", () =>
- provideTmpdirInstance(
- (dir) =>
- Effect.gen(function* () {
- const { test, prompt, chat } = yield* boot()
- 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* test.calls).toBe(0)
- }),
- { git: true },
- ),
- )
- it.effect("loop calls LLM and returns assistant message", () =>
- provideTmpdirInstance(
- (dir) =>
- Effect.gen(function* () {
- const { test, prompt, chat } = yield* boot()
- yield* test.reply(...replyStop("world"))
- yield* user(chat.id, "hello")
- 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* test.calls).toBe(1)
- }),
- { git: true, config: cfg },
- ),
- )
- it.effect("loop continues when finish is tool-calls", () =>
- provideTmpdirInstance(
- (dir) =>
- Effect.gen(function* () {
- const { test, prompt, chat } = yield* boot()
- yield* test.reply(...replyToolCalls("first"))
- yield* test.reply(...replyStop("second"))
- yield* user(chat.id, "hello")
- const result = yield* prompt.loop({ sessionID: chat.id })
- expect(yield* test.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: cfg },
- ),
- )
- it.effect("failed subtask preserves metadata on error tool state", () =>
- provideTmpdirInstance(
- (dir) =>
- Effect.gen(function* () {
- const { test, prompt, chat } = yield* boot({ title: "Pinned" })
- yield* test.reply(
- start(),
- toolInputStart("task-1", "task"),
- toolCall("task-1", "task", {
- description: "inspect bug",
- prompt: "look into the cache key path",
- subagent_type: "general",
- }),
- {
- type: "finish-step",
- finishReason: "tool-calls",
- rawFinishReason: "tool_calls",
- response: { id: "res", modelId: "test-model", timestamp: new Date() },
- providerMetadata: undefined,
- usage: usage(),
- },
- { type: "finish", finishReason: "tool-calls", rawFinishReason: "tool_calls", totalUsage: usage() },
- )
- yield* test.reply(...replyStop("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* test.calls).toBe(2)
- const msgs = yield* Effect.promise(() => MessageV2.filterCompacted(MessageV2.stream(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: {
- ...cfg,
- agent: {
- general: {
- model: "test/missing-model",
- },
- },
- },
- },
- ),
- )
- it.effect("loop sets status to busy then idle", () =>
- provideTmpdirInstance(
- (dir) =>
- Effect.gen(function* () {
- const test = yield* TestLLM
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const bus = yield* Bus.Service
- yield* test.reply(start(), textStart(), textDelta("t", "ok"), textEnd(), finishStep(), finish())
- const chat = yield* sessions.create({})
- yield* user(chat.id, "hi")
- const types: string[] = []
- const idle = defer<void>()
- const off = yield* bus.subscribeCallback(SessionStatus.Event.Status, (evt) => {
- if (evt.properties.sessionID !== chat.id) return
- types.push(evt.properties.status.type)
- if (evt.properties.status.type === "idle") idle.resolve()
- })
- yield* prompt.loop({ sessionID: chat.id })
- yield* Effect.promise(() => idle.promise)
- off()
- expect(types).toContain("busy")
- expect(types[types.length - 1]).toBe("idle")
- }),
- { git: true, config: cfg },
- ),
- )
- // Cancel semantics
- it.effect(
- "cancel interrupts loop and resolves with an assistant message",
- () =>
- provideTmpdirInstance(
- (dir) =>
- Effect.gen(function* () {
- const { test, prompt, chat } = yield* boot()
- yield* seed(chat.id)
- // Make LLM hang so the loop blocks
- yield* test.push((input) => hang(input, start()))
- // Seed a new user message so the loop enters the LLM path
- yield* user(chat.id, "more")
- const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- // Give the loop time to start
- yield* waitMs(200)
- 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: cfg },
- ),
- 30_000,
- )
- it.effect(
- "cancel records MessageAbortedError on interrupted process",
- () =>
- provideTmpdirInstance(
- (dir) =>
- Effect.gen(function* () {
- const ready = defer<void>()
- const { test, prompt, chat } = yield* boot()
- yield* test.push((input) =>
- hang(input, start()).pipe(
- Stream.tap((event) => (event.type === "start" ? Effect.sync(() => ready.resolve()) : Effect.void)),
- ),
- )
- yield* user(chat.id, "hello")
- const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- yield* Effect.promise(() => ready.promise)
- 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: cfg },
- ),
- 30_000,
- )
- it.effect(
- "cancel with queued callers resolves all cleanly",
- () =>
- provideTmpdirInstance(
- (dir) =>
- Effect.gen(function* () {
- const ready = defer<void>()
- const { test, prompt, chat } = yield* boot()
- yield* test.push((input) =>
- hang(input, start()).pipe(
- Stream.tap((event) => (event.type === "start" ? Effect.sync(() => ready.resolve()) : Effect.void)),
- ),
- )
- yield* user(chat.id, "hello")
- const a = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- yield* Effect.promise(() => ready.promise)
- // Queue a second caller
- const b = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- yield* waitMs(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: cfg },
- ),
- 30_000,
- )
- // Queue semantics
- it.effect("concurrent loop callers get same result", () =>
- provideTmpdirInstance(
- (dir) =>
- Effect.gen(function* () {
- const { prompt, 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* prompt.assertNotBusy(chat.id)
- }),
- { git: true },
- ),
- )
- it.effect("concurrent loop callers all receive same error result", () =>
- provideTmpdirInstance(
- (dir) =>
- Effect.gen(function* () {
- const { test, prompt, chat } = yield* boot()
- // Push a stream that fails — the loop records the error on the assistant message
- yield* test.push(Stream.fail(new Error("boom")))
- yield* user(chat.id, "hello")
- const [a, b] = yield* Effect.all([prompt.loop({ sessionID: chat.id }), prompt.loop({ sessionID: chat.id })], {
- concurrency: "unbounded",
- })
- // Both callers get the same assistant with an error recorded
- expect(a.info.id).toBe(b.info.id)
- expect(a.info.role).toBe("assistant")
- if (a.info.role === "assistant") {
- expect(a.info.error).toBeDefined()
- }
- if (b.info.role === "assistant") {
- expect(b.info.error).toBeDefined()
- }
- }),
- { git: true, config: cfg },
- ),
- )
- it.effect(
- "prompt submitted during an active run is included in the next LLM input",
- () =>
- provideTmpdirInstance(
- (dir) =>
- Effect.gen(function* () {
- const ready = defer<void>()
- const gate = defer<void>()
- const { test, prompt, sessions, chat } = yield* boot()
- yield* test.push((_input) =>
- stream(start()).pipe(
- Stream.tap((event) => (event.type === "start" ? Effect.sync(() => ready.resolve()) : Effect.void)),
- Stream.concat(
- Stream.fromEffect(Effect.promise(() => gate.promise)).pipe(
- Stream.flatMap(() =>
- stream(textStart("a"), textDelta("a", "first"), textEnd("a"), finishStep(), finish()),
- ),
- ),
- ),
- ),
- )
- const a = yield* prompt
- .prompt({
- sessionID: chat.id,
- agent: "build",
- model: ref,
- parts: [{ type: "text", text: "first" }],
- })
- .pipe(Effect.forkChild)
- yield* Effect.promise(() => ready.promise)
- 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")
- })
- yield* test.reply(...replyStop("second"))
- 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* test.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* test.inputs
- expect(inputs).toHaveLength(2)
- expect(JSON.stringify(inputs.at(-1)?.messages)).toContain("second")
- }),
- { git: true, config: cfg },
- ),
- 30_000,
- )
- it.effect(
- "assertNotBusy throws BusyError when loop running",
- () =>
- provideTmpdirInstance(
- (dir) =>
- Effect.gen(function* () {
- const ready = defer<void>()
- const test = yield* TestLLM
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- yield* test.push((input) =>
- hang(input, start()).pipe(
- Stream.tap((event) => (event.type === "start" ? Effect.sync(() => ready.resolve()) : Effect.void)),
- ),
- )
- const chat = yield* sessions.create({})
- yield* user(chat.id, "hi")
- const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- yield* Effect.promise(() => ready.promise)
- const exit = yield* prompt.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: cfg },
- ),
- 30_000,
- )
- it.effect("assertNotBusy succeeds when idle", () =>
- provideTmpdirInstance(
- (dir) =>
- Effect.gen(function* () {
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({})
- const exit = yield* prompt.assertNotBusy(chat.id).pipe(Effect.exit)
- expect(Exit.isSuccess(exit)).toBe(true)
- }),
- { git: true },
- ),
- )
- // Shell semantics
- it.effect(
- "shell rejects with BusyError when loop running",
- () =>
- provideTmpdirInstance(
- (dir) =>
- Effect.gen(function* () {
- const ready = defer<void>()
- const { test, prompt, chat } = yield* boot()
- yield* test.push((input) =>
- hang(input, start()).pipe(
- Stream.tap((event) => (event.type === "start" ? Effect.sync(() => ready.resolve()) : Effect.void)),
- ),
- )
- yield* user(chat.id, "hi")
- const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- yield* Effect.promise(() => ready.promise)
- 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: cfg },
- ),
- 30_000,
- )
- unix("shell captures stdout and stderr in completed tool output", () =>
- provideTmpdirInstance(
- (dir) =>
- Effect.gen(function* () {
- const { prompt, 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* prompt.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,
- )
- unix(
- "loop waits while shell runs and starts after shell exits",
- () =>
- provideTmpdirInstance(
- (dir) =>
- Effect.gen(function* () {
- const { test, prompt, chat } = yield* boot()
- yield* test.reply(...replyStop("after-shell"))
- const sh = yield* prompt
- .shell({ sessionID: chat.id, agent: "build", command: "sleep 0.2" })
- .pipe(Effect.forkChild)
- yield* waitMs(50)
- const run = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- yield* waitMs(50)
- expect(yield* test.calls).toBe(0)
- yield* Fiber.await(sh)
- const exit = yield* Fiber.await(run)
- 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* test.calls).toBe(1)
- }),
- { git: true, config: cfg },
- ),
- 30_000,
- )
- unix(
- "shell completion resumes queued loop callers",
- () =>
- provideTmpdirInstance(
- (dir) =>
- Effect.gen(function* () {
- const { test, prompt, chat } = yield* boot()
- yield* test.reply(...replyStop("done"))
- const sh = yield* prompt
- .shell({ sessionID: chat.id, agent: "build", command: "sleep 0.2" })
- .pipe(Effect.forkChild)
- yield* waitMs(50)
- const a = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- const b = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- yield* waitMs(50)
- expect(yield* test.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* test.calls).toBe(1)
- }),
- { git: true, config: cfg },
- ),
- 30_000,
- )
- unix(
- "cancel interrupts shell and resolves cleanly",
- () =>
- withSh(() =>
- 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* waitMs(50)
- yield* prompt.cancel(chat.id)
- const status = yield* SessionStatus.Service
- expect((yield* status.get(chat.id)).type).toBe("idle")
- const busy = yield* prompt.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* waitMs(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 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* waitMs(50)
- const run = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- yield* waitMs(50)
- yield* prompt.cancel(chat.id)
- const exit = yield* Fiber.await(run)
- expect(Exit.isSuccess(exit)).toBe(true)
- yield* Fiber.await(sh)
- }),
- { git: true, config: cfg },
- ),
- 30_000,
- )
- unix(
- "shell rejects when another shell is already running",
- () =>
- withSh(() =>
- provideTmpdirInstance(
- (dir) =>
- Effect.gen(function* () {
- const { prompt, chat } = yield* boot()
- const a = yield* prompt
- .shell({ sessionID: chat.id, agent: "build", command: "sleep 30" })
- .pipe(Effect.forkChild)
- yield* waitMs(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,
- )
|