feat: add opencode HTTP API helpers for part PATCH/DELETE
This commit is contained in:
parent
7727e51e5a
commit
450a5bf954
@ -46,6 +46,7 @@ export * from "./session-utils"
|
|||||||
export * from "./tmux"
|
export * from "./tmux"
|
||||||
export * from "./model-suggestion-retry"
|
export * from "./model-suggestion-retry"
|
||||||
export * from "./opencode-server-auth"
|
export * from "./opencode-server-auth"
|
||||||
|
export * from "./opencode-http-api"
|
||||||
export * from "./port-utils"
|
export * from "./port-utils"
|
||||||
export * from "./git-worktree"
|
export * from "./git-worktree"
|
||||||
export * from "./safe-create-hook"
|
export * from "./safe-create-hook"
|
||||||
|
|||||||
176
src/shared/opencode-http-api.test.ts
Normal file
176
src/shared/opencode-http-api.test.ts
Normal file
@ -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",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
141
src/shared/opencode-http-api.ts
Normal file
141
src/shared/opencode-http-api.ts
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
import { getServerBasicAuthHeader } from "./opencode-server-auth"
|
||||||
|
import { log } from "./logger"
|
||||||
|
|
||||||
|
type UnknownRecord = Record<string, unknown>
|
||||||
|
|
||||||
|
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<string, unknown>
|
||||||
|
): Promise<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user