feat(app): support current event transport (#38464)

This commit is contained in:
Brendan Allan
2026-07-23 17:19:44 +08:00
committed by GitHub
parent 347510a73b
commit e59ba24b80
8 changed files with 527 additions and 121 deletions
@@ -97,6 +97,7 @@ export async function setupTimeline(
locale?: string locale?: string
deviceScaleFactor?: number deviceScaleFactor?: number
seedHistory?: boolean seedHistory?: boolean
protocol?: "v1" | "v2"
} = {}, } = {},
) { ) {
const sessions = input.sessions ?? [session()] const sessions = input.sessions ?? [session()]
@@ -114,6 +115,7 @@ export async function setupTimeline(
retry: input.eventRetry ?? 20, retry: input.eventRetry ?? 20,
}) })
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
protocol: input.protocol,
directory, directory,
project: project(), project: project(),
provider: provider(), provider: provider(),
@@ -89,8 +89,8 @@ test("reconnects after a stream error", async ({ page }) => {
expect((await timeline.transport.connections())[0]?.endedBy).toBe("error") expect((await timeline.transport.connections())[0]?.endedBy).toBe("error")
}) })
test("records event IDs and reconnect Last-Event-ID headers", async ({ page }) => { test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => {
const timeline = await setupTimeline(page, { eventRetry: 10 }) const timeline = await setupTimeline(page, { eventRetry: 10, protocol: "v2" })
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), { const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
id: "timeline-event-7", id: "timeline-event-7",
}) })
@@ -100,7 +100,7 @@ test("records event IDs and reconnect Last-Event-ID headers", async ({ page }) =
const connection = await timeline.transport.waitForConnection({ after: first.connectionID }) const connection = await timeline.transport.waitForConnection({ after: first.connectionID })
expect(first.eventID).toBe("timeline-event-7") expect(first.eventID).toBe("timeline-event-7")
expect(connection.headers["last-event-id"]).toBe("timeline-event-7") expect(connection.headers["last-event-id"]).toBeUndefined()
}) })
test("passes through non-event fetches", async ({ page }) => { test("passes through non-event fetches", async ({ page }) => {
+247 -10
View File
@@ -4,6 +4,7 @@ const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/sta
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"]) const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"])
export interface MockServerConfig { export interface MockServerConfig {
protocol?: "v1" | "v2"
provider: unknown provider: unknown
directory: string directory: string
project: unknown project: unknown
@@ -54,14 +55,21 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
if (url.port !== targetPort && url.port !== appPort) return route.fallback() if (url.port !== targetPort && url.port !== appPort) return route.fallback()
const path = url.pathname const path = url.pathname
if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry) if (path === "/global/event" || path === "/event" || path === "/api/event") {
if (path === "/global/health") return json(route, { healthy: true }) const events = config.events?.()
if (path === "/api/session") return sse(
return json(route, { route,
data: config.sessions.map((session) => v2Session(session, config.directory)), path === "/api/event"
cursor: {}, ? [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events?.map(currentEvent) ?? [])]
}) : events,
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: false }) config.eventRetry,
)
}
if (path === "/global/health")
return config.protocol === "v2" ? json(route, {}, undefined, 404) : json(route, { healthy: true })
if (path === "/api/health" && config.protocol === "v2")
return json(route, { healthy: true, version: "2.0.0", pid: 1 })
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true })
if (path === "/permission") if (path === "/permission")
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])) return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
if (path === "/question") if (path === "/question")
@@ -89,10 +97,122 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
}, },
data: [], data: [],
}) })
if (path === "/api/agent")
return json(route, {
location: location(config),
data: [
{
id: "build",
name: "Build",
mode: "primary",
hidden: false,
request: { settings: {}, headers: {}, body: {} },
permissions: [],
},
],
})
if (path === "/api/command") return json(route, { location: location(config), data: [] })
if (path === "/api/mcp") return json(route, { location: location(config), data: [] })
if (path === "/api/mcp/resource")
return json(route, { location: location(config), data: { resources: [], templates: [] } })
const integration = path.match(/^\/api\/integration\/([^/]+)$/)?.[1]
if (integration && route.request().method() === "GET")
return json(route, {
location: location(config),
data: { id: integration, name: integration, methods: [{ type: "key", label: "API key" }], connections: [] },
})
if (/^\/api\/integration\/[^/]+\/connect\/key$/.test(path) && route.request().method() === "POST")
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
if (path === "/api/project") return json(route, [config.project])
if (path === "/api/project/current")
return json(route, { id: (config.project as { id?: string }).id, directory: config.directory })
if (path.startsWith("/api/project/") && route.request().method() === "PATCH") return json(route, config.project)
if (path === "/api/path")
return json(route, {
state: config.directory,
config: config.directory,
worktree: config.directory,
directory: config.directory,
home: "C:/OpenCode",
})
if (path === "/api/permission/request")
return json(route, {
location: location(config),
data: (typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])).map(
currentPermission,
),
})
if (path === "/api/question/request")
return json(route, {
location: location(config),
data: typeof config.questions === "function" ? config.questions() : (config.questions ?? []),
})
if (path === "/api/vcs")
return json(route, { location: location(config), data: { branch: "main", defaultBranch: "main" } })
if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] })
if (path === "/api/vcs/diff") return json(route, { location: location(config), data: config.vcsDiff ?? [] })
if (path === "/api/pty/shells") return json(route, { location: location(config), data: [] })
if (/^\/api\/pty\/[^/]+\/connect-token$/.test(path))
return json(route, { location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } })
if (emptyObject.has(path)) return json(route, {}) if (emptyObject.has(path)) return json(route, {})
if (emptyList.has(path)) return json(route, []) if (emptyList.has(path)) return json(route, [])
if (path === "/api/session") {
const directory = url.searchParams.get("directory")
const parentID = url.searchParams.get("parentID")
const limit = Number(url.searchParams.get("limit") ?? 50)
const offset = Number(url.searchParams.get("cursor") ?? 0)
const sessions = config.sessions
.filter((session) => !directory || session.directory === directory)
.filter((session) => parentID !== "null" || session.parentID === undefined)
.filter((session) => {
const search = url.searchParams.get("search")?.toLowerCase()
return !search || String(session.title ?? "").toLowerCase().includes(search)
})
const ordered = url.searchParams.get("order") === "asc" ? sessions.toReversed() : sessions
const data = ordered.slice(offset, offset + limit)
const next = offset + limit < ordered.length ? String(offset + limit) : undefined
return json(route, {
data: data.map((session) => currentSession(session, config.directory)),
cursor: { next },
})
}
if (path === "/api/session/active") {
const statuses = (config.sessionStatus ?? {}) as Record<string, { type?: string }>
return json(route, {
data: Object.fromEntries(
Object.entries(statuses).flatMap(([id, status]) => (status.type === "idle" ? [] : [[id, { type: "running" }]])),
),
})
}
if (/^\/api\/session\/[^/]+\/shell$/.test(path) && route.request().method() === "POST") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (/^\/api\/session\/[^/]+\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (
/^\/api\/session\/[^/]+\/(archive|rename|interrupt|revert\/clear|revert\/commit)$/.test(path) &&
route.request().method() === "POST"
) {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (/^\/api\/session\/[^/]+$/.test(path) && route.request().method() === "DELETE") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (path in staticRoutes) return json(route, staticRoutes[path]) if (path in staticRoutes) return json(route, staticRoutes[path])
const currentSessionMatch = path.match(/^\/api\/session\/([^/]+)$/)
if (currentSessionMatch) {
const session = config.sessions.find((item) => item.id === currentSessionMatch[1])
if (!session) return json(route, { error: "Session not found" }, undefined, 404)
return json(route, {
data: currentSession(session, config.directory),
})
}
const sessionMatch = path.match(/^\/session\/([^/]+)$/) const sessionMatch = path.match(/^\/session\/([^/]+)$/)
if (sessionMatch) { if (sessionMatch) {
const session = config.sessions.find((s) => s.id === sessionMatch[1]) const session = config.sessions.find((s) => s.id === sessionMatch[1])
@@ -115,6 +235,24 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? []) if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? [])
if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, []) if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, [])
const currentMessagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/)
if (currentMessagesMatch) {
const token = url.searchParams.get("cursor") ?? undefined
const before = token ? cursors.get(token) : undefined
if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400)
config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "start" })
await config.beforeMessagesResponse?.({ sessionID: currentMessagesMatch[1]!, before })
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
const pageData = config.pageMessages(currentMessagesMatch[1], Number(url.searchParams.get("limit") ?? 50), before)
config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "end" })
const cursor = pageData.cursor ? `cursor_${++nextCursor}` : undefined
if (cursor) cursors.set(cursor, pageData.cursor!)
return json(route, {
data: pageData.items.map(currentMessage).reverse(),
cursor: { next: cursor },
})
}
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/) const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
if (messagesMatch) { if (messagesMatch) {
const token = url.searchParams.get("before") ?? undefined const token = url.searchParams.get("before") ?? undefined
@@ -137,12 +275,36 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
}) })
} }
function v2Session(session: { id: string } & Record<string, unknown>, fallbackDirectory: string) { function location(config: MockServerConfig) {
return {
directory: config.directory,
project: { id: (config.project as { id?: string }).id, directory: config.directory },
}
}
function currentPermission(value: unknown) {
const permission = value as Record<string, unknown>
if (permission.action) return permission
const tool = permission.tool as { messageID?: string; callID?: string } | undefined
return {
id: permission.id,
sessionID: permission.sessionID,
action: permission.permission,
resources: permission.patterns ?? [],
save: permission.always,
metadata: permission.metadata,
source: tool?.messageID && tool.callID ? { type: "tool", messageID: tool.messageID, callID: tool.callID } : undefined,
}
}
export function currentSession(session: { id: string } & Record<string, unknown>, fallbackDirectory?: string) {
const time = session.time && typeof session.time === "object" ? session.time : {} const time = session.time && typeof session.time === "object" ? session.time : {}
return { return {
id: session.id, id: session.id,
parentID: session.parentID, parentID: session.parentID,
projectID: session.projectID ?? "project", projectID: session.projectID ?? "project",
agent: session.agent ?? "build",
model: session.model ?? { id: "mock-model", providerID: "mock-provider" },
cost: session.cost ?? 0, cost: session.cost ?? 0,
tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { time: {
@@ -157,7 +319,67 @@ function v2Session(session: { id: string } & Record<string, unknown>, fallbackDi
directory: typeof session.directory === "string" ? session.directory : fallbackDirectory, directory: typeof session.directory === "string" ? session.directory : fallbackDirectory,
...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}), ...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}),
}, },
...(typeof session.path === "string" ? { subpath: session.path } : {}), subpath: session.path,
revert: session.revert,
}
}
function currentMessage(value: unknown) {
const item = value as {
info: Record<string, unknown> & { id: string; role: "user" | "assistant"; time: { created: number } }
parts: Array<Record<string, unknown> & { type: string }>
}
if (item.info.role === "user") {
return {
id: item.info.id,
type: "user",
time: item.info.time,
text: item.parts
.flatMap((part) => (part.type === "text" && typeof part.text === "string" ? [part.text] : []))
.join("\n"),
}
}
return {
id: item.info.id,
type: "assistant",
time: item.info.time,
agent: item.info.agent ?? "build",
model: { id: item.info.modelID ?? "model", providerID: item.info.providerID ?? "provider" },
cost: item.info.cost,
tokens: item.info.tokens,
error: item.info.error,
content: item.parts.flatMap<unknown>((part) => {
if (part.type === "text" || part.type === "reasoning") return [{ type: part.type, text: part.text ?? "" }]
if (part.type !== "tool") return []
const state = part.state as Record<string, unknown>
return [
{
type: "tool",
id: part.id,
name: part.tool,
time: state.time ?? { created: item.info.time.created },
state:
state.status === "pending"
? { status: "streaming", input: state.raw ?? JSON.stringify(state.input ?? {}) }
: state.status === "completed"
? {
status: "completed",
input: state.input ?? {},
structured: state.metadata ?? {},
content: [{ type: "text", text: state.output ?? "" }],
}
: state.status === "error"
? {
status: "error",
input: state.input ?? {},
structured: state.metadata ?? {},
content: [],
error: { type: "ToolError", message: state.error ?? "Tool failed" },
}
: { status: "running", input: state.input ?? {}, structured: state.metadata ?? {}, content: [] },
},
]
}),
} }
} }
@@ -181,3 +403,18 @@ function sse(route: Route, events?: unknown[], retry?: number) {
body: `${retry === undefined ? "" : `retry: ${retry}\n\n`}${events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n"}`, body: `${retry === undefined ? "" : `retry: ${retry}\n\n`}${events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n"}`,
}) })
} }
function currentEvent(input: unknown) {
if (!input || typeof input !== "object" || !("payload" in input)) return input
const envelope = input as { directory?: string; payload?: unknown }
if (!envelope.payload || typeof envelope.payload !== "object") return input
const payload = envelope.payload as { id?: string; type?: string; properties?: unknown }
if (!payload.type) return input
return {
id: payload.id ?? `evt_mock_${Date.now()}`,
created: Date.now(),
type: payload.type,
data: payload.properties ?? {},
location: envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined,
}
}
+30 -7
View File
@@ -3,7 +3,7 @@ import type { Page } from "@playwright/test"
export type SseConnectionRecord = { export type SseConnectionRecord = {
id: number id: number
url: string url: string
path: "/global/event" | "/event" path: "/global/event" | "/event" | "/api/event"
headers: Record<string, string> headers: Record<string, string>
openedAt: number openedAt: number
endedAt?: number endedAt?: number
@@ -93,6 +93,20 @@ export async function installSseTransport<T>(
eventOptions.retry === undefined ? "" : `retry: ${eventOptions.retry}\n`, eventOptions.retry === undefined ? "" : `retry: ${eventOptions.retry}\n`,
`data: ${JSON.stringify(payload)}\n\n`, `data: ${JSON.stringify(payload)}\n\n`,
].join("") ].join("")
const currentEvent = (input: unknown) => {
if (!input || typeof input !== "object" || !("payload" in input)) return input
const envelope = input as { directory?: string; payload?: unknown }
if (!envelope.payload || typeof envelope.payload !== "object") return input
const payload = envelope.payload as { id?: string; type?: string; properties?: unknown }
if (!payload.type) return input
return {
id: payload.id ?? `evt_mock_${Date.now()}`,
created: Date.now(),
type: payload.type,
data: payload.properties ?? {},
location: envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined,
}
}
const acknowledge = ( const acknowledge = (
connection: Connection, connection: Connection,
bytes: number, bytes: number,
@@ -140,14 +154,14 @@ export async function installSseTransport<T>(
output.forEach((chunk) => connection.controller.enqueue(chunk)) output.forEach((chunk) => connection.controller.enqueue(chunk))
return acknowledge(connection, input.bytes.length, output.length) return acknowledge(connection, input.bytes.length, output.length)
} }
const encoded = input.deliveries.map((delivery) => ({ const encoded = input.deliveries.map((delivery) => {
delivery, const payload = connection.path === "/api/event" ? currentEvent(delivery.payload) : delivery.payload
bytes: encoder.encode(frame(delivery.payload, delivery.options)), return { delivery, payload, bytes: encoder.encode(frame(payload, delivery.options)) }
})) })
encoded.forEach((item) => marker(item.delivery.options?.marker)) encoded.forEach((item) => marker(item.delivery.options?.marker))
if (input.burst) { if (input.burst) {
const bytes = encoder.encode( const bytes = encoder.encode(
encoded.map((item) => frame(item.delivery.payload, item.delivery.options)).join(""), encoded.map((item) => frame(item.payload, item.delivery.options)).join(""),
) )
connection.controller.enqueue(bytes) connection.controller.enqueue(bytes)
return encoded.map((item) => acknowledge(connection, item.bytes.byteLength, 1, item.delivery.options?.id)) return encoded.map((item) => acknowledge(connection, item.bytes.byteLength, 1, item.delivery.options?.id))
@@ -161,7 +175,10 @@ export async function installSseTransport<T>(
const fetch = (input: RequestInfo | URL, init?: RequestInit) => { const fetch = (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init) const request = new Request(input, init)
const url = new URL(request.url) const url = new URL(request.url)
if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event")) if (
url.origin !== server ||
(url.pathname !== "/global/event" && url.pathname !== "/event" && url.pathname !== "/api/event")
)
return originalFetch(request) return originalFetch(request)
const id = ++nextConnectionID const id = ++nextConnectionID
@@ -177,6 +194,12 @@ export async function installSseTransport<T>(
record.controller = controller record.controller = controller
connections.push(record) connections.push(record)
if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`)) if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`))
if (url.pathname === "/api/event")
controller.enqueue(
encoder.encode(
frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} }),
),
)
request.signal.addEventListener( request.signal.addEventListener(
"abort", "abort",
() => { () => {
+36 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk" import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import type { Event } from "@opencode-ai/sdk/v2/client" import type { Event } from "@opencode-ai/sdk/v2/client"
describe("resumeStreamAfterPageShow", () => { describe("resumeStreamAfterPageShow", () => {
@@ -14,6 +15,23 @@ describe("resumeStreamAfterPageShow", () => {
}) })
}) })
describe("adaptServerEvent", () => {
test("preserves V2 events while adapting permission requests for existing consumers", () => {
const current = {
id: "evt_1",
created: 1,
type: "permission.v2.asked",
data: { id: "perm_1", sessionID: "ses_1", action: "read", resources: ["src/**"] },
} as OpenCodeEvent
expect(adaptServerEvent(current)).toMatchObject({
type: "permission.asked",
properties: { id: "perm_1", sessionID: "ses_1", permission: "read", patterns: ["src/**"] },
current,
})
})
})
describe("coalesceServerEvents", () => { describe("coalesceServerEvents", () => {
const delta = (value: string, field = "text", partID = "part") => ({ const delta = (value: string, field = "text", partID = "part") => ({
directory: "/repo", directory: "/repo",
@@ -34,6 +52,23 @@ describe("coalesceServerEvents", () => {
expect(result[0]?.payload).toMatchObject({ id: "second", properties: { delta: "hello world" } }) expect(result[0]?.payload).toMatchObject({ id: "second", properties: { delta: "hello world" } })
}) })
test("merges adjacent current text deltas", () => {
const current = (id: string, value: string) => adaptServerEvent({
id,
created: 1,
type: "session.text.delta",
location: { directory: "/repo" },
data: { sessionID: "ses", assistantMessageID: "msg", ordinal: 0, delta: value },
} as OpenCodeEvent)
const result = coalesceServerEvents([
{ directory: "/repo", payload: current("evt_1", "hello ") },
{ directory: "/repo", payload: current("evt_2", "world") },
])
expect(result).toHaveLength(1)
expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { delta: "hello world" } })
})
test("preserves event boundaries and distinct fields", () => { test("preserves event boundaries and distinct fields", () => {
const status = { const status = {
directory: "/repo", directory: "/repo",
+143 -51
View File
@@ -1,21 +1,60 @@
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import type { Event } from "@opencode-ai/sdk/v2/client" import type { Event } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus" import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { makeEventListener } from "@solid-primitives/event-listener" import { makeEventListener } from "@solid-primitives/event-listener"
import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js" import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js"
import { createSdkForServer } from "@/utils/server" import { createApiForServer, createSdkForServer, type ServerApi } from "@/utils/server"
import { useLanguage } from "./language" import { useLanguage } from "./language"
import { usePlatform } from "./platform" import { usePlatform } from "./platform"
import { ServerConnection, useServer } from "./server" import { ServerConnection, useServer } from "./server"
import { createRefCountMap } from "@/utils/refcount" import { createRefCountMap } from "@/utils/refcount"
import { useGlobal } from "./global" import { useGlobal } from "./global"
import { ServerScope } from "@/utils/server-scope" import { ServerScope } from "@/utils/server-scope"
import { detectServerProtocol, type ServerProtocol } from "@/utils/server-protocol"
import { createCompatibleApi, type CompatibleApi } from "@/utils/server-compat"
const isAbortError = (error: unknown) => const isAbortError = (error: unknown) =>
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError" error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true
type QueuedServerEvent = { directory: string; payload: Event } export type ServerEvent = Event & { current?: OpenCodeEvent }
type QueuedServerEvent = { directory: string; payload: ServerEvent }
type CurrentDelta = Extract<
OpenCodeEvent,
{ type: "session.text.delta" | "session.reasoning.delta" | "session.tool.input.delta" | "session.compaction.delta" }
>
export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
if (event.type === "permission.v2.asked") {
return {
id: event.id,
type: "permission.asked",
properties: {
id: event.data.id,
sessionID: event.data.sessionID,
permission: event.data.action,
patterns: event.data.resources,
always: event.data.save ?? [],
metadata: event.data.metadata ?? {},
tool:
event.data.source?.type === "tool"
? { messageID: event.data.source.messageID, callID: event.data.source.callID }
: undefined,
},
current: event,
} as ServerEvent
}
if (event.type === "permission.v2.replied")
return { id: event.id, type: "permission.replied", properties: event.data, current: event } as ServerEvent
if (event.type === "question.v2.asked")
return { id: event.id, type: "question.asked", properties: event.data, current: event } as ServerEvent
if (event.type === "question.v2.replied")
return { id: event.id, type: "question.replied", properties: event.data, current: event } as ServerEvent
if (event.type === "question.v2.rejected")
return { id: event.id, type: "question.rejected", properties: event.data, current: event } as ServerEvent
return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
}
const coalescedKey = (event: QueuedServerEvent) => { const coalescedKey = (event: QueuedServerEvent) => {
if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}` if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}`
@@ -40,6 +79,34 @@ export function enqueueServerEvent(queue: QueuedServerEvent[], event: QueuedServ
export function coalesceServerEvents(events: QueuedServerEvent[]) { export function coalesceServerEvents(events: QueuedServerEvent[]) {
const output: QueuedServerEvent[] = [] const output: QueuedServerEvent[] = []
events.forEach((event) => { events.forEach((event) => {
const current = currentDelta(event.payload.current)
if (current) {
const previous = output[output.length - 1]
const prior = currentDelta(previous?.payload.current)
if (
previous &&
prior &&
previous.directory === event.directory &&
currentDeltaKey(prior) === currentDeltaKey(current)
) {
const fragment = currentDeltaFragment(prior) + currentDeltaFragment(current)
const data =
current.type === "session.compaction.delta"
? { ...current.data, text: fragment }
: { ...current.data, delta: fragment }
output[output.length - 1] = {
directory: event.directory,
payload: {
...event.payload,
properties: data,
current: { ...current, data } as CurrentDelta,
} as ServerEvent,
}
return
}
output.push(event)
return
}
if (event.payload.type !== "message.part.delta") { if (event.payload.type !== "message.part.delta") {
output.push(event) output.push(event)
return return
@@ -71,12 +138,52 @@ export function coalesceServerEvents(events: QueuedServerEvent[]) {
return output return output
} }
function currentDelta(event: OpenCodeEvent | undefined): CurrentDelta | undefined {
if (
event?.type === "session.text.delta" ||
event?.type === "session.reasoning.delta" ||
event?.type === "session.tool.input.delta" ||
event?.type === "session.compaction.delta"
)
return event
}
function currentDeltaKey(event: CurrentDelta) {
if (event.type === "session.tool.input.delta")
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.callID}`
if (event.type === "session.compaction.delta") return `${event.type}:${event.data.sessionID}`
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.ordinal}`
}
function currentDeltaFragment(event: CurrentDelta) {
return event.type === "session.compaction.delta" ? event.data.text : event.data.delta
}
export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: () => unknown) { export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: () => unknown) {
if (!event.persisted) return if (!event.persisted) return
start() start()
} }
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope) { type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<{ [key: string]: ServerEvent }>>
type ServerSDKBase = {
server: ServerConnection.Any
scope: ServerScope
protocol: Promise<ServerProtocol>
url: string
client: ReturnType<typeof createSdkForServer>
api: CompatibleApi
currentApi: ServerApi
event: {
on: ServerEventEmitter["on"]
listen: ServerEventEmitter["listen"]
start: () => Promise<void> | undefined
}
createClient: (
opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">,
) => ReturnType<typeof createSdkForServer>
}
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
const platform = usePlatform() const platform = usePlatform()
const abort = new AbortController() const abort = new AbortController()
@@ -91,13 +198,15 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
} }
})() })()
const eventApi = createApiForServer({ server: server.http, fetch: eventFetch })
const eventSdk = createSdkForServer({ const eventSdk = createSdkForServer({
signal: abort.signal, signal: abort.signal,
fetch: eventFetch, fetch: eventFetch,
server: server.http, server: server.http,
}) })
const protocol = detectServerProtocol(server.http, platform.fetch ?? globalThis.fetch)
const emitter = createGlobalEmitter<{ const emitter = createGlobalEmitter<{
[key: string]: Event [key: string]: ServerEvent
}>() }>()
type Queued = QueuedServerEvent type Queued = QueuedServerEvent
@@ -142,21 +251,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
let run: Promise<void> | undefined let run: Promise<void> | undefined
let started = false let started = false
let generation = 0 let generation = 0
const HEARTBEAT_TIMEOUT_MS = 15_000
let lastEventAt = Date.now()
let heartbeat: ReturnType<typeof setTimeout> | undefined
const resetHeartbeat = () => {
lastEventAt = Date.now()
if (heartbeat) clearTimeout(heartbeat)
heartbeat = setTimeout(() => {
attempt?.abort()
}, HEARTBEAT_TIMEOUT_MS)
}
const clearHeartbeat = () => {
if (!heartbeat) return
clearTimeout(heartbeat)
heartbeat = undefined
}
const start = () => { const start = () => {
if (started) return run if (started) return run
@@ -168,35 +262,24 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
// oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit // oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit
while (!abort.signal.aborted && started && generation === active) { while (!abort.signal.aborted && started && generation === active) {
attempt = new AbortController() attempt = new AbortController()
lastEventAt = Date.now()
const onAbort = () => { const onAbort = () => {
attempt?.abort() attempt?.abort()
} }
abort.signal.addEventListener("abort", onAbort) abort.signal.addEventListener("abort", onAbort)
try { try {
const events = await eventSdk.global.event({ const kind = await protocol
signal: attempt.signal, const events =
onSseError: (error) => { kind === "v1"
if (isStreamClosed(error, attempt?.signal)) return ? (await eventSdk.global.event({ signal: attempt.signal })).stream
if (streamErrorLogged) return : eventApi.event.subscribe({ signal: attempt.signal })
streamErrorLogged = true
console.error("[global-sdk] event stream error", {
url: server.http.url,
fetch: eventFetch ? "platform" : "webview",
error,
})
},
})
let yielded = Date.now() let yielded = Date.now()
resetHeartbeat() for await (const event of events) {
for await (const event of events.stream) {
resetHeartbeat()
streamErrorLogged = false streamErrorLogged = false
if (event.payload.type !== "sync") { const legacy = "payload" in event
const directory = event.directory ?? "global" if (legacy && event.payload.type === "sync") continue
const payload = event.payload as Event const directory = legacy ? (event.directory ?? "global") : (event.location?.directory ?? "global")
if (enqueueServerEvent(queue, { directory, payload })) schedule() const payload = legacy ? (event.payload as Event) : adaptServerEvent(event)
} if (enqueueServerEvent(queue, { directory, payload })) schedule()
if (Date.now() - yielded < STREAM_YIELD_MS) continue if (Date.now() - yielded < STREAM_YIELD_MS) continue
yielded = Date.now() yielded = Date.now()
@@ -214,7 +297,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
} finally { } finally {
abort.signal.removeEventListener("abort", onAbort) abort.signal.removeEventListener("abort", onAbort)
attempt = undefined attempt = undefined
clearHeartbeat()
} }
if (abort.signal.aborted || !started || generation !== active) return if (abort.signal.aborted || !started || generation !== active) return
@@ -233,18 +315,11 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
started = false started = false
generation++ generation++
attempt?.abort() attempt?.abort()
clearHeartbeat()
} }
onMount(() => { onMount(() => {
makeEventListener(window, "pagehide", stop) makeEventListener(window, "pagehide", stop)
makeEventListener(window, "pageshow", (event) => resumeStreamAfterPageShow(event, start)) makeEventListener(window, "pageshow", (event) => resumeStreamAfterPageShow(event, start))
makeEventListener(document, "visibilitychange", () => {
if (document.visibilityState !== "visible") return
if (!started) return
if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return
attempt?.abort()
})
}) })
onCleanup(() => { onCleanup(() => {
@@ -258,12 +333,24 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
fetch: platform.fetch, fetch: platform.fetch,
throwOnError: true, throwOnError: true,
}) })
const currentApi: ServerApi = createApiForServer({ server: server.http, fetch: platform.fetch })
const legacy = (directory?: string) =>
createSdkForServer({
server: server.http,
fetch: platform.fetch,
throwOnError: true,
directory,
})
const api = createCompatibleApi({ protocol, current: currentApi, legacy })
return { return {
server, server,
scope, scope,
protocol,
url: server.http.url, url: server.http.url,
client: sdk, client: sdk,
api,
currentApi,
event: { event: {
on: emitter.on.bind(emitter), on: emitter.on.bind(emitter),
listen: emitter.listen.bind(emitter), listen: emitter.listen.bind(emitter),
@@ -279,7 +366,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
} }
} }
type ServerSDKBase = ReturnType<typeof createServerSdkContextBase>
export type ServerSDK = ServerSDKBase & { export type ServerSDK = ServerSDKBase & {
ensureDirSdkContext: (directory: string) => ReturnType<typeof createDirSdkContext> ensureDirSdkContext: (directory: string) => ReturnType<typeof createDirSdkContext>
} }
@@ -309,7 +395,7 @@ export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleCo
}) })
type SDKEventMap = { type SDKEventMap = {
[key in Event["type"]]: Extract<Event, { type: key }> [key in Event["type"]]: Extract<ServerEvent, { type: key }>
} }
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) { function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
@@ -329,6 +415,12 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
scope: serverSDK.scope, scope: serverSDK.scope,
directory, directory,
client, client,
api: createCompatibleApi({
protocol: serverSDK.protocol,
current: serverSDK.currentApi,
legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }),
directory,
}),
event: emitter, event: emitter,
get url() { get url() {
return serverSDK.url return serverSDK.url
+20 -2
View File
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
import { createApiForServer, createSdkForServer } from "./server" import { createApiForServer, createSdkForServer } from "./server"
import { createCompatibleApi } from "./server-compat" import { createCompatibleApi } from "./server-compat"
function setup(protocol: "v1" | "v2") { function setup(protocol: "v1" | "v2" | Promise<"v1" | "v2">) {
const requests: Request[] = [] const requests: Request[] = []
const fetcher = Object.assign( const fetcher = Object.assign(
async (input: string | URL | Request, init?: RequestInit) => { async (input: string | URL | Request, init?: RequestInit) => {
@@ -39,7 +39,7 @@ function setup(protocol: "v1" | "v2") {
) )
const server = { url: "http://localhost:4096" } const server = { url: "http://localhost:4096" }
const api = createCompatibleApi({ const api = createCompatibleApi({
protocol: Promise.resolve(protocol), protocol: typeof protocol === "string" ? Promise.resolve(protocol) : protocol,
current: createApiForServer({ server, fetch: fetcher }), current: createApiForServer({ server, fetch: fetcher }),
legacy: (directory) => createSdkForServer({ server, fetch: fetcher, directory, throwOnError: true }), legacy: (directory) => createSdkForServer({ server, fetch: fetcher, directory, throwOnError: true }),
directory: "/repo", directory: "/repo",
@@ -86,6 +86,24 @@ describe("createCompatibleApi", () => {
expect(requests[0]!.method).toBe("POST") expect(requests[0]!.method).toBe("POST")
}) })
test("resolves protocol detection once across implementation methods", async () => {
let detections = 0
const resolved = Promise.resolve<"v1" | "v2">("v2")
const protocol = new Proxy(resolved, {
get(target, property) {
if (property !== "then") return Reflect.get(target, property, target)
detections++
return target.then.bind(target)
},
})
const { api } = setup(protocol)
await api.session.archive({ sessionID: "ses_1" })
await api.session.list()
expect(detections).toBe(1)
})
test("uses the global V1 session search endpoint", async () => { test("uses the global V1 session search endpoint", async () => {
const { api, requests } = setup("v1") const { api, requests } = setup("v1")
await api.session.list({ parentID: null, search: "session", limit: 50 }) await api.session.list({ parentID: null, search: "session", limit: 50 })
+46 -47
View File
@@ -37,6 +37,12 @@ type LegacyPrompt = {
variant?: string variant?: string
} }
type LegacyLocation = { directory?: string } type LegacyLocation = { directory?: string }
type CompatibleInput = {
protocol: Promise<ServerProtocol>
current: ServerApi
legacy: LegacyFor
directory?: string
}
function mime(uri: string) { function mime(uri: string) {
const match = /^data:([^;,]+)/.exec(uri) const match = /^data:([^;,]+)/.exec(uri)
@@ -68,15 +74,48 @@ function sessionInfo(session: Session): SessionInfo {
} }
} }
export function createCompatibleApi(input: { export function createCompatibleApi(input: CompatibleInput): CompatibleApi {
protocol: Promise<ServerProtocol> const v1 = createV1Api(input)
current: ServerApi return lazyApi(
legacy: LegacyFor input.protocol.then((protocol) => (protocol === "v1" ? v1 : input.current)),
directory?: string input.current,
}): CompatibleApi { )
}
function lazyApi<T extends object>(implementation: Promise<T>, shape: T): T {
const cache = new Map<PropertyKey, unknown>()
return new Proxy(shape, {
get(target, property, receiver) {
const sample = Reflect.get(target, property, receiver)
if (typeof sample === "function") {
return (...args: unknown[]) =>
implementation.then((value) => {
const method = Reflect.get(value, property)
if (typeof method !== "function") throw new Error(`API method unavailable: ${String(property)}`)
return Reflect.apply(method, value, args)
})
}
if (sample === null || typeof sample !== "object") return sample
if (cache.has(property)) return cache.get(property)
const nested = lazyApi(
implementation.then((value) => {
const result = Reflect.get(value, property)
if (result === null || typeof result !== "object") {
throw new Error(`API namespace unavailable: ${String(property)}`)
}
return result
}),
sample,
)
cache.set(property, nested)
return nested
},
})
}
function createV1Api(input: CompatibleInput): CompatibleApi {
const directory = (location?: { directory?: string }) => location?.directory ?? input.directory const directory = (location?: { directory?: string }) => location?.directory ?? input.directory
const legacy = (location?: { directory?: string }) => input.legacy(directory(location)) const legacy = (location?: { directory?: string }) => input.legacy(directory(location))
const isV1 = () => input.protocol.then((protocol) => protocol === "v1")
const located = <T>(data: T, value?: { directory?: string }) => ({ const located = <T>(data: T, value?: { directory?: string }) => ({
location: { location: {
directory: directory(value) ?? "", directory: directory(value) ?? "",
@@ -93,7 +132,6 @@ export function createCompatibleApi(input: {
value?: Parameters<ServerApi["session"]["list"]>[0], value?: Parameters<ServerApi["session"]["list"]>[0],
options?: Parameters<ServerApi["session"]["list"]>[1], options?: Parameters<ServerApi["session"]["list"]>[1],
) { ) {
if (!(await isV1())) return input.current.session.list(value, options)
if (!value?.directory && value?.search !== undefined) { if (!value?.directory && value?.search !== undefined) {
const result = await legacy().experimental.session.list( const result = await legacy().experimental.session.list(
{ {
@@ -114,7 +152,6 @@ export function createCompatibleApi(input: {
return { data: (result.data ?? []).map(sessionInfo), cursor: {} } return { data: (result.data ?? []).map(sessionInfo), cursor: {} }
}, },
async create(value?: Parameters<ServerApi["session"]["create"]>[0]) { async create(value?: Parameters<ServerApi["session"]["create"]>[0]) {
if (!(await isV1())) return input.current.session.create(value)
const result = await legacy(value?.location ?? undefined).session.create({ const result = await legacy(value?.location ?? undefined).session.create({
directory: directory(value?.location ?? undefined), directory: directory(value?.location ?? undefined),
}) })
@@ -122,13 +159,11 @@ export function createCompatibleApi(input: {
return sessionInfo(result.data) return sessionInfo(result.data)
}, },
async get(value: Parameters<ServerApi["session"]["get"]>[0]) { async get(value: Parameters<ServerApi["session"]["get"]>[0]) {
if (!(await isV1())) return input.current.session.get(value)
const result = await legacy().session.get(value) const result = await legacy().session.get(value)
if (!result.data) throw new Error(`Session not found: ${value.sessionID}`) if (!result.data) throw new Error(`Session not found: ${value.sessionID}`)
return sessionInfo(result.data) return sessionInfo(result.data)
}, },
async active() { async active() {
if (!(await isV1())) return input.current.session.active()
const result = await legacy().session.status() const result = await legacy().session.status()
return Object.fromEntries( return Object.fromEntries(
Object.entries(result.data ?? {}).flatMap(([sessionID, status]) => Object.entries(result.data ?? {}).flatMap(([sessionID, status]) =>
@@ -137,29 +172,23 @@ export function createCompatibleApi(input: {
) )
}, },
async rename(value: Parameters<ServerApi["session"]["rename"]>[0] & LegacyLocation) { async rename(value: Parameters<ServerApi["session"]["rename"]>[0] & LegacyLocation) {
if (!(await isV1())) return input.current.session.rename(value)
await legacy(value).session.update({ sessionID: value.sessionID, title: value.title }) await legacy(value).session.update({ sessionID: value.sessionID, title: value.title })
}, },
async archive(value: Parameters<ServerApi["session"]["archive"]>[0] & LegacyLocation) { async archive(value: Parameters<ServerApi["session"]["archive"]>[0] & LegacyLocation) {
if (!(await isV1())) return input.current.session.archive(value)
await legacy(value).session.update({ sessionID: value.sessionID, time: { archived: Date.now() } }) await legacy(value).session.update({ sessionID: value.sessionID, time: { archived: Date.now() } })
}, },
async remove(value: Parameters<ServerApi["session"]["remove"]>[0] & LegacyLocation) { async remove(value: Parameters<ServerApi["session"]["remove"]>[0] & LegacyLocation) {
if (!(await isV1())) return input.current.session.remove(value)
await legacy(value).session.delete(value) await legacy(value).session.delete(value)
}, },
async fork(value: Parameters<ServerApi["session"]["fork"]>[0]) { async fork(value: Parameters<ServerApi["session"]["fork"]>[0]) {
if (!(await isV1())) return input.current.session.fork(value)
const result = await legacy().session.fork(value) const result = await legacy().session.fork(value)
if (!result.data) throw new Error("Failed to fork session") if (!result.data) throw new Error("Failed to fork session")
return sessionInfo(result.data) return sessionInfo(result.data)
}, },
async interrupt(value: Parameters<ServerApi["session"]["interrupt"]>[0]) { async interrupt(value: Parameters<ServerApi["session"]["interrupt"]>[0]) {
if (!(await isV1())) return input.current.session.interrupt(value)
await legacy().session.abort(value) await legacy().session.abort(value)
}, },
async prompt(value: SessionPromptInput & LegacyPrompt) { async prompt(value: SessionPromptInput & LegacyPrompt) {
if (!(await isV1())) return input.current.session.prompt(value)
await legacy().session.promptAsync({ await legacy().session.promptAsync({
sessionID: value.sessionID, sessionID: value.sessionID,
messageID: value.id ?? undefined, messageID: value.id ?? undefined,
@@ -194,7 +223,6 @@ export function createCompatibleApi(input: {
} }
}, },
async command(value: SessionCommandInput) { async command(value: SessionCommandInput) {
if (!(await isV1())) return input.current.session.command(value)
await legacy().session.command({ await legacy().session.command({
sessionID: value.sessionID, sessionID: value.sessionID,
messageID: value.id ?? undefined, messageID: value.id ?? undefined,
@@ -221,7 +249,6 @@ export function createCompatibleApi(input: {
} }
}, },
async shell(value: SessionShellInput & LegacyPrompt) { async shell(value: SessionShellInput & LegacyPrompt) {
if (!(await isV1())) return input.current.session.shell(value)
await legacy().session.shell({ await legacy().session.shell({
sessionID: value.sessionID, sessionID: value.sessionID,
command: value.command, command: value.command,
@@ -230,7 +257,6 @@ export function createCompatibleApi(input: {
}) })
}, },
compact: async (value: SessionCompactInput & { model?: LegacyPrompt["model"] }) => { compact: async (value: SessionCompactInput & { model?: LegacyPrompt["model"] }) => {
if (!(await isV1())) return input.current.session.compact(value)
if (!value.model) throw new Error("A model is required to compact a V1 session") if (!value.model) throw new Error("A model is required to compact a V1 session")
await legacy().session.summarize({ await legacy().session.summarize({
sessionID: value.sessionID, sessionID: value.sessionID,
@@ -247,12 +273,10 @@ export function createCompatibleApi(input: {
}, },
revert: { revert: {
stage: async (value: Parameters<ServerApi["session"]["revert"]["stage"]>[0]) => { stage: async (value: Parameters<ServerApi["session"]["revert"]["stage"]>[0]) => {
if (!(await isV1())) return input.current.session.revert.stage(value)
await legacy().session.revert(value) await legacy().session.revert(value)
return { messageID: value.messageID } return { messageID: value.messageID }
}, },
clear: async (value: Parameters<ServerApi["session"]["revert"]["clear"]>[0]) => { clear: async (value: Parameters<ServerApi["session"]["revert"]["clear"]>[0]) => {
if (!(await isV1())) return input.current.session.revert.clear(value)
await legacy().session.unrevert(value) await legacy().session.unrevert(value)
}, },
commit: input.current.session.revert.commit, commit: input.current.session.revert.commit,
@@ -261,17 +285,14 @@ export function createCompatibleApi(input: {
project: { project: {
...input.current.project, ...input.current.project,
async list() { async list() {
if (!(await isV1())) return input.current.project.list()
return ((await legacy().project.list()).data ?? []) as Project[] return ((await legacy().project.list()).data ?? []) as Project[]
}, },
async current(value?: Parameters<ServerApi["project"]["current"]>[0]) { async current(value?: Parameters<ServerApi["project"]["current"]>[0]) {
if (!(await isV1())) return input.current.project.current(value)
const result = await legacy(value?.location).project.current() const result = await legacy(value?.location).project.current()
if (!result.data) throw new Error("Project not found") if (!result.data) throw new Error("Project not found")
return { id: result.data.id, directory: result.data.worktree } satisfies ProjectCurrent return { id: result.data.id, directory: result.data.worktree } satisfies ProjectCurrent
}, },
async update(value: Parameters<ServerApi["project"]["update"]>[0]) { async update(value: Parameters<ServerApi["project"]["update"]>[0]) {
if (!(await isV1())) return input.current.project.update(value)
const project = (await legacy().project.list()).data?.find((item) => item.id === value.projectID) const project = (await legacy().project.list()).data?.find((item) => item.id === value.projectID)
const result = await legacy({ directory: project?.worktree }).project.update({ const result = await legacy({ directory: project?.worktree }).project.update({
...value, ...value,
@@ -281,7 +302,6 @@ export function createCompatibleApi(input: {
return result.data as Project return result.data as Project
}, },
async directories(value: Parameters<ServerApi["project"]["directories"]>[0]) { async directories(value: Parameters<ServerApi["project"]["directories"]>[0]) {
if (!(await isV1())) return input.current.project.directories(value)
const result = await legacy(value.location).worktree.list() const result = await legacy(value.location).worktree.list()
return (result.data ?? []).map((item) => ({ directory: item })) return (result.data ?? []).map((item) => ({ directory: item }))
}, },
@@ -289,7 +309,6 @@ export function createCompatibleApi(input: {
path: { path: {
...input.current.path, ...input.current.path,
async get(value?: Parameters<ServerApi["path"]["get"]>[0]) { async get(value?: Parameters<ServerApi["path"]["get"]>[0]) {
if (!(await isV1())) return input.current.path.get(value)
const result = await legacy(value?.location).path.get() const result = await legacy(value?.location).path.get()
if (!result.data) throw new Error("Path unavailable") if (!result.data) throw new Error("Path unavailable")
return result.data return result.data
@@ -298,17 +317,14 @@ export function createCompatibleApi(input: {
vcs: { vcs: {
...input.current.vcs, ...input.current.vcs,
async get(value?: Parameters<ServerApi["vcs"]["get"]>[0]) { async get(value?: Parameters<ServerApi["vcs"]["get"]>[0]) {
if (!(await isV1())) return input.current.vcs.get(value)
const result = await legacy(value?.location).vcs.get() const result = await legacy(value?.location).vcs.get()
return located({ branch: result.data?.branch, defaultBranch: undefined }, value?.location) return located({ branch: result.data?.branch, defaultBranch: undefined }, value?.location)
}, },
async status(value?: Parameters<ServerApi["vcs"]["status"]>[0]) { async status(value?: Parameters<ServerApi["vcs"]["status"]>[0]) {
if (!(await isV1())) return input.current.vcs.status(value)
const result = await legacy(value?.location).vcs.status() const result = await legacy(value?.location).vcs.status()
return located(result.data ?? [], value?.location) return located(result.data ?? [], value?.location)
}, },
async diff(value: Parameters<ServerApi["vcs"]["diff"]>[0]) { async diff(value: Parameters<ServerApi["vcs"]["diff"]>[0]) {
if (!(await isV1())) return input.current.vcs.diff(value)
const result = await legacy(value.location).vcs.diff({ const result = await legacy(value.location).vcs.diff({
mode: value.mode === "working" ? "git" : value.mode, mode: value.mode === "working" ? "git" : value.mode,
context: value.context, context: value.context,
@@ -328,12 +344,10 @@ export function createCompatibleApi(input: {
file: { file: {
...input.current.file, ...input.current.file,
async list(value?: Parameters<ServerApi["file"]["list"]>[0]) { async list(value?: Parameters<ServerApi["file"]["list"]>[0]) {
if (!(await isV1())) return input.current.file.list(value)
const result = await legacy(value?.location).file.list({ path: value?.path ?? "" }) const result = await legacy(value?.location).file.list({ path: value?.path ?? "" })
return located(result.data ?? [], value?.location) return located(result.data ?? [], value?.location)
}, },
async find(value: Parameters<ServerApi["file"]["find"]>[0]) { async find(value: Parameters<ServerApi["file"]["find"]>[0]) {
if (!(await isV1())) return input.current.file.find(value)
const result = await legacy(value.location).find.files({ const result = await legacy(value.location).find.files({
query: value.query, query: value.query,
type: value.type, type: value.type,
@@ -348,7 +362,6 @@ export function createCompatibleApi(input: {
integration: { integration: {
...input.current.integration, ...input.current.integration,
async get(value: Parameters<ServerApi["integration"]["get"]>[0]) { async get(value: Parameters<ServerApi["integration"]["get"]>[0]) {
if (!(await isV1())) return input.current.integration.get(value)
const methods = ((await legacy(value.location).provider.auth()).data?.[value.integrationID] ?? []).map( const methods = ((await legacy(value.location).provider.auth()).data?.[value.integrationID] ?? []).map(
(method, index) => (method, index) =>
method.type === "api" method.type === "api"
@@ -368,7 +381,6 @@ export function createCompatibleApi(input: {
connect: { connect: {
...input.current.integration.connect, ...input.current.integration.connect,
key: async (value: Parameters<ServerApi["integration"]["connect"]["key"]>[0]) => { key: async (value: Parameters<ServerApi["integration"]["connect"]["key"]>[0]) => {
if (!(await isV1())) return input.current.integration.connect.key(value)
await legacy(value.location).auth.set({ await legacy(value.location).auth.set({
providerID: value.integrationID, providerID: value.integrationID,
auth: { type: "api", key: value.key }, auth: { type: "api", key: value.key },
@@ -378,7 +390,6 @@ export function createCompatibleApi(input: {
oauth: { oauth: {
...input.current.integration.oauth, ...input.current.integration.oauth,
connect: async (value: Parameters<ServerApi["integration"]["oauth"]["connect"]>[0]) => { connect: async (value: Parameters<ServerApi["integration"]["oauth"]["connect"]>[0]) => {
if (!(await isV1())) return input.current.integration.oauth.connect(value)
const method = Number(value.methodID) const method = Number(value.methodID)
const result = await legacy(value.location).provider.oauth.authorize( const result = await legacy(value.location).provider.oauth.authorize(
{ providerID: value.integrationID, method, inputs: value.inputs }, { providerID: value.integrationID, method, inputs: value.inputs },
@@ -397,7 +408,6 @@ export function createCompatibleApi(input: {
) )
}, },
complete: async (value: Parameters<ServerApi["integration"]["oauth"]["complete"]>[0]) => { complete: async (value: Parameters<ServerApi["integration"]["oauth"]["complete"]>[0]) => {
if (!(await isV1())) return input.current.integration.oauth.complete(value)
const method = Number(value.attemptID.split(":").at(-1)) const method = Number(value.attemptID.split(":").at(-1))
await legacy(value.location).provider.oauth.callback( await legacy(value.location).provider.oauth.callback(
{ providerID: value.integrationID, method, code: value.code }, { providerID: value.integrationID, method, code: value.code },
@@ -405,7 +415,6 @@ export function createCompatibleApi(input: {
) )
}, },
status: async (value: Parameters<ServerApi["integration"]["oauth"]["status"]>[0]) => { status: async (value: Parameters<ServerApi["integration"]["oauth"]["status"]>[0]) => {
if (!(await isV1())) return input.current.integration.oauth.status(value)
const method = Number(value.attemptID.split(":").at(-1)) const method = Number(value.attemptID.split(":").at(-1))
await legacy(value.location).provider.oauth.callback( await legacy(value.location).provider.oauth.callback(
{ providerID: value.integrationID, method }, { providerID: value.integrationID, method },
@@ -421,15 +430,12 @@ export function createCompatibleApi(input: {
pty: { pty: {
...input.current.pty, ...input.current.pty,
async shells(value?: Parameters<ServerApi["pty"]["shells"]>[0]) { async shells(value?: Parameters<ServerApi["pty"]["shells"]>[0]) {
if (!(await isV1())) return input.current.pty.shells(value)
return located((await legacy(value?.location).pty.shells()).data ?? [], value?.location) return located((await legacy(value?.location).pty.shells()).data ?? [], value?.location)
}, },
async list(value?: Parameters<ServerApi["pty"]["list"]>[0]) { async list(value?: Parameters<ServerApi["pty"]["list"]>[0]) {
if (!(await isV1())) return input.current.pty.list(value)
return located((await legacy(value?.location).pty.list()).data ?? [], value?.location) return located((await legacy(value?.location).pty.list()).data ?? [], value?.location)
}, },
async create(value?: Parameters<ServerApi["pty"]["create"]>[0]) { async create(value?: Parameters<ServerApi["pty"]["create"]>[0]) {
if (!(await isV1())) return input.current.pty.create(value)
const result = await legacy(value?.location).pty.create({ const result = await legacy(value?.location).pty.create({
command: value?.command, command: value?.command,
args: value?.args ? [...value.args] : undefined, args: value?.args ? [...value.args] : undefined,
@@ -441,13 +447,11 @@ export function createCompatibleApi(input: {
return located(result.data, value?.location) return located(result.data, value?.location)
}, },
async get(value: Parameters<ServerApi["pty"]["get"]>[0]) { async get(value: Parameters<ServerApi["pty"]["get"]>[0]) {
if (!(await isV1())) return input.current.pty.get(value)
const result = await legacy(value.location).pty.get({ ptyID: value.ptyID }) const result = await legacy(value.location).pty.get({ ptyID: value.ptyID })
if (!result.data) throw new Error(`Terminal not found: ${value.ptyID}`) if (!result.data) throw new Error(`Terminal not found: ${value.ptyID}`)
return located(result.data, value.location) return located(result.data, value.location)
}, },
async update(value: Parameters<ServerApi["pty"]["update"]>[0]) { async update(value: Parameters<ServerApi["pty"]["update"]>[0]) {
if (!(await isV1())) return input.current.pty.update(value)
const result = await legacy(value.location).pty.update({ const result = await legacy(value.location).pty.update({
ptyID: value.ptyID, ptyID: value.ptyID,
title: value.title, title: value.title,
@@ -457,11 +461,9 @@ export function createCompatibleApi(input: {
return located(result.data, value.location) return located(result.data, value.location)
}, },
async remove(value: Parameters<ServerApi["pty"]["remove"]>[0]) { async remove(value: Parameters<ServerApi["pty"]["remove"]>[0]) {
if (!(await isV1())) return input.current.pty.remove(value)
await legacy(value.location).pty.remove({ ptyID: value.ptyID }) await legacy(value.location).pty.remove({ ptyID: value.ptyID })
}, },
async connectToken(value: Parameters<ServerApi["pty"]["connectToken"]>[0]) { async connectToken(value: Parameters<ServerApi["pty"]["connectToken"]>[0]) {
if (!(await isV1())) return input.current.pty.connectToken(value)
const result = await legacy(value.location).pty.connectToken({ ptyID: value.ptyID }) const result = await legacy(value.location).pty.connectToken({ ptyID: value.ptyID })
if (!result.data) throw new Error(`Failed to connect terminal: ${value.ptyID}`) if (!result.data) throw new Error(`Failed to connect terminal: ${value.ptyID}`)
return located(result.data, value.location) return located(result.data, value.location)
@@ -470,7 +472,6 @@ export function createCompatibleApi(input: {
permission: { permission: {
...input.current.permission, ...input.current.permission,
async reply(value: Parameters<ServerApi["permission"]["reply"]>[0]) { async reply(value: Parameters<ServerApi["permission"]["reply"]>[0]) {
if (!(await isV1())) return input.current.permission.reply(value)
await legacy().permission.respond({ await legacy().permission.respond({
sessionID: value.sessionID, sessionID: value.sessionID,
permissionID: value.requestID, permissionID: value.requestID,
@@ -481,14 +482,12 @@ export function createCompatibleApi(input: {
question: { question: {
...input.current.question, ...input.current.question,
async reply(value: Parameters<ServerApi["question"]["reply"]>[0]) { async reply(value: Parameters<ServerApi["question"]["reply"]>[0]) {
if (!(await isV1())) return input.current.question.reply(value)
await legacy().question.reply({ await legacy().question.reply({
requestID: value.requestID, requestID: value.requestID,
answers: value.answers.map((answer) => [...answer]), answers: value.answers.map((answer) => [...answer]),
}) })
}, },
async reject(value: Parameters<ServerApi["question"]["reject"]>[0]) { async reject(value: Parameters<ServerApi["question"]["reject"]>[0]) {
if (!(await isV1())) return input.current.question.reject(value)
await legacy().question.reject({ requestID: value.requestID }) await legacy().question.reject({ requestID: value.requestID })
}, },
}, },