diff --git a/docs/adapters/overview.md b/docs/adapters/overview.md index 1f5717cffb..7a38872d2d 100644 --- a/docs/adapters/overview.md +++ b/docs/adapters/overview.md @@ -137,3 +137,12 @@ Rough tiers, richest first: ## UI Parser Contract External adapters can ship a self-contained UI parser that tells the Paperclip web UI how to render their stdout. Without it, the UI uses a generic shell parser. See the [UI Parser Contract](/adapters/adapter-ui-parser) for details. + +### Cursor failure details + +The Cursor CLI adapter uses structured error output first, then the first +nonempty diagnostic line. It skips the informational `cursor-retrieval: tracing +to ...` file-location notice. If the CLI exits unsuccessfully with only that +notice, the run shows the exit code. The original stdout and stderr remain in +the local run result and log for troubleshooting. Environment probes use the +same diagnostic selection. This does not change retry or success decisions. diff --git a/packages/adapters/cursor-local/src/server/execute.test.ts b/packages/adapters/cursor-local/src/server/execute.test.ts index f8d555169a..913d34cdc4 100644 --- a/packages/adapters/cursor-local/src/server/execute.test.ts +++ b/packages/adapters/cursor-local/src/server/execute.test.ts @@ -138,6 +138,43 @@ function createFreshLeaseSandboxRunner(options: { } describe("cursor execute", () => { + it.each([ + { detail: "Authentication failed", structured: "", expected: "Authentication failed" }, + { detail: "", structured: "", expected: "Cursor exited with code 7" }, + { detail: "stderr fallback", structured: '{"type":"error","message":"Structured failure"}', expected: "Structured failure" }, + ])("keeps the actual failure after a retrieval trace announcement: $expected", async ({ detail, structured, expected }) => { + setPrepareCursorSandboxCommand.mockReset(); + setPrepareCursorSandboxCommand.mockImplementation(async (input) => ({ + command: input.command, env: input.env, remoteSystemHomeDir: null, + addedPathEntry: null, preferredCommandPath: null, + })); + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-cursor-diagnostic-")); + const command = path.join(root, "agent.sh"); + const trace = "cursor-retrieval: tracing to '/tmp/fixture-cursor-retrieval.log'"; + // Values are fixed test fixtures, passed through env rather than shell code. + await fs.writeFile(command, `#!/bin/sh +cat >/dev/null +printf '%s\\n' "$FIXTURE_TRACE" "$FIXTURE_DETAIL" >&2 +printf '%s\\n' "$FIXTURE_STRUCTURED" +exit 7 +`, { mode: 0o755 }); + try { + const result = await execute({ + runId: "run-diagnostic-1", + agent: { id: "agent-1", companyId: "company-1", name: "Cursor", adapterType: "cursor", adapterConfig: {} }, + runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null }, + config: { command, cwd: root, env: { FIXTURE_TRACE: trace, FIXTURE_DETAIL: detail, FIXTURE_STRUCTURED: structured } }, + context: createPromptContextFixture(), authToken: "fixture-run-token", onLog: async () => {}, + }); + expect(result.exitCode).toBe(7); + expect(result.errorMessage).toBe(expected); + expect(result.resultJson?.stderr).toContain(trace); + expect(result.resultJson?.stderr).toContain(detail); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + it("installs the default agent command on a fresh sandbox lease before execution", async () => { setPrepareCursorSandboxCommand.mockReset(); setPrepareCursorSandboxCommand.mockImplementation(async (input) => { diff --git a/packages/adapters/cursor-local/src/server/execute.ts b/packages/adapters/cursor-local/src/server/execute.ts index f11ca77a3a..6083145372 100644 --- a/packages/adapters/cursor-local/src/server/execute.ts +++ b/packages/adapters/cursor-local/src/server/execute.ts @@ -52,7 +52,7 @@ import { joinPromptSections, } from "@paperclipai/adapter-utils/server-utils"; import { DEFAULT_CURSOR_LOCAL_MODEL, SANDBOX_INSTALL_COMMAND } from "../index.js"; -import { parseCursorJsonl, isCursorUnknownSessionError } from "./parse.js"; +import { firstCursorDiagnosticLine, parseCursorJsonl, isCursorUnknownSessionError } from "./parse.js"; import { prepareCursorSandboxCommand } from "./remote-command.js"; import { normalizeCursorStreamLine } from "../shared/stream.js"; import { hasCursorTrustBypassArg } from "../shared/trust.js"; @@ -60,15 +60,6 @@ import { resolveCursorSkillsHome } from "./skills.js"; const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); -function firstNonEmptyLine(text: string): string { - return ( - text - .split(/\r?\n/) - .map((line) => line.trim()) - .find(Boolean) ?? "" - ); -} - function hasNonEmptyEnvValue(env: Record, key: string): boolean { const raw = env[key]; return typeof raw === "string" && raw.trim().length > 0; @@ -726,7 +717,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise) : null; const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : ""; - const stderrLine = firstNonEmptyLine(attempt.proc.stderr); + const stderrLine = firstCursorDiagnosticLine(attempt.proc.stderr); const fallbackErrorMessage = parsedError || stderrLine || diff --git a/packages/adapters/cursor-local/src/server/parse.test.ts b/packages/adapters/cursor-local/src/server/parse.test.ts new file mode 100644 index 0000000000..0790626079 --- /dev/null +++ b/packages/adapters/cursor-local/src/server/parse.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { firstCursorDiagnosticLine } from "./parse.js"; + +describe("Cursor diagnostic selection", () => { + it("skips trace announcements with CRLF, whitespace and ANSI color", () => { + expect(firstCursorDiagnosticLine("\r\n\x1b[90m cursor-retrieval: tracing to '/tmp/fixture.log' \x1b[0m\r\ncursor-retrieval: tracing to \"/tmp/second.log\"\r\nAuthentication failed\r\nmore detail")) + .toBe("Authentication failed"); + }); + + it("keeps retrieval failures and unknown diagnostic formats", () => { + for (const line of [ + "cursor-retrieval: failed to open trace file", + "cursor-retrieval: tracing to '/tmp/fixture.log' failed: permission denied", + "unknown warning: preserve this diagnostic", + ]) expect(firstCursorDiagnosticLine(line)).toBe(line); + }); + + it("leaves no diagnostic when only a trace location was printed", () => { + expect(firstCursorDiagnosticLine("cursor-retrieval: tracing to '/tmp/fixture.log'\n")).toBe(""); + expect(firstCursorDiagnosticLine("\n\r\n")).toBe(""); + }); +}); diff --git a/packages/adapters/cursor-local/src/server/parse.ts b/packages/adapters/cursor-local/src/server/parse.ts index 8644e106a4..053e5f2a80 100644 --- a/packages/adapters/cursor-local/src/server/parse.ts +++ b/packages/adapters/cursor-local/src/server/parse.ts @@ -1,6 +1,17 @@ +import { stripVTControlCharacters } from "node:util"; import { asString, asNumber, parseObject, parseJson } from "@paperclipai/adapter-utils/server-utils"; import { normalizeCursorStreamLine } from "../shared/stream.js"; +/** Select diagnostics without mistaking Cursor's trace-file notice for an error. */ +export function firstCursorDiagnosticLine(text: string): string { + for (const raw of text.split(/\r?\n/)) { + const line = stripVTControlCharacters(raw).trim(); + if (!line || /^cursor-retrieval: tracing to (?:'[^']*'|"[^"]*")$/.test(line)) continue; + return line; + } + return ""; +} + function asErrorText(value: unknown): string { if (typeof value === "string") return value; const rec = parseObject(value); diff --git a/packages/adapters/cursor-local/src/server/test.test.ts b/packages/adapters/cursor-local/src/server/test.test.ts index 72b3f9ede0..46d241e5a1 100644 --- a/packages/adapters/cursor-local/src/server/test.test.ts +++ b/packages/adapters/cursor-local/src/server/test.test.ts @@ -70,6 +70,30 @@ function createSandboxRunner(options: { homeDir: string; installCommandPath: str } describe("cursor testEnvironment", () => { + it("shows the probe failure after an informational retrieval trace notice", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-cursor-probe-diagnostic-")); + const command = path.join(root, "agent"); + await fs.writeFile(command, `#!/bin/sh +if [ "$1" = "--version" ]; then + printf '%s\\n' 'Cursor Agent 1.2.3' + exit 0 +fi +printf '%s\\n' "cursor-retrieval: tracing to '/tmp/fixture.log'" 'Provider connection failed' >&2 +exit 7 +`, { mode: 0o755 }); + try { + const result = await testEnvironment({ + companyId: "company-1", adapterType: "cursor", + config: { command: "agent", cwd: root, env: { PATH: `${root}${path.delimiter}${process.env.PATH ?? ""}`, CURSOR_API_KEY: "fixture-cursor-key" } }, + }); + expect(result.checks).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: "cursor_hello_probe_failed", detail: "Provider connection failed" }), + ])); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + it("re-resolves the installed agent under ~/.cursor/bin and verifies --version before the hello probe", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-cursor-envtest-")); const homeDir = path.join(root, "home"); diff --git a/packages/adapters/cursor-local/src/server/test.ts b/packages/adapters/cursor-local/src/server/test.ts index 8059d89768..762177e7d1 100644 --- a/packages/adapters/cursor-local/src/server/test.ts +++ b/packages/adapters/cursor-local/src/server/test.ts @@ -22,7 +22,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { DEFAULT_CURSOR_LOCAL_MODEL, SANDBOX_INSTALL_COMMAND } from "../index.js"; -import { parseCursorJsonl } from "./parse.js"; +import { firstCursorDiagnosticLine, parseCursorJsonl } from "./parse.js"; import { isDefaultCursorCommand, prepareCursorSandboxCommand } from "./remote-command.js"; import { hasCursorTrustBypassArg } from "../shared/trust.js"; @@ -36,17 +36,8 @@ function isNonEmpty(value: unknown): value is string { return typeof value === "string" && value.trim().length > 0; } -function firstNonEmptyLine(text: string): string { - return ( - text - .split(/\r?\n/) - .map((line) => line.trim()) - .find(Boolean) ?? "" - ); -} - function summarizeProbeDetail(stdout: string, stderr: string, parsedError: string | null): string | null { - const raw = parsedError?.trim() || firstNonEmptyLine(stderr) || firstNonEmptyLine(stdout); + const raw = parsedError?.trim() || firstCursorDiagnosticLine(stderr) || firstCursorDiagnosticLine(stdout); if (!raw) return null; const clean = raw.replace(/\s+/g, " ").trim(); const max = 240;