Skip to content
Open
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
33 changes: 29 additions & 4 deletions packages/opencode/src/cli/cmd/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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() {
Expand Down
138 changes: 138 additions & 0 deletions packages/opencode/test/cli/github-action.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})