| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258 |
- import { NodeFileSystem } from "@effect/platform-node"
- import { describe, expect } from "bun:test"
- import { Effect, Layer, ServiceMap } from "effect"
- import * as Stream from "effect/Stream"
- import path from "path"
- import { Agent as AgentSvc } from "../../src/agent/agent"
- import { Bus } from "../../src/bus"
- import { Config } from "../../src/config/config"
- 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 { SessionProcessor } from "../../src/session/processor"
- import { MessageID, PartID, SessionID } from "../../src/session/schema"
- import { SessionStatus } from "../../src/session/status"
- import { Snapshot } from "../../src/snapshot"
- 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>
- class TestLLM extends ServiceMap.Service<
- TestLLM,
- {
- readonly reply: (...items: LLM.Event[]) => Effect.Effect<void>
- }
- >()("@test/EmptyToolCallsLLM") {}
- function model(): Provider.Model {
- return {
- id: "test-model",
- providerID: "test",
- name: "Test",
- limit: { context: 128000, output: 4096 },
- cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
- capabilities: {
- toolcall: true,
- attachment: false,
- reasoning: false,
- temperature: true,
- input: { text: true, image: false, audio: false, video: false },
- output: { text: true, image: false, audio: false, video: false },
- },
- api: { npm: "@ai-sdk/openai" },
- options: {},
- } as Provider.Model
- }
- function usage() {
- return {
- inputTokens: 100,
- outputTokens: 41,
- totalTokens: 141,
- }
- }
- const llm = Layer.unwrap(
- Effect.gen(function* () {
- const queue: Script[] = []
- const push = (item: Script) => {
- queue.push(item)
- return Effect.void
- }
- const reply = (...items: LLM.Event[]) => push(Stream.make(...items))
- return Layer.mergeAll(
- Layer.succeed(
- LLM.Service,
- LLM.Service.of({
- stream: () => {
- const item = queue.shift() ?? Stream.empty
- return item
- },
- }),
- ),
- Layer.succeed(TestLLM, TestLLM.of({ reply })),
- )
- }),
- )
- 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,
- Permission.defaultLayer,
- Plugin.defaultLayer,
- Config.defaultLayer,
- status,
- llm,
- ).pipe(Layer.provideMerge(infra))
- const env = SessionProcessor.layer.pipe(Layer.provideMerge(deps))
- const it = testEffect(env)
- describe("session processor empty tool-calls", () => {
- it.effect("converts finish to stop when model returns tool-calls with no tools", () =>
- provideTmpdirInstance(
- (dir) =>
- Effect.gen(function* () {
- const test = yield* TestLLM
- const processors = yield* SessionProcessor.Service
- const session = yield* Session.Service
- yield* test.reply(
- { type: "start" },
- {
- type: "start-step",
- } as LLM.Event,
- {
- type: "finish-step",
- finishReason: "tool-calls",
- usage: usage(),
- providerMetadata: undefined,
- } as LLM.Event,
- { type: "finish" } as LLM.Event,
- )
- const chat = yield* session.create({})
- const parent = yield* session.updateMessage({
- id: MessageID.ascending(),
- role: "user",
- sessionID: chat.id,
- agent: "code",
- model: ref,
- time: { created: Date.now() },
- })
- const msg: MessageV2.Assistant = {
- id: MessageID.ascending(),
- role: "assistant",
- sessionID: chat.id,
- parentID: parent.id,
- mode: "code",
- agent: "code",
- path: { cwd: path.resolve(dir), root: path.resolve(dir) },
- cost: 0,
- tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
- modelID: ref.modelID,
- providerID: ref.providerID,
- time: { created: Date.now() },
- }
- yield* session.updateMessage(msg)
- const mdl = model()
- const handle = yield* processors.create({
- assistantMessage: msg,
- sessionID: chat.id,
- model: mdl,
- })
- const input: LLM.StreamInput = {
- user: parent as MessageV2.User,
- sessionID: chat.id,
- model: mdl,
- agent: { name: "code", mode: "primary", permission: [], options: {} } as any,
- system: [],
- messages: [],
- tools: {},
- }
- yield* handle.process(input)
- expect(handle.message.finish).toBe("stop")
- const parts = MessageV2.parts(msg.id)
- const tools = parts.filter((p) => p.type === "tool")
- expect(tools.length).toBe(0)
- }),
- { git: true },
- ),
- )
- it.live("preserves tool-calls finish when tool parts exist", () =>
- provideTmpdirInstance(
- (dir) =>
- Effect.gen(function* () {
- const test = yield* TestLLM
- const processors = yield* SessionProcessor.Service
- const session = yield* Session.Service
- yield* test.reply(
- { type: "start" },
- {
- type: "start-step",
- } as LLM.Event,
- { type: "tool-input-start", id: "call_1", toolName: "test_tool" } as LLM.Event,
- {
- type: "finish-step",
- finishReason: "tool-calls",
- usage: usage(),
- providerMetadata: undefined,
- } as LLM.Event,
- { type: "finish" } as LLM.Event,
- )
- const chat = yield* session.create({})
- const parent = yield* session.updateMessage({
- id: MessageID.ascending(),
- role: "user",
- sessionID: chat.id,
- agent: "code",
- model: ref,
- time: { created: Date.now() },
- })
- const msg: MessageV2.Assistant = {
- id: MessageID.ascending(),
- role: "assistant",
- sessionID: chat.id,
- parentID: parent.id,
- mode: "code",
- agent: "code",
- path: { cwd: path.resolve(dir), root: path.resolve(dir) },
- cost: 0,
- tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
- modelID: ref.modelID,
- providerID: ref.providerID,
- time: { created: Date.now() },
- }
- yield* session.updateMessage(msg)
- const mdl = model()
- const handle = yield* processors.create({
- assistantMessage: msg,
- sessionID: chat.id,
- model: mdl,
- })
- const input: LLM.StreamInput = {
- user: parent as MessageV2.User,
- sessionID: chat.id,
- model: mdl,
- agent: { name: "code", mode: "primary", permission: [], options: {} } as any,
- system: [],
- messages: [],
- tools: {},
- }
- const result = yield* handle.process(input)
- expect(handle.message.finish).toBe("tool-calls")
- expect(result).toBe("continue")
- const parts = MessageV2.parts(msg.id)
- const tools = parts.filter((p) => p.type === "tool")
- expect(tools.length).toBe(1)
- }),
- { git: true },
- ),
- )
- })
|