Skip to content

Commit ee1d476

Browse files
committed
fix(action): retry cursor-agent with -p after empty chat failure
Add diagnostics (version, invocation mode, merged stderr) and README troubleshooting.
1 parent 58d5bec commit ee1d476

7 files changed

Lines changed: 470 additions & 79 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"cursor-action": patch
3+
---
4+
5+
Retry `cursor-agent` with headless print mode (`-p`) when the primary `chat` invocation fails silently or looks like a CLI mismatch; collect `cursor-agent --version` and add job-summary diagnostics. Document CI troubleshooting in the README.

README.md

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,15 @@
2525
2626
## Inputs
2727
28-
| Input | Required | Default | Description |
29-
| ------------------- | -------- | ------------------- | ---------------------------------------------------------------------------------------------------- |
30-
| `api-key` | ✅ | — | Your Cursor API key. Store as a secret. |
31-
| `prompt` | ✅ | — | The prompt to pass to `cursor-agent`. |
32-
| `cursor-version` | ❌ | `latest` | Cursor CLI build to install. Use `latest` or an exact Cursor lab build id like `2026.03.20-44cb435`. |
33-
| `model` | ❌ | `auto` | Model for the agent to use. |
34-
| `working-directory` | ❌ | `.` | Directory the agent operates in. |
35-
| `permissions` | ❌ | `read-only` | Agent permissions: `read-only`, `read-write`, or `full`. |
36-
| `timeout` | ❌ | `300` | Timeout in seconds before the agent is killed. |
28+
| Input | Required | Default | Description |
29+
| ------------------- | -------- | ----------- | ---------------------------------------------------------------------------------------------------- |
30+
| `api-key` | ✅ | — | Your Cursor API key. Store as a secret. |
31+
| `prompt` | ✅ | — | The prompt to pass to `cursor-agent`. |
32+
| `cursor-version` | ❌ | `latest` | Cursor CLI build to install. Use `latest` or an exact Cursor lab build id like `2026.03.20-44cb435`. |
33+
| `model` | ❌ | `auto` | Model for the agent to use. |
34+
| `working-directory` | ❌ | `.` | Directory the agent operates in. |
35+
| `permissions` | ❌ | `read-only` | Agent permissions: `read-only`, `read-write`, or `full`. |
36+
| `timeout` | ❌ | `300` | Timeout in seconds before the agent is killed. |
3737

3838
## Outputs
3939

@@ -125,6 +125,17 @@ The action caches the extracted Cursor CLI package across jobs using `@actions/c
125125

126126
---
127127

128+
## Troubleshooting (CI / smoke tests)
129+
130+
### `cursor-agent` exits with code 1 and little or no output
131+
132+
- **API key & billing**: Ensure `CURSOR_API_KEY` is set and valid. Agent / headless features may require an eligible Cursor plan; some errors only show up once the CLI talks to Cursor’s API.
133+
- **Model**: The default `model: auto` should work for most accounts. If you pin `model`, confirm that model is available for your subscription.
134+
- **CLI contract changes**: This action first runs `cursor-agent chat …` (with `--allow-*` flags from `permissions`). If that fails with no output or an “unknown command”-style error, it automatically retries using headless **print mode** (`-p`, `--output-format text`) as documented in the [Cursor headless CLI](https://cursor.com/docs/cli/headless) docs.
135+
- **Debugging**: On failure, check the **job summary** — it includes `cursor-agent --version`, which invocation mode was used (`chat` vs `print`), merged stderr, and a **Diagnostics** section when both attempts fail.
136+
137+
---
138+
128139
## Versioning
129140

130141
This project uses [changesets](https://github.com/changesets/changesets) for versioning. See [`.changeset/README.md`](.changeset/README.md) for how to add a changeset when contributing.

__tests__/output.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,16 @@ describe("setOutputs", () => {
9191
expect(outputs.summary).toBe("Output field text");
9292
});
9393

94+
it("extracts summary from JSON text field", async () => {
95+
const result = {
96+
exitCode: 0,
97+
stderr: "",
98+
stdout: JSON.stringify({ text: "Text field value" }),
99+
};
100+
const outputs = await setOutputs(result, false);
101+
expect(outputs.summary).toBe("Text field value");
102+
});
103+
94104
it("handles empty stdout gracefully", async () => {
95105
const result = { exitCode: 0, stderr: "", stdout: "" };
96106
const outputs = await setOutputs(result, false);

__tests__/runner.test.ts

Lines changed: 127 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,14 @@ type ExecFn = (
1111
options?: ExecOptions
1212
) => Promise<number>;
1313

14+
type GetExecOutputFn = (
15+
commandLine: string,
16+
args?: string[],
17+
options?: ExecOptions
18+
) => Promise<{ exitCode: number; stderr: string; stdout: string }>;
19+
1420
const mockExec = mock<ExecFn>();
21+
const mockGetExecOutput = mock<GetExecOutputFn>();
1522
const mockWarning = mock<typeof actionsCore.warning>();
1623

1724
mock.module("@actions/core", () => ({
@@ -23,28 +30,39 @@ mock.module("@actions/core", () => ({
2330

2431
mock.module("@actions/exec", () => ({
2532
exec: mockExec,
33+
getExecOutput: mockGetExecOutput,
2634
}));
2735

2836
const { runAgent } = await import("../src/runner");
2937

38+
/** All `exec` invocations from the last test run. */
39+
const getExecCalls = (): {
40+
args?: string[];
41+
commandLine: string;
42+
options?: ExecOptions;
43+
}[] =>
44+
mockExec.mock.calls.map((call) => {
45+
const [commandLine, args, options] = call as [
46+
string,
47+
string[] | undefined,
48+
ExecOptions | undefined,
49+
];
50+
return { args, commandLine, options };
51+
});
52+
3053
/** First `exec` invocation from the last test run (asserts the call exists). */
3154
const getExecCall = (): {
3255
args?: string[];
3356
commandLine: string;
3457
options?: ExecOptions;
3558
} => {
36-
expect(mockExec).toHaveBeenCalled();
37-
const [call] = mockExec.mock.calls;
38-
expect(call).toBeDefined();
39-
if (call === undefined) {
59+
const calls = getExecCalls();
60+
expect(calls.length).toBeGreaterThan(0);
61+
const [first] = calls;
62+
if (first === undefined) {
4063
throw new Error("expected exec to have been called");
4164
}
42-
const [commandLine, args, options] = call as [
43-
string,
44-
string[] | undefined,
45-
ExecOptions | undefined,
46-
];
47-
return { args, commandLine, options };
65+
return first;
4866
};
4967

5068
const baseInputs: ActionInputs = {
@@ -60,6 +78,11 @@ const baseInputs: ActionInputs = {
6078
describe("runAgent", () => {
6179
beforeEach(() => {
6280
mock.clearAllMocks();
81+
mockGetExecOutput.mockResolvedValue({
82+
exitCode: 0,
83+
stderr: "",
84+
stdout: "cursor-agent 9.9.9-test\n",
85+
});
6386
});
6487

6588
it("calls cursor-agent with correct base args", async () => {
@@ -79,6 +102,7 @@ describe("runAgent", () => {
79102
ignoreReturnCode: true,
80103
})
81104
);
105+
expect(mockGetExecOutput).not.toHaveBeenCalled();
82106
});
83107

84108
it("includes --model flag", async () => {
@@ -122,6 +146,7 @@ describe("runAgent", () => {
122146
mockExec.mockResolvedValue(42);
123147
const result = await runAgent(baseInputs);
124148
expect(result.exitCode).toBe(42);
149+
expect(mockExec).toHaveBeenCalledTimes(2);
125150
});
126151

127152
it("surfaces stderr in a warning when cursor-agent fails", async () => {
@@ -132,6 +157,7 @@ describe("runAgent", () => {
132157

133158
await runAgent(baseInputs);
134159

160+
expect(mockExec).toHaveBeenCalledTimes(1);
135161
expect(mockWarning).toHaveBeenCalledWith(
136162
expect.stringContaining("cursor-agent stderr:")
137163
);
@@ -140,9 +166,99 @@ describe("runAgent", () => {
140166
);
141167
});
142168

169+
it("does not fall back to print when chat fails with substantive stderr", async () => {
170+
mockExec.mockImplementation((_cmd, _args, options) => {
171+
options?.listeners?.stderr?.(Buffer.from("billing error for model\n"));
172+
return Promise.resolve(1);
173+
});
174+
175+
const result = await runAgent(baseInputs);
176+
177+
expect(mockExec).toHaveBeenCalledTimes(1);
178+
expect(result.invocationMode).toBe("chat");
179+
expect(result.diagnostics).toContain("Primary (chat)");
180+
expect(result.diagnostics).not.toContain("Fallback (print)");
181+
});
182+
183+
it("falls back to headless print when chat exits with no output", async () => {
184+
mockExec.mockResolvedValueOnce(1).mockResolvedValueOnce(0);
185+
186+
const result = await runAgent(baseInputs);
187+
188+
expect(mockExec).toHaveBeenCalledTimes(2);
189+
expect(mockGetExecOutput).toHaveBeenCalledWith(
190+
"cursor-agent",
191+
["--version"],
192+
expect.objectContaining({ silent: true })
193+
);
194+
const [, second] = getExecCalls();
195+
const [firstPrintArg] = second?.args ?? [];
196+
expect(firstPrintArg).toBe("-p");
197+
expect(second?.args).toContain("--output-format");
198+
expect(second?.args).toContain("text");
199+
expect(result.exitCode).toBe(0);
200+
expect(result.invocationMode).toBe("print");
201+
});
202+
203+
it("falls back to print when stderr suggests unknown command", async () => {
204+
mockExec
205+
.mockImplementationOnce((_cmd, _args, options) => {
206+
options?.listeners?.stderr?.(
207+
Buffer.from("Error: unknown command chat\n")
208+
);
209+
return Promise.resolve(1);
210+
})
211+
.mockResolvedValueOnce(0);
212+
213+
await runAgent(baseInputs);
214+
215+
expect(mockExec).toHaveBeenCalledTimes(2);
216+
const [, secondUnknown] = getExecCalls();
217+
const [firstPrintArgUnknown] = secondUnknown?.args ?? [];
218+
expect(firstPrintArgUnknown).toBe("-p");
219+
});
220+
221+
it("adds --force on print fallback for read-write permissions", async () => {
222+
mockExec.mockResolvedValueOnce(1).mockResolvedValueOnce(0);
223+
224+
await runAgent({ ...baseInputs, permissions: "read-write" });
225+
226+
const [, secondRw] = getExecCalls();
227+
const printArgs = secondRw?.args;
228+
expect(printArgs).toContain("--force");
229+
});
230+
231+
it("does not add --force on print fallback for read-only", async () => {
232+
mockExec.mockResolvedValueOnce(1).mockResolvedValueOnce(0);
233+
234+
await runAgent({ ...baseInputs, permissions: "read-only" });
235+
236+
const [, secondRo] = getExecCalls();
237+
const printArgs = secondRo?.args;
238+
expect(printArgs).not.toContain("--force");
239+
});
240+
241+
it("merges stderr and sets diagnostics when both invocations fail", async () => {
242+
mockExec
243+
.mockResolvedValueOnce(1)
244+
.mockImplementationOnce((_cmd, _args, options) => {
245+
options?.listeners?.stderr?.(Buffer.from("print mode failed\n"));
246+
return Promise.resolve(2);
247+
});
248+
249+
const result = await runAgent(baseInputs);
250+
251+
expect(result.exitCode).toBe(2);
252+
expect(result.invocationMode).toBe("print");
253+
expect(result.stderr).toContain("primary (chat)");
254+
expect(result.stderr).toContain("fallback (print -p)");
255+
expect(result.diagnostics).toContain("Hints:");
256+
expect(result.diagnostics).toContain("CURSOR_API_KEY");
257+
});
258+
143259
it("sets CURSOR_DISABLE_UPDATE for pinned versions", async () => {
144260
mockExec.mockResolvedValue(0);
145-
await runAgent({ ...baseInputs, cursorVersion: "1.2.3" });
261+
await runAgent({ ...baseInputs, cursorVersion: "2026.03.20-44cb435" });
146262

147263
const { options } = getExecCall();
148264
expect(options?.env?.CURSOR_DISABLE_UPDATE).toBe("1");

src/output.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ const parseSummary = (stdout: string): string => {
2626
if (typeof parsed.output === "string") {
2727
return parsed.output.trim();
2828
}
29+
if (typeof parsed.text === "string") {
30+
return parsed.text.trim();
31+
}
2932
}
3033
} catch {
3134
// Not JSON — fall through to raw text handling
@@ -50,15 +53,25 @@ const writeJobSummary = async (
5053
? "✅ Success"
5154
: `❌ Failed (exit ${result.exitCode})`;
5255

56+
const tableRows: [string, string][] = [
57+
["Status", status],
58+
["Exit Code", String(result.exitCode)],
59+
];
60+
if (result.invocationMode) {
61+
tableRows.push(["Invocation mode", result.invocationMode]);
62+
}
63+
if (result.cliVersion) {
64+
tableRows.push(["cursor-agent --version", result.cliVersion]);
65+
}
66+
5367
await summary
5468
.addHeading("Cursor Agent Run", 2)
5569
.addTable([
5670
[
5771
{ data: "Field", header: true },
5872
{ data: "Value", header: true },
5973
],
60-
["Status", status],
61-
["Exit Code", String(result.exitCode)],
74+
...tableRows.map(([field, value]) => [field, value]),
6275
])
6376
.addHeading("Agent Response", 3)
6477
.addRaw(text ? `\n\`\`\`\n${text}\n\`\`\`\n` : "_No output was produced._");
@@ -72,6 +85,15 @@ const writeJobSummary = async (
7285
);
7386
}
7487

88+
const diag = result.diagnostics?.trim();
89+
if (diag) {
90+
await summary
91+
.addHeading("Diagnostics", 3)
92+
.addRaw(
93+
`\n\`\`\`\n${diag.slice(0, 20_000)}${diag.length > 20_000 ? "\n… (truncated)" : ""}\n\`\`\`\n`
94+
);
95+
}
96+
7597
await summary.write();
7698
};
7799

0 commit comments

Comments
 (0)