Files

78 lines
3.7 KiB
TypeScript

import { describe, expect, test } from "bun:test"
import { LanguageModel, LLMEvent, LLMResponse, Message, Usage } from "../src/effect.js"
import { addUsage, nextRequest, pendingCalls } from "../src/loop.js"
import { OpenAIChat } from "../src/protocols/openai-chat.js"
import { LLM } from "../src/llm.js"
const model = LanguageModel.make({ id: "fictional-model", provider: "fixture", route: OpenAIChat.route })
const step = (...events: ReadonlyArray<LLMEvent>) =>
LLMResponse.fromEvents([...events, LLMEvent.finish({ reason: { normalized: "tool-calls" } })])!
const cutOff = (...events: ReadonlyArray<LLMEvent>) =>
LLMResponse.fromEvents([...events, LLMEvent.finish({ reason: { normalized: "length" } })])!
const read = LLMEvent.toolCall({ id: "call-1", name: "read_file", input: { path: "a.txt" } })
const hosted = LLMEvent.toolCall({ id: "call-2", name: "web_search", input: {}, providerExecuted: true })
const invalid = LLMEvent.toolInputError({ id: "call-4", name: "read_file", raw: '{"path": "a' })
const answer = LLMEvent.toolCall({ id: "call-3", name: "generate_object", input: { text: "hi" } })
describe("pendingCalls", () => {
test("returns client calls and skips provider-executed ones", () => {
expect(pendingCalls(step(read, hosted)).map((item) => item.call.id)).toEqual(["call-1"])
})
test("answers input that is not valid JSON with an error and an empty replayed call", () => {
expect(pendingCalls(step(invalid))).toEqual([
{
call: expect.objectContaining({ type: "tool-call", id: "call-4", name: "read_file", input: {} }),
error: 'Invalid tool arguments: the input is not valid JSON. Received: {"path": "a',
},
])
})
test("returns nothing when the step hit the output token limit", () => {
expect(pendingCalls(cutOff(read, invalid))).toEqual([])
})
test("returns nothing once the answer tool is called", () => {
expect(pendingCalls(step(read, answer), "generate_object")).toEqual([])
expect(pendingCalls(step(read, answer)).map((item) => item.call.id)).toEqual(["call-1", "call-3"])
})
})
describe("nextRequest", () => {
test("appends the assistant message and one tool message per call", () => {
const request = LLM.request({ model, prompt: "Read the file." })
const response = step(read)
const results = [{ result: { type: "text", value: "hello" } as const, events: [] }]
const next = nextRequest(request, response, [{ call: read }], results, false)
expect(next.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"])
expect(next.messages[1]).toBe(response.message)
expect(next.messages[2]).toEqual(
Message.tool({ id: "call-1", name: "read_file", result: { type: "text", value: "hello" } }),
)
})
test("resets a forced toolChoice unless it is kept, and adds the replayed call for invalid input", () => {
const request = LLM.request({ model, prompt: "Read the file.", toolChoice: "required" })
const response = step(invalid)
const calls = pendingCalls(response)
const results = [{ result: { type: "error", value: "bad" } as const, events: [] }]
expect(nextRequest(request, response, calls, results, false).toolChoice?.type).toBe("auto")
expect(nextRequest(request, response, calls, results, true).toolChoice?.type).toBe("required")
expect(nextRequest(request, response, calls, results, false).messages[1].content).toEqual([calls[0].call])
})
})
describe("addUsage", () => {
test("sums counts and keeps the latest context size", () => {
const total = addUsage(
new Usage({ inputTokens: 10, outputTokens: 2, contextTokens: 12 }),
new Usage({ inputTokens: 20, contextTokens: 34 }),
)
expect(total).toEqual(new Usage({ inputTokens: 30, outputTokens: 2, contextTokens: 34 }))
})
})