test: finish HTTP mock processor coverage (#20372)
parent
181b5f6236
commit
7532d99e5b
|
|
@ -1,31 +1,12 @@
|
||||||
import { NodeHttpServer } from "@effect/platform-node"
|
import { NodeHttpServer, NodeHttpServerRequest } from "@effect/platform-node"
|
||||||
import * as Http from "node:http"
|
import * as Http from "node:http"
|
||||||
import { Deferred, Effect, Layer, ServiceMap, Stream } from "effect"
|
import { Deferred, Effect, Layer, ServiceMap, Stream } from "effect"
|
||||||
import * as HttpServer from "effect/unstable/http/HttpServer"
|
import * as HttpServer from "effect/unstable/http/HttpServer"
|
||||||
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||||
|
|
||||||
type Step =
|
export type Usage = { input: number; output: number }
|
||||||
| {
|
|
||||||
type: "text"
|
type Line = Record<string, unknown>
|
||||||
text: string
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: "tool"
|
|
||||||
tool: string
|
|
||||||
input: unknown
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: "fail"
|
|
||||||
message: string
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: "hang"
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: "hold"
|
|
||||||
text: string
|
|
||||||
wait: PromiseLike<unknown>
|
|
||||||
}
|
|
||||||
|
|
||||||
type Hit = {
|
type Hit = {
|
||||||
url: URL
|
url: URL
|
||||||
|
|
@ -37,49 +18,72 @@ type Wait = {
|
||||||
ready: Deferred.Deferred<void>
|
ready: Deferred.Deferred<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
function sse(lines: unknown[]) {
|
type Sse = {
|
||||||
return HttpServerResponse.stream(
|
type: "sse"
|
||||||
Stream.fromIterable([
|
head: unknown[]
|
||||||
[...lines.map((line) => `data: ${JSON.stringify(line)}`), "data: [DONE]"].join("\n\n") + "\n\n",
|
tail: unknown[]
|
||||||
]).pipe(Stream.encodeText),
|
wait?: PromiseLike<unknown>
|
||||||
{ contentType: "text/event-stream" },
|
hang?: boolean
|
||||||
)
|
error?: unknown
|
||||||
|
reset?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function text(step: Extract<Step, { type: "text" }>) {
|
type HttpError = {
|
||||||
return sse([
|
type: "http-error"
|
||||||
{
|
status: number
|
||||||
id: "chatcmpl-test",
|
body: unknown
|
||||||
object: "chat.completion.chunk",
|
|
||||||
choices: [{ delta: { role: "assistant" } }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "chatcmpl-test",
|
|
||||||
object: "chat.completion.chunk",
|
|
||||||
choices: [{ delta: { content: step.text } }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "chatcmpl-test",
|
|
||||||
object: "chat.completion.chunk",
|
|
||||||
choices: [{ delta: {}, finish_reason: "stop" }],
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function tool(step: Extract<Step, { type: "tool" }>, seq: number) {
|
export type Item = Sse | HttpError
|
||||||
const id = `call_${seq}`
|
|
||||||
const args = JSON.stringify(step.input)
|
const done = Symbol("done")
|
||||||
return sse([
|
|
||||||
{
|
function line(input: unknown) {
|
||||||
id: "chatcmpl-test",
|
if (input === done) return "data: [DONE]\n\n"
|
||||||
object: "chat.completion.chunk",
|
return `data: ${JSON.stringify(input)}\n\n`
|
||||||
choices: [{ delta: { role: "assistant" } }],
|
}
|
||||||
},
|
|
||||||
{
|
function tokens(input?: Usage) {
|
||||||
|
if (!input) return
|
||||||
|
return {
|
||||||
|
prompt_tokens: input.input,
|
||||||
|
completion_tokens: input.output,
|
||||||
|
total_tokens: input.input + input.output,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function chunk(input: { delta?: Record<string, unknown>; finish?: string; usage?: Usage }) {
|
||||||
|
return {
|
||||||
id: "chatcmpl-test",
|
id: "chatcmpl-test",
|
||||||
object: "chat.completion.chunk",
|
object: "chat.completion.chunk",
|
||||||
choices: [
|
choices: [
|
||||||
{
|
{
|
||||||
|
delta: input.delta ?? {},
|
||||||
|
...(input.finish ? { finish_reason: input.finish } : {}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
...(input.usage ? { usage: tokens(input.usage) } : {}),
|
||||||
|
} satisfies Line
|
||||||
|
}
|
||||||
|
|
||||||
|
function role() {
|
||||||
|
return chunk({ delta: { role: "assistant" } })
|
||||||
|
}
|
||||||
|
|
||||||
|
function textLine(value: string) {
|
||||||
|
return chunk({ delta: { content: value } })
|
||||||
|
}
|
||||||
|
|
||||||
|
function reasonLine(value: string) {
|
||||||
|
return chunk({ delta: { reasoning_content: value } })
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishLine(reason: string, usage?: Usage) {
|
||||||
|
return chunk({ finish: reason, usage })
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolStartLine(id: string, name: string) {
|
||||||
|
return chunk({
|
||||||
delta: {
|
delta: {
|
||||||
tool_calls: [
|
tool_calls: [
|
||||||
{
|
{
|
||||||
|
|
@ -87,97 +91,220 @@ function tool(step: Extract<Step, { type: "tool" }>, seq: number) {
|
||||||
id,
|
id,
|
||||||
type: "function",
|
type: "function",
|
||||||
function: {
|
function: {
|
||||||
name: step.tool,
|
name,
|
||||||
arguments: "",
|
arguments: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
})
|
||||||
],
|
}
|
||||||
},
|
|
||||||
{
|
function toolArgsLine(value: string) {
|
||||||
id: "chatcmpl-test",
|
return chunk({
|
||||||
object: "chat.completion.chunk",
|
|
||||||
choices: [
|
|
||||||
{
|
|
||||||
delta: {
|
delta: {
|
||||||
tool_calls: [
|
tool_calls: [
|
||||||
{
|
{
|
||||||
index: 0,
|
index: 0,
|
||||||
function: {
|
function: {
|
||||||
arguments: args,
|
arguments: value,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
})
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "chatcmpl-test",
|
|
||||||
object: "chat.completion.chunk",
|
|
||||||
choices: [{ delta: {}, finish_reason: "tool_calls" }],
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function fail(step: Extract<Step, { type: "fail" }>) {
|
function bytes(input: Iterable<unknown>) {
|
||||||
return HttpServerResponse.stream(
|
return Stream.fromIterable([...input].map(line)).pipe(Stream.encodeText)
|
||||||
Stream.fromIterable([
|
|
||||||
'data: {"id":"chatcmpl-test","object":"chat.completion.chunk","choices":[{"delta":{"role":"assistant"}}]}\n\n',
|
|
||||||
]).pipe(Stream.encodeText, Stream.concat(Stream.fail(new Error(step.message)))),
|
|
||||||
{ contentType: "text/event-stream" },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hang() {
|
function send(item: Sse) {
|
||||||
return HttpServerResponse.stream(
|
const head = bytes(item.head)
|
||||||
Stream.fromIterable([
|
const tail = bytes([...item.tail, ...(item.hang || item.error ? [] : [done])])
|
||||||
'data: {"id":"chatcmpl-test","object":"chat.completion.chunk","choices":[{"delta":{"role":"assistant"}}]}\n\n',
|
const empty = Stream.fromIterable<Uint8Array>([])
|
||||||
]).pipe(Stream.encodeText, Stream.concat(Stream.never)),
|
const wait = item.wait
|
||||||
{ contentType: "text/event-stream" },
|
const body: Stream.Stream<Uint8Array, unknown> = wait
|
||||||
)
|
? Stream.concat(head, Stream.fromEffect(Effect.promise(() => wait)).pipe(Stream.flatMap(() => tail)))
|
||||||
|
: Stream.concat(head, tail)
|
||||||
|
let end: Stream.Stream<Uint8Array, unknown> = empty
|
||||||
|
if (item.error) end = Stream.concat(empty, Stream.fail(item.error))
|
||||||
|
else if (item.hang) end = Stream.concat(empty, Stream.never)
|
||||||
|
|
||||||
|
return HttpServerResponse.stream(Stream.concat(body, end), { contentType: "text/event-stream" })
|
||||||
}
|
}
|
||||||
|
|
||||||
function hold(step: Extract<Step, { type: "hold" }>) {
|
const reset = Effect.fn("TestLLMServer.reset")(function* (item: Sse) {
|
||||||
return HttpServerResponse.stream(
|
const req = yield* HttpServerRequest.HttpServerRequest
|
||||||
Stream.fromIterable([
|
const res = NodeHttpServerRequest.toServerResponse(req)
|
||||||
'data: {"id":"chatcmpl-test","object":"chat.completion.chunk","choices":[{"delta":{"role":"assistant"}}]}\n\n',
|
yield* Effect.sync(() => {
|
||||||
]).pipe(
|
res.writeHead(200, { "content-type": "text/event-stream" })
|
||||||
Stream.encodeText,
|
for (const part of item.head) res.write(line(part))
|
||||||
Stream.concat(
|
for (const part of item.tail) res.write(line(part))
|
||||||
Stream.fromEffect(Effect.promise(() => step.wait)).pipe(
|
res.destroy(new Error("connection reset"))
|
||||||
Stream.flatMap(() =>
|
})
|
||||||
Stream.fromIterable([
|
yield* Effect.never
|
||||||
`data: ${JSON.stringify({
|
})
|
||||||
id: "chatcmpl-test",
|
|
||||||
object: "chat.completion.chunk",
|
function fail(item: HttpError) {
|
||||||
choices: [{ delta: { content: step.text } }],
|
return HttpServerResponse.text(JSON.stringify(item.body), {
|
||||||
})}\n\n`,
|
status: item.status,
|
||||||
`data: ${JSON.stringify({
|
contentType: "application/json",
|
||||||
id: "chatcmpl-test",
|
})
|
||||||
object: "chat.completion.chunk",
|
}
|
||||||
choices: [{ delta: {}, finish_reason: "stop" }],
|
|
||||||
})}\n\n`,
|
export class Reply {
|
||||||
"data: [DONE]\n\n",
|
#head: unknown[] = [role()]
|
||||||
]).pipe(Stream.encodeText),
|
#tail: unknown[] = []
|
||||||
),
|
#usage: Usage | undefined
|
||||||
),
|
#finish: string | undefined
|
||||||
),
|
#wait: PromiseLike<unknown> | undefined
|
||||||
),
|
#hang = false
|
||||||
{ contentType: "text/event-stream" },
|
#error: unknown
|
||||||
)
|
#reset = false
|
||||||
|
#seq = 0
|
||||||
|
|
||||||
|
#id() {
|
||||||
|
this.#seq += 1
|
||||||
|
return `call_${this.#seq}`
|
||||||
|
}
|
||||||
|
|
||||||
|
text(value: string) {
|
||||||
|
this.#tail = [...this.#tail, textLine(value)]
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
reason(value: string) {
|
||||||
|
this.#tail = [...this.#tail, reasonLine(value)]
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
usage(value: Usage) {
|
||||||
|
this.#usage = value
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
wait(value: PromiseLike<unknown>) {
|
||||||
|
this.#wait = value
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
this.#finish = "stop"
|
||||||
|
this.#hang = false
|
||||||
|
this.#error = undefined
|
||||||
|
this.#reset = false
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
toolCalls() {
|
||||||
|
this.#finish = "tool_calls"
|
||||||
|
this.#hang = false
|
||||||
|
this.#error = undefined
|
||||||
|
this.#reset = false
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
tool(name: string, input: unknown) {
|
||||||
|
const id = this.#id()
|
||||||
|
const args = JSON.stringify(input)
|
||||||
|
this.#tail = [...this.#tail, toolStartLine(id, name), toolArgsLine(args)]
|
||||||
|
return this.toolCalls()
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingTool(name: string, input: unknown) {
|
||||||
|
const id = this.#id()
|
||||||
|
const args = JSON.stringify(input)
|
||||||
|
const size = Math.max(1, Math.floor(args.length / 2))
|
||||||
|
this.#tail = [...this.#tail, toolStartLine(id, name), toolArgsLine(args.slice(0, size))]
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
hang() {
|
||||||
|
this.#finish = undefined
|
||||||
|
this.#hang = true
|
||||||
|
this.#error = undefined
|
||||||
|
this.#reset = false
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
streamError(error: unknown = "boom") {
|
||||||
|
this.#finish = undefined
|
||||||
|
this.#hang = false
|
||||||
|
this.#error = error
|
||||||
|
this.#reset = false
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.#finish = undefined
|
||||||
|
this.#hang = false
|
||||||
|
this.#error = undefined
|
||||||
|
this.#reset = true
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
item(): Item {
|
||||||
|
return {
|
||||||
|
type: "sse",
|
||||||
|
head: this.#head,
|
||||||
|
tail: this.#finish ? [...this.#tail, finishLine(this.#finish, this.#usage)] : this.#tail,
|
||||||
|
wait: this.#wait,
|
||||||
|
hang: this.#hang,
|
||||||
|
error: this.#error,
|
||||||
|
reset: this.#reset,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reply() {
|
||||||
|
return new Reply()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function httpError(status: number, body: unknown): Item {
|
||||||
|
return {
|
||||||
|
type: "http-error",
|
||||||
|
status,
|
||||||
|
body,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function raw(input: {
|
||||||
|
chunks?: unknown[]
|
||||||
|
head?: unknown[]
|
||||||
|
tail?: unknown[]
|
||||||
|
wait?: PromiseLike<unknown>
|
||||||
|
hang?: boolean
|
||||||
|
error?: unknown
|
||||||
|
reset?: boolean
|
||||||
|
}): Item {
|
||||||
|
return {
|
||||||
|
type: "sse",
|
||||||
|
head: input.head ?? input.chunks ?? [],
|
||||||
|
tail: input.tail ?? [],
|
||||||
|
wait: input.wait,
|
||||||
|
hang: input.hang,
|
||||||
|
error: input.error,
|
||||||
|
reset: input.reset,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function item(input: Item | Reply) {
|
||||||
|
return input instanceof Reply ? input.item() : input
|
||||||
}
|
}
|
||||||
|
|
||||||
namespace TestLLMServer {
|
namespace TestLLMServer {
|
||||||
export interface Service {
|
export interface Service {
|
||||||
readonly url: string
|
readonly url: string
|
||||||
readonly text: (value: string) => Effect.Effect<void>
|
readonly push: (...input: (Item | Reply)[]) => Effect.Effect<void>
|
||||||
readonly tool: (tool: string, input: unknown) => Effect.Effect<void>
|
readonly text: (value: string, opts?: { usage?: Usage }) => Effect.Effect<void>
|
||||||
readonly fail: (message?: string) => Effect.Effect<void>
|
readonly tool: (name: string, input: unknown) => Effect.Effect<void>
|
||||||
|
readonly toolHang: (name: string, input: unknown) => Effect.Effect<void>
|
||||||
|
readonly reason: (value: string, opts?: { text?: string; usage?: Usage }) => Effect.Effect<void>
|
||||||
|
readonly fail: (message?: unknown) => Effect.Effect<void>
|
||||||
|
readonly error: (status: number, body: unknown) => Effect.Effect<void>
|
||||||
readonly hang: Effect.Effect<void>
|
readonly hang: Effect.Effect<void>
|
||||||
readonly hold: (text: string, wait: PromiseLike<unknown>) => Effect.Effect<void>
|
readonly hold: (value: string, wait: PromiseLike<unknown>) => Effect.Effect<void>
|
||||||
readonly hits: Effect.Effect<Hit[]>
|
readonly hits: Effect.Effect<Hit[]>
|
||||||
readonly calls: Effect.Effect<number>
|
readonly calls: Effect.Effect<number>
|
||||||
readonly wait: (count: number) => Effect.Effect<void>
|
readonly wait: (count: number) => Effect.Effect<void>
|
||||||
|
|
@ -194,12 +321,11 @@ export class TestLLMServer extends ServiceMap.Service<TestLLMServer, TestLLMServ
|
||||||
const router = yield* HttpRouter.HttpRouter
|
const router = yield* HttpRouter.HttpRouter
|
||||||
|
|
||||||
let hits: Hit[] = []
|
let hits: Hit[] = []
|
||||||
let list: Step[] = []
|
let list: Item[] = []
|
||||||
let seq = 0
|
|
||||||
let waits: Wait[] = []
|
let waits: Wait[] = []
|
||||||
|
|
||||||
const push = (step: Step) => {
|
const queue = (...input: (Item | Reply)[]) => {
|
||||||
list = [...list, step]
|
list = [...list, ...input.map(item)]
|
||||||
}
|
}
|
||||||
|
|
||||||
const notify = Effect.fnUntraced(function* () {
|
const notify = Effect.fnUntraced(function* () {
|
||||||
|
|
@ -210,11 +336,10 @@ export class TestLLMServer extends ServiceMap.Service<TestLLMServer, TestLLMServ
|
||||||
})
|
})
|
||||||
|
|
||||||
const pull = () => {
|
const pull = () => {
|
||||||
const step = list[0]
|
const first = list[0]
|
||||||
if (!step) return { step: undefined, seq }
|
if (!first) return
|
||||||
seq += 1
|
|
||||||
list = list.slice(1)
|
list = list.slice(1)
|
||||||
return { step, seq }
|
return first
|
||||||
}
|
}
|
||||||
|
|
||||||
yield* router.add(
|
yield* router.add(
|
||||||
|
|
@ -223,21 +348,22 @@ export class TestLLMServer extends ServiceMap.Service<TestLLMServer, TestLLMServ
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const req = yield* HttpServerRequest.HttpServerRequest
|
const req = yield* HttpServerRequest.HttpServerRequest
|
||||||
const next = pull()
|
const next = pull()
|
||||||
if (!next.step) return HttpServerResponse.text("unexpected request", { status: 500 })
|
if (!next) return HttpServerResponse.text("unexpected request", { status: 500 })
|
||||||
const json = yield* req.json.pipe(Effect.orElseSucceed(() => ({})))
|
const body = yield* req.json.pipe(Effect.orElseSucceed(() => ({})))
|
||||||
hits = [
|
hits = [
|
||||||
...hits,
|
...hits,
|
||||||
{
|
{
|
||||||
url: new URL(req.originalUrl, "http://localhost"),
|
url: new URL(req.originalUrl, "http://localhost"),
|
||||||
body: json && typeof json === "object" ? (json as Record<string, unknown>) : {},
|
body: body && typeof body === "object" ? (body as Record<string, unknown>) : {},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
yield* notify()
|
yield* notify()
|
||||||
if (next.step.type === "text") return text(next.step)
|
if (next.type === "sse" && next.reset) {
|
||||||
if (next.step.type === "tool") return tool(next.step, next.seq)
|
yield* reset(next)
|
||||||
if (next.step.type === "fail") return fail(next.step)
|
return HttpServerResponse.empty()
|
||||||
if (next.step.type === "hang") return hang()
|
}
|
||||||
return hold(next.step)
|
if (next.type === "sse") return send(next)
|
||||||
|
return fail(next)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -248,20 +374,37 @@ export class TestLLMServer extends ServiceMap.Service<TestLLMServer, TestLLMServ
|
||||||
server.address._tag === "TcpAddress"
|
server.address._tag === "TcpAddress"
|
||||||
? `http://127.0.0.1:${server.address.port}/v1`
|
? `http://127.0.0.1:${server.address.port}/v1`
|
||||||
: `unix://${server.address.path}/v1`,
|
: `unix://${server.address.path}/v1`,
|
||||||
text: Effect.fn("TestLLMServer.text")(function* (value: string) {
|
push: Effect.fn("TestLLMServer.push")(function* (...input: (Item | Reply)[]) {
|
||||||
push({ type: "text", text: value })
|
queue(...input)
|
||||||
}),
|
}),
|
||||||
tool: Effect.fn("TestLLMServer.tool")(function* (tool: string, input: unknown) {
|
text: Effect.fn("TestLLMServer.text")(function* (value: string, opts?: { usage?: Usage }) {
|
||||||
push({ type: "tool", tool, input })
|
const out = reply().text(value)
|
||||||
|
if (opts?.usage) out.usage(opts.usage)
|
||||||
|
queue(out.stop().item())
|
||||||
}),
|
}),
|
||||||
fail: Effect.fn("TestLLMServer.fail")(function* (message = "boom") {
|
tool: Effect.fn("TestLLMServer.tool")(function* (name: string, input: unknown) {
|
||||||
push({ type: "fail", message })
|
queue(reply().tool(name, input).item())
|
||||||
|
}),
|
||||||
|
toolHang: Effect.fn("TestLLMServer.toolHang")(function* (name: string, input: unknown) {
|
||||||
|
queue(reply().pendingTool(name, input).hang().item())
|
||||||
|
}),
|
||||||
|
reason: Effect.fn("TestLLMServer.reason")(function* (value: string, opts?: { text?: string; usage?: Usage }) {
|
||||||
|
const out = reply().reason(value)
|
||||||
|
if (opts?.text) out.text(opts.text)
|
||||||
|
if (opts?.usage) out.usage(opts.usage)
|
||||||
|
queue(out.stop().item())
|
||||||
|
}),
|
||||||
|
fail: Effect.fn("TestLLMServer.fail")(function* (message: unknown = "boom") {
|
||||||
|
queue(reply().streamError(message).item())
|
||||||
|
}),
|
||||||
|
error: Effect.fn("TestLLMServer.error")(function* (status: number, body: unknown) {
|
||||||
|
queue(httpError(status, body))
|
||||||
}),
|
}),
|
||||||
hang: Effect.gen(function* () {
|
hang: Effect.gen(function* () {
|
||||||
push({ type: "hang" })
|
queue(reply().hang().item())
|
||||||
}).pipe(Effect.withSpan("TestLLMServer.hang")),
|
}).pipe(Effect.withSpan("TestLLMServer.hang")),
|
||||||
hold: Effect.fn("TestLLMServer.hold")(function* (text: string, wait: PromiseLike<unknown>) {
|
hold: Effect.fn("TestLLMServer.hold")(function* (value: string, wait: PromiseLike<unknown>) {
|
||||||
push({ type: "hold", text, wait })
|
queue(reply().wait(wait).text(value).stop().item())
|
||||||
}),
|
}),
|
||||||
hits: Effect.sync(() => [...hits]),
|
hits: Effect.sync(() => [...hits]),
|
||||||
calls: Effect.sync(() => hits.length),
|
calls: Effect.sync(() => hits.length),
|
||||||
|
|
@ -275,8 +418,5 @@ export class TestLLMServer extends ServiceMap.Service<TestLLMServer, TestLLMServ
|
||||||
pending: Effect.sync(() => list.length),
|
pending: Effect.sync(() => list.length),
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
).pipe(
|
).pipe(Layer.provide(HttpRouter.layer), Layer.provide(NodeHttpServer.layer(() => Http.createServer(), { port: 0 })))
|
||||||
Layer.provide(HttpRouter.layer), //
|
|
||||||
Layer.provide(NodeHttpServer.layer(() => Http.createServer(), { port: 0 })),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
import { NodeFileSystem } from "@effect/platform-node"
|
import { NodeFileSystem } from "@effect/platform-node"
|
||||||
import { expect } from "bun:test"
|
import { expect } from "bun:test"
|
||||||
import { APICallError } from "ai"
|
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
|
||||||
import { Cause, Effect, Exit, Fiber, Layer, ServiceMap } from "effect"
|
|
||||||
import * as Stream from "effect/Stream"
|
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import type { Agent } from "../../src/agent/agent"
|
import type { Agent } from "../../src/agent/agent"
|
||||||
import { Agent as AgentSvc } from "../../src/agent/agent"
|
import { Agent as AgentSvc } from "../../src/agent/agent"
|
||||||
|
|
@ -10,7 +8,7 @@ import { Bus } from "../../src/bus"
|
||||||
import { Config } from "../../src/config/config"
|
import { Config } from "../../src/config/config"
|
||||||
import { Permission } from "../../src/permission"
|
import { Permission } from "../../src/permission"
|
||||||
import { Plugin } from "../../src/plugin"
|
import { Plugin } from "../../src/plugin"
|
||||||
import type { Provider } from "../../src/provider/provider"
|
import { Provider } from "../../src/provider/provider"
|
||||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||||
import { Session } from "../../src/session"
|
import { Session } from "../../src/session"
|
||||||
import { LLM } from "../../src/session/llm"
|
import { LLM } from "../../src/session/llm"
|
||||||
|
|
@ -21,8 +19,9 @@ import { SessionStatus } from "../../src/session/status"
|
||||||
import { Snapshot } from "../../src/snapshot"
|
import { Snapshot } from "../../src/snapshot"
|
||||||
import { Log } from "../../src/util/log"
|
import { Log } from "../../src/util/log"
|
||||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
import { provideTmpdirServer } from "../fixture/fixture"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
|
import { reply, TestLLMServer } from "../lib/llm-server"
|
||||||
|
|
||||||
Log.init({ print: false })
|
Log.init({ print: false })
|
||||||
|
|
||||||
|
|
@ -31,116 +30,49 @@ const ref = {
|
||||||
modelID: ModelID.make("test-model"),
|
modelID: ModelID.make("test-model"),
|
||||||
}
|
}
|
||||||
|
|
||||||
type Script = Stream.Stream<LLM.Event, unknown> | ((input: LLM.StreamInput) => Stream.Stream<LLM.Event, unknown>)
|
const cfg = {
|
||||||
|
provider: {
|
||||||
class TestLLM extends ServiceMap.Service<
|
test: {
|
||||||
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/SessionProcessorLLM") {}
|
|
||||||
|
|
||||||
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 reasoningStart(id: string): LLM.Event {
|
|
||||||
return { type: "reasoning-start", id }
|
|
||||||
}
|
|
||||||
|
|
||||||
function reasoningDelta(id: string, text: string): LLM.Event {
|
|
||||||
return { type: "reasoning-delta", id, text }
|
|
||||||
}
|
|
||||||
|
|
||||||
function reasoningEnd(id: string): LLM.Event {
|
|
||||||
return { type: "reasoning-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 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 fail<E>(err: E, ...items: LLM.Event[]) {
|
|
||||||
return stream(...items).pipe(Stream.concat(Stream.fail(err)))
|
|
||||||
}
|
|
||||||
|
|
||||||
function hang(_input: LLM.StreamInput, ...items: LLM.Event[]) {
|
|
||||||
return stream(...items).pipe(Stream.concat(Stream.fromEffect(Effect.never)))
|
|
||||||
}
|
|
||||||
|
|
||||||
function model(context: number): Provider.Model {
|
|
||||||
return {
|
|
||||||
id: "test-model",
|
|
||||||
providerID: "test",
|
|
||||||
name: "Test",
|
name: "Test",
|
||||||
limit: { context, output: 10 },
|
id: "test",
|
||||||
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
|
env: [],
|
||||||
capabilities: {
|
npm: "@ai-sdk/openai-compatible",
|
||||||
toolcall: true,
|
models: {
|
||||||
|
"test-model": {
|
||||||
|
id: "test-model",
|
||||||
|
name: "Test Model",
|
||||||
attachment: false,
|
attachment: false,
|
||||||
reasoning: false,
|
reasoning: false,
|
||||||
temperature: true,
|
temperature: false,
|
||||||
input: { text: true, image: false, audio: false, video: false },
|
tool_call: true,
|
||||||
output: { text: true, image: false, audio: false, video: false },
|
release_date: "2025-01-01",
|
||||||
},
|
limit: { context: 100000, output: 10000 },
|
||||||
api: { npm: "@ai-sdk/anthropic" },
|
cost: { input: 0, output: 0 },
|
||||||
options: {},
|
options: {},
|
||||||
} as Provider.Model
|
},
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function agent(): Agent.Info {
|
function agent(): Agent.Info {
|
||||||
|
|
@ -211,43 +143,6 @@ const assistant = Effect.fn("TestSession.assistant")(function* (
|
||||||
return msg
|
return msg
|
||||||
})
|
})
|
||||||
|
|
||||||
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 status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer))
|
const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer))
|
||||||
const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer)
|
const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer)
|
||||||
const deps = Layer.mergeAll(
|
const deps = Layer.mergeAll(
|
||||||
|
|
@ -257,27 +152,37 @@ const deps = Layer.mergeAll(
|
||||||
Permission.layer,
|
Permission.layer,
|
||||||
Plugin.defaultLayer,
|
Plugin.defaultLayer,
|
||||||
Config.defaultLayer,
|
Config.defaultLayer,
|
||||||
|
LLM.defaultLayer,
|
||||||
|
Provider.defaultLayer,
|
||||||
status,
|
status,
|
||||||
llm,
|
|
||||||
).pipe(Layer.provideMerge(infra))
|
).pipe(Layer.provideMerge(infra))
|
||||||
const env = SessionProcessor.layer.pipe(Layer.provideMerge(deps))
|
const env = Layer.mergeAll(TestLLMServer.layer, SessionProcessor.layer.pipe(Layer.provideMerge(deps)))
|
||||||
|
|
||||||
const it = testEffect(env)
|
const it = testEffect(env)
|
||||||
|
|
||||||
it.live("session.processor effect tests capture llm input cleanly", () => {
|
const boot = Effect.fn("test.boot")(function* () {
|
||||||
return provideTmpdirInstance(
|
|
||||||
(dir) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const test = yield* TestLLM
|
|
||||||
const processors = yield* SessionProcessor.Service
|
const processors = yield* SessionProcessor.Service
|
||||||
const session = yield* Session.Service
|
const session = yield* Session.Service
|
||||||
|
const provider = yield* Provider.Service
|
||||||
|
return { processors, session, provider }
|
||||||
|
})
|
||||||
|
|
||||||
yield* test.reply(start(), textStart(), textDelta("t", "hello"), textEnd(), finishStep(), finish())
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it.live("session.processor effect tests capture llm input cleanly", () =>
|
||||||
|
provideTmpdirServer(
|
||||||
|
({ dir, llm }) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const { processors, session, provider } = yield* boot()
|
||||||
|
|
||||||
|
yield* llm.text("hello")
|
||||||
|
|
||||||
const chat = yield* session.create({})
|
const chat = yield* session.create({})
|
||||||
const parent = yield* user(chat.id, "hi")
|
const parent = yield* user(chat.id, "hi")
|
||||||
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
||||||
const mdl = model(100)
|
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
|
||||||
const handle = yield* processors.create({
|
const handle = yield* processors.create({
|
||||||
assistantMessage: msg,
|
assistantMessage: msg,
|
||||||
sessionID: chat.id,
|
sessionID: chat.id,
|
||||||
|
|
@ -303,46 +208,29 @@ it.live("session.processor effect tests capture llm input cleanly", () => {
|
||||||
|
|
||||||
const value = yield* handle.process(input)
|
const value = yield* handle.process(input)
|
||||||
const parts = yield* Effect.promise(() => MessageV2.parts(msg.id))
|
const parts = yield* Effect.promise(() => MessageV2.parts(msg.id))
|
||||||
const calls = yield* test.calls
|
const calls = yield* llm.calls
|
||||||
const inputs = yield* test.inputs
|
|
||||||
|
|
||||||
expect(value).toBe("continue")
|
expect(value).toBe("continue")
|
||||||
expect(calls).toBe(1)
|
expect(calls).toBe(1)
|
||||||
expect(inputs).toHaveLength(1)
|
|
||||||
expect(inputs[0].messages).toStrictEqual([{ role: "user", content: "hi" }])
|
|
||||||
expect(parts.some((part) => part.type === "text" && part.text === "hello")).toBe(true)
|
expect(parts.some((part) => part.type === "text" && part.text === "hello")).toBe(true)
|
||||||
}),
|
}),
|
||||||
{ git: true },
|
{ git: true, config: (url) => providerCfg(url) },
|
||||||
|
),
|
||||||
)
|
)
|
||||||
})
|
|
||||||
|
|
||||||
it.live("session.processor effect tests stop after token overflow requests compaction", () => {
|
it.live("session.processor effect tests stop after token overflow requests compaction", () =>
|
||||||
return provideTmpdirInstance(
|
provideTmpdirServer(
|
||||||
(dir) =>
|
({ dir, llm }) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const test = yield* TestLLM
|
const { processors, session, provider } = yield* boot()
|
||||||
const processors = yield* SessionProcessor.Service
|
|
||||||
const session = yield* Session.Service
|
|
||||||
|
|
||||||
yield* test.reply(
|
yield* llm.text("after", { usage: { input: 100, output: 0 } })
|
||||||
start(),
|
|
||||||
{
|
|
||||||
type: "finish-step",
|
|
||||||
finishReason: "stop",
|
|
||||||
rawFinishReason: "stop",
|
|
||||||
response: { id: "res", modelId: "test-model", timestamp: new Date() },
|
|
||||||
providerMetadata: undefined,
|
|
||||||
usage: usage(100, 0, 100),
|
|
||||||
},
|
|
||||||
textStart(),
|
|
||||||
textDelta("t", "after"),
|
|
||||||
textEnd(),
|
|
||||||
)
|
|
||||||
|
|
||||||
const chat = yield* session.create({})
|
const chat = yield* session.create({})
|
||||||
const parent = yield* user(chat.id, "compact")
|
const parent = yield* user(chat.id, "compact")
|
||||||
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
||||||
const mdl = model(20)
|
const base = yield* provider.getModel(ref.providerID, ref.modelID)
|
||||||
|
const mdl = { ...base, limit: { context: 20, output: 10 } }
|
||||||
const handle = yield* processors.create({
|
const handle = yield* processors.create({
|
||||||
assistantMessage: msg,
|
assistantMessage: msg,
|
||||||
sessionID: chat.id,
|
sessionID: chat.id,
|
||||||
|
|
@ -369,51 +257,73 @@ it.live("session.processor effect tests stop after token overflow requests compa
|
||||||
const parts = yield* Effect.promise(() => MessageV2.parts(msg.id))
|
const parts = yield* Effect.promise(() => MessageV2.parts(msg.id))
|
||||||
|
|
||||||
expect(value).toBe("compact")
|
expect(value).toBe("compact")
|
||||||
expect(parts.some((part) => part.type === "text")).toBe(false)
|
expect(parts.some((part) => part.type === "text" && part.text === "after")).toBe(true)
|
||||||
expect(parts.some((part) => part.type === "step-finish")).toBe(true)
|
expect(parts.some((part) => part.type === "step-finish")).toBe(true)
|
||||||
}),
|
}),
|
||||||
{ git: true },
|
{ git: true, config: (url) => providerCfg(url) },
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it.live("session.processor effect tests reset reasoning state across retries", () => {
|
|
||||||
return provideTmpdirInstance(
|
|
||||||
(dir) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const test = yield* TestLLM
|
|
||||||
const processors = yield* SessionProcessor.Service
|
|
||||||
const session = yield* Session.Service
|
|
||||||
|
|
||||||
yield* test.push(
|
|
||||||
fail(
|
|
||||||
new APICallError({
|
|
||||||
message: "boom",
|
|
||||||
url: "https://example.com/v1/chat/completions",
|
|
||||||
requestBodyValues: {},
|
|
||||||
statusCode: 503,
|
|
||||||
responseHeaders: { "retry-after-ms": "0" },
|
|
||||||
responseBody: '{"error":"boom"}',
|
|
||||||
isRetryable: true,
|
|
||||||
}),
|
|
||||||
start(),
|
|
||||||
reasoningStart("r"),
|
|
||||||
reasoningDelta("r", "one"),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
yield* test.reply(
|
it.live("session.processor effect tests capture reasoning from http mock", () =>
|
||||||
start(),
|
provideTmpdirServer(
|
||||||
reasoningStart("r"),
|
({ dir, llm }) =>
|
||||||
reasoningDelta("r", "two"),
|
Effect.gen(function* () {
|
||||||
reasoningEnd("r"),
|
const { processors, session, provider } = yield* boot()
|
||||||
finishStep(),
|
|
||||||
finish(),
|
yield* llm.push(reply().reason("think").text("done").stop())
|
||||||
)
|
|
||||||
|
|
||||||
const chat = yield* session.create({})
|
const chat = yield* session.create({})
|
||||||
const parent = yield* user(chat.id, "reason")
|
const parent = yield* user(chat.id, "reason")
|
||||||
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
||||||
const mdl = model(100)
|
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
|
||||||
|
const handle = yield* processors.create({
|
||||||
|
assistantMessage: msg,
|
||||||
|
sessionID: chat.id,
|
||||||
|
model: mdl,
|
||||||
|
})
|
||||||
|
|
||||||
|
const value = yield* handle.process({
|
||||||
|
user: {
|
||||||
|
id: parent.id,
|
||||||
|
sessionID: chat.id,
|
||||||
|
role: "user",
|
||||||
|
time: parent.time,
|
||||||
|
agent: parent.agent,
|
||||||
|
model: { providerID: ref.providerID, modelID: ref.modelID },
|
||||||
|
} satisfies MessageV2.User,
|
||||||
|
sessionID: chat.id,
|
||||||
|
model: mdl,
|
||||||
|
agent: agent(),
|
||||||
|
system: [],
|
||||||
|
messages: [{ role: "user", content: "reason" }],
|
||||||
|
tools: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
const parts = yield* Effect.promise(() => MessageV2.parts(msg.id))
|
||||||
|
const reasoning = parts.find((part): part is MessageV2.ReasoningPart => part.type === "reasoning")
|
||||||
|
const text = parts.find((part): part is MessageV2.TextPart => part.type === "text")
|
||||||
|
|
||||||
|
expect(value).toBe("continue")
|
||||||
|
expect(yield* llm.calls).toBe(1)
|
||||||
|
expect(reasoning?.text).toBe("think")
|
||||||
|
expect(text?.text).toBe("done")
|
||||||
|
}),
|
||||||
|
{ git: true, config: (url) => providerCfg(url) },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("session.processor effect tests reset reasoning state across retries", () =>
|
||||||
|
provideTmpdirServer(
|
||||||
|
({ dir, llm }) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const { processors, session, provider } = yield* boot()
|
||||||
|
|
||||||
|
yield* llm.push(reply().reason("one").reset(), reply().reason("two").stop())
|
||||||
|
|
||||||
|
const chat = yield* session.create({})
|
||||||
|
const parent = yield* user(chat.id, "reason")
|
||||||
|
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
||||||
|
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
|
||||||
const handle = yield* processors.create({
|
const handle = yield* processors.create({
|
||||||
assistantMessage: msg,
|
assistantMessage: msg,
|
||||||
sessionID: chat.id,
|
sessionID: chat.id,
|
||||||
|
|
@ -441,28 +351,26 @@ it.live("session.processor effect tests reset reasoning state across retries", (
|
||||||
const reasoning = parts.filter((part): part is MessageV2.ReasoningPart => part.type === "reasoning")
|
const reasoning = parts.filter((part): part is MessageV2.ReasoningPart => part.type === "reasoning")
|
||||||
|
|
||||||
expect(value).toBe("continue")
|
expect(value).toBe("continue")
|
||||||
expect(yield* test.calls).toBe(2)
|
expect(yield* llm.calls).toBe(2)
|
||||||
expect(reasoning.some((part) => part.text === "two")).toBe(true)
|
expect(reasoning.some((part) => part.text === "two")).toBe(true)
|
||||||
expect(reasoning.some((part) => part.text === "onetwo")).toBe(false)
|
expect(reasoning.some((part) => part.text === "onetwo")).toBe(false)
|
||||||
}),
|
}),
|
||||||
{ git: true },
|
{ git: true, config: (url) => providerCfg(url) },
|
||||||
|
),
|
||||||
)
|
)
|
||||||
})
|
|
||||||
|
|
||||||
it.live("session.processor effect tests do not retry unknown json errors", () => {
|
it.live("session.processor effect tests do not retry unknown json errors", () =>
|
||||||
return provideTmpdirInstance(
|
provideTmpdirServer(
|
||||||
(dir) =>
|
({ dir, llm }) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const test = yield* TestLLM
|
const { processors, session, provider } = yield* boot()
|
||||||
const processors = yield* SessionProcessor.Service
|
|
||||||
const session = yield* Session.Service
|
|
||||||
|
|
||||||
yield* test.push(fail({ error: { message: "no_kv_space" } }, start()))
|
yield* llm.error(400, { error: { message: "no_kv_space" } })
|
||||||
|
|
||||||
const chat = yield* session.create({})
|
const chat = yield* session.create({})
|
||||||
const parent = yield* user(chat.id, "json")
|
const parent = yield* user(chat.id, "json")
|
||||||
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
||||||
const mdl = model(100)
|
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
|
||||||
const handle = yield* processors.create({
|
const handle = yield* processors.create({
|
||||||
assistantMessage: msg,
|
assistantMessage: msg,
|
||||||
sessionID: chat.id,
|
sessionID: chat.id,
|
||||||
|
|
@ -487,29 +395,26 @@ it.live("session.processor effect tests do not retry unknown json errors", () =>
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(value).toBe("stop")
|
expect(value).toBe("stop")
|
||||||
expect(yield* test.calls).toBe(1)
|
expect(yield* llm.calls).toBe(1)
|
||||||
expect(yield* test.inputs).toHaveLength(1)
|
expect(handle.message.error?.name).toBe("APIError")
|
||||||
expect(handle.message.error?.name).toBe("UnknownError")
|
|
||||||
}),
|
}),
|
||||||
{ git: true },
|
{ git: true, config: (url) => providerCfg(url) },
|
||||||
|
),
|
||||||
)
|
)
|
||||||
})
|
|
||||||
|
|
||||||
it.live("session.processor effect tests retry recognized structured json errors", () => {
|
it.live("session.processor effect tests retry recognized structured json errors", () =>
|
||||||
return provideTmpdirInstance(
|
provideTmpdirServer(
|
||||||
(dir) =>
|
({ dir, llm }) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const test = yield* TestLLM
|
const { processors, session, provider } = yield* boot()
|
||||||
const processors = yield* SessionProcessor.Service
|
|
||||||
const session = yield* Session.Service
|
|
||||||
|
|
||||||
yield* test.push(fail({ type: "error", error: { type: "too_many_requests" } }, start()))
|
yield* llm.error(429, { type: "error", error: { type: "too_many_requests" } })
|
||||||
yield* test.reply(start(), textStart(), textDelta("t", "after"), textEnd(), finishStep(), finish())
|
yield* llm.text("after")
|
||||||
|
|
||||||
const chat = yield* session.create({})
|
const chat = yield* session.create({})
|
||||||
const parent = yield* user(chat.id, "retry json")
|
const parent = yield* user(chat.id, "retry json")
|
||||||
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
||||||
const mdl = model(100)
|
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
|
||||||
const handle = yield* processors.create({
|
const handle = yield* processors.create({
|
||||||
assistantMessage: msg,
|
assistantMessage: msg,
|
||||||
sessionID: chat.id,
|
sessionID: chat.id,
|
||||||
|
|
@ -536,43 +441,28 @@ it.live("session.processor effect tests retry recognized structured json errors"
|
||||||
const parts = yield* Effect.promise(() => MessageV2.parts(msg.id))
|
const parts = yield* Effect.promise(() => MessageV2.parts(msg.id))
|
||||||
|
|
||||||
expect(value).toBe("continue")
|
expect(value).toBe("continue")
|
||||||
expect(yield* test.calls).toBe(2)
|
expect(yield* llm.calls).toBe(2)
|
||||||
expect(parts.some((part) => part.type === "text" && part.text === "after")).toBe(true)
|
expect(parts.some((part) => part.type === "text" && part.text === "after")).toBe(true)
|
||||||
expect(handle.message.error).toBeUndefined()
|
expect(handle.message.error).toBeUndefined()
|
||||||
}),
|
}),
|
||||||
{ git: true },
|
{ git: true, config: (url) => providerCfg(url) },
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it.live("session.processor effect tests publish retry status updates", () => {
|
|
||||||
return provideTmpdirInstance(
|
|
||||||
(dir) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const test = yield* TestLLM
|
|
||||||
const processors = yield* SessionProcessor.Service
|
|
||||||
const session = yield* Session.Service
|
|
||||||
const bus = yield* Bus.Service
|
|
||||||
|
|
||||||
yield* test.push(
|
|
||||||
fail(
|
|
||||||
new APICallError({
|
|
||||||
message: "boom",
|
|
||||||
url: "https://example.com/v1/chat/completions",
|
|
||||||
requestBodyValues: {},
|
|
||||||
statusCode: 503,
|
|
||||||
responseHeaders: { "retry-after-ms": "0" },
|
|
||||||
responseBody: '{"error":"boom"}',
|
|
||||||
isRetryable: true,
|
|
||||||
}),
|
|
||||||
start(),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
yield* test.reply(start(), finishStep(), finish())
|
|
||||||
|
it.live("session.processor effect tests publish retry status updates", () =>
|
||||||
|
provideTmpdirServer(
|
||||||
|
({ dir, llm }) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const { processors, session, provider } = yield* boot()
|
||||||
|
const bus = yield* Bus.Service
|
||||||
|
|
||||||
|
yield* llm.error(503, { error: "boom" })
|
||||||
|
yield* llm.text("")
|
||||||
|
|
||||||
const chat = yield* session.create({})
|
const chat = yield* session.create({})
|
||||||
const parent = yield* user(chat.id, "retry")
|
const parent = yield* user(chat.id, "retry")
|
||||||
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
||||||
const mdl = model(100)
|
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
|
||||||
const states: number[] = []
|
const states: number[] = []
|
||||||
const off = yield* bus.subscribeCallback(SessionStatus.Event.Status, (evt) => {
|
const off = yield* bus.subscribeCallback(SessionStatus.Event.Status, (evt) => {
|
||||||
if (evt.properties.sessionID !== chat.id) return
|
if (evt.properties.sessionID !== chat.id) return
|
||||||
|
|
@ -604,27 +494,25 @@ it.live("session.processor effect tests publish retry status updates", () => {
|
||||||
off()
|
off()
|
||||||
|
|
||||||
expect(value).toBe("continue")
|
expect(value).toBe("continue")
|
||||||
expect(yield* test.calls).toBe(2)
|
expect(yield* llm.calls).toBe(2)
|
||||||
expect(states).toStrictEqual([1])
|
expect(states).toStrictEqual([1])
|
||||||
}),
|
}),
|
||||||
{ git: true },
|
{ git: true, config: (url) => providerCfg(url) },
|
||||||
|
),
|
||||||
)
|
)
|
||||||
})
|
|
||||||
|
|
||||||
it.live("session.processor effect tests compact on structured context overflow", () => {
|
it.live("session.processor effect tests compact on structured context overflow", () =>
|
||||||
return provideTmpdirInstance(
|
provideTmpdirServer(
|
||||||
(dir) =>
|
({ dir, llm }) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const test = yield* TestLLM
|
const { processors, session, provider } = yield* boot()
|
||||||
const processors = yield* SessionProcessor.Service
|
|
||||||
const session = yield* Session.Service
|
|
||||||
|
|
||||||
yield* test.push(fail({ type: "error", error: { code: "context_length_exceeded" } }, start()))
|
yield* llm.error(400, { type: "error", error: { code: "context_length_exceeded" } })
|
||||||
|
|
||||||
const chat = yield* session.create({})
|
const chat = yield* session.create({})
|
||||||
const parent = yield* user(chat.id, "compact json")
|
const parent = yield* user(chat.id, "compact json")
|
||||||
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
||||||
const mdl = model(100)
|
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
|
||||||
const handle = yield* processors.create({
|
const handle = yield* processors.create({
|
||||||
assistantMessage: msg,
|
assistantMessage: msg,
|
||||||
sessionID: chat.id,
|
sessionID: chat.id,
|
||||||
|
|
@ -649,32 +537,25 @@ it.live("session.processor effect tests compact on structured context overflow",
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(value).toBe("compact")
|
expect(value).toBe("compact")
|
||||||
expect(yield* test.calls).toBe(1)
|
expect(yield* llm.calls).toBe(1)
|
||||||
expect(handle.message.error).toBeUndefined()
|
expect(handle.message.error).toBeUndefined()
|
||||||
}),
|
}),
|
||||||
{ git: true },
|
{ git: true, config: (url) => providerCfg(url) },
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it.live("session.processor effect tests mark pending tools as aborted on cleanup", () => {
|
|
||||||
return provideTmpdirInstance(
|
|
||||||
(dir) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const ready = defer<void>()
|
|
||||||
const test = yield* TestLLM
|
|
||||||
const processors = yield* SessionProcessor.Service
|
|
||||||
const session = yield* Session.Service
|
|
||||||
|
|
||||||
yield* test.push((input) =>
|
|
||||||
hang(input, start(), toolInputStart("tool-1", "bash"), toolCall("tool-1", "bash", { cmd: "pwd" })).pipe(
|
|
||||||
Stream.tap((event) => (event.type === "tool-call" ? Effect.sync(() => ready.resolve()) : Effect.void)),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.live("session.processor effect tests mark pending tools as aborted on cleanup", () =>
|
||||||
|
provideTmpdirServer(
|
||||||
|
({ dir, llm }) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const { processors, session, provider } = yield* boot()
|
||||||
|
|
||||||
|
yield* llm.toolHang("bash", { cmd: "pwd" })
|
||||||
|
|
||||||
const chat = yield* session.create({})
|
const chat = yield* session.create({})
|
||||||
const parent = yield* user(chat.id, "tool abort")
|
const parent = yield* user(chat.id, "tool abort")
|
||||||
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
||||||
const mdl = model(100)
|
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
|
||||||
const handle = yield* processors.create({
|
const handle = yield* processors.create({
|
||||||
assistantMessage: msg,
|
assistantMessage: msg,
|
||||||
sessionID: chat.id,
|
sessionID: chat.id,
|
||||||
|
|
@ -700,7 +581,15 @@ it.live("session.processor effect tests mark pending tools as aborted on cleanup
|
||||||
})
|
})
|
||||||
.pipe(Effect.forkChild)
|
.pipe(Effect.forkChild)
|
||||||
|
|
||||||
yield* Effect.promise(() => ready.promise)
|
yield* llm.wait(1)
|
||||||
|
yield* Effect.promise(async () => {
|
||||||
|
const end = Date.now() + 500
|
||||||
|
while (Date.now() < end) {
|
||||||
|
const parts = await MessageV2.parts(msg.id)
|
||||||
|
if (parts.some((part) => part.type === "tool")) return
|
||||||
|
await Bun.sleep(10)
|
||||||
|
}
|
||||||
|
})
|
||||||
yield* Fiber.interrupt(run)
|
yield* Fiber.interrupt(run)
|
||||||
|
|
||||||
const exit = yield* Fiber.await(run)
|
const exit = yield* Fiber.await(run)
|
||||||
|
|
@ -708,45 +597,38 @@ it.live("session.processor effect tests mark pending tools as aborted on cleanup
|
||||||
yield* handle.abort()
|
yield* handle.abort()
|
||||||
}
|
}
|
||||||
const parts = yield* Effect.promise(() => MessageV2.parts(msg.id))
|
const parts = yield* Effect.promise(() => MessageV2.parts(msg.id))
|
||||||
const tool = parts.find((part): part is MessageV2.ToolPart => part.type === "tool")
|
const call = parts.find((part): part is MessageV2.ToolPart => part.type === "tool")
|
||||||
|
|
||||||
expect(Exit.isFailure(exit)).toBe(true)
|
expect(Exit.isFailure(exit)).toBe(true)
|
||||||
if (Exit.isFailure(exit)) {
|
if (Exit.isFailure(exit)) {
|
||||||
expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true)
|
expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true)
|
||||||
}
|
}
|
||||||
expect(yield* test.calls).toBe(1)
|
expect(yield* llm.calls).toBe(1)
|
||||||
expect(tool?.state.status).toBe("error")
|
expect(call?.state.status).toBe("error")
|
||||||
if (tool?.state.status === "error") {
|
if (call?.state.status === "error") {
|
||||||
expect(tool.state.error).toBe("Tool execution aborted")
|
expect(call.state.error).toBe("Tool execution aborted")
|
||||||
expect(tool.state.time.end).toBeDefined()
|
expect(call.state.time.end).toBeDefined()
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
{ git: true },
|
{ git: true, config: (url) => providerCfg(url) },
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it.live("session.processor effect tests record aborted errors and idle state", () => {
|
|
||||||
return provideTmpdirInstance(
|
|
||||||
(dir) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const ready = defer<void>()
|
|
||||||
const seen = defer<void>()
|
|
||||||
const test = yield* TestLLM
|
|
||||||
const processors = yield* SessionProcessor.Service
|
|
||||||
const session = yield* Session.Service
|
|
||||||
const bus = yield* Bus.Service
|
|
||||||
const status = yield* SessionStatus.Service
|
|
||||||
|
|
||||||
yield* test.push((input) =>
|
|
||||||
hang(input, start()).pipe(
|
|
||||||
Stream.tap((event) => (event.type === "start" ? Effect.sync(() => ready.resolve()) : Effect.void)),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.live("session.processor effect tests record aborted errors and idle state", () =>
|
||||||
|
provideTmpdirServer(
|
||||||
|
({ dir, llm }) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const seen = defer<void>()
|
||||||
|
const { processors, session, provider } = yield* boot()
|
||||||
|
const bus = yield* Bus.Service
|
||||||
|
const sts = yield* SessionStatus.Service
|
||||||
|
|
||||||
|
yield* llm.hang
|
||||||
|
|
||||||
const chat = yield* session.create({})
|
const chat = yield* session.create({})
|
||||||
const parent = yield* user(chat.id, "abort")
|
const parent = yield* user(chat.id, "abort")
|
||||||
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
||||||
const mdl = model(100)
|
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
|
||||||
const errs: string[] = []
|
const errs: string[] = []
|
||||||
const off = yield* bus.subscribeCallback(Session.Event.Error, (evt) => {
|
const off = yield* bus.subscribeCallback(Session.Event.Error, (evt) => {
|
||||||
if (evt.properties.sessionID !== chat.id) return
|
if (evt.properties.sessionID !== chat.id) return
|
||||||
|
|
@ -779,7 +661,7 @@ it.live("session.processor effect tests record aborted errors and idle state", (
|
||||||
})
|
})
|
||||||
.pipe(Effect.forkChild)
|
.pipe(Effect.forkChild)
|
||||||
|
|
||||||
yield* Effect.promise(() => ready.promise)
|
yield* llm.wait(1)
|
||||||
yield* Fiber.interrupt(run)
|
yield* Fiber.interrupt(run)
|
||||||
|
|
||||||
const exit = yield* Fiber.await(run)
|
const exit = yield* Fiber.await(run)
|
||||||
|
|
@ -788,7 +670,7 @@ it.live("session.processor effect tests record aborted errors and idle state", (
|
||||||
}
|
}
|
||||||
yield* Effect.promise(() => seen.promise)
|
yield* Effect.promise(() => seen.promise)
|
||||||
const stored = yield* Effect.promise(() => MessageV2.get({ sessionID: chat.id, messageID: msg.id }))
|
const stored = yield* Effect.promise(() => MessageV2.get({ sessionID: chat.id, messageID: msg.id }))
|
||||||
const state = yield* status.get(chat.id)
|
const state = yield* sts.get(chat.id)
|
||||||
off()
|
off()
|
||||||
|
|
||||||
expect(Exit.isFailure(exit)).toBe(true)
|
expect(Exit.isFailure(exit)).toBe(true)
|
||||||
|
|
@ -803,30 +685,23 @@ it.live("session.processor effect tests record aborted errors and idle state", (
|
||||||
expect(state).toMatchObject({ type: "idle" })
|
expect(state).toMatchObject({ type: "idle" })
|
||||||
expect(errs).toContain("MessageAbortedError")
|
expect(errs).toContain("MessageAbortedError")
|
||||||
}),
|
}),
|
||||||
{ git: true },
|
{ git: true, config: (url) => providerCfg(url) },
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it.live("session.processor effect tests mark interruptions aborted without manual abort", () => {
|
|
||||||
return provideTmpdirInstance(
|
|
||||||
(dir) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const ready = defer<void>()
|
|
||||||
const processors = yield* SessionProcessor.Service
|
|
||||||
const session = yield* Session.Service
|
|
||||||
const status = yield* SessionStatus.Service
|
|
||||||
const test = yield* TestLLM
|
|
||||||
|
|
||||||
yield* test.push((input) =>
|
|
||||||
hang(input, start()).pipe(
|
|
||||||
Stream.tap((event) => (event.type === "start" ? Effect.sync(() => ready.resolve()) : Effect.void)),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.live("session.processor effect tests mark interruptions aborted without manual abort", () =>
|
||||||
|
provideTmpdirServer(
|
||||||
|
({ dir, llm }) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const { processors, session, provider } = yield* boot()
|
||||||
|
const sts = yield* SessionStatus.Service
|
||||||
|
|
||||||
|
yield* llm.hang
|
||||||
|
|
||||||
const chat = yield* session.create({})
|
const chat = yield* session.create({})
|
||||||
const parent = yield* user(chat.id, "interrupt")
|
const parent = yield* user(chat.id, "interrupt")
|
||||||
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
||||||
const mdl = model(100)
|
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
|
||||||
const handle = yield* processors.create({
|
const handle = yield* processors.create({
|
||||||
assistantMessage: msg,
|
assistantMessage: msg,
|
||||||
sessionID: chat.id,
|
sessionID: chat.id,
|
||||||
|
|
@ -852,12 +727,12 @@ it.live("session.processor effect tests mark interruptions aborted without manua
|
||||||
})
|
})
|
||||||
.pipe(Effect.forkChild)
|
.pipe(Effect.forkChild)
|
||||||
|
|
||||||
yield* Effect.promise(() => ready.promise)
|
yield* llm.wait(1)
|
||||||
yield* Fiber.interrupt(run)
|
yield* Fiber.interrupt(run)
|
||||||
|
|
||||||
const exit = yield* Fiber.await(run)
|
const exit = yield* Fiber.await(run)
|
||||||
const stored = yield* Effect.promise(() => MessageV2.get({ sessionID: chat.id, messageID: msg.id }))
|
const stored = yield* Effect.promise(() => MessageV2.get({ sessionID: chat.id, messageID: msg.id }))
|
||||||
const state = yield* status.get(chat.id)
|
const state = yield* sts.get(chat.id)
|
||||||
|
|
||||||
expect(Exit.isFailure(exit)).toBe(true)
|
expect(Exit.isFailure(exit)).toBe(true)
|
||||||
expect(handle.message.error?.name).toBe("MessageAbortedError")
|
expect(handle.message.error?.name).toBe("MessageAbortedError")
|
||||||
|
|
@ -867,6 +742,6 @@ it.live("session.processor effect tests mark interruptions aborted without manua
|
||||||
}
|
}
|
||||||
expect(state).toMatchObject({ type: "idle" })
|
expect(state).toMatchObject({ type: "idle" })
|
||||||
}),
|
}),
|
||||||
{ git: true },
|
{ git: true, config: (url) => providerCfg(url) },
|
||||||
|
),
|
||||||
)
|
)
|
||||||
})
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue