Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/report-agent-status-and-failures.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@pungrumpy/cursor-action": patch
---

Handle agent run statuses and report failures accurately:

- Map cancelled and errored agent runs from `run.wait()` to exit code `1` instead of reporting success.
- Surface error messages from `runResult.error` in stderr and the step summary when a run fails.
- Expose the new `status` output (`finished`, `error`, or `cancelled`).
- Display run duration and total token usage in the job summary table when available from the SDK.
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,11 @@ The action runs on `ubuntu-latest`, `windows-latest`, and `macos-latest`.

### Outputs

| Output | Description |
| ----------- | ----------------------------------------- |
| `summary` | Text summary returned by the cursor-agent |
| `exit-code` | Exit code from the cursor-agent process |
| Output | Description |
| --- | --- |
| `summary` | Text summary returned by the cursor-agent |
| `exit-code` | Exit code from the cursor-agent process |
| `status` | Run status returned by the cursor-agent (finished, error, or cancelled) |

<!-- reference:end -->

Expand Down
27 changes: 27 additions & 0 deletions __tests__/output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,10 @@ describe("setOutputs", () => {
"Here is my analysis."
);
expect(mockSetOutput).toHaveBeenCalledWith("exit-code", "0");
expect(mockSetOutput).toHaveBeenCalledWith("status", "finished");
expect(outputs.summary).toBe("Here is my analysis.");
expect(outputs.exitCode).toBe(0);
expect(outputs.status).toBe("finished");
expect(mockSummaryChain.write).toHaveBeenCalled();
});

Expand Down Expand Up @@ -123,6 +125,31 @@ describe("setOutputs", () => {
const outputs = await setOutputs(result);
expect(outputs.exitCode).toBe(1);
expect(mockSetOutput).toHaveBeenCalledWith("exit-code", "1");
expect(mockSetOutput).toHaveBeenCalledWith("status", "error");
expect(outputs.status).toBe("error");
});

it("includes custom status, duration, and token usage in job summary", async () => {
const result = {
durationMs: 3500,
exitCode: 1,
status: "cancelled",
stderr: "",
stdout: "Done",
usage: {
totalTokens: 250,
},
};
const outputs = await setOutputs(result);
expect(outputs.status).toBe("cancelled");
expect(mockSetOutput).toHaveBeenCalledWith("status", "cancelled");
expect(mockSummaryChain.addTable).toHaveBeenCalledWith(
expect.arrayContaining([
["Agent Status", "cancelled"],
["Duration", "3.5s"],
["Total Tokens", "250"],
])
);
});
});

Expand Down
68 changes: 66 additions & 2 deletions __tests__/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ describe("runAgent", () => {
expect(mockAgentSend).toHaveBeenCalledWith("Analyze this code");
expect(mockRunCancel).not.toHaveBeenCalled();
expect(result.exitCode).toBe(0);
expect(result.status).toBe("finished");
expect(result.stdout).toBe("Hello from stream chunk 1. And chunk 2.");
expect(result.stderr).toBe("");
});
Expand All @@ -114,6 +115,7 @@ describe("runAgent", () => {
const result = await runAgent(baseInputs);

expect(result.exitCode).toBe(1);
expect(result.status).toBe("error");
expect(result.stderr).toContain("Invalid API key");
expect(mockWarning).toHaveBeenCalledWith(
expect.stringContaining("Invalid API key")
Expand All @@ -126,12 +128,20 @@ describe("runAgent", () => {
cancel: mockRunCancel,
stream: streamAfter1100ms,
supports: (op: string) => op === "cancel",
wait: finishedWait(""),
wait: () =>
Promise.resolve({
id: "run-test",
result: "",
status: "cancelled" as const,
}),
});

await runAgent({ ...baseInputs, timeout: 1 });
const result = await runAgent({ ...baseInputs, timeout: 1 });

expect(mockRunCancel).toHaveBeenCalled();
expect(result.exitCode).toBe(1);
expect(result.status).toBe("cancelled");
expect(result.stderr).toContain("timed out");
}, 5000);

it("does not call run.cancel when cancel is unsupported", async () => {
Expand All @@ -147,6 +157,7 @@ describe("runAgent", () => {

expect(mockRunCancel).not.toHaveBeenCalled();
expect(result.exitCode).toBe(0);
expect(result.status).toBe("finished");
}, 5000);

it("returns exitCode 1 when stream throws an error", async () => {
Expand All @@ -160,9 +171,62 @@ describe("runAgent", () => {
const result = await runAgent(baseInputs);

expect(result.exitCode).toBe(1);
expect(result.status).toBe("error");
expect(result.stderr).toContain("Stream aborted");
expect(mockWarning).toHaveBeenCalledWith(
expect.stringContaining("Stream aborted")
);
});

it("returns exitCode 1 and surfaces error message when run.wait() finishes with error status", async () => {
mockAgentSend.mockResolvedValue({
cancel: mockRunCancel,
stream: mockStreamSuccess,
supports: (op: string) => op === "cancel",
wait: () =>
Promise.resolve({
error: { message: "Internal server error during turn" },
id: "run-test",
result: "",
status: "error" as const,
}),
});

const result = await runAgent(baseInputs);

expect(result.exitCode).toBe(1);
expect(result.status).toBe("error");
expect(result.stderr).toContain("Internal server error during turn");
expect(mockWarning).toHaveBeenCalledWith(
expect.stringContaining("Internal server error during turn")
);
});

it("captures usage and duration from run.wait()", async () => {
mockAgentSend.mockResolvedValue({
cancel: mockRunCancel,
stream: mockStreamSuccess,
supports: (op: string) => op === "cancel",
wait: () =>
Promise.resolve({
durationMs: 4200,
id: "run-test",
result: "Done analysis",
status: "finished" as const,
usage: {
cacheReadTokens: 10,
cacheWriteTokens: 5,
inputTokens: 100,
outputTokens: 50,
totalTokens: 150,
},
}),
});

const result = await runAgent(baseInputs);

expect(result.exitCode).toBe(0);
expect(result.durationMs).toBe(4200);
expect(result.usage?.totalTokens).toBe(150);
});
});
4 changes: 4 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ outputs:
description: "Exit code from the cursor-agent process"
value: ${{ steps.agent.outputs.exit-code }}

status:
description: "Run status returned by the cursor-agent (finished, error, or cancelled)"
value: ${{ steps.agent.outputs.status }}

# @cursor/sdk cannot be bundled: it dynamically imports its own webpack chunks
# at runtime and resolves a native @cursor/sdk-<platform> package. It has to be
# installed as a real dependency tree, which a `node24` action cannot do.
Expand Down
76 changes: 56 additions & 20 deletions dist/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -50,18 +50,17 @@ const parseSummary = (stdout) => {
return trimmed.replaceAll(/\u001B\[[0-9;]*[mGKHF]/gu, "");
};
const writeJobSummary = async (text, result) => {
const status = result.exitCode === 0 ? "✅ Success" : `❌ Failed (exit ${result.exitCode})`;
await summary.addHeading("Cursor Agent Run", 2).addTable([
[{
data: "Field",
header: true
}, {
data: "Value",
header: true
}],
["Status", status],
["Exit Code", String(result.exitCode)]
]).addHeading("Agent Response", 3).addRaw(text ? `\n\`\`\`\n${text}\n\`\`\`\n` : "_No output was produced._");
const tableRows = [["Status", result.exitCode === 0 ? "✅ Success" : `❌ Failed (exit ${result.exitCode})`], ["Exit Code", String(result.exitCode)]];
if (result.status) tableRows.push(["Agent Status", result.status]);
if (result.durationMs !== void 0) tableRows.push(["Duration", `${(result.durationMs / 1e3).toFixed(1)}s`]);
if (result.usage?.totalTokens !== void 0) tableRows.push(["Total Tokens", String(result.usage.totalTokens)]);
await summary.addHeading("Cursor Agent Run", 2).addTable([[{
data: "Field",
header: true
}, {
data: "Value",
header: true
}], ...tableRows]).addHeading("Agent Response", 3).addRaw(text ? `\n\`\`\`\n${text}\n\`\`\`\n` : "_No output was produced._");
const errText = result.stderr.trim();
if (errText) await summary.addHeading("Agent Error (stderr)", 3).addRaw(`\n\`\`\`\n${errText.slice(0, 2e4)}${errText.length > 2e4 ? "\n… (truncated)" : ""}\n\`\`\`\n`);
const diag = result.diagnostics?.trim();
Expand All @@ -70,17 +69,35 @@ const writeJobSummary = async (text, result) => {
};
const setOutputs = async (result) => {
const text = parseSummary(result.stdout);
const status = result.status ?? (result.exitCode === 0 ? "finished" : "error");
setOutput("summary", text);
setOutput("exit-code", String(result.exitCode));
setOutput("status", status);
await writeJobSummary(text, result);
return {
exitCode: result.exitCode,
status,
summary: text
};
};
const maskSecret = (apiKey) => setSecret(apiKey);
//#endregion
//#region src/runner.ts
const extractErrorMessage = (error) => {
if (error instanceof Error) return error.cause ? `${error.message}\nCause: ${error.cause}` : error.message;
return String(error);
};
const mapUsage = (usage) => {
if (!usage) return;
const { cacheReadTokens, cacheWriteTokens, inputTokens, outputTokens, totalTokens } = usage;
return {
cacheReadTokens,
cacheWriteTokens,
inputTokens,
outputTokens,
totalTokens
};
};
const runAgent = async (inputs) => {
const cwd = path.resolve(inputs.workingDirectory);
info(`Running Cursor Agent in: ${cwd}`);
Expand All @@ -89,6 +106,9 @@ const runAgent = async (inputs) => {
let stdout = "";
let stderr = "";
let exitCode = 0;
let status = "finished";
let durationMs;
let usage;
try {
const run = await (await Agent.create({
apiKey: inputs.apiKey,
Expand All @@ -97,7 +117,9 @@ const runAgent = async (inputs) => {
})).send(inputs.prompt);
const timeoutMs = inputs.timeout * 1e3;
let cancelTimer;
let timedOut = false;
if (timeoutMs > 0 && Number.isFinite(timeoutMs)) cancelTimer = setTimeout(() => {
timedOut = true;
(async () => {
if (run.supports("cancel")) try {
await run.cancel();
Expand All @@ -106,25 +128,39 @@ const runAgent = async (inputs) => {
}, timeoutMs);
try {
for await (const event of run.stream()) if ("text" in event && typeof event.text === "string") stdout += event.text;
await run.wait();
const finalResult = run.result;
if (finalResult && typeof finalResult === "string") stdout = finalResult;
const runResult = await run.wait();
({durationMs} = runResult);
usage = mapUsage(runResult.usage);
if (runResult.result && typeof runResult.result === "string") stdout = runResult.result;
({status} = runResult);
if (status === "error") {
exitCode = 1;
const msg = runResult.error?.message ?? "Agent run failed with error.";
stderr += stderr ? `\n${msg}` : msg;
warning(`Agent execution failed: ${msg}`);
} else if (status === "cancelled") {
exitCode = 1;
const msg = timedOut ? `Agent run timed out after ${inputs.timeout}s and was cancelled.` : "Agent run was cancelled.";
stderr += stderr ? `\n${msg}` : msg;
warning(msg);
}
} finally {
if (cancelTimer !== void 0) clearTimeout(cancelTimer);
}
} catch (error) {
exitCode = 1;
if (error instanceof Error) {
stderr += error.message;
if (error.cause) stderr += `\nCause: ${error.cause}`;
} else stderr += String(error);
status = "error";
stderr += extractErrorMessage(error);
warning(`Agent execution failed: ${stderr}`);
}
return {
diagnostics: exitCode === 0 ? void 0 : stderr,
durationMs,
exitCode,
status,
stderr,
stdout
stdout,
usage
};
};
//#endregion
Expand Down
12 changes: 6 additions & 6 deletions docs/content/behaviour.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: Behaviour
description: What the action actually does at runtime, including what it gets wrong.
description: What the action does at runtime and how it reports results.
---

## The run
Expand All @@ -9,13 +9,13 @@ description: What the action actually does at runtime, including what it gets wr
2. The API key is registered with `::add-mask::`.
3. A local SDK agent is created against the resolved `working-directory` (`Agent.create({ local: { cwd } })`) and the prompt is sent.
4. Streamed text is collected, then replaced by the final run result when the SDK provides one.
5. A job summary is written with the status, exit code, agent response, and any stderr or diagnostics.
5. A job summary is written with the status, exit code, agent response, duration, token usage, and any stderr or diagnostics.

## Timeouts report success
## Timeouts and failures

When `timeout` elapses the action asks the SDK to cancel the run, and a cancelled run still exits `0`. The same is true when the run itself ends in an error state: `run.wait()` returns a result carrying `status` and `error`, and the action currently ignores both.
When `timeout` elapses, the action requests a run cancellation. Cancelled runs exit with code `1`. The step fails and reports the cancellation in the job summary.

Until v2 maps those onto real exit codes, a green step does not prove the agent finished.
The same applies when a run ends with an error. The action reads `status` and `error` from `run.wait()`, sets exit code `1`, and writes the error details to stderr.

## Why it installs instead of shipping one file

Expand All @@ -27,7 +27,7 @@ The action is a composite action: it sets up Node.js 24, runs `npm ci --omit=dev

## Treat the summary as untrusted

`summary` is model output. Interpolating it into a `run:` script or a `github-script` body splices the text into the script before it executes. Pass it through `env:` instead — every example on [Examples](/examples) does.
`summary` is model output. Interpolating it into a `run:` script or a `github-script` body splices the text into the script before it executes. Pass it through `env:` instead. Every example on [Examples](/examples) does.

## Isolation

Expand Down
5 changes: 1 addition & 4 deletions docs/content/quickstart.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,6 @@ The action runs on `ubuntu-latest`, `windows-latest`, and `macos-latest`.

## What it does not do yet

Two inputs are accepted but have no effect. They are documented on [Reference](/reference), and both are addressed in v2:

- `permissions` does **not** restrict the agent. `read-only` will not stop it from editing files or running shell commands.
- A run that times out or fails inside the agent still reports success.
The `permissions` input does not restrict the agent today. Passing `read-only` will not stop it from editing files or running shell commands. Tool restrictions connect to the SDK in v2.

Read [Behaviour](/behaviour) before wiring this into anything that gates a merge.
15 changes: 9 additions & 6 deletions docs/content/reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,11 @@ The tables below are generated from `action.yml` by `bun run docs:reference`, an

### Outputs

| Output | Description |
| ----------- | ----------------------------------------- |
| `summary` | Text summary returned by the cursor-agent |
| `exit-code` | Exit code from the cursor-agent process |
| Output | Description |
| --- | --- |
| `summary` | Text summary returned by the cursor-agent |
| `exit-code` | Exit code from the cursor-agent process |
| `status` | Run status returned by the cursor-agent (finished, error, or cancelled) |

{/* reference:end */}

Expand All @@ -42,6 +43,8 @@ Ignored since v1.0.0. The SDK resolves the agent version itself, and pinning a C

## Outputs

`summary` is model output. Pass it through `env:` rather than interpolating `${{ steps.<id>.outputs.summary }}` into a `run:` script or a `github-script` body interpolation splices the text into the script before it executes. Every example on [Examples](/examples) does this.
`summary` is model output. Pass it through `env:` instead of interpolating `${{ steps.<id>.outputs.summary }}` into a `run:` script or a `github-script` body, because interpolation splices text before execution. Every example on [Examples](/examples) follows this pattern.

`exit-code` is `0` when the SDK call succeeded and `1` when it threw. It does not yet distinguish a timeout from a failure; see [Behaviour](/behaviour).
`exit-code` is `0` when the agent run finishes successfully. It is `1` when the run times out, cancels, or encounters an error.

`status` reports the terminal state of the agent run: `finished`, `error`, or `cancelled`.
Loading
Loading