session-processor-empty-tool-calls.test.ts 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  1. import { NodeFileSystem } from "@effect/platform-node"
  2. import { describe, expect } from "bun:test"
  3. import { Effect, Layer, ServiceMap } from "effect"
  4. import * as Stream from "effect/Stream"
  5. import path from "path"
  6. import { Agent as AgentSvc } from "../../src/agent/agent"
  7. import { Bus } from "../../src/bus"
  8. import { Config } from "../../src/config/config"
  9. import { Permission } from "../../src/permission"
  10. import { Plugin } from "../../src/plugin"
  11. import type { Provider } from "../../src/provider/provider"
  12. import { ModelID, ProviderID } from "../../src/provider/schema"
  13. import { Session } from "../../src/session"
  14. import { LLM } from "../../src/session/llm"
  15. import { MessageV2 } from "../../src/session/message-v2"
  16. import { SessionProcessor } from "../../src/session/processor"
  17. import { MessageID, PartID, SessionID } from "../../src/session/schema"
  18. import { SessionStatus } from "../../src/session/status"
  19. import { Snapshot } from "../../src/snapshot"
  20. import { Log } from "../../src/util/log"
  21. import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
  22. import { provideTmpdirInstance } from "../fixture/fixture"
  23. import { testEffect } from "../lib/effect"
  24. Log.init({ print: false })
  25. const ref = {
  26. providerID: ProviderID.make("test"),
  27. modelID: ModelID.make("test-model"),
  28. }
  29. type Script = Stream.Stream<LLM.Event, unknown>
  30. class TestLLM extends ServiceMap.Service<
  31. TestLLM,
  32. {
  33. readonly reply: (...items: LLM.Event[]) => Effect.Effect<void>
  34. }
  35. >()("@test/EmptyToolCallsLLM") {}
  36. function model(): Provider.Model {
  37. return {
  38. id: "test-model",
  39. providerID: "test",
  40. name: "Test",
  41. limit: { context: 128000, output: 4096 },
  42. cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
  43. capabilities: {
  44. toolcall: true,
  45. attachment: false,
  46. reasoning: false,
  47. temperature: true,
  48. input: { text: true, image: false, audio: false, video: false },
  49. output: { text: true, image: false, audio: false, video: false },
  50. },
  51. api: { npm: "@ai-sdk/openai" },
  52. options: {},
  53. } as Provider.Model
  54. }
  55. function usage() {
  56. return {
  57. inputTokens: 100,
  58. outputTokens: 41,
  59. totalTokens: 141,
  60. }
  61. }
  62. const llm = Layer.unwrap(
  63. Effect.gen(function* () {
  64. const queue: Script[] = []
  65. const push = (item: Script) => {
  66. queue.push(item)
  67. return Effect.void
  68. }
  69. const reply = (...items: LLM.Event[]) => push(Stream.make(...items))
  70. return Layer.mergeAll(
  71. Layer.succeed(
  72. LLM.Service,
  73. LLM.Service.of({
  74. stream: () => {
  75. const item = queue.shift() ?? Stream.empty
  76. return item
  77. },
  78. }),
  79. ),
  80. Layer.succeed(TestLLM, TestLLM.of({ reply })),
  81. )
  82. }),
  83. )
  84. const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer))
  85. const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer)
  86. const deps = Layer.mergeAll(
  87. Session.defaultLayer,
  88. Snapshot.defaultLayer,
  89. AgentSvc.defaultLayer,
  90. Permission.defaultLayer,
  91. Plugin.defaultLayer,
  92. Config.defaultLayer,
  93. status,
  94. llm,
  95. ).pipe(Layer.provideMerge(infra))
  96. const env = SessionProcessor.layer.pipe(Layer.provideMerge(deps))
  97. const it = testEffect(env)
  98. describe("session processor empty tool-calls", () => {
  99. it.effect("converts finish to stop when model returns tool-calls with no tools", () =>
  100. provideTmpdirInstance(
  101. (dir) =>
  102. Effect.gen(function* () {
  103. const test = yield* TestLLM
  104. const processors = yield* SessionProcessor.Service
  105. const session = yield* Session.Service
  106. yield* test.reply(
  107. { type: "start" },
  108. {
  109. type: "start-step",
  110. } as LLM.Event,
  111. {
  112. type: "finish-step",
  113. finishReason: "tool-calls",
  114. usage: usage(),
  115. providerMetadata: undefined,
  116. } as LLM.Event,
  117. { type: "finish" } as LLM.Event,
  118. )
  119. const chat = yield* session.create({})
  120. const parent = yield* session.updateMessage({
  121. id: MessageID.ascending(),
  122. role: "user",
  123. sessionID: chat.id,
  124. agent: "code",
  125. model: ref,
  126. time: { created: Date.now() },
  127. })
  128. const msg: MessageV2.Assistant = {
  129. id: MessageID.ascending(),
  130. role: "assistant",
  131. sessionID: chat.id,
  132. parentID: parent.id,
  133. mode: "code",
  134. agent: "code",
  135. path: { cwd: path.resolve(dir), root: path.resolve(dir) },
  136. cost: 0,
  137. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  138. modelID: ref.modelID,
  139. providerID: ref.providerID,
  140. time: { created: Date.now() },
  141. }
  142. yield* session.updateMessage(msg)
  143. const mdl = model()
  144. const handle = yield* processors.create({
  145. assistantMessage: msg,
  146. sessionID: chat.id,
  147. model: mdl,
  148. })
  149. const input: LLM.StreamInput = {
  150. user: parent as MessageV2.User,
  151. sessionID: chat.id,
  152. model: mdl,
  153. agent: { name: "code", mode: "primary", permission: [], options: {} } as any,
  154. system: [],
  155. messages: [],
  156. tools: {},
  157. }
  158. yield* handle.process(input)
  159. expect(handle.message.finish).toBe("stop")
  160. const parts = MessageV2.parts(msg.id)
  161. const tools = parts.filter((p) => p.type === "tool")
  162. expect(tools.length).toBe(0)
  163. }),
  164. { git: true },
  165. ),
  166. )
  167. it.live("preserves tool-calls finish when tool parts exist", () =>
  168. provideTmpdirInstance(
  169. (dir) =>
  170. Effect.gen(function* () {
  171. const test = yield* TestLLM
  172. const processors = yield* SessionProcessor.Service
  173. const session = yield* Session.Service
  174. yield* test.reply(
  175. { type: "start" },
  176. {
  177. type: "start-step",
  178. } as LLM.Event,
  179. { type: "tool-input-start", id: "call_1", toolName: "test_tool" } as LLM.Event,
  180. {
  181. type: "finish-step",
  182. finishReason: "tool-calls",
  183. usage: usage(),
  184. providerMetadata: undefined,
  185. } as LLM.Event,
  186. { type: "finish" } as LLM.Event,
  187. )
  188. const chat = yield* session.create({})
  189. const parent = yield* session.updateMessage({
  190. id: MessageID.ascending(),
  191. role: "user",
  192. sessionID: chat.id,
  193. agent: "code",
  194. model: ref,
  195. time: { created: Date.now() },
  196. })
  197. const msg: MessageV2.Assistant = {
  198. id: MessageID.ascending(),
  199. role: "assistant",
  200. sessionID: chat.id,
  201. parentID: parent.id,
  202. mode: "code",
  203. agent: "code",
  204. path: { cwd: path.resolve(dir), root: path.resolve(dir) },
  205. cost: 0,
  206. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  207. modelID: ref.modelID,
  208. providerID: ref.providerID,
  209. time: { created: Date.now() },
  210. }
  211. yield* session.updateMessage(msg)
  212. const mdl = model()
  213. const handle = yield* processors.create({
  214. assistantMessage: msg,
  215. sessionID: chat.id,
  216. model: mdl,
  217. })
  218. const input: LLM.StreamInput = {
  219. user: parent as MessageV2.User,
  220. sessionID: chat.id,
  221. model: mdl,
  222. agent: { name: "code", mode: "primary", permission: [], options: {} } as any,
  223. system: [],
  224. messages: [],
  225. tools: {},
  226. }
  227. const result = yield* handle.process(input)
  228. expect(handle.message.finish).toBe("tool-calls")
  229. expect(result).toBe("continue")
  230. const parts = MessageV2.parts(msg.id)
  231. const tools = parts.filter((p) => p.type === "tool")
  232. expect(tools.length).toBe(1)
  233. }),
  234. { git: true },
  235. ),
  236. )
  237. })