feat(app): support current event transport (#38464)
This commit is contained in:
parent
347510a73b
commit
e59ba24b80
8 changed files with 527 additions and 121 deletions
|
|
@ -97,6 +97,7 @@ export async function setupTimeline(
|
|||
locale?: string
|
||||
deviceScaleFactor?: number
|
||||
seedHistory?: boolean
|
||||
protocol?: "v1" | "v2"
|
||||
} = {},
|
||||
) {
|
||||
const sessions = input.sessions ?? [session()]
|
||||
|
|
@ -114,6 +115,7 @@ export async function setupTimeline(
|
|||
retry: input.eventRetry ?? 20,
|
||||
})
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: input.protocol,
|
||||
directory,
|
||||
project: project(),
|
||||
provider: provider(),
|
||||
|
|
|
|||
|
|
@ -89,8 +89,8 @@ test("reconnects after a stream error", async ({ page }) => {
|
|||
expect((await timeline.transport.connections())[0]?.endedBy).toBe("error")
|
||||
})
|
||||
|
||||
test("records event IDs and reconnect Last-Event-ID headers", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, { eventRetry: 10 })
|
||||
test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, { eventRetry: 10, protocol: "v2" })
|
||||
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
|
||||
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 })
|
||||
|
||||
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 }) => {
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
||||
export interface MockServerConfig {
|
||||
protocol?: "v1" | "v2"
|
||||
provider: unknown
|
||||
directory: string
|
||||
project: unknown
|
||||
|
|
@ -54,14 +55,21 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||
if (url.port !== targetPort && url.port !== appPort) return route.fallback()
|
||||
|
||||
const path = url.pathname
|
||||
if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
|
||||
if (path === "/global/health") return json(route, { healthy: true })
|
||||
if (path === "/api/session")
|
||||
return json(route, {
|
||||
data: config.sessions.map((session) => v2Session(session, config.directory)),
|
||||
cursor: {},
|
||||
})
|
||||
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: false })
|
||||
if (path === "/global/event" || path === "/event" || path === "/api/event") {
|
||||
const events = config.events?.()
|
||||
return sse(
|
||||
route,
|
||||
path === "/api/event"
|
||||
? [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events?.map(currentEvent) ?? [])]
|
||||
: events,
|
||||
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")
|
||||
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
|
||||
if (path === "/question")
|
||||
|
|
@ -89,10 +97,122 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||
},
|
||||
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 (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])
|
||||
|
||||
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\/([^/]+)$/)
|
||||
if (sessionMatch) {
|
||||
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 (/^\/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$/)
|
||||
if (messagesMatch) {
|
||||
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 : {}
|
||||
return {
|
||||
id: session.id,
|
||||
parentID: session.parentID,
|
||||
projectID: session.projectID ?? "project",
|
||||
agent: session.agent ?? "build",
|
||||
model: session.model ?? { id: "mock-model", providerID: "mock-provider" },
|
||||
cost: session.cost ?? 0,
|
||||
tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: {
|
||||
|
|
@ -157,7 +319,67 @@ function v2Session(session: { id: string } & Record<string, unknown>, fallbackDi
|
|||
directory: typeof session.directory === "string" ? session.directory : fallbackDirectory,
|
||||
...(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"}`,
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { Page } from "@playwright/test"
|
|||
export type SseConnectionRecord = {
|
||||
id: number
|
||||
url: string
|
||||
path: "/global/event" | "/event"
|
||||
path: "/global/event" | "/event" | "/api/event"
|
||||
headers: Record<string, string>
|
||||
openedAt: number
|
||||
endedAt?: number
|
||||
|
|
@ -93,6 +93,20 @@ export async function installSseTransport<T>(
|
|||
eventOptions.retry === undefined ? "" : `retry: ${eventOptions.retry}\n`,
|
||||
`data: ${JSON.stringify(payload)}\n\n`,
|
||||
].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 = (
|
||||
connection: Connection,
|
||||
bytes: number,
|
||||
|
|
@ -140,14 +154,14 @@ export async function installSseTransport<T>(
|
|||
output.forEach((chunk) => connection.controller.enqueue(chunk))
|
||||
return acknowledge(connection, input.bytes.length, output.length)
|
||||
}
|
||||
const encoded = input.deliveries.map((delivery) => ({
|
||||
delivery,
|
||||
bytes: encoder.encode(frame(delivery.payload, delivery.options)),
|
||||
}))
|
||||
const encoded = input.deliveries.map((delivery) => {
|
||||
const payload = connection.path === "/api/event" ? currentEvent(delivery.payload) : delivery.payload
|
||||
return { delivery, payload, bytes: encoder.encode(frame(payload, delivery.options)) }
|
||||
})
|
||||
encoded.forEach((item) => marker(item.delivery.options?.marker))
|
||||
if (input.burst) {
|
||||
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)
|
||||
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 request = new Request(input, init)
|
||||
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)
|
||||
|
||||
const id = ++nextConnectionID
|
||||
|
|
@ -177,6 +194,12 @@ export async function installSseTransport<T>(
|
|||
record.controller = controller
|
||||
connections.push(record)
|
||||
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(
|
||||
"abort",
|
||||
() => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
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"
|
||||
|
||||
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", () => {
|
||||
const delta = (value: string, field = "text", partID = "part") => ({
|
||||
directory: "/repo",
|
||||
|
|
@ -34,6 +52,23 @@ describe("coalesceServerEvents", () => {
|
|||
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", () => {
|
||||
const status = {
|
||||
directory: "/repo",
|
||||
|
|
|
|||
|
|
@ -1,21 +1,60 @@
|
|||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import type { Event } from "@opencode-ai/sdk/v2/client"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
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 { usePlatform } from "./platform"
|
||||
import { ServerConnection, useServer } from "./server"
|
||||
import { createRefCountMap } from "@/utils/refcount"
|
||||
import { useGlobal } from "./global"
|
||||
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) =>
|
||||
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
|
||||
|
||||
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) => {
|
||||
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[]) {
|
||||
const output: QueuedServerEvent[] = []
|
||||
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") {
|
||||
output.push(event)
|
||||
return
|
||||
|
|
@ -71,12 +138,52 @@ export function coalesceServerEvents(events: QueuedServerEvent[]) {
|
|||
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) {
|
||||
if (!event.persisted) return
|
||||
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 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({
|
||||
signal: abort.signal,
|
||||
fetch: eventFetch,
|
||||
server: server.http,
|
||||
})
|
||||
const protocol = detectServerProtocol(server.http, platform.fetch ?? globalThis.fetch)
|
||||
const emitter = createGlobalEmitter<{
|
||||
[key: string]: Event
|
||||
[key: string]: ServerEvent
|
||||
}>()
|
||||
|
||||
type Queued = QueuedServerEvent
|
||||
|
|
@ -142,21 +251,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
|||
let run: Promise<void> | undefined
|
||||
let started = false
|
||||
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 = () => {
|
||||
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
|
||||
while (!abort.signal.aborted && started && generation === active) {
|
||||
attempt = new AbortController()
|
||||
lastEventAt = Date.now()
|
||||
const onAbort = () => {
|
||||
attempt?.abort()
|
||||
}
|
||||
abort.signal.addEventListener("abort", onAbort)
|
||||
try {
|
||||
const events = await eventSdk.global.event({
|
||||
signal: attempt.signal,
|
||||
onSseError: (error) => {
|
||||
if (isStreamClosed(error, attempt?.signal)) return
|
||||
if (streamErrorLogged) return
|
||||
streamErrorLogged = true
|
||||
console.error("[global-sdk] event stream error", {
|
||||
url: server.http.url,
|
||||
fetch: eventFetch ? "platform" : "webview",
|
||||
error,
|
||||
})
|
||||
},
|
||||
})
|
||||
const kind = await protocol
|
||||
const events =
|
||||
kind === "v1"
|
||||
? (await eventSdk.global.event({ signal: attempt.signal })).stream
|
||||
: eventApi.event.subscribe({ signal: attempt.signal })
|
||||
let yielded = Date.now()
|
||||
resetHeartbeat()
|
||||
for await (const event of events.stream) {
|
||||
resetHeartbeat()
|
||||
for await (const event of events) {
|
||||
streamErrorLogged = false
|
||||
if (event.payload.type !== "sync") {
|
||||
const directory = event.directory ?? "global"
|
||||
const payload = event.payload as Event
|
||||
if (enqueueServerEvent(queue, { directory, payload })) schedule()
|
||||
}
|
||||
const legacy = "payload" in event
|
||||
if (legacy && event.payload.type === "sync") continue
|
||||
const directory = legacy ? (event.directory ?? "global") : (event.location?.directory ?? "global")
|
||||
const payload = legacy ? (event.payload as Event) : adaptServerEvent(event)
|
||||
if (enqueueServerEvent(queue, { directory, payload })) schedule()
|
||||
|
||||
if (Date.now() - yielded < STREAM_YIELD_MS) continue
|
||||
yielded = Date.now()
|
||||
|
|
@ -214,7 +297,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
|||
} finally {
|
||||
abort.signal.removeEventListener("abort", onAbort)
|
||||
attempt = undefined
|
||||
clearHeartbeat()
|
||||
}
|
||||
|
||||
if (abort.signal.aborted || !started || generation !== active) return
|
||||
|
|
@ -233,18 +315,11 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
|||
started = false
|
||||
generation++
|
||||
attempt?.abort()
|
||||
clearHeartbeat()
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
makeEventListener(window, "pagehide", stop)
|
||||
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(() => {
|
||||
|
|
@ -258,12 +333,24 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
|||
fetch: platform.fetch,
|
||||
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 {
|
||||
server,
|
||||
scope,
|
||||
protocol,
|
||||
url: server.http.url,
|
||||
client: sdk,
|
||||
api,
|
||||
currentApi,
|
||||
event: {
|
||||
on: emitter.on.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 & {
|
||||
ensureDirSdkContext: (directory: string) => ReturnType<typeof createDirSdkContext>
|
||||
}
|
||||
|
|
@ -309,7 +395,7 @@ export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleCo
|
|||
})
|
||||
|
||||
type SDKEventMap = {
|
||||
[key in Event["type"]]: Extract<Event, { type: key }>
|
||||
[key in Event["type"]]: Extract<ServerEvent, { type: key }>
|
||||
}
|
||||
|
||||
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
|
||||
|
|
@ -329,6 +415,12 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
|
|||
scope: serverSDK.scope,
|
||||
directory,
|
||||
client,
|
||||
api: createCompatibleApi({
|
||||
protocol: serverSDK.protocol,
|
||||
current: serverSDK.currentApi,
|
||||
legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }),
|
||||
directory,
|
||||
}),
|
||||
event: emitter,
|
||||
get url() {
|
||||
return serverSDK.url
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
|
|||
import { createApiForServer, createSdkForServer } from "./server"
|
||||
import { createCompatibleApi } from "./server-compat"
|
||||
|
||||
function setup(protocol: "v1" | "v2") {
|
||||
function setup(protocol: "v1" | "v2" | Promise<"v1" | "v2">) {
|
||||
const requests: Request[] = []
|
||||
const fetcher = Object.assign(
|
||||
async (input: string | URL | Request, init?: RequestInit) => {
|
||||
|
|
@ -39,7 +39,7 @@ function setup(protocol: "v1" | "v2") {
|
|||
)
|
||||
const server = { url: "http://localhost:4096" }
|
||||
const api = createCompatibleApi({
|
||||
protocol: Promise.resolve(protocol),
|
||||
protocol: typeof protocol === "string" ? Promise.resolve(protocol) : protocol,
|
||||
current: createApiForServer({ server, fetch: fetcher }),
|
||||
legacy: (directory) => createSdkForServer({ server, fetch: fetcher, directory, throwOnError: true }),
|
||||
directory: "/repo",
|
||||
|
|
@ -86,6 +86,24 @@ describe("createCompatibleApi", () => {
|
|||
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 () => {
|
||||
const { api, requests } = setup("v1")
|
||||
await api.session.list({ parentID: null, search: "session", limit: 50 })
|
||||
|
|
|
|||
|
|
@ -37,6 +37,12 @@ type LegacyPrompt = {
|
|||
variant?: string
|
||||
}
|
||||
type LegacyLocation = { directory?: string }
|
||||
type CompatibleInput = {
|
||||
protocol: Promise<ServerProtocol>
|
||||
current: ServerApi
|
||||
legacy: LegacyFor
|
||||
directory?: string
|
||||
}
|
||||
|
||||
function mime(uri: string) {
|
||||
const match = /^data:([^;,]+)/.exec(uri)
|
||||
|
|
@ -68,15 +74,48 @@ function sessionInfo(session: Session): SessionInfo {
|
|||
}
|
||||
}
|
||||
|
||||
export function createCompatibleApi(input: {
|
||||
protocol: Promise<ServerProtocol>
|
||||
current: ServerApi
|
||||
legacy: LegacyFor
|
||||
directory?: string
|
||||
}): CompatibleApi {
|
||||
export function createCompatibleApi(input: CompatibleInput): CompatibleApi {
|
||||
const v1 = createV1Api(input)
|
||||
return lazyApi(
|
||||
input.protocol.then((protocol) => (protocol === "v1" ? v1 : input.current)),
|
||||
input.current,
|
||||
)
|
||||
}
|
||||
|
||||
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 legacy = (location?: { directory?: string }) => input.legacy(directory(location))
|
||||
const isV1 = () => input.protocol.then((protocol) => protocol === "v1")
|
||||
const located = <T>(data: T, value?: { directory?: string }) => ({
|
||||
location: {
|
||||
directory: directory(value) ?? "",
|
||||
|
|
@ -93,7 +132,6 @@ export function createCompatibleApi(input: {
|
|||
value?: Parameters<ServerApi["session"]["list"]>[0],
|
||||
options?: Parameters<ServerApi["session"]["list"]>[1],
|
||||
) {
|
||||
if (!(await isV1())) return input.current.session.list(value, options)
|
||||
if (!value?.directory && value?.search !== undefined) {
|
||||
const result = await legacy().experimental.session.list(
|
||||
{
|
||||
|
|
@ -114,7 +152,6 @@ export function createCompatibleApi(input: {
|
|||
return { data: (result.data ?? []).map(sessionInfo), cursor: {} }
|
||||
},
|
||||
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({
|
||||
directory: directory(value?.location ?? undefined),
|
||||
})
|
||||
|
|
@ -122,13 +159,11 @@ export function createCompatibleApi(input: {
|
|||
return sessionInfo(result.data)
|
||||
},
|
||||
async get(value: Parameters<ServerApi["session"]["get"]>[0]) {
|
||||
if (!(await isV1())) return input.current.session.get(value)
|
||||
const result = await legacy().session.get(value)
|
||||
if (!result.data) throw new Error(`Session not found: ${value.sessionID}`)
|
||||
return sessionInfo(result.data)
|
||||
},
|
||||
async active() {
|
||||
if (!(await isV1())) return input.current.session.active()
|
||||
const result = await legacy().session.status()
|
||||
return Object.fromEntries(
|
||||
Object.entries(result.data ?? {}).flatMap(([sessionID, status]) =>
|
||||
|
|
@ -137,29 +172,23 @@ export function createCompatibleApi(input: {
|
|||
)
|
||||
},
|
||||
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 })
|
||||
},
|
||||
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() } })
|
||||
},
|
||||
async remove(value: Parameters<ServerApi["session"]["remove"]>[0] & LegacyLocation) {
|
||||
if (!(await isV1())) return input.current.session.remove(value)
|
||||
await legacy(value).session.delete(value)
|
||||
},
|
||||
async fork(value: Parameters<ServerApi["session"]["fork"]>[0]) {
|
||||
if (!(await isV1())) return input.current.session.fork(value)
|
||||
const result = await legacy().session.fork(value)
|
||||
if (!result.data) throw new Error("Failed to fork session")
|
||||
return sessionInfo(result.data)
|
||||
},
|
||||
async interrupt(value: Parameters<ServerApi["session"]["interrupt"]>[0]) {
|
||||
if (!(await isV1())) return input.current.session.interrupt(value)
|
||||
await legacy().session.abort(value)
|
||||
},
|
||||
async prompt(value: SessionPromptInput & LegacyPrompt) {
|
||||
if (!(await isV1())) return input.current.session.prompt(value)
|
||||
await legacy().session.promptAsync({
|
||||
sessionID: value.sessionID,
|
||||
messageID: value.id ?? undefined,
|
||||
|
|
@ -194,7 +223,6 @@ export function createCompatibleApi(input: {
|
|||
}
|
||||
},
|
||||
async command(value: SessionCommandInput) {
|
||||
if (!(await isV1())) return input.current.session.command(value)
|
||||
await legacy().session.command({
|
||||
sessionID: value.sessionID,
|
||||
messageID: value.id ?? undefined,
|
||||
|
|
@ -221,7 +249,6 @@ export function createCompatibleApi(input: {
|
|||
}
|
||||
},
|
||||
async shell(value: SessionShellInput & LegacyPrompt) {
|
||||
if (!(await isV1())) return input.current.session.shell(value)
|
||||
await legacy().session.shell({
|
||||
sessionID: value.sessionID,
|
||||
command: value.command,
|
||||
|
|
@ -230,7 +257,6 @@ export function createCompatibleApi(input: {
|
|||
})
|
||||
},
|
||||
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")
|
||||
await legacy().session.summarize({
|
||||
sessionID: value.sessionID,
|
||||
|
|
@ -247,12 +273,10 @@ export function createCompatibleApi(input: {
|
|||
},
|
||||
revert: {
|
||||
stage: async (value: Parameters<ServerApi["session"]["revert"]["stage"]>[0]) => {
|
||||
if (!(await isV1())) return input.current.session.revert.stage(value)
|
||||
await legacy().session.revert(value)
|
||||
return { messageID: value.messageID }
|
||||
},
|
||||
clear: async (value: Parameters<ServerApi["session"]["revert"]["clear"]>[0]) => {
|
||||
if (!(await isV1())) return input.current.session.revert.clear(value)
|
||||
await legacy().session.unrevert(value)
|
||||
},
|
||||
commit: input.current.session.revert.commit,
|
||||
|
|
@ -261,17 +285,14 @@ export function createCompatibleApi(input: {
|
|||
project: {
|
||||
...input.current.project,
|
||||
async list() {
|
||||
if (!(await isV1())) return input.current.project.list()
|
||||
return ((await legacy().project.list()).data ?? []) as Project[]
|
||||
},
|
||||
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()
|
||||
if (!result.data) throw new Error("Project not found")
|
||||
return { id: result.data.id, directory: result.data.worktree } satisfies ProjectCurrent
|
||||
},
|
||||
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 result = await legacy({ directory: project?.worktree }).project.update({
|
||||
...value,
|
||||
|
|
@ -281,7 +302,6 @@ export function createCompatibleApi(input: {
|
|||
return result.data as Project
|
||||
},
|
||||
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()
|
||||
return (result.data ?? []).map((item) => ({ directory: item }))
|
||||
},
|
||||
|
|
@ -289,7 +309,6 @@ export function createCompatibleApi(input: {
|
|||
path: {
|
||||
...input.current.path,
|
||||
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()
|
||||
if (!result.data) throw new Error("Path unavailable")
|
||||
return result.data
|
||||
|
|
@ -298,17 +317,14 @@ export function createCompatibleApi(input: {
|
|||
vcs: {
|
||||
...input.current.vcs,
|
||||
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()
|
||||
return located({ branch: result.data?.branch, defaultBranch: undefined }, value?.location)
|
||||
},
|
||||
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()
|
||||
return located(result.data ?? [], value?.location)
|
||||
},
|
||||
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({
|
||||
mode: value.mode === "working" ? "git" : value.mode,
|
||||
context: value.context,
|
||||
|
|
@ -328,12 +344,10 @@ export function createCompatibleApi(input: {
|
|||
file: {
|
||||
...input.current.file,
|
||||
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 ?? "" })
|
||||
return located(result.data ?? [], value?.location)
|
||||
},
|
||||
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({
|
||||
query: value.query,
|
||||
type: value.type,
|
||||
|
|
@ -348,7 +362,6 @@ export function createCompatibleApi(input: {
|
|||
integration: {
|
||||
...input.current.integration,
|
||||
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(
|
||||
(method, index) =>
|
||||
method.type === "api"
|
||||
|
|
@ -368,7 +381,6 @@ export function createCompatibleApi(input: {
|
|||
connect: {
|
||||
...input.current.integration.connect,
|
||||
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({
|
||||
providerID: value.integrationID,
|
||||
auth: { type: "api", key: value.key },
|
||||
|
|
@ -378,7 +390,6 @@ export function createCompatibleApi(input: {
|
|||
oauth: {
|
||||
...input.current.integration.oauth,
|
||||
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 result = await legacy(value.location).provider.oauth.authorize(
|
||||
{ providerID: value.integrationID, method, inputs: value.inputs },
|
||||
|
|
@ -397,7 +408,6 @@ export function createCompatibleApi(input: {
|
|||
)
|
||||
},
|
||||
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))
|
||||
await legacy(value.location).provider.oauth.callback(
|
||||
{ providerID: value.integrationID, method, code: value.code },
|
||||
|
|
@ -405,7 +415,6 @@ export function createCompatibleApi(input: {
|
|||
)
|
||||
},
|
||||
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))
|
||||
await legacy(value.location).provider.oauth.callback(
|
||||
{ providerID: value.integrationID, method },
|
||||
|
|
@ -421,15 +430,12 @@ export function createCompatibleApi(input: {
|
|||
pty: {
|
||||
...input.current.pty,
|
||||
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)
|
||||
},
|
||||
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)
|
||||
},
|
||||
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({
|
||||
command: value?.command,
|
||||
args: value?.args ? [...value.args] : undefined,
|
||||
|
|
@ -441,13 +447,11 @@ export function createCompatibleApi(input: {
|
|||
return located(result.data, value?.location)
|
||||
},
|
||||
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 })
|
||||
if (!result.data) throw new Error(`Terminal not found: ${value.ptyID}`)
|
||||
return located(result.data, value.location)
|
||||
},
|
||||
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({
|
||||
ptyID: value.ptyID,
|
||||
title: value.title,
|
||||
|
|
@ -457,11 +461,9 @@ export function createCompatibleApi(input: {
|
|||
return located(result.data, value.location)
|
||||
},
|
||||
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 })
|
||||
},
|
||||
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 })
|
||||
if (!result.data) throw new Error(`Failed to connect terminal: ${value.ptyID}`)
|
||||
return located(result.data, value.location)
|
||||
|
|
@ -470,7 +472,6 @@ export function createCompatibleApi(input: {
|
|||
permission: {
|
||||
...input.current.permission,
|
||||
async reply(value: Parameters<ServerApi["permission"]["reply"]>[0]) {
|
||||
if (!(await isV1())) return input.current.permission.reply(value)
|
||||
await legacy().permission.respond({
|
||||
sessionID: value.sessionID,
|
||||
permissionID: value.requestID,
|
||||
|
|
@ -481,14 +482,12 @@ export function createCompatibleApi(input: {
|
|||
question: {
|
||||
...input.current.question,
|
||||
async reply(value: Parameters<ServerApi["question"]["reply"]>[0]) {
|
||||
if (!(await isV1())) return input.current.question.reply(value)
|
||||
await legacy().question.reply({
|
||||
requestID: value.requestID,
|
||||
answers: value.answers.map((answer) => [...answer]),
|
||||
})
|
||||
},
|
||||
async reject(value: Parameters<ServerApi["question"]["reject"]>[0]) {
|
||||
if (!(await isV1())) return input.current.question.reject(value)
|
||||
await legacy().question.reject({ requestID: value.requestID })
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue