test: extend mock llm server coverage

Add fixture support for tmpdir-backed mock server tests, extend the mock LLM server DSL for failure and hanging cases, and migrate the next prompt tests to the HTTP-backed path.
pull/20304/head
Kit Langton 2026-03-31 12:12:01 -04:00
parent 123123b6c3
commit 21ec3207e7
4 changed files with 215 additions and 164 deletions

View File

@ -3,9 +3,12 @@ import * as fs from "fs/promises"
import os from "os"
import path from "path"
import { Effect, FileSystem, ServiceMap } from "effect"
import type * as PlatformError from "effect/PlatformError"
import type * as Scope from "effect/Scope"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import type { Config } from "../../src/config/config"
import { Instance } from "../../src/project/instance"
import { TestLLMServer } from "../lib/llm-server"
// Strip null bytes from paths (defensive fix for CI environment issues)
function sanitizePath(p: string): string {
@ -139,3 +142,20 @@ export function provideTmpdirInstance<A, E, R>(
return yield* self(path).pipe(provideInstance(path))
})
}
export function provideTmpdirServer<A, E, R>(
self: (input: { dir: string; llm: TestLLMServer["Service"] }) => Effect.Effect<A, E, R>,
options?: { git?: boolean; config?: (url: string) => Partial<Config.Info> },
): Effect.Effect<
A,
E | PlatformError.PlatformError,
R | TestLLMServer | FileSystem.FileSystem | ChildProcessSpawner.ChildProcessSpawner | Scope.Scope
> {
return Effect.gen(function* () {
const llm = yield* TestLLMServer
return yield* provideTmpdirInstance((dir) => self({ dir, llm }), {
git: options?.git,
config: options?.config?.(llm.url),
})
})
}

View File

@ -14,6 +14,13 @@ type Step =
tool: string
input: unknown
}
| {
type: "fail"
message: string
}
| {
type: "hang"
}
type Hit = {
url: URL
@ -105,16 +112,34 @@ function tool(step: Extract<Step, { type: "tool" }>, seq: number) {
])
}
export class TestLLMServer extends ServiceMap.Service<
TestLLMServer,
{
function fail(step: Extract<Step, { type: "fail" }>) {
return HttpServerResponse.text(step.message, { status: 500 })
}
function hang() {
return HttpServerResponse.stream(
Stream.fromIterable([
'data: {"id":"chatcmpl-test","object":"chat.completion.chunk","choices":[{"delta":{"role":"assistant"}}]}\n\n',
]).pipe(Stream.encodeText, Stream.concat(Stream.never)),
{ contentType: "text/event-stream" },
)
}
namespace TestLLMServer {
export interface Service {
readonly url: string
readonly text: (value: string) => Effect.Effect<void>
readonly tool: (tool: string, input: unknown) => Effect.Effect<void>
readonly fail: (message?: string) => Effect.Effect<void>
readonly hang: Effect.Effect<void>
readonly hits: Effect.Effect<Hit[]>
readonly calls: Effect.Effect<number>
readonly inputs: Effect.Effect<Record<string, unknown>[]>
readonly pending: Effect.Effect<number>
}
>()("@test/LLMServer") {
}
export class TestLLMServer extends ServiceMap.Service<TestLLMServer, TestLLMServer.Service>()("@test/LLMServer") {
static readonly layer = Layer.effect(
TestLLMServer,
Effect.gen(function* () {
@ -153,7 +178,9 @@ export class TestLLMServer extends ServiceMap.Service<
},
]
if (next.step.type === "text") return text(next.step)
return tool(next.step, next.seq)
if (next.step.type === "tool") return tool(next.step, next.seq)
if (next.step.type === "fail") return fail(next.step)
return hang()
}),
)
@ -170,7 +197,15 @@ export class TestLLMServer extends ServiceMap.Service<
tool: Effect.fn("TestLLMServer.tool")(function* (tool: string, input: unknown) {
push({ type: "tool", tool, input })
}),
fail: Effect.fn("TestLLMServer.fail")(function* (message = "boom") {
push({ type: "fail", message })
}),
hang: Effect.gen(function* () {
push({ type: "hang" })
}).pipe(Effect.withSpan("TestLLMServer.hang")),
hits: Effect.sync(() => [...hits]),
calls: Effect.sync(() => hits.length),
inputs: Effect.sync(() => hits.map((hit) => hit.body)),
pending: Effect.sync(() => list.length),
})
}),

View File

@ -31,7 +31,7 @@ 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 { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { TestLLMServer } from "../lib/llm-server"
@ -451,36 +451,32 @@ it.live("loop exits immediately when last assistant has stop finish", () =>
)
http.live("loop calls LLM and returns assistant message", () =>
Effect.gen(function* () {
const llm = yield* TestLLMServer
return yield* provideTmpdirInstance(
() =>
Effect.gen(function* () {
const chat = yield* Effect.promise(() =>
Session.create({
title: "Pinned",
permission: [{ permission: "*", pattern: "*", action: "allow" }],
}),
)
yield* Effect.promise(() =>
SessionPrompt.prompt({
sessionID: chat.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello" }],
}),
)
yield* llm.text("world")
const result = yield* Effect.promise(() => SessionPrompt.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)
provideTmpdirServer(
Effect.fnUntraced(function* ({ llm }: { dir: string; llm: TestLLMServer["Service"] }) {
const chat = yield* Effect.promise(() =>
Session.create({
title: "Pinned",
permission: [{ permission: "*", pattern: "*", action: "allow" }],
}),
{ git: true, config: providerCfg(llm.url) },
)
}),
)
yield* Effect.promise(() =>
SessionPrompt.prompt({
sessionID: chat.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello" }],
}),
)
yield* llm.text("world")
const result = yield* Effect.promise(() => SessionPrompt.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("loop continues when finish is tool-calls", () =>
@ -1039,74 +1035,82 @@ unix(
30_000,
)
unix(
http.live(
"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"))
provideTmpdirServer(
Effect.fnUntraced(function* ({ llm }: { dir: string; llm: TestLLMServer["Service"] }) {
const chat = yield* Effect.promise(() =>
Session.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* waitMs(50)
const sh = yield* Effect.promise(() =>
SessionPrompt.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)
const run = yield* Effect.promise(() => SessionPrompt.loop({ sessionID: chat.id })).pipe(Effect.forkChild)
yield* waitMs(50)
expect(yield* test.calls).toBe(0)
expect(yield* llm.calls).toBe(0)
yield* Fiber.await(sh)
const exit = yield* Fiber.await(run)
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 },
expect(Exit.isSuccess(exit)).toBe(true)
if (Exit.isSuccess(exit)) {
expect(exit.value.info.role).toBe("assistant")
expect(exit.value.parts.some((part) => part.type === "text" && part.text === "after-shell")).toBe(true)
}
expect(yield* llm.calls).toBe(1)
}),
{ git: true, config: providerCfg },
),
30_000,
5_000,
)
unix(
http.live(
"shell completion resumes queued loop callers",
() =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const { test, prompt, chat } = yield* boot()
yield* test.reply(...replyStop("done"))
provideTmpdirServer(
Effect.fnUntraced(function* ({ llm }: { dir: string; llm: TestLLMServer["Service"] }) {
const chat = yield* Effect.promise(() =>
Session.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* waitMs(50)
const sh = yield* Effect.promise(() =>
SessionPrompt.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)
const a = yield* Effect.promise(() => SessionPrompt.loop({ sessionID: chat.id })).pipe(Effect.forkChild)
const b = yield* Effect.promise(() => SessionPrompt.loop({ sessionID: chat.id })).pipe(Effect.forkChild)
yield* waitMs(50)
expect(yield* test.calls).toBe(0)
expect(yield* llm.calls).toBe(0)
yield* Fiber.await(sh)
const [ea, eb] = yield* Effect.all([Fiber.await(a), Fiber.await(b)])
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 },
expect(Exit.isSuccess(ea)).toBe(true)
expect(Exit.isSuccess(eb)).toBe(true)
if (Exit.isSuccess(ea) && Exit.isSuccess(eb)) {
expect(ea.value.info.id).toBe(eb.value.info.id)
expect(ea.value.info.role).toBe("assistant")
}
expect(yield* llm.calls).toBe(1)
}),
{ git: true, config: providerCfg },
),
30_000,
5_000,
)
unix(

View File

@ -6,7 +6,7 @@ import { Session } from "../../src/session"
import { SessionPrompt } from "../../src/session/prompt"
import { Log } from "../../src/util/log"
import { testEffect } from "../lib/effect"
import { provideTmpdirInstance } from "../fixture/fixture"
import { provideTmpdirServer } from "../fixture/fixture"
import { TestLLMServer } from "../lib/llm-server"
import { Layer } from "effect"
@ -53,88 +53,80 @@ function makeConfig(url: string) {
describe("session.prompt provider integration", () => {
it.live("loop returns assistant text through local provider", () =>
Effect.gen(function* () {
const llm = yield* TestLLMServer
return yield* provideTmpdirInstance(
() =>
Effect.gen(function* () {
const session = yield* Effect.promise(() =>
Session.create({
title: "Prompt provider",
permission: [{ permission: "*", pattern: "*", action: "allow" }],
}),
)
yield* Effect.promise(() =>
SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello" }],
}),
)
yield* llm.text("world")
const result = yield* Effect.promise(() => SessionPrompt.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)
provideTmpdirServer(
Effect.fnUntraced(function* ({ llm }: { dir: string; llm: TestLLMServer["Service"] }) {
const session = yield* Effect.promise(() =>
Session.create({
title: "Prompt provider",
permission: [{ permission: "*", pattern: "*", action: "allow" }],
}),
{ git: true, config: makeConfig(llm.url) },
)
}),
)
yield* Effect.promise(() =>
SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello" }],
}),
)
yield* llm.text("world")
const result = yield* Effect.promise(() => SessionPrompt.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: makeConfig },
),
)
it.live("loop consumes queued replies across turns", () =>
Effect.gen(function* () {
const llm = yield* TestLLMServer
return yield* provideTmpdirInstance(
() =>
Effect.gen(function* () {
const session = yield* Effect.promise(() =>
Session.create({
title: "Prompt provider turns",
permission: [{ permission: "*", pattern: "*", action: "allow" }],
}),
)
yield* Effect.promise(() =>
SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello one" }],
}),
)
yield* llm.text("world one")
const first = yield* Effect.promise(() => SessionPrompt.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* Effect.promise(() =>
SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello two" }],
}),
)
yield* llm.text("world two")
const second = yield* Effect.promise(() => SessionPrompt.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)
provideTmpdirServer(
Effect.fnUntraced(function* ({ llm }: { dir: string; llm: TestLLMServer["Service"] }) {
const session = yield* Effect.promise(() =>
Session.create({
title: "Prompt provider turns",
permission: [{ permission: "*", pattern: "*", action: "allow" }],
}),
{ git: true, config: makeConfig(llm.url) },
)
}),
)
yield* Effect.promise(() =>
SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello one" }],
}),
)
yield* llm.text("world one")
const first = yield* Effect.promise(() => SessionPrompt.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* Effect.promise(() =>
SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello two" }],
}),
)
yield* llm.text("world two")
const second = yield* Effect.promise(() => SessionPrompt.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: makeConfig },
),
)
})