diff --git a/.changeset/collect-assistant-text.md b/.changeset/collect-assistant-text.md new file mode 100644 index 0000000..ea03665 --- /dev/null +++ b/.changeset/collect-assistant-text.md @@ -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. diff --git a/__tests__/runner.test.ts b/__tests__/runner.test.ts index 0189f66..286e1a4 100644 --- a/__tests__/runner.test.ts +++ b/__tests__/runner.test.ts @@ -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} 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} 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) => () => @@ -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); + }); }); diff --git a/dist/index.mjs b/dist/index.mjs index 3ca3737..9d4181b 100644 --- a/dist/index.mjs +++ b/dist/index.mjs @@ -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; @@ -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); diff --git a/src/runner.ts b/src/runner.ts index a6e1517..bcff008 100644 --- a/src/runner.ts +++ b/src/runner.ts @@ -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"; @@ -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; @@ -85,9 +103,7 @@ export const runAgent = async (inputs: ActionInputs): Promise => { 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();