From 450a5bf95419893b9a3bfbb14042cf441070d6e2 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 15 Feb 2026 14:48:14 +0900 Subject: [PATCH] feat: add opencode HTTP API helpers for part PATCH/DELETE --- src/shared/index.ts | 1 + src/shared/opencode-http-api.test.ts | 176 +++++++++++++++++++++++++++ src/shared/opencode-http-api.ts | 141 +++++++++++++++++++++ 3 files changed, 318 insertions(+) create mode 100644 src/shared/opencode-http-api.test.ts create mode 100644 src/shared/opencode-http-api.ts diff --git a/src/shared/index.ts b/src/shared/index.ts index 6a0ef5bf0..cbee9bf45 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -46,6 +46,7 @@ export * from "./session-utils" export * from "./tmux" export * from "./model-suggestion-retry" export * from "./opencode-server-auth" +export * from "./opencode-http-api" export * from "./port-utils" export * from "./git-worktree" export * from "./safe-create-hook" diff --git a/src/shared/opencode-http-api.test.ts b/src/shared/opencode-http-api.test.ts new file mode 100644 index 000000000..fc5538b47 --- /dev/null +++ b/src/shared/opencode-http-api.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect, vi, beforeEach } from "bun:test" +import { getServerBaseUrl, patchPart, deletePart } from "./opencode-http-api" + +// Mock fetch globally +const mockFetch = vi.fn() +global.fetch = mockFetch + +// Mock log +vi.mock("./logger", () => ({ + log: vi.fn(), +})) + +import { log } from "./logger" + +describe("getServerBaseUrl", () => { + it("returns baseUrl from client._client.getConfig().baseUrl", () => { + // given + const mockClient = { + _client: { + getConfig: () => ({ baseUrl: "https://api.example.com" }), + }, + } + + // when + const result = getServerBaseUrl(mockClient) + + // then + expect(result).toBe("https://api.example.com") + }) + + it("returns baseUrl from client.session._client.getConfig().baseUrl when first attempt fails", () => { + // given + const mockClient = { + _client: { + getConfig: () => ({}), + }, + session: { + _client: { + getConfig: () => ({ baseUrl: "https://session.example.com" }), + }, + }, + } + + // when + const result = getServerBaseUrl(mockClient) + + // then + expect(result).toBe("https://session.example.com") + }) + + it("returns null for incompatible client", () => { + // given + const mockClient = {} + + // when + const result = getServerBaseUrl(mockClient) + + // then + expect(result).toBeNull() + }) +}) + +describe("patchPart", () => { + beforeEach(() => { + vi.clearAllMocks() + mockFetch.mockResolvedValue({ ok: true }) + process.env.OPENCODE_SERVER_PASSWORD = "testpassword" + process.env.OPENCODE_SERVER_USERNAME = "opencode" + }) + + it("constructs correct URL and sends PATCH with auth", async () => { + // given + const mockClient = { + _client: { + getConfig: () => ({ baseUrl: "https://api.example.com" }), + }, + } + const sessionID = "ses123" + const messageID = "msg456" + const partID = "part789" + const body = { content: "test" } + + // when + const result = await patchPart(mockClient, sessionID, messageID, partID, body) + + // then + expect(result).toBe(true) + expect(mockFetch).toHaveBeenCalledWith( + "https://api.example.com/session/ses123/message/msg456/part/part789", + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "Authorization": "Basic b3BlbmNvZGU6dGVzdHBhc3N3b3Jk", + }, + body: JSON.stringify(body), + } + ) + }) + + it("returns false on network error", async () => { + // given + const mockClient = { + _client: { + getConfig: () => ({ baseUrl: "https://api.example.com" }), + }, + } + mockFetch.mockRejectedValue(new Error("Network error")) + + // when + const result = await patchPart(mockClient, "ses123", "msg456", "part789", {}) + + // then + expect(result).toBe(false) + expect(log).toHaveBeenCalledWith("[opencode-http-api] PATCH error", { + message: "Network error", + url: "https://api.example.com/session/ses123/message/msg456/part/part789", + }) + }) +}) + +describe("deletePart", () => { + beforeEach(() => { + vi.clearAllMocks() + mockFetch.mockResolvedValue({ ok: true }) + process.env.OPENCODE_SERVER_PASSWORD = "testpassword" + process.env.OPENCODE_SERVER_USERNAME = "opencode" + }) + + it("constructs correct URL and sends DELETE", async () => { + // given + const mockClient = { + _client: { + getConfig: () => ({ baseUrl: "https://api.example.com" }), + }, + } + const sessionID = "ses123" + const messageID = "msg456" + const partID = "part789" + + // when + const result = await deletePart(mockClient, sessionID, messageID, partID) + + // then + expect(result).toBe(true) + expect(mockFetch).toHaveBeenCalledWith( + "https://api.example.com/session/ses123/message/msg456/part/part789", + { + method: "DELETE", + headers: { + "Authorization": "Basic b3BlbmNvZGU6dGVzdHBhc3N3b3Jk", + }, + } + ) + }) + + it("returns false on non-ok response", async () => { + // given + const mockClient = { + _client: { + getConfig: () => ({ baseUrl: "https://api.example.com" }), + }, + } + mockFetch.mockResolvedValue({ ok: false, status: 404 }) + + // when + const result = await deletePart(mockClient, "ses123", "msg456", "part789") + + // then + expect(result).toBe(false) + expect(log).toHaveBeenCalledWith("[opencode-http-api] DELETE failed", { + status: 404, + url: "https://api.example.com/session/ses123/message/msg456/part/part789", + }) + }) +}) \ No newline at end of file diff --git a/src/shared/opencode-http-api.ts b/src/shared/opencode-http-api.ts new file mode 100644 index 000000000..22d8afaaa --- /dev/null +++ b/src/shared/opencode-http-api.ts @@ -0,0 +1,141 @@ +import { getServerBasicAuthHeader } from "./opencode-server-auth" +import { log } from "./logger" + +type UnknownRecord = Record + +function isRecord(value: unknown): value is UnknownRecord { + return typeof value === "object" && value !== null +} + +function getInternalClient(client: unknown): UnknownRecord | null { + if (!isRecord(client)) { + return null + } + + const internal = client["_client"] + return isRecord(internal) ? internal : null +} + +export function getServerBaseUrl(client: unknown): string | null { + // Try client._client.getConfig().baseUrl + const internal = getInternalClient(client) + if (internal) { + const getConfig = internal["getConfig"] + if (typeof getConfig === "function") { + const config = getConfig() + if (isRecord(config)) { + const baseUrl = config["baseUrl"] + if (typeof baseUrl === "string") { + return baseUrl + } + } + } + } + + // Try client.session._client.getConfig().baseUrl + if (isRecord(client)) { + const session = client["session"] + if (isRecord(session)) { + const internal = session["_client"] + if (isRecord(internal)) { + const getConfig = internal["getConfig"] + if (typeof getConfig === "function") { + const config = getConfig() + if (isRecord(config)) { + const baseUrl = config["baseUrl"] + if (typeof baseUrl === "string") { + return baseUrl + } + } + } + } + } + } + + return null +} + +export async function patchPart( + client: unknown, + sessionID: string, + messageID: string, + partID: string, + body: Record +): Promise { + const baseUrl = getServerBaseUrl(client) + if (!baseUrl) { + log("[opencode-http-api] Could not extract baseUrl from client") + return false + } + + const auth = getServerBasicAuthHeader() + if (!auth) { + log("[opencode-http-api] No auth header available") + return false + } + + const url = `${baseUrl}/session/${sessionID}/message/${messageID}/part/${partID}` + + try { + const response = await fetch(url, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "Authorization": auth, + }, + body: JSON.stringify(body), + }) + + if (!response.ok) { + log("[opencode-http-api] PATCH failed", { status: response.status, url }) + return false + } + + return true + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + log("[opencode-http-api] PATCH error", { message, url }) + return false + } +} + +export async function deletePart( + client: unknown, + sessionID: string, + messageID: string, + partID: string +): Promise { + const baseUrl = getServerBaseUrl(client) + if (!baseUrl) { + log("[opencode-http-api] Could not extract baseUrl from client") + return false + } + + const auth = getServerBasicAuthHeader() + if (!auth) { + log("[opencode-http-api] No auth header available") + return false + } + + const url = `${baseUrl}/session/${sessionID}/message/${messageID}/part/${partID}` + + try { + const response = await fetch(url, { + method: "DELETE", + headers: { + "Authorization": auth, + }, + }) + + if (!response.ok) { + log("[opencode-http-api] DELETE failed", { status: response.status, url }) + return false + } + + return true + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + log("[opencode-http-api] DELETE error", { message, url }) + return false + } +} \ No newline at end of file