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
5 changes: 5 additions & 0 deletions .changeset/collect-assistant-text.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pungrumpy/cursor-action": patch
---

Collect the agent's answer from `assistant` stream events instead of `thinking` events. A cancelled or errored run's `summary` now holds the model's partial answer rather than its reasoning.
75 changes: 68 additions & 7 deletions __tests__/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,32 +33,49 @@ const baseInputs: ActionInputs = {
workingDirectory: ".",
};

const assistantEvent = (text: string) => ({
agent_id: "agent-test",
message: {
content: [{ text, type: "text" as const }],
role: "assistant" as const,
},
run_id: "run-test",
type: "assistant" as const,
});

const thinkingEvent = (text: string) => ({
agent_id: "agent-test",
run_id: "run-test",
text,
type: "thinking" as const,
});

const mockStreamSuccess = async function* mockStreamSuccess() {
yield { text: "Hello from stream chunk 1. " };
yield { text: "And chunk 2." };
yield assistantEvent("Hello from stream chunk 1. ");
yield assistantEvent("And chunk 2.");
};

const mockStreamError = async function* mockStreamError() {
yield { text: "Starting..." };
yield assistantEvent("Starting...");
throw new Error("Stream aborted");
};

/**
* Yields only after 1.1s so a 1s action timeout can fire first.
* @yields {{ text: string }} The chunk that arrives too late.
* @yields {ReturnType<typeof assistantEvent>} The assistant event that arrives too late.
*/
const streamAfter1100ms = async function* streamAfter1100ms() {
await Bun.sleep(1100);
yield { text: "too late" };
yield assistantEvent("too late");
};

/**
* Completes only after 1.2s when cancel is unsupported (timeout still scheduled).
* @yields {{ text: string }} The chunk that arrives after the timeout.
* @yields {ReturnType<typeof assistantEvent>} The assistant event that arrives after the timeout.
*/
const streamAfter1200ms = async function* streamAfter1200ms() {
await Bun.sleep(1200);
yield { text: "ok" };
yield assistantEvent("ok");
};

const finishedWait = (result: string) => () =>
Expand Down Expand Up @@ -229,4 +246,48 @@ describe("runAgent", () => {
expect(result.durationMs).toBe(4200);
expect(result.usage?.totalTokens).toBe(150);
});

it("collects only assistant text when the run is cancelled before a result exists", async () => {
const mixedStream = async function* mixedStream() {
yield thinkingEvent("Let me think about this.");
yield assistantEvent("Partial ");
yield {
agent_id: "agent-test",
run_id: "run-test",
text: "subagent chatter",
type: "task" as const,
};
yield assistantEvent("answer");
};
mockAgentSend.mockResolvedValue({
cancel: mockRunCancel,
stream: mixedStream,
supports: (op: string) => op === "cancel",
wait: () =>
Promise.resolve({ id: "run-test", status: "cancelled" as const }),
});

const result = await runAgent(baseInputs);

expect(result.stdout).toBe("Partial answer");
expect(result.status).toBe("cancelled");
expect(result.exitCode).toBe(1);
});

it("prefers the final result text over the streamed text when the run finishes", async () => {
const partialStream = async function* partialStream() {
yield assistantEvent("streamed draft");
};
mockAgentSend.mockResolvedValue({
cancel: mockRunCancel,
stream: partialStream,
supports: (op: string) => op === "cancel",
wait: finishedWait("final answer"),
});

const result = await runAgent(baseInputs);

expect(result.stdout).toBe("final answer");
expect(result.exitCode).toBe(0);
});
});
13 changes: 12 additions & 1 deletion dist/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,17 @@ const extractErrorMessage = (error) => {
if (error instanceof Error) return error.cause ? `${error.message}\nCause: ${error.cause}` : error.message;
return String(error);
};
/**
* The SDK streams the answer as `assistant` events, one text delta per event.
* `thinking` and `task` events also carry a `text` field, but that is the
* model's reasoning and subagent chatter, not the answer.
*/
const assistantText = (event) => {
if (event.type !== "assistant") return "";
let text = "";
for (const block of event.message.content) if (block.type === "text") text += block.text;
return text;
};
const mapUsage = (usage) => {
if (!usage) return;
const { cacheReadTokens, cacheWriteTokens, inputTokens, outputTokens, totalTokens } = usage;
Expand Down Expand Up @@ -148,7 +159,7 @@ const runAgent = async (inputs) => {
})();
}, timeoutMs);
try {
for await (const event of run.stream()) if ("text" in event && typeof event.text === "string") stdout += event.text;
for await (const event of run.stream()) stdout += assistantText(event);
const runResult = await run.wait();
({durationMs} = runResult);
usage = mapUsage(runResult.usage);
Expand Down
24 changes: 20 additions & 4 deletions src/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import path from "node:path";

import { info, warning } from "@actions/core";
import { Agent } from "@cursor/sdk";
import type { RunResult } from "@cursor/sdk";
import type { RunResult, SDKMessage } from "@cursor/sdk";

import type { ActionInputs, AgentResult, TokenUsageStats } from "./types";

Expand All @@ -15,6 +15,24 @@ const extractErrorMessage = (error: unknown): string => {
return String(error);
};

/**
* The SDK streams the answer as `assistant` events, one text delta per event.
* `thinking` and `task` events also carry a `text` field, but that is the
* model's reasoning and subagent chatter, not the answer.
*/
const assistantText = (event: SDKMessage): string => {
if (event.type !== "assistant") {
return "";
}
let text = "";
for (const block of event.message.content) {
if (block.type === "text") {
text += block.text;
}
}
return text;
};

const mapUsage = (usage: RunResult["usage"]): TokenUsageStats | undefined => {
if (!usage) {
return undefined;
Expand Down Expand Up @@ -85,9 +103,7 @@ export const runAgent = async (inputs: ActionInputs): Promise<AgentResult> => {

try {
for await (const event of run.stream()) {
if ("text" in event && typeof event.text === "string") {
stdout += event.text;
}
stdout += assistantText(event);
}

const runResult = await run.wait();
Expand Down
Loading