diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index 37aed2426d1..ceba8dab60a 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -147,6 +147,34 @@ export function parseGitHubRemote(url: string): { owner: string; repo: string } return { owner: match[1], repo: match[2] } } +/** + * Extracts displayable text from assistant response parts. + * Handles text, reasoning, and tool-only responses gracefully. + */ +export function extractResponseText(parts: MessageV2.Part[]): string { + // Priority 1: Look for text parts + const textPart = parts.findLast((p) => p.type === "text") + if (textPart) return textPart.text + + // Priority 2: Check reasoning parts (for thinking models) + const reasoningPart = parts.findLast((p) => p.type === "reasoning") + if (reasoningPart) return `[Reasoning] ${reasoningPart.text}` + + // Priority 3: Tool-only responses - generate summary from completed tools + const toolParts = parts.filter( + (p): p is MessageV2.ToolPart & { state: MessageV2.ToolStateCompleted } => + p.type === "tool" && p.state.status === "completed", + ) + if (toolParts.length > 0) { + const summaries = toolParts.map((p) => `- ${p.tool}${p.state.title ? `: ${p.state.title}` : ""}`) + return `Completed ${toolParts.length} tool call(s):\n${summaries.join("\n")}` + } + + // Improved error with debugging info + const partTypes = parts.map((p) => p.type).join(", ") || "none" + throw new Error(`Failed to parse response. Part types found: [${partTypes}]`) +} + export const GithubCommand = cmd({ command: "github", describe: "manage GitHub agent", @@ -856,10 +884,7 @@ export const GithubRunCommand = cmd({ ) } - const match = result.parts.findLast((p) => p.type === "text") - if (!match) throw new Error("Failed to parse the text response") - - return match.text + return extractResponseText(result.parts) } async function getOidcToken() { diff --git a/packages/opencode/test/cli/github-action.test.ts b/packages/opencode/test/cli/github-action.test.ts new file mode 100644 index 00000000000..fa9a73e9e63 --- /dev/null +++ b/packages/opencode/test/cli/github-action.test.ts @@ -0,0 +1,138 @@ +import { test, expect, describe } from "bun:test" +import { extractResponseText } from "../../src/cli/cmd/github" +import type { MessageV2 } from "../../src/session/message-v2" + +// Helper to create minimal valid parts +function createTextPart(text: string): MessageV2.Part { + return { + id: "1", + sessionID: "s", + messageID: "m", + type: "text" as const, + text, + } +} + +function createReasoningPart(text: string): MessageV2.Part { + return { + id: "1", + sessionID: "s", + messageID: "m", + type: "reasoning" as const, + text, + time: { start: 0 }, + } +} + +function createToolPart(tool: string, title: string, status: "completed" | "running" = "completed"): MessageV2.Part { + if (status === "completed") { + return { + id: "1", + sessionID: "s", + messageID: "m", + type: "tool" as const, + callID: "c1", + tool, + state: { + status: "completed", + input: {}, + output: "", + title, + metadata: {}, + time: { start: 0, end: 1 }, + }, + } + } + return { + id: "1", + sessionID: "s", + messageID: "m", + type: "tool" as const, + callID: "c1", + tool, + state: { + status: "running", + input: {}, + time: { start: 0 }, + }, + } +} + +function createStepStartPart(): MessageV2.Part { + return { + id: "1", + sessionID: "s", + messageID: "m", + type: "step-start" as const, + } +} + +describe("extractResponseText", () => { + test("returns text from text part", () => { + const parts = [createTextPart("Hello world")] + expect(extractResponseText(parts)).toBe("Hello world") + }) + + test("returns last text part when multiple exist", () => { + const parts = [createTextPart("First"), createTextPart("Last")] + expect(extractResponseText(parts)).toBe("Last") + }) + + test("returns text even when tool parts follow", () => { + const parts = [createTextPart("I'll help with that."), createToolPart("todowrite", "3 todos")] + expect(extractResponseText(parts)).toBe("I'll help with that.") + }) + + test("falls back to reasoning part with prefix", () => { + const parts = [createReasoningPart("Let me think about this...")] + expect(extractResponseText(parts)).toBe("[Reasoning] Let me think about this...") + }) + + test("handles tool-only response with summary (reproduces original bug)", () => { + // This is the exact scenario from the bug report - todowrite with no text + const parts = [createToolPart("todowrite", "8 todos")] + const result = extractResponseText(parts) + expect(result).toContain("Completed 1 tool call") + expect(result).toContain("todowrite: 8 todos") + }) + + test("handles multiple completed tools", () => { + const parts = [ + createToolPart("read", "src/file.ts"), + createToolPart("edit", "src/file.ts"), + createToolPart("bash", "bun test"), + ] + const result = extractResponseText(parts) + expect(result).toContain("Completed 3 tool call") + expect(result).toContain("read: src/file.ts") + expect(result).toContain("edit: src/file.ts") + expect(result).toContain("bash: bun test") + }) + + test("handles tool with empty title", () => { + const parts = [createToolPart("todowrite", "")] + const result = extractResponseText(parts) + expect(result).toContain("Completed 1 tool call") + expect(result).toContain("- todowrite") + expect(result).not.toContain("todowrite:") + }) + + test("ignores running tool parts", () => { + const parts = [createToolPart("bash", "", "running")] + expect(() => extractResponseText(parts)).toThrow("Failed to parse response") + }) + + test("throws with part types on empty array", () => { + expect(() => extractResponseText([])).toThrow("Part types found: [none]") + }) + + test("throws with part types on unhandled parts", () => { + const parts = [createStepStartPart()] + expect(() => extractResponseText(parts)).toThrow("Part types found: [step-start]") + }) + + test("prefers text over reasoning when both present", () => { + const parts = [createReasoningPart("Internal thinking..."), createTextPart("Final answer")] + expect(extractResponseText(parts)).toBe("Final answer") + }) +})