diff --git a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts index 9b75150209ee..4ff3de04469b 100644 --- a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts @@ -1,5 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; +import type { InteractionUpdate } from "@cursor/sdk"; import { CursorSettings, EnvironmentId, @@ -311,6 +312,317 @@ describe("CursorAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(Layer.merge(NodeServices.layer, idAllocatorLayer))), ); + it.effect("projects Cursor directory trees and lint diagnostics as file search results", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspace = yield* fileSystem.makeTempDirectoryScoped({ prefix: "cursor-v2-search-" }); + const instanceId = ProviderInstanceId.make("cursor"); + const threadId = ThreadId.make("cursor-search-thread"); + const modelSelection = { instanceId, model: "composer-2.5" }; + const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: workspace, + }); + const emptyLsNode = { + childrenDirs: [], + childrenFiles: [], + childrenWereProcessed: true, + fullSubtreeExtensionCounts: {}, + numFiles: 0, + }; + const updates: ReadonlyArray = [ + { + type: "tool-call-completed", + modelCallId: "native-model-call", + callId: "ls-nested", + toolCall: { + type: "ls", + args: { path: workspace }, + result: { + status: "success", + value: { + directoryTreeRoot: { + ...emptyLsNode, + absPath: workspace, + childrenFiles: [{ name: "README.md" }], + childrenDirs: [ + { + ...emptyLsNode, + absPath: path.join(workspace, "src"), + childrenFiles: [{ name: "index.ts" }], + childrenDirs: [ + { + ...emptyLsNode, + absPath: path.join(workspace, "src", "nested"), + childrenFiles: [{ name: "util.ts" }], + }, + ], + }, + ], + }, + }, + }, + }, + }, + { + type: "tool-call-completed", + modelCallId: "native-model-call", + callId: "ls-empty", + toolCall: { + type: "ls", + args: { path: path.join(workspace, "empty") }, + result: { + status: "success", + value: { + directoryTreeRoot: { ...emptyLsNode, absPath: path.join(workspace, "empty") }, + }, + }, + }, + }, + { + type: "tool-call-completed", + modelCallId: "native-model-call", + callId: "ls-failed", + toolCall: { + type: "ls", + args: { path: path.join(workspace, "missing") }, + result: { status: "error", error: "ENOENT" }, + }, + }, + { + type: "tool-call-completed", + modelCallId: "native-model-call", + callId: "lints", + toolCall: { + type: "readLints", + args: { paths: ["src/a.ts", "src/b.ts"] }, + result: { + status: "success", + value: { + totalFiles: 2, + totalDiagnostics: 3, + fileDiagnostics: [ + { + path: "src/a.ts", + diagnosticsCount: 2, + diagnostics: [ + { + message: "Unused variable", + code: "TS6133", + source: "ts", + severity: "warning", + range: { start: { line: 0, character: 4 } }, + }, + { + message: "Cannot find name", + code: "TS2304", + source: "ts", + severity: "error", + range: { start: { line: 11 } }, + }, + ], + }, + { + path: "src/b.ts", + diagnosticsCount: 1, + diagnostics: [ + { + message: "File-level diagnostic", + code: "TS0", + source: "ts", + severity: "information", + }, + ], + }, + ], + }, + }, + }, + }, + { + type: "tool-call-completed", + modelCallId: "native-model-call", + callId: "lints-empty", + toolCall: { + type: "readLints", + args: { paths: ["src/clean.ts"] }, + result: { + status: "success", + value: { + totalFiles: 1, + totalDiagnostics: 0, + fileDiagnostics: [{ path: "src/clean.ts", diagnosticsCount: 0, diagnostics: [] }], + }, + }, + }, + }, + { + type: "tool-call-completed", + modelCallId: "native-model-call", + callId: "lints-failed", + toolCall: { + type: "readLints", + args: { paths: ["src/a.ts"] }, + result: { status: "error", error: "lint failed" }, + }, + }, + ]; + const adapter = makeCursorAdapterV2({ + instanceId, + settings: yield* decodeCursorSettings({}), + environment: { HOME: workspace }, + fileSystem, + path, + idAllocator: yield* IdAllocatorV2, + serverConfig: yield* ServerConfig.pipe( + Effect.provide(serverConfigLayerTest(workspace, { prefix: "cursor-v2-search-config-" })), + ), + runner: { + assertComplete: Effect.void, + open: () => + Effect.succeed({ + agentId: "native-cursor-search", + listMessages: Effect.succeed([]), + close: Effect.void, + send: (input) => + Effect.gen(function* () { + for (const update of updates) { + if (input.onDelta !== undefined) { + yield* input.onDelta(update).pipe(Effect.orDie); + } + } + return { + agentId: "native-cursor-search", + runId: "native-cursor-run", + wait: Effect.succeed({ + id: "native-cursor-run", + requestId: "native-request", + status: "finished" as const, + model: { id: "composer-2.5" }, + durationMs: 1, + }), + cancel: Effect.void, + }; + }), + }), + }, + }); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("cursor-search-session"), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const now = yield* DateTime.now; + yield* runtime.startTurn({ + threadId, + providerThread, + modelSelection, + runtimePolicy, + runId: RunId.make("cursor-search-run"), + runOrdinal: 1, + providerTurnOrdinal: 1, + attemptId: RunAttemptId.make("cursor-search-attempt"), + rootNodeId: NodeId.make("cursor-search-root"), + appThread: { + id: threadId, + projectId: ProjectId.make("cursor-search-project"), + createdBy: "user", + creationSource: "web", + title: "Cursor search results", + providerInstanceId: instanceId, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: providerThread.id, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }, + message: { + messageId: MessageId.make("cursor-search-message"), + createdBy: "user", + creationSource: "web", + text: "inspect the workspace", + attachments: [], + }, + }); + const events = yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + ); + const fileSearchItems = events.flatMap((event) => + event.type === "turn_item.updated" && + event.turnItem.type === "file_search" && + event.turnItem.status !== "running" + ? [event.turnItem] + : [], + ); + assert.deepEqual( + fileSearchItems.map((item) => ({ + pattern: item.pattern, + status: item.status, + results: item.results, + })), + [ + { + pattern: workspace, + status: "completed", + results: [ + { fileName: path.join(workspace, "README.md") }, + { fileName: path.join(workspace, "src", "index.ts") }, + { fileName: path.join(workspace, "src", "nested", "util.ts") }, + ], + }, + { + pattern: path.join(workspace, "empty"), + status: "completed", + results: undefined, + }, + { + pattern: path.join(workspace, "missing"), + status: "failed", + results: undefined, + }, + { + pattern: "src/a.ts, src/b.ts", + status: "completed", + results: [ + { fileName: "src/a.ts", line: 1, column: 5, preview: "Unused variable" }, + { fileName: "src/a.ts", line: 12, preview: "Cannot find name" }, + { fileName: "src/b.ts", preview: "File-level diagnostic" }, + ], + }, + { + pattern: "src/clean.ts", + status: "completed", + results: undefined, + }, + { + pattern: "src/a.ts", + status: "failed", + results: undefined, + }, + ], + ); + }).pipe(Effect.scoped, Effect.provide(Layer.merge(NodeServices.layer, idAllocatorLayer))), + ); + it("maps Cursor auto and model parameters to SDK selections", () => { assert.deepEqual( cursorSdkModelSelection({ diff --git a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts index 5e8b1441ec90..cbbad9d8d0dc 100644 --- a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts @@ -433,9 +433,28 @@ function cursorToolSearchPattern(toolCall: ToolCall): string | undefined { } } -function cursorToolSearchResults(toolCall: ToolCall): ReadonlyArray<{ +type CursorLsDirectoryNode = Extract< + Extract["result"], + { readonly status: "success" } +>["value"]["directoryTreeRoot"]; + +function cursorLsSearchResults( + node: CursorLsDirectoryNode, + path: Path.Path, +): ReadonlyArray<{ readonly fileName: string }> { + return [ + ...node.childrenFiles.map((file) => ({ fileName: path.join(node.absPath, file.name) })), + ...node.childrenDirs.flatMap((child) => cursorLsSearchResults(child, path)), + ]; +} + +function cursorToolSearchResults( + toolCall: ToolCall, + path: Path.Path, +): ReadonlyArray<{ readonly fileName: string; readonly line?: number; + readonly column?: number; readonly preview?: string; }> { if (toolCall.result?.status !== "success") { @@ -468,6 +487,27 @@ function cursorToolSearchResults(toolCall: ToolCall): ReadonlyArray<{ preview: entry.line, })); }); + case "ls": + return cursorLsSearchResults(toolCall.result.value.directoryTreeRoot, path); + case "readLints": + return toolCall.result.value.fileDiagnostics.flatMap((file) => + file.diagnostics.map((diagnostic) => { + const line = diagnostic.range?.start?.line; + const character = diagnostic.range?.start?.character; + const hasLine = typeof line === "number" && Number.isInteger(line) && line >= 0; + return { + fileName: file.path, + ...(hasLine ? { line: line + 1 } : {}), + ...(hasLine && + typeof character === "number" && + Number.isInteger(character) && + character >= 0 + ? { column: character + 1 } + : {}), + preview: diagnostic.message, + }; + }), + ); case "semSearch": return [ { @@ -1219,7 +1259,7 @@ export function makeCursorAdapterV2( case "ls": case "readLints": case "semSearch": { - const results = cursorToolSearchResults(toolCall); + const results = cursorToolSearchResults(toolCall, path); turnItem = { ...base, type: "file_search",