snapshot-tool-race.test.ts 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189
  1. /**
  2. * Reproducer for snapshot race condition with instant tool execution.
  3. *
  4. * When the mock LLM returns a tool call response instantly, the AI SDK
  5. * processes the tool call and executes the tool (e.g. apply_patch) before
  6. * the processor's start-step handler can capture a pre-tool snapshot.
  7. * Both the "before" and "after" snapshots end up with the same git tree
  8. * hash, so computeDiff returns empty and the session summary shows 0 files.
  9. *
  10. * This is a real bug: the snapshot system assumes it can capture state
  11. * before tools run by hooking into start-step, but the AI SDK executes
  12. * tools internally during multi-step processing before emitting events.
  13. */
  14. import { expect } from "bun:test"
  15. import { Effect, Layer } from "effect"
  16. import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
  17. import fs from "fs/promises"
  18. import path from "path"
  19. import { Session } from "@/session/session"
  20. import { SessionPrompt } from "../../src/session/prompt"
  21. import { SessionSummary } from "../../src/session/summary"
  22. import { MessageV2 } from "../../src/session/message-v2"
  23. import { SessionV1 } from "@kirincode-ai/core/v1/session"
  24. import { Database } from "@kirincode-ai/core/database/database"
  25. import { SessionProjector } from "@kirincode-ai/core/session/projector"
  26. import { provideTmpdirServer } from "../fixture/fixture"
  27. import { testEffect } from "../lib/effect"
  28. import { TestLLMServer } from "../lib/llm-server"
  29. import { LSP } from "@/lsp/lsp"
  30. import { MCP } from "../../src/mcp"
  31. import { CrossSpawnSpawner } from "@kirincode-ai/core/cross-spawn-spawner"
  32. import { RuntimeFlags } from "@/effect/runtime-flags"
  33. const mcp = Layer.succeed(
  34. MCP.Service,
  35. MCP.Service.of({
  36. status: () => Effect.succeed({}),
  37. clients: () => Effect.succeed({}),
  38. instructions: () => Effect.succeed([]),
  39. tools: () => Effect.succeed({}),
  40. prompts: () => Effect.succeed({}),
  41. resources: () => Effect.succeed({}),
  42. resourceTemplates: () => Effect.succeed({}),
  43. add: () => Effect.succeed({ status: { status: "disabled" as const } }),
  44. connect: () => Effect.void,
  45. disconnect: () => Effect.void,
  46. getPrompt: () => Effect.succeed(undefined),
  47. readResource: () => Effect.succeed(undefined),
  48. startAuth: () => Effect.die("unexpected MCP auth"),
  49. authenticate: () => Effect.die("unexpected MCP auth"),
  50. finishAuth: () => Effect.die("unexpected MCP auth"),
  51. removeAuth: () => Effect.void,
  52. supportsOAuth: () => Effect.succeed(false),
  53. hasStoredTokens: () => Effect.succeed(false),
  54. getAuthStatus: () => Effect.succeed("not_authenticated" as const),
  55. }),
  56. )
  57. const lsp = Layer.succeed(
  58. LSP.Service,
  59. LSP.Service.of({
  60. init: () => Effect.void,
  61. status: () => Effect.succeed([]),
  62. hasClients: () => Effect.succeed(false),
  63. touchFile: () => Effect.void,
  64. diagnostics: () => Effect.succeed({}),
  65. hover: () => Effect.succeed(undefined),
  66. definition: () => Effect.succeed([]),
  67. references: () => Effect.succeed([]),
  68. implementation: () => Effect.succeed([]),
  69. documentSymbol: () => Effect.succeed([]),
  70. workspaceSymbol: () => Effect.succeed([]),
  71. prepareCallHierarchy: () => Effect.succeed([]),
  72. incomingCalls: () => Effect.succeed([]),
  73. outgoingCalls: () => Effect.succeed([]),
  74. }),
  75. )
  76. const root = LayerNode.group([
  77. SessionPrompt.node,
  78. Session.node,
  79. SessionProjector.node,
  80. SessionSummary.node,
  81. Database.node,
  82. CrossSpawnSpawner.node,
  83. LayerNode.make({ service: TestLLMServer, layer: TestLLMServer.layer, deps: [] }),
  84. ])
  85. const it = testEffect(
  86. LayerNode.compile(root, [
  87. [MCP.node, mcp],
  88. [LSP.node, lsp],
  89. [RuntimeFlags.node, RuntimeFlags.layer({ experimentalEventSystem: true })],
  90. ]),
  91. )
  92. const providerCfg = (url: string) => ({
  93. provider: {
  94. test: {
  95. name: "Test",
  96. id: "test",
  97. env: [],
  98. npm: "@ai-sdk/openai-compatible",
  99. models: {
  100. "test-model": {
  101. id: "test-model",
  102. name: "Test Model",
  103. attachment: false,
  104. reasoning: false,
  105. temperature: false,
  106. tool_call: true,
  107. release_date: "2025-01-01",
  108. limit: { context: 100000, output: 10000 },
  109. cost: { input: 0, output: 0 },
  110. options: {},
  111. },
  112. },
  113. options: {
  114. apiKey: "test-key",
  115. baseURL: url,
  116. },
  117. },
  118. },
  119. })
  120. it.live("tool execution produces non-empty session diff (snapshot race)", () =>
  121. provideTmpdirServer(
  122. Effect.fnUntraced(function* ({ dir, llm }) {
  123. const prompt = yield* SessionPrompt.Service
  124. const sessions = yield* Session.Service
  125. const summary = yield* SessionSummary.Service
  126. const session = yield* sessions.create({
  127. title: "snapshot race test",
  128. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  129. })
  130. // Use bash tool (always registered) to create a file
  131. const command = `echo 'snapshot race test content' > ${path.join(dir, "race-test.txt")}`
  132. yield* llm.toolMatch((hit) => JSON.stringify(hit.body).includes("create the file"), "bash", {
  133. command,
  134. })
  135. yield* llm.textMatch((hit) => JSON.stringify(hit.body).includes("bash"), "done")
  136. // Seed user message
  137. yield* prompt.prompt({
  138. sessionID: session.id,
  139. agent: "build",
  140. noReply: true,
  141. parts: [{ type: "text", text: "create the file" }],
  142. })
  143. // Run the agent loop
  144. const result = yield* prompt.loop({ sessionID: session.id })
  145. expect(result.info.role).toBe("assistant")
  146. // Verify the file was created
  147. const filePath = path.join(dir, "race-test.txt")
  148. const fileExists = yield* Effect.promise(() =>
  149. fs
  150. .access(filePath)
  151. .then(() => true)
  152. .catch(() => false),
  153. )
  154. expect(fileExists).toBe(true)
  155. // Verify the tool call completed (in the first assistant message)
  156. const allMsgs = yield* MessageV2.filterCompactedEffect(session.id)
  157. const user = allMsgs.find(
  158. (msg): msg is SessionV1.WithParts & { info: SessionV1.User } => msg.info.role === "user",
  159. )
  160. const tool = allMsgs
  161. .flatMap((m) => m.parts)
  162. .find((p): p is SessionV1.ToolPart => p.type === "tool" && p.tool === "bash")
  163. expect(tool?.state.status).toBe("completed")
  164. if (!user) throw new Error("Expected user message")
  165. // Poll for the turn diff — summarize() is fire-and-forget.
  166. let diff: Array<{ file?: string }> = []
  167. for (let i = 0; i < 50; i++) {
  168. diff = yield* summary.diff({ sessionID: session.id, messageID: user.info.id })
  169. if (diff.length > 0) break
  170. yield* Effect.sleep("100 millis")
  171. }
  172. expect(diff.length).toBeGreaterThan(0)
  173. }),
  174. { git: true, config: providerCfg },
  175. ),
  176. )