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 <noreply@paperclip.ing>
This commit is contained in:
Devin FoleyandPaperclip authored and GitHub committed 2026-09-29 16:36:36 -07:00
1 parent 3b34d45a92
commit 35a4448c02
7 files changed
+107 -22

No files matched your search

+9
View File
@@ -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.
@@ -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) => {
@@ -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<string, string>, 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<AdapterExec
} as Record<string, unknown>)
: 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 ||
@@ -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("");
});
});
@@ -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);
@@ -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");
@@ -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;