From 35a4448c02dd1e6e2406a8be255d1145929f3a88 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Tue, 29 Sep 2026 16:36:36 -0700 Subject: [PATCH] fix(cursor): select failure diagnostics after trace notices (#14636) ## Thinking Path > - Paperclip manages work performed by AI agents. > - The Cursor CLI adapter turns process output into run results. > - Cursor can print a trace-file notice before a real error. > - The adapter used the first stderr line as the failure summary. > - This could hide the error behind an informational file path. > - This change selects the first diagnostic after that known notice and preserves the full logs. ## Linked Issues or Issue Description **What happened?** When Cursor exits with a nonzero code, a leading `cursor-retrieval: tracing to ...` notice can become the error summary. A later error remains in stderr but is absent from the summary. If the notice is the only output, the summary does not explain that the process exited unsuccessfully. **Expected behavior** Prefer the structured error, then a stderr diagnostic, then the exit code. Keep the run failed and preserve the original logs. **Steps to reproduce** 1. Use a fixture Cursor executable that writes the trace-file notice to stderr. 2. Write `Authentication failed` on the next line, then exit with code 7. 3. The old adapter reports the trace-file notice. This change reports the authentication error. 4. Repeat with only the notice. This change reports `Cursor exited with code 7`. **Paperclip version or commit** Reproduced against master commit `17780751551b3bc1c2521f7694026c34534c46c9` with local process fixtures. **Deployment mode** Local CLI adapter. The diagnostic helper is also used by environment probes. Searched open Cursor PRs and issues. PRs #14631 and #14435 concern native ACP support; #11106 concerns MCP configuration. None changes this legacy CLI diagnostic selection. ## What Changed - Skip only the exact trace-location notice when choosing a diagnostic line. - Remove terminal control codes from summary candidates. - Use the same selection for execution and environment probes. - Preserve structured-error priority, exit status, retry decisions, and raw stdout/stderr. - Add child-process regression tests and narrow parsing cases. Document the behavior. ## Verification - Two execution regression cases failed on the previous implementation; structured-error priority already passed. - `pnpm exec vitest run packages/adapters/cursor-local`: all 16 tests passed across five files. - `pnpm --filter @paperclipai/adapter-cursor-local typecheck` passed. - `pnpm -r typecheck` passed. - All GitHub CI checks passed on the PR head. One preview-runtime readiness test failed on the first attempt; its full local suite passed (28 tests, three skips) and the failed CI shard passed on retry. No unrelated source change was needed. - The broad local `pnpm test:run` command did not complete in the available verification window and was stopped; no full local-suite pass is claimed. The full sharded GitHub CI suite passed. `pnpm build` passed. - Tests use local fixture processes. They make no Cursor provider requests. ## Risks A future Cursor notice format may no longer match and will remain visible. Retrieval error lines and unknown diagnostics remain visible. This improves diagnosis; it does not claim to fix an unknown provider or machine failure. There are no schema, authentication, cancellation, or retry-policy changes. ## Model Used OpenAI Codex (GPT-6), with reasoning, repository inspection, and command execution. The session does not expose a more specific model revision or context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal Paperclip ticket id or instance-derived details - [x] I have run the targeted tests locally and they pass; full checks are in progress - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip --- docs/adapters/overview.md | 9 +++++ .../cursor-local/src/server/execute.test.ts | 37 +++++++++++++++++++ .../cursor-local/src/server/execute.ts | 13 +------ .../cursor-local/src/server/parse.test.ts | 22 +++++++++++ .../adapters/cursor-local/src/server/parse.ts | 11 ++++++ .../cursor-local/src/server/test.test.ts | 24 ++++++++++++ .../adapters/cursor-local/src/server/test.ts | 13 +------ 7 files changed, 107 insertions(+), 22 deletions(-) create mode 100644 packages/adapters/cursor-local/src/server/parse.test.ts 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;