|
|
@@ -0,0 +1,233 @@
|
|
|
+/**
|
|
|
+ * Reproducer for snapshot race condition with instant tool execution.
|
|
|
+ *
|
|
|
+ * When the mock LLM returns a tool call response instantly, the AI SDK
|
|
|
+ * processes the tool call and executes the tool (e.g. apply_patch) before
|
|
|
+ * the processor's start-step handler can capture a pre-tool snapshot.
|
|
|
+ * Both the "before" and "after" snapshots end up with the same git tree
|
|
|
+ * hash, so computeDiff returns empty and the session summary shows 0 files.
|
|
|
+ *
|
|
|
+ * This is a real bug: the snapshot system assumes it can capture state
|
|
|
+ * before tools run by hooking into start-step, but the AI SDK executes
|
|
|
+ * tools internally during multi-step processing before emitting events.
|
|
|
+ */
|
|
|
+import { expect } from "bun:test"
|
|
|
+import { Effect } from "effect"
|
|
|
+import fs from "fs/promises"
|
|
|
+import path from "path"
|
|
|
+import { Session } from "../../src/session"
|
|
|
+import { LLM } from "../../src/session/llm"
|
|
|
+import { SessionPrompt } from "../../src/session/prompt"
|
|
|
+import { SessionSummary } from "../../src/session/summary"
|
|
|
+import { MessageV2 } from "../../src/session/message-v2"
|
|
|
+import { Log } from "../../src/util/log"
|
|
|
+import { provideTmpdirServer } from "../fixture/fixture"
|
|
|
+import { testEffect } from "../lib/effect"
|
|
|
+import { TestLLMServer } from "../lib/llm-server"
|
|
|
+
|
|
|
+// Same layer setup as prompt-effect.test.ts
|
|
|
+import { NodeFileSystem } from "@effect/platform-node"
|
|
|
+import { Layer } from "effect"
|
|
|
+import { Agent as AgentSvc } from "../../src/agent/agent"
|
|
|
+import { Bus } from "../../src/bus"
|
|
|
+import { Command } from "../../src/command"
|
|
|
+import { Config } from "../../src/config/config"
|
|
|
+import { FileTime } from "../../src/file/time"
|
|
|
+import { LSP } from "../../src/lsp"
|
|
|
+import { MCP } from "../../src/mcp"
|
|
|
+import { Permission } from "../../src/permission"
|
|
|
+import { Plugin } from "../../src/plugin"
|
|
|
+import { Provider as ProviderSvc } from "../../src/provider/provider"
|
|
|
+import { SessionCompaction } from "../../src/session/compaction"
|
|
|
+import { SessionProcessor } from "../../src/session/processor"
|
|
|
+import { SessionStatus } from "../../src/session/status"
|
|
|
+import { Shell } from "../../src/shell/shell"
|
|
|
+import { Snapshot } from "../../src/snapshot"
|
|
|
+import { ToolRegistry } from "../../src/tool/registry"
|
|
|
+import { Truncate } from "../../src/tool/truncate"
|
|
|
+import { AppFileSystem } from "../../src/filesystem"
|
|
|
+import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
|
|
+
|
|
|
+Log.init({ print: false })
|
|
|
+
|
|
|
+const mcp = Layer.succeed(
|
|
|
+ MCP.Service,
|
|
|
+ MCP.Service.of({
|
|
|
+ status: () => Effect.succeed({}),
|
|
|
+ clients: () => Effect.succeed({}),
|
|
|
+ tools: () => Effect.succeed({}),
|
|
|
+ prompts: () => Effect.succeed({}),
|
|
|
+ resources: () => Effect.succeed({}),
|
|
|
+ add: () => Effect.succeed({ status: { status: "disabled" as const } }),
|
|
|
+ connect: () => Effect.void,
|
|
|
+ disconnect: () => Effect.void,
|
|
|
+ getPrompt: () => Effect.succeed(undefined),
|
|
|
+ readResource: () => Effect.succeed(undefined),
|
|
|
+ startAuth: () => Effect.die("unexpected MCP auth"),
|
|
|
+ authenticate: () => Effect.die("unexpected MCP auth"),
|
|
|
+ finishAuth: () => Effect.die("unexpected MCP auth"),
|
|
|
+ removeAuth: () => Effect.void,
|
|
|
+ supportsOAuth: () => Effect.succeed(false),
|
|
|
+ hasStoredTokens: () => Effect.succeed(false),
|
|
|
+ getAuthStatus: () => Effect.succeed("not_authenticated" as const),
|
|
|
+ }),
|
|
|
+)
|
|
|
+
|
|
|
+const lsp = Layer.succeed(
|
|
|
+ LSP.Service,
|
|
|
+ LSP.Service.of({
|
|
|
+ init: () => Effect.void,
|
|
|
+ status: () => Effect.succeed([]),
|
|
|
+ hasClients: () => Effect.succeed(false),
|
|
|
+ touchFile: () => Effect.void,
|
|
|
+ diagnostics: () => Effect.succeed({}),
|
|
|
+ hover: () => Effect.succeed(undefined),
|
|
|
+ definition: () => Effect.succeed([]),
|
|
|
+ references: () => Effect.succeed([]),
|
|
|
+ implementation: () => Effect.succeed([]),
|
|
|
+ documentSymbol: () => Effect.succeed([]),
|
|
|
+ workspaceSymbol: () => Effect.succeed([]),
|
|
|
+ prepareCallHierarchy: () => Effect.succeed([]),
|
|
|
+ incomingCalls: () => Effect.succeed([]),
|
|
|
+ outgoingCalls: () => Effect.succeed([]),
|
|
|
+ }),
|
|
|
+)
|
|
|
+
|
|
|
+const filetime = Layer.succeed(
|
|
|
+ FileTime.Service,
|
|
|
+ FileTime.Service.of({
|
|
|
+ read: () => Effect.void,
|
|
|
+ get: () => Effect.succeed(undefined),
|
|
|
+ assert: () => Effect.void,
|
|
|
+ withLock: (_filepath, fn) => Effect.promise(fn),
|
|
|
+ }),
|
|
|
+)
|
|
|
+
|
|
|
+const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer))
|
|
|
+const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer)
|
|
|
+
|
|
|
+function makeHttp() {
|
|
|
+ const deps = Layer.mergeAll(
|
|
|
+ Session.defaultLayer,
|
|
|
+ Snapshot.defaultLayer,
|
|
|
+ LLM.defaultLayer,
|
|
|
+ AgentSvc.defaultLayer,
|
|
|
+ Command.defaultLayer,
|
|
|
+ Permission.layer,
|
|
|
+ Plugin.defaultLayer,
|
|
|
+ Config.defaultLayer,
|
|
|
+ ProviderSvc.defaultLayer,
|
|
|
+ filetime,
|
|
|
+ lsp,
|
|
|
+ mcp,
|
|
|
+ AppFileSystem.defaultLayer,
|
|
|
+ status,
|
|
|
+ ).pipe(Layer.provideMerge(infra))
|
|
|
+ const registry = ToolRegistry.layer.pipe(Layer.provideMerge(deps))
|
|
|
+ const trunc = Truncate.layer.pipe(Layer.provideMerge(deps))
|
|
|
+ const proc = SessionProcessor.layer.pipe(Layer.provideMerge(deps))
|
|
|
+ const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps))
|
|
|
+ return Layer.mergeAll(
|
|
|
+ TestLLMServer.layer,
|
|
|
+ SessionPrompt.layer.pipe(
|
|
|
+ Layer.provideMerge(compact),
|
|
|
+ Layer.provideMerge(proc),
|
|
|
+ Layer.provideMerge(registry),
|
|
|
+ Layer.provideMerge(trunc),
|
|
|
+ Layer.provideMerge(deps),
|
|
|
+ ),
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+const it = testEffect(makeHttp())
|
|
|
+
|
|
|
+const providerCfg = (url: string) => ({
|
|
|
+ provider: {
|
|
|
+ test: {
|
|
|
+ name: "Test",
|
|
|
+ id: "test",
|
|
|
+ env: [],
|
|
|
+ npm: "@ai-sdk/openai-compatible",
|
|
|
+ models: {
|
|
|
+ "test-model": {
|
|
|
+ id: "test-model",
|
|
|
+ name: "Test Model",
|
|
|
+ attachment: false,
|
|
|
+ reasoning: false,
|
|
|
+ temperature: false,
|
|
|
+ tool_call: true,
|
|
|
+ release_date: "2025-01-01",
|
|
|
+ limit: { context: 100000, output: 10000 },
|
|
|
+ cost: { input: 0, output: 0 },
|
|
|
+ options: {},
|
|
|
+ },
|
|
|
+ },
|
|
|
+ options: {
|
|
|
+ apiKey: "test-key",
|
|
|
+ baseURL: url,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+})
|
|
|
+
|
|
|
+it.live("tool execution produces non-empty session diff (snapshot race)", () =>
|
|
|
+ provideTmpdirServer(
|
|
|
+ Effect.fnUntraced(function* ({ dir, llm }) {
|
|
|
+ const prompt = yield* SessionPrompt.Service
|
|
|
+ const sessions = yield* Session.Service
|
|
|
+
|
|
|
+ const session = yield* sessions.create({
|
|
|
+ title: "snapshot race test",
|
|
|
+ permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
|
|
+ })
|
|
|
+
|
|
|
+ // Use bash tool (always registered) to create a file
|
|
|
+ const command = `echo 'snapshot race test content' > ${path.join(dir, "race-test.txt")}`
|
|
|
+ yield* llm.toolMatch(
|
|
|
+ (hit) => JSON.stringify(hit.body).includes("create the file"),
|
|
|
+ "bash",
|
|
|
+ { command, description: "create test file" },
|
|
|
+ )
|
|
|
+ yield* llm.textMatch(
|
|
|
+ (hit) => JSON.stringify(hit.body).includes("bash"),
|
|
|
+ "done",
|
|
|
+ )
|
|
|
+
|
|
|
+ // Seed user message
|
|
|
+ yield* prompt.prompt({
|
|
|
+ sessionID: session.id,
|
|
|
+ agent: "build",
|
|
|
+ noReply: true,
|
|
|
+ parts: [{ type: "text", text: "create the file" }],
|
|
|
+ })
|
|
|
+
|
|
|
+ // Run the agent loop
|
|
|
+ const result = yield* prompt.loop({ sessionID: session.id })
|
|
|
+ expect(result.info.role).toBe("assistant")
|
|
|
+
|
|
|
+ // Verify the file was created
|
|
|
+ const filePath = path.join(dir, "race-test.txt")
|
|
|
+ const fileExists = yield* Effect.promise(() =>
|
|
|
+ fs.access(filePath).then(() => true).catch(() => false),
|
|
|
+ )
|
|
|
+ expect(fileExists).toBe(true)
|
|
|
+
|
|
|
+ // Verify the tool call completed (in the first assistant message)
|
|
|
+ const allMsgs = yield* Effect.promise(() => MessageV2.filterCompacted(MessageV2.stream(session.id)))
|
|
|
+ const tool = allMsgs
|
|
|
+ .flatMap((m) => m.parts)
|
|
|
+ .find((p): p is MessageV2.ToolPart => p.type === "tool" && p.tool === "bash")
|
|
|
+ expect(tool?.state.status).toBe("completed")
|
|
|
+
|
|
|
+ // Poll for diff — summarize() is fire-and-forget
|
|
|
+ let diff: Awaited<ReturnType<typeof SessionSummary.diff>> = []
|
|
|
+ for (let i = 0; i < 50; i++) {
|
|
|
+ diff = yield* Effect.promise(() => SessionSummary.diff({ sessionID: session.id }))
|
|
|
+ if (diff.length > 0) break
|
|
|
+ yield* Effect.sleep("100 millis")
|
|
|
+ }
|
|
|
+ expect(diff.length).toBeGreaterThan(0)
|
|
|
+ }),
|
|
|
+ { git: true, config: providerCfg },
|
|
|
+ ),
|
|
|
+)
|