diff --git a/packages/coding-agent/src/tools/gh.ts b/packages/coding-agent/src/tools/gh.ts index c8091a329..a39223eaf 100644 --- a/packages/coding-agent/src/tools/gh.ts +++ b/packages/coding-agent/src/tools/gh.ts @@ -6,9 +6,17 @@ import type { AgentToolUpdateCallback, ToolApprovalDecision, } from "@oh-my-pi/pi-agent-core"; -import { prompt, untilAborted } from "@oh-my-pi/pi-utils"; +import { + BINARY_SNIFF_BYTES, + formatBytes, + isProbablyBinaryHeader, + parseImageMetadata, + prompt, + untilAborted, +} from "@oh-my-pi/pi-utils"; import githubDescription from "../prompts/tools/github.md" with { type: "text" }; import * as git from "../utils/git"; +import { loadImageAttachmentInput, webpExclusionForModel } from "../utils/image-loading"; import type { ToolSession } from "."; import { buildTextResult, normalizeOptionalString, requireNonEmpty, resolveGitHubRepo } from "./gh-common"; import { executePrCheckout, executePrCreate, executePrPush } from "./gh-pr-checkout"; @@ -23,6 +31,7 @@ import { import { executeRepoView } from "./gh-view"; import type { OutputMeta } from "./output-meta"; import { ToolError } from "./tool-errors"; +import { toolResult } from "./tool-result"; export { parsePositiveDecimalInt, resolveDefaultRepoMemoized } from "./gh-common"; export { @@ -83,6 +92,34 @@ const githubSchema = type({ type GithubInput = typeof githubSchema.infer; +interface GitHubContentsFile { + type?: string; + encoding?: string; + size?: number; + content?: string; + html_url?: string | null; +} + +type GitHubContentsResponse = GitHubContentsFile | GitHubContentsFile[]; + +function isGitHubContentsFile(response: GitHubContentsResponse): response is GitHubContentsFile { + return !Array.isArray(response) && response.type === "file"; +} + +function buildBinaryFileReadResult( + filePath: string, + size: number, + sourceUrl: string, + repo: string, + branch: string | undefined, +): AgentToolResult { + return buildTextResult( + `[Cannot read binary file '${filePath}' (${formatBytes(size)}); not valid UTF-8 text. Open ${sourceUrl} to view it.]`, + sourceUrl, + { repo, branch }, + ); +} + export interface GhToolDetails { meta?: OutputMeta; artifactId?: string; @@ -232,15 +269,68 @@ async function executeFileRead( "--method", "GET", "-H", - "Accept: application/vnd.github.raw+json", + "Accept: application/vnd.github+json", + "-H", + "Accept-Encoding: identity", ]; if (branch) { args.push("-f", `ref=${branch}`); } - const text = await git.github.text(session.cwd, args, signal, { + const response = await git.github.json(session.cwd, args, signal, { repoProvided: true, trimOutput: false, }); - const sourceUrl = `https://github.com/${repo}/blob/${encodeURIComponent(branch ?? "HEAD")}/${endpointPath}`; - return buildTextResult(text, sourceUrl, { repo, branch }); + if (!isGitHubContentsFile(response)) { + throw new ToolError(`GitHub path '${filePath}' is not a file.`); + } + + const fallbackSourceUrl = `https://github.com/${repo}/blob/${encodeURIComponent(branch ?? "HEAD")}/${endpointPath}`; + const sourceUrl = response.html_url || fallbackSourceUrl; + if (response.encoding !== "base64" || typeof response.content !== "string") { + const size = + typeof response.size === "number" && response.size >= 0 ? formatBytes(response.size) : "unknown size"; + return buildTextResult( + `[GitHub did not return file bytes for '${filePath}' (${size}). Open ${sourceUrl} to view it.]`, + sourceUrl, + { repo, branch }, + ); + } + + const encoded = response.content.replaceAll(/\s/g, ""); + const bytes = Buffer.from(encoded, "base64"); + const imageMetadata = parseImageMetadata(bytes); + if (imageMetadata) { + const image = await loadImageAttachmentInput({ + image: { type: "image", data: encoded, mimeType: imageMetadata.mimeType }, + label: filePath, + uri: sourceUrl, + autoResize: session.settings.get("images.autoResize"), + excludeWebP: webpExclusionForModel(session.getActiveModel?.()), + }); + if (image) { + const dimensions = + imageMetadata.width !== undefined && imageMetadata.height !== undefined + ? `\nDimensions: ${imageMetadata.width}x${imageMetadata.height}` + : ""; + return toolResult({ repo, branch }) + .content([ + { + type: "text", + text: `Image file: ${filePath}\nMIME: ${image.mimeType}\nSize: ${formatBytes(bytes.byteLength)}${dimensions}`, + }, + { type: "image", data: image.data, mimeType: image.mimeType }, + ]) + .sourceUrl(sourceUrl) + .done(); + } + } + + if (isProbablyBinaryHeader(bytes.subarray(0, BINARY_SNIFF_BYTES))) { + return buildBinaryFileReadResult(filePath, bytes.byteLength, sourceUrl, repo, branch); + } + try { + return buildTextResult(new TextDecoder("utf-8", { fatal: true }).decode(bytes), sourceUrl, { repo, branch }); + } catch { + return buildBinaryFileReadResult(filePath, bytes.byteLength, sourceUrl, repo, branch); + } } diff --git a/packages/coding-agent/test/tools/gh.test.ts b/packages/coding-agent/test/tools/gh.test.ts index e40e60c5a..ad0cb6aae 100644 --- a/packages/coding-agent/test/tools/gh.test.ts +++ b/packages/coding-agent/test/tools/gh.test.ts @@ -19,6 +19,9 @@ import * as git from "@oh-my-pi/pi-coding-agent/utils/git"; import * as piUtils from "@oh-my-pi/pi-utils"; import { $which, getAgentDir, hashPath, removeWithRetries, setAgentDir, WhichCachePolicy } from "@oh-my-pi/pi-utils"; +const TINY_PNG_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="; + // Isolate every `git` invocation in this file from the developer's host // configuration. The fixture spawns dozens of git subprocesses against tiny // throwaway repos; any leak from `~/.gitconfig` or system config (LFS filters, @@ -433,8 +436,14 @@ describe("github tool", () => { expect(text).toContain("Topics: cli, github"); }); - it("reads repository files through the GitHub contents API", async () => { - const textSpy = vi.spyOn(git.github, "text").mockResolvedValue('{"version":"16.3.11"}\n'); + it("reads repository text through GitHub's JSON contents API", async () => { + const jsonSpy = vi.spyOn(git.github, "json").mockResolvedValue({ + type: "file", + encoding: "base64", + size: 20, + content: Buffer.from('{"version":"16.3.11"}\n').toString("base64"), + html_url: "https://github.com/can1357/oh-my-pi/blob/main/packages/coding-agent/package.json", + }); const tool = new GithubTool(createSession()); const result = await tool.execute("file-read", { op: "file_read", @@ -445,7 +454,7 @@ describe("github tool", () => { const text = result.content[0]?.type === "text" ? result.content[0].text : ""; expect(text).toBe('{"version":"16.3.11"}\n'); - expect(textSpy).toHaveBeenCalledWith( + expect(jsonSpy).toHaveBeenCalledWith( "/tmp/test", [ "api", @@ -453,7 +462,9 @@ describe("github tool", () => { "--method", "GET", "-H", - "Accept: application/vnd.github.raw+json", + "Accept: application/vnd.github+json", + "-H", + "Accept-Encoding: identity", "-f", "ref=main", ], @@ -462,6 +473,55 @@ describe("github tool", () => { ); }); + it("returns GitHub images as model image content", async () => { + vi.spyOn(git.github, "json").mockResolvedValue({ + type: "file", + encoding: "base64", + size: Buffer.byteLength(TINY_PNG_BASE64, "base64"), + content: TINY_PNG_BASE64, + html_url: "https://github.com/anomalyco/opencode/blob/main/packages/web/src/assets/lander/screenshot.png", + }); + const tool = new GithubTool( + createSession("/tmp/test", Settings.isolated({ "github.enabled": true, "images.autoResize": false })), + ); + const result = await tool.execute("file-read", { + op: "file_read", + repo: "anomalyco/opencode", + branch: "main", + path: "packages/web/src/assets/lander/screenshot.png", + }); + const text = result.content[0]?.type === "text" ? result.content[0].text : ""; + + expect(text).toContain("Image file: packages/web/src/assets/lander/screenshot.png"); + expect(result.content).toContainEqual({ + type: "image", + data: TINY_PNG_BASE64, + mimeType: "image/png", + }); + }); + + it("identifies files GitHub returns without inline bytes", async () => { + const sourceUrl = "https://github.com/anomalyco/opencode/blob/main/packages/web/src/assets/lander/screenshot.png"; + vi.spyOn(git.github, "json").mockResolvedValue({ + type: "file", + encoding: "none", + size: 2 * 1024 * 1024, + html_url: sourceUrl, + }); + const tool = new GithubTool(createSession()); + const result = await tool.execute("file-read", { + op: "file_read", + repo: "anomalyco/opencode", + branch: "main", + path: "packages/web/src/assets/lander/screenshot.png", + }); + const text = result.content[0]?.type === "text" ? result.content[0].text : ""; + + expect(text).toContain("GitHub did not return file bytes"); + expect(text).toContain("packages/web/src/assets/lander/screenshot.png"); + expect(text).toContain(sourceUrl); + }); + it("creates a pull request via gh and renders the resulting summary", async () => { const textCalls: string[][] = []; const textSpy = vi.spyOn(git.github, "text").mockImplementation(async (_cwd, args) => {