feat(app): support current event transport (#38464)
This commit is contained in:
@@ -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 }) => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
() => {
|
() => {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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 })
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user