Compare commits

...

2 Commits

Author SHA1 Message Date
Brendan Allan 712d4fb800 feat(app): add v2 backend adapter 2026-07-13 15:16:10 +00:00
Brendan Allan 4d2875912d feat(app): add versioned backend interface 2026-07-13 22:37:59 +08:00
7 changed files with 3554 additions and 0 deletions
+4
View File
@@ -40,9 +40,11 @@
"@dnd-kit/helpers": "0.5.0",
"@dnd-kit/solid": "0.5.0",
"@kobalte/core": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/sdk-v1": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.17.18.tgz",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@pierre/trees": "1.0.0-beta.4",
@@ -2218,6 +2220,8 @@
"@opencode-ai/sdk-next": ["@opencode-ai/sdk-next@workspace:packages/sdk-next"],
"@opencode-ai/sdk-v1": ["@opencode-ai/sdk@https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.17.18.tgz", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-c/C9PhY8PrbcxDY+JIYtOZsrmMD0KzoVvxq+RGUrZ6LQp57SuVBbT4lfwA2G8Se5RNC1N5JtYjiuaXeECnF2SQ=="],
"@opencode-ai/server": ["@opencode-ai/server@workspace:packages/server"],
"@opencode-ai/session-ui": ["@opencode-ai/session-ui@workspace:packages/session-ui"],
+2
View File
@@ -53,9 +53,11 @@
"@dnd-kit/helpers": "0.5.0",
"@dnd-kit/solid": "0.5.0",
"@kobalte/core": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/sdk-v1": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.17.18.tgz",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@pierre/trees": "1.0.0-beta.4",
+140
View File
@@ -0,0 +1,140 @@
import { describe, expect, test } from "bun:test"
import { createOpencodeClient } from "@opencode-ai/sdk-v1/v2/client"
import { createV1Backend } from "./backend-v1"
function setup(respond: (request: Request) => Response | Promise<Response>) {
const requests: Request[] = []
const fetch = Object.assign(
async (input: RequestInfo | URL, init?: RequestInit) => {
const request = input instanceof Request ? input : new Request(input, init)
requests.push(request)
return respond(request)
},
{ preconnect: globalThis.fetch.preconnect },
) satisfies typeof globalThis.fetch
return {
requests,
backend: createV1Backend(createOpencodeClient({ baseUrl: "http://localhost", fetch })),
}
}
function json(data: unknown, headers?: HeadersInit) {
return new Response(JSON.stringify(data), {
headers: { "content-type": "application/json", ...Object.fromEntries(new Headers(headers)) },
})
}
const session = {
id: "ses_1",
slug: "one",
projectID: "project",
directory: "/repo",
title: "Session",
version: "1",
time: { created: 1, updated: 2 },
}
describe("createV1Backend", () => {
test("normalizes session pagination and location", async () => {
const setupResult = setup(() => json([session], { "x-next-cursor": "456" }))
const result = await setupResult.backend.common.sessions.list({
location: { directory: "/repo", workspaceID: "workspace" },
roots: true,
limit: 10,
cursor: "123",
})
expect(result).toEqual({
items: [
{
id: "ses_1",
parentID: undefined,
projectID: "project",
location: { directory: "/repo", workspaceID: undefined },
title: "Session",
cost: 0,
tokens: undefined,
time: { created: 1, updated: 2 },
share: undefined,
revert: undefined,
},
],
next: "456",
})
const url = new URL(setupResult.requests[0].url)
expect(url.pathname).toBe("/experimental/session")
expect(url.searchParams.get("directory")).toBe("/repo")
expect(url.searchParams.get("workspace")).toBe("workspace")
expect(url.searchParams.get("roots")).toBe("true")
expect(url.searchParams.get("cursor")).toBe("123")
})
test("converts normalized prompts to legacy parts", async () => {
const setupResult = setup(() => new Response(null, { status: 204 }))
await setupResult.backend.common.sessions.prompt({
sessionID: "ses_1",
id: "msg_1",
text: "hello",
selection: {
agent: "build",
model: { id: "model", providerID: "provider", variant: "high" },
},
files: [{ uri: "data:text/plain;base64,aGk=", name: "hi.txt", mime: "text/plain" }],
agents: [{ name: "explore", text: "@explore", start: 6, end: 14 }],
})
const request = setupResult.requests[0]
expect(new URL(request.url).pathname).toBe("/session/ses_1/prompt_async")
expect(await request.json()).toEqual({
messageID: "msg_1",
model: { providerID: "provider", modelID: "model" },
agent: "build",
variant: "high",
parts: [
{ type: "text", text: "hello" },
{ type: "file", mime: "text/plain", filename: "hi.txt", url: "data:text/plain;base64,aGk=" },
{ type: "agent", name: "explore", source: { value: "@explore", start: 6, end: 14 } },
],
})
})
test("combines mixed file search and decodes binary content", async () => {
const setupResult = setup((request) => {
const url = new URL(request.url)
if (url.pathname === "/find/file") {
return json(url.searchParams.get("type") === "file" ? ["a.txt", "shared"] : ["dir", "shared"])
}
return json({ type: "binary", content: "AAEC", encoding: "base64", mimeType: "application/octet-stream" })
})
const found = await setupResult.backend.common.files.find({ query: "a" })
const content = await setupResult.backend.common.files.read({ path: "a.bin" })
expect(found).toEqual([
{ path: "a.txt", type: "file" },
{ path: "shared", type: "directory" },
{ path: "dir", type: "directory" },
])
expect([...content.bytes]).toEqual([0, 1, 2])
expect(content.kind).toBe("binary")
expect(content.mimeType).toBe("application/octet-stream")
})
test("merges global config updates with untouched fields", async () => {
const bodies: unknown[] = []
const setupResult = setup(async (request) => {
if (request.method === "GET") return json({ autoupdate: true, model: "old", disabled_providers: ["one"] })
bodies.push(await request.json())
return json({})
})
await setupResult.backend.capabilities.configuration?.updateGlobal({
model: "new",
disabledProviders: ["two"],
})
expect(bodies).toEqual([{ autoupdate: true, model: "new", disabled_providers: ["two"] }])
})
})
+992
View File
@@ -0,0 +1,992 @@
import type {
Config,
Event,
GlobalEvent,
Message,
Model,
OpencodeClient,
Part,
Project,
Provider,
Session,
} from "@opencode-ai/sdk-v1/v2/client"
import type {
AppAgent,
AppClient,
AppCommand,
AppConfig,
AppEvent,
AppEventEnvelope,
AppFileDiff,
AppModel,
AppPermissionRequest,
AppProject,
AppProvider,
AppQuestionRequest,
AppReference,
AppSession,
CommandInput,
DecoratedFileContent,
FileContent,
FileEntry,
LocationInput,
LocationRef,
Page,
PromptFile,
PromptInput,
ProviderCatalog,
RequestOptions,
SessionActivity,
TimelineContent,
TimelineItem,
ToolState,
} from "./backend"
type CachedMessage = {
info: Message
parts: Part[]
}
export function createV1Backend(client: OpencodeClient, defaultLocation?: LocationRef): AppClient {
const messages = new Map<string, CachedMessage>()
const options = (input?: RequestOptions) => ({ signal: input?.signal, throwOnError: true as const })
const location = (input?: LocationInput) => legacyLocation(input?.location ?? defaultLocation)
const cache = (input: CachedMessage) => {
messages.set(input.info.id, input)
return toTimelineItem(input.info, input.parts)
}
const loadMessage = async (input: { sessionID: string; messageID: string }, request?: RequestOptions) => {
const cached = messages.get(input.messageID)
if (cached) return cached
const result = await client.session.message({ ...input, ...legacyLocation(defaultLocation) }, options(request))
messages.set(input.messageID, result.data)
return result.data
}
return {
version: "v1",
common: {
health: {
get: async (request) => {
const result = await client.global.health(options(request))
return result.data
},
},
projects: {
list: async (request) => {
const result = await client.project.list(undefined, options(request))
return result.data.map(toProject)
},
current: async (input, request) => {
const params = location(input)
const [project, path] = await Promise.all([
client.project.current(params, options(request)),
client.path.get(params, options(request)),
])
return { id: project.data.id, directory: path.data.directory }
},
},
catalog: {
providers: async (input, request) => {
const result = await client.provider.list(location(input), options(request))
return toProviderCatalog(result.data)
},
agents: async (input, request) => {
const result = await client.app.agents(location(input), options(request))
return normalizeAgents(result.data).map(toAgent)
},
},
commands: {
list: async (input, request) => {
const result = await client.command.list(location(input), options(request))
return result.data.map(toCommand)
},
},
references: {
list: async (input, request) => {
const result = await client.v2.reference.list(
{ location: apiLocation(input?.location ?? defaultLocation) },
options(request),
)
return result.data.data.map(toReference)
},
},
sessions: {
list: async (input, request) => {
const cursor = input?.cursor === undefined ? undefined : Number(input.cursor)
if (cursor !== undefined && !Number.isFinite(cursor))
throw new Error(`Invalid session cursor: ${input?.cursor}`)
const result = await client.experimental.session.list(
{
...location(input),
roots: input?.roots,
limit: input?.limit,
search: input?.search,
cursor,
},
options(request),
)
return {
items: result.data.map(toSession),
next: result.response.headers.get("x-next-cursor") ?? undefined,
}
},
create: async (input, request) => {
const result = await client.session.create(
{
...location(input),
agent: input?.agent,
model: input?.model,
},
options(request),
)
return toSession(result.data)
},
get: async (input, request) => {
const result = await client.session.get({ sessionID: input.sessionID, ...location(input) }, options(request))
return toSession(result.data)
},
remove: async (input, request) => {
await client.session.delete({ sessionID: input.sessionID, ...location(input) }, options(request))
},
fork: async (input, request) => {
const result = await client.session.fork(
{ sessionID: input.sessionID, messageID: input.messageID, ...location(input) },
options(request),
)
return toSession(result.data)
},
rename: async (input, request) => {
await client.session.update(
{ sessionID: input.sessionID, title: input.title, ...location(input) },
options(request),
)
},
interrupt: async (input, request) => {
await client.session.abort({ sessionID: input.sessionID, ...location(input) }, options(request))
},
activity: async (input, request) => {
const result = await client.session.status(location(input), options(request))
return Object.fromEntries(
Object.entries(result.data).flatMap(([sessionID, status]) => {
const activity = toActivity(status)
return activity ? [[sessionID, activity] as const] : []
}),
)
},
history: async (input, request) => {
const result = await client.session.messages(
{
sessionID: input.sessionID,
limit: input.limit,
before: input.cursor,
...legacyLocation(defaultLocation),
},
options(request),
)
return {
items: result.data.map(cache),
next: result.response.headers.get("x-next-cursor") ?? undefined,
}
},
message: async (input, request) => cache(await loadMessage(input, request)),
prompt: async (input, request) => {
await client.session.promptAsync(
{
sessionID: input.sessionID,
messageID: input.id,
agent: input.selection?.agent,
model: input.selection?.model && {
providerID: input.selection.model.providerID,
modelID: input.selection.model.id,
},
variant: input.selection?.model?.variant,
parts: toPromptParts(input),
...legacyLocation(defaultLocation),
},
options(request),
)
},
command: async (input, request) => {
await client.session.command(
{
sessionID: input.sessionID,
messageID: input.id,
command: input.command,
arguments: input.arguments ?? "",
agent: input.agent,
model: input.model && `${input.model.providerID}/${input.model.id}`,
variant: input.model?.variant,
parts: input.files?.map(toFilePart),
...legacyLocation(defaultLocation),
},
options(request),
)
},
},
files: {
list: async (input, request) => {
const result = await client.file.list({ path: input.path ?? "", ...location(input) }, options(request))
return result.data.map((item) => ({ path: item.path, type: item.type }))
},
find: async (input, request) => {
const find = async (type: FileEntry["type"]) => {
const result = await client.find.files(
{ query: input.query, type, limit: input.limit, ...location(input) },
options(request),
)
return result.data.map((path) => ({ path, type }))
}
if (input.type) return find(input.type)
const result = await Promise.all([find("file"), find("directory")])
return [...new Map(result.flat().map((item) => [item.path, item])).values()]
},
read: async (input, request) => {
const result = await client.file.read({ path: input.path, ...location(input) }, options(request))
return toFileContent(result.data)
},
},
permissions: {
pending: async (input, request) => {
const result = await client.permission.list(location(input), options(request))
return result.data.map(toPermission)
},
reply: async (input, request) => {
await client.permission.reply(
{
requestID: input.requestID,
reply: input.reply,
message: input.message,
...legacyLocation(defaultLocation),
},
options(request),
)
},
},
questions: {
pending: async (input, request) => {
const result = await client.question.list(location(input), options(request))
return result.data.map(toQuestion)
},
reply: async (input, request) => {
await client.question.reply(
{
requestID: input.requestID,
answers: input.answers.map((answer) => [...answer]),
...legacyLocation(defaultLocation),
},
options(request),
)
},
reject: async (input, request) => {
await client.question.reject(
{ requestID: input.requestID, ...legacyLocation(defaultLocation) },
options(request),
)
},
},
vcs: {
status: async (input, request) => {
const result = await client.vcs.status(location(input), options(request))
return result.data
},
diff: async (input, request) => {
const result = await client.vcs.diff(
{
mode: input.mode === "working" ? "git" : "branch",
context: input.context,
...location(input),
},
options(request),
)
return result.data
},
},
mcp: {
list: async (input, request) => {
const result = await client.mcp.status(location(input), options(request))
return Object.entries(result.data).map(([name, status]) => ({ name, status }))
},
resources: async (input, request) => {
const result = await client.experimental.resource.list(location(input), options(request))
return {
resources: Object.values(result.data).map((item) => ({
server: item.client,
name: item.name,
uri: item.uri,
description: item.description,
mimeType: item.mimeType,
})),
templates: [],
}
},
},
pty: {
list: async (input, request) => {
const result = await client.pty.list(location(input), options(request))
return result.data.map(toPty)
},
create: async (input, request) => {
const result = await client.pty.create(
{
title: input.title,
command: input.command,
args: input.args ? [...input.args] : undefined,
cwd: input.cwd,
env: input.env ? { ...input.env } : undefined,
...location(input),
},
options(request),
)
return toPty(result.data)
},
get: async (input, request) => {
const result = await client.pty.get({ ptyID: input.ptyID, ...location(input) }, options(request))
return toPty(result.data)
},
update: async (input, request) => {
const result = await client.pty.update(
{
ptyID: input.ptyID,
title: input.title,
size: input.size,
...location(input),
},
options(request),
)
return toPty(result.data)
},
remove: async (input, request) => {
await client.pty.remove({ ptyID: input.ptyID, ...location(input) }, options(request))
},
},
events: {
subscribe: (request) => ({
async *[Symbol.asyncIterator]() {
const result = await client.global.event(options(request))
for await (const input of result.stream) {
const event = await toEvent(input, messages, loadMessage)
yield {
location:
input.directory === "global"
? undefined
: { directory: input.directory, workspaceID: input.workspace },
event,
} satisfies AppEventEnvelope
}
},
}),
},
disposeLocation: async (input, request) => {
await client.instance.dispose(location(input), options(request))
},
},
capabilities: {
configuration: {
getGlobal: async (request) => {
const result = await client.global.config.get(options(request))
return toConfig(result.data)
},
updateGlobal: async (config, request) => {
const current = await client.global.config.get(options(request))
await client.global.config.update({ config: { ...current.data, ...fromConfig(config) } }, options(request))
},
get: async (input, request) => {
const result = await client.config.get(location(input), options(request))
return toConfig(result.data)
},
},
providerAuthV1: {
methods: async (input, request) => {
const result = await client.provider.auth(location(input), options(request))
return result.data
},
authorize: async (input, request) => {
const result = await client.provider.oauth.authorize(
{
providerID: input.providerID,
method: input.method,
inputs: input.values ? { ...input.values } : undefined,
...location(input),
},
options(request),
)
return result.data
},
callback: async (input, request) => {
await client.provider.oauth.callback(
{
providerID: input.providerID,
method: input.method,
code: input.code,
...location(input),
},
options(request),
)
},
setApiKey: async (input, request) => {
await client.auth.set(
{
providerID: input.providerID,
auth: { type: "api", key: input.key, metadata: input.metadata && { ...input.metadata } },
},
options(request),
)
},
remove: async (input, request) => {
await client.auth.remove(input, options(request))
},
},
projectEditing: {
update: async (input, request) => {
const result = await client.project.update(
{
projectID: input.projectID,
name: input.name,
icon: input.icon,
commands: input.commands,
...location(input),
},
options(request),
)
return toProject(result.data)
},
initGit: async (input, request) => {
const result = await client.project.initGit(location(input), options(request))
return toProject(result.data)
},
},
worktreesV1: {
list: async (input, request) => {
const result = await client.worktree.list(location(input), options(request))
return result.data
},
create: async (input, request) => {
const result = await client.worktree.create(location(input), options(request))
return { directory: result.data.directory, branch: result.data.branch }
},
remove: async (input, request) => {
await client.worktree.remove(
{ ...location(input), worktreeRemoveInput: { directory: input.directory } },
options(request),
)
},
reset: async (input, request) => {
await client.worktree.reset(
{ ...location(input), worktreeResetInput: { directory: input.directory } },
options(request),
)
},
},
sessionExtrasV1: {
archive: async (sessionID, archivedAt, request) => {
await client.session.update(
{ sessionID, time: { archived: archivedAt }, ...legacyLocation(defaultLocation) },
options(request),
)
},
share: async (sessionID, request) => {
const result = await client.session.share({ sessionID, ...legacyLocation(defaultLocation) }, options(request))
if (!result.data.share) throw new Error(`Session ${sessionID} was shared without a URL`)
return result.data.share.url
},
unshare: async (sessionID, request) => {
await client.session.unshare({ sessionID, ...legacyLocation(defaultLocation) }, options(request))
},
diff: async (sessionID, request) => {
const result = await client.session.diff({ sessionID, ...legacyLocation(defaultLocation) }, options(request))
return result.data.flatMap((item) => (item.file ? [{ ...item, file: item.file }] : []))
},
todos: async (sessionID, request) => {
const result = await client.session.todo({ sessionID, ...legacyLocation(defaultLocation) }, options(request))
return result.data.map((item) => ({ content: item.content, status: item.status }))
},
summarize: async (sessionID, model, request) => {
await client.session.summarize(
{
sessionID,
providerID: model.providerID,
modelID: model.id,
...legacyLocation(defaultLocation),
},
options(request),
)
},
revert: async (sessionID, messageID, request) => {
await client.session.revert({ sessionID, messageID, ...legacyLocation(defaultLocation) }, options(request))
},
clearRevert: async (sessionID, request) => {
await client.session.unrevert({ sessionID, ...legacyLocation(defaultLocation) }, options(request))
},
shell: async (input, request) => {
await client.session.shell(
{
sessionID: input.sessionID,
messageID: input.id,
command: input.command,
agent: input.agent,
model: input.model && { providerID: input.model.providerID, modelID: input.model.id },
...location(input),
},
options(request),
)
},
},
lsp: {
status: async (input, request) => {
const result = await client.lsp.status(location(input), options(request))
return result.data.map((item) => ({ id: item.id, name: item.name, status: item.status }))
},
},
mcpControl: {
connect: async (input, request) => {
await client.mcp.connect({ name: input.name, ...location(input) }, options(request))
},
disconnect: async (input, request) => {
await client.mcp.disconnect({ name: input.name, ...location(input) }, options(request))
},
authenticate: async (input, request) => {
await client.mcp.auth.authenticate({ name: input.name, ...location(input) }, options(request))
},
},
pathInfo: {
get: async (input, request) => {
const result = await client.path.get(location(input), options(request))
return result.data
},
},
vcsInfo: {
get: async (input, request) => {
const result = await client.vcs.get(location(input), options(request))
return { branch: result.data.branch, defaultBranch: result.data.default_branch }
},
},
decoratedFiles: {
read: async (input, request) => {
const result = await client.file.read({ path: input.path, ...location(input) }, options(request))
return toDecoratedFile(result.data)
},
},
ptyTransport: {
connectToken: async (input, request) => {
const result = await client.pty.connectToken({ ptyID: input.ptyID, ...location(input) }, options(request))
return { ticket: result.data.ticket }
},
},
shellDiscovery: {
list: async (input, request) => {
const result = await client.pty.shells(location(input), options(request))
return result.data
},
},
runtimeV1: {
disposeAll: async (request) => {
await client.global.dispose(options(request))
},
},
},
}
}
function legacyLocation(input?: LocationRef) {
return {
directory: input?.directory,
workspace: input?.workspaceID,
}
}
function apiLocation(input?: LocationRef) {
if (!input) return
return {
directory: input.directory,
workspace: input.workspaceID,
}
}
function toProject(input: Project): AppProject {
return {
id: input.id,
worktree: input.worktree,
name: input.name,
icon: input.icon,
commands: input.commands,
sandboxes: input.sandboxes,
}
}
function toSession(input: Session): AppSession {
return {
id: input.id,
parentID: input.parentID,
projectID: input.projectID,
location: { directory: input.directory, workspaceID: input.workspaceID },
title: input.title,
cost: input.cost ?? 0,
tokens: input.tokens,
time: input.time,
share: input.share,
revert: input.revert && { messageID: input.revert.messageID },
}
}
function toModel(input: Model): AppModel {
return {
id: input.id,
providerID: input.providerID,
name: input.name,
family: input.family,
releaseDate: input.release_date,
cost: {
input: input.cost.input,
output: input.cost.output,
cacheRead: input.cost.cache.read,
cacheWrite: input.cost.cache.write,
},
capabilities: {
reasoning: input.capabilities.reasoning,
input: input.capabilities.input,
},
limit: input.limit,
variants: input.variants,
}
}
function toProvider(input: Provider): AppProvider {
return {
id: input.id,
name: input.name,
models: Object.fromEntries(
Object.entries(input.models).flatMap(([id, model]) =>
model.status === "deprecated" ? [] : [[id, toModel(model)]],
),
),
}
}
function toProviderCatalog(input: {
all: Provider[]
connected: string[]
default: Record<string, string>
}): ProviderCatalog {
return {
providers: new Map(input.all.map((provider) => [provider.id, toProvider(provider)])),
connected: input.connected,
defaults: input.default,
}
}
function normalizeAgents(input: unknown) {
const valid = (item: unknown): item is import("@opencode-ai/sdk-v1/v2/client").Agent => {
if (!item || typeof item !== "object") return false
if (!("name" in item) || typeof item.name !== "string") return false
if (!("mode" in item)) return false
return item.mode === "subagent" || item.mode === "primary" || item.mode === "all"
}
if (Array.isArray(input)) return input.filter(valid)
if (valid(input)) return [input]
if (!input || typeof input !== "object") return []
return Object.values(input).filter(valid)
}
function toAgent(input: import("@opencode-ai/sdk-v1/v2/client").Agent): AppAgent {
return {
id: input.name,
name: input.name,
description: input.description,
mode: input.mode,
hidden: input.hidden ?? false,
color: input.color,
model: input.model && {
id: input.model.modelID,
providerID: input.model.providerID,
variant: input.variant,
},
}
}
function toCommand(input: import("@opencode-ai/sdk-v1/v2/client").Command): AppCommand {
return { name: input.name, description: input.description, source: input.source }
}
function toReference(input: import("@opencode-ai/sdk-v1/v2/client").ReferenceInfo): AppReference {
return input
}
function toActivity(input: import("@opencode-ai/sdk-v1/v2/client").SessionStatus): SessionActivity | undefined {
if (input.type === "idle") return
if (input.type === "busy") return { type: "running" }
return input
}
function toTimelineItem(info: Message, parts: readonly Part[]): TimelineItem {
if (info.role === "user") {
return {
type: "user",
id: info.id,
sessionID: info.sessionID,
created: info.time.created,
content: parts.flatMap(toTimelineContent),
agent: info.agent,
model: {
id: info.model.modelID,
providerID: info.model.providerID,
variant: info.model.variant,
},
raw: { info, parts },
}
}
return {
type: "assistant",
id: info.id,
sessionID: info.sessionID,
parentID: info.parentID,
created: info.time.created,
completed: info.time.completed,
content: parts.flatMap(toTimelineContent),
agent: info.agent,
model: { id: info.modelID, providerID: info.providerID, variant: info.variant },
tokens: info.tokens,
error: info.error,
raw: { info, parts },
}
}
function toTimelineContent(input: Part): TimelineContent[] {
if (input.type === "text")
return [
{
type: input.type,
id: input.id,
text: input.text,
synthetic: input.synthetic,
ignored: input.ignored,
metadata: input.metadata,
},
]
if (input.type === "reasoning") return [{ type: input.type, id: input.id, text: input.text }]
if (input.type === "file")
return [
{
type: input.type,
id: input.id,
uri: input.url,
name: input.filename,
mime: input.mime,
source: input.source && {
...input.source,
text: {
text: input.source.text.value,
start: input.source.text.start,
end: input.source.text.end,
},
},
},
]
if (input.type === "agent")
return [
{
type: input.type,
id: input.id,
name: input.name,
source: input.source && { text: input.source.value, start: input.source.start, end: input.source.end },
},
]
if (input.type === "tool")
return [{ type: input.type, id: input.id, callID: input.callID, tool: input.tool, state: toToolState(input.state) }]
return []
}
function toToolState(input: import("@opencode-ai/sdk-v1/v2/client").ToolState): ToolState {
if (input.status === "pending") return { status: input.status, input: input.input, raw: input.raw }
if (input.status === "running")
return { status: input.status, input: input.input, title: input.title, metadata: input.metadata }
if (input.status === "completed")
return {
status: input.status,
input: input.input,
output: input.output,
title: input.title,
metadata: input.metadata,
}
return { status: input.status, input: input.input, error: input.error, metadata: input.metadata }
}
function toFilePart(input: PromptFile) {
return {
type: "file" as const,
mime: input.mime ?? "text/plain",
filename: input.name,
url: input.uri,
source: input.source?.path
? {
type: "file" as const,
path: input.source.path,
text: { value: input.source.text, start: input.source.start, end: input.source.end },
}
: undefined,
}
}
function toPromptParts(input: PromptInput) {
return [
{ type: "text" as const, text: input.text },
...(input.files?.map(toFilePart) ?? []),
...(input.agents?.map((agent) => ({
type: "agent" as const,
name: agent.name,
source:
agent.text !== undefined && agent.start !== undefined && agent.end !== undefined
? { value: agent.text, start: agent.start, end: agent.end }
: undefined,
})) ?? []),
]
}
function toFileContent(input: import("@opencode-ai/sdk-v1/v2/client").FileContent): FileContent {
if (input.encoding !== "base64") {
return { bytes: new TextEncoder().encode(input.content), kind: input.type, mimeType: input.mimeType }
}
return {
bytes: Uint8Array.from(atob(input.content), (character) => character.charCodeAt(0)),
kind: input.type,
mimeType: input.mimeType,
}
}
function toDecoratedFile(input: import("@opencode-ai/sdk-v1/v2/client").FileContent): DecoratedFileContent {
return {
type: input.type,
content: input.content,
diff: input.diff,
encoding: input.encoding,
mimeType: input.mimeType,
patch: input.patch && { hunks: input.patch.hunks.map((hunk) => ({ lines: hunk.lines })) },
}
}
function toPermission(input: import("@opencode-ai/sdk-v1/v2/client").PermissionRequest): AppPermissionRequest {
return {
id: input.id,
sessionID: input.sessionID,
action: input.permission,
resources: input.patterns,
metadata: input.metadata,
}
}
function toQuestion(input: import("@opencode-ai/sdk-v1/v2/client").QuestionRequest): AppQuestionRequest {
return {
id: input.id,
sessionID: input.sessionID,
questions: input.questions,
}
}
function toPty(input: import("@opencode-ai/sdk-v1/v2/client").Pty) {
return { id: input.id, title: input.title }
}
async function toEvent(
envelope: GlobalEvent,
messages: Map<string, CachedMessage>,
loadMessage: (input: { sessionID: string; messageID: string }) => Promise<CachedMessage>,
): Promise<AppEvent> {
const input = envelope.payload as Event
if (input.type === "server.connected") return { type: input.type }
if (input.type === "global.disposed") return { type: "server.disposed" }
if (input.type === "server.instance.disposed")
return { type: "server.disposed", location: { directory: input.properties.directory } }
if (input.type === "project.updated") return { type: input.type, project: toProject(input.properties) }
if (input.type === "session.created" || input.type === "session.updated")
return { type: input.type, session: toSession(input.properties.info) }
if (input.type === "session.deleted") return { type: input.type, sessionID: input.properties.sessionID }
if (input.type === "session.status") {
const activity = toActivity(input.properties.status)
if (activity) return { type: "session.activity", sessionID: input.properties.sessionID, activity }
return { type: "unknown", raw: input }
}
if (input.type === "session.error")
return { type: input.type, sessionID: input.properties.sessionID, error: input.properties.error }
if (input.type === "message.updated") {
const cached = messages.get(input.properties.info.id)
const value = { info: input.properties.info, parts: cached?.parts ?? [] }
messages.set(input.properties.info.id, value)
return { type: "timeline.updated", item: toTimelineItem(value.info, value.parts) }
}
if (input.type === "message.part.updated") {
const messageID = input.properties.part.messageID
const value = await loadMessage({ sessionID: input.properties.sessionID, messageID })
const parts = [...value.parts.filter((part) => part.id !== input.properties.part.id), input.properties.part]
const next = { info: value.info, parts }
messages.set(messageID, next)
return { type: "timeline.updated", item: toTimelineItem(next.info, next.parts) }
}
if (input.type === "message.removed") {
messages.delete(input.properties.messageID)
return { type: "timeline.removed", sessionID: input.properties.sessionID, itemID: input.properties.messageID }
}
if (input.type === "message.part.removed") {
const value = await loadMessage({ sessionID: input.properties.sessionID, messageID: input.properties.messageID })
const next = { info: value.info, parts: value.parts.filter((part) => part.id !== input.properties.partID) }
messages.set(input.properties.messageID, next)
return { type: "timeline.updated", item: toTimelineItem(next.info, next.parts) }
}
if (input.type === "permission.asked")
return { type: "permission.requested", request: toPermission(input.properties) }
if (input.type === "permission.v2.asked")
return {
type: "permission.requested",
request: {
id: input.properties.id,
sessionID: input.properties.sessionID,
action: input.properties.action,
resources: input.properties.resources,
metadata: input.properties.metadata,
},
}
if (input.type === "permission.replied" || input.type === "permission.v2.replied")
return {
type: "permission.replied",
sessionID: input.properties.sessionID,
requestID: input.properties.requestID,
}
if (input.type === "question.asked" || input.type === "question.v2.asked")
return { type: "question.requested", request: toQuestion(input.properties) }
if (input.type === "question.replied" || input.type === "question.v2.replied")
return { type: "question.replied", sessionID: input.properties.sessionID, requestID: input.properties.requestID }
if (input.type === "question.rejected" || input.type === "question.v2.rejected")
return { type: "question.rejected", sessionID: input.properties.sessionID, requestID: input.properties.requestID }
if (input.type === "file.watcher.updated")
return { type: "file.changed", path: input.properties.file, change: input.properties.event }
if (input.type === "vcs.branch.updated") return { type: input.type, branch: input.properties.branch }
if (input.type === "pty.exited") return { type: input.type, ptyID: input.properties.id }
return { type: "unknown", raw: input }
}
function toConfig(input: Config): AppConfig {
return {
shell: input.shell,
model: input.model,
share: input.share,
plugin: input.plugin,
disabledProviders: input.disabled_providers,
provider: input.provider,
permission: input.permission,
}
}
function fromConfig(input: AppConfig): Config {
return {
shell: input.shell,
model: input.model,
share: input.share,
plugin: input.plugin?.map((plugin): NonNullable<Config["plugin"]>[number] =>
typeof plugin === "string" ? plugin : [plugin[0], { ...plugin[1] }],
),
disabled_providers: input.disabledProviders ? [...input.disabledProviders] : undefined,
provider: input.provider as Config["provider"],
permission: input.permission as Config["permission"],
}
}
+256
View File
@@ -0,0 +1,256 @@
import { describe, expect, test } from "bun:test"
import { OpenCode } from "@opencode-ai/client"
import { createV2Backend } from "./backend-v2"
function setup(respond: (request: Request) => Response | Promise<Response>) {
const requests: Request[] = []
const fetch = Object.assign(
async (input: RequestInfo | URL, init?: RequestInit) => {
const request = input instanceof Request ? input : new Request(input, init)
requests.push(request)
return respond(request)
},
{ preconnect: globalThis.fetch.preconnect },
) satisfies typeof globalThis.fetch
return {
requests,
backend: createV2Backend(OpenCode.make({ baseUrl: "http://localhost", fetch }), {
directory: "/default",
workspaceID: "default-workspace",
}),
}
}
function json(data: unknown) {
return new Response(JSON.stringify(data), { headers: { "content-type": "application/json" } })
}
const session = {
id: "ses_1",
projectID: "project",
cost: 1.5,
tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } },
time: { created: 10, updated: 20 },
title: "Session",
location: { directory: "/repo", workspaceID: "workspace" },
}
describe("createV2Backend", () => {
test("normalizes session pages and preserves location precedence", async () => {
const setupResult = setup(() => json({ data: [session], cursor: { previous: "before", next: "after" } }))
const result = await setupResult.backend.common.sessions.list({
location: { directory: "/explicit", workspaceID: "explicit-workspace" },
roots: true,
limit: 10,
cursor: "cursor",
})
expect(result).toEqual({
items: [
{
id: "ses_1",
parentID: undefined,
projectID: "project",
location: { directory: "/repo", workspaceID: "workspace" },
title: "Session",
cost: 1.5,
tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } },
time: { created: 10, updated: 20 },
revert: undefined,
},
],
previous: "before",
next: "after",
})
const url = new URL(setupResult.requests[0].url)
expect(url.pathname).toBe("/api/session")
expect(url.searchParams.get("directory")).toBe("/explicit")
expect(url.searchParams.get("workspace")).toBe("explicit-workspace")
expect(url.searchParams.get("parentID")).toBe("null")
expect(url.searchParams.get("cursor")).toBe("cursor")
})
test("uses the default location and maps binary file responses", async () => {
const setupResult = setup(() => new Response(Uint8Array.from([0, 1, 2])))
const result = await setupResult.backend.common.files.read({ path: "dir/a.bin" })
expect([...result.bytes]).toEqual([0, 1, 2])
const url = new URL(setupResult.requests[0].url)
expect(url.pathname).toBe("/api/fs/read/dir/a.bin")
expect(url.searchParams.get("location[directory]")).toBe("/default")
expect(url.searchParams.get("location[workspace]")).toBe("default-workspace")
})
test("switches prompt selection before admission", async () => {
const setupResult = setup((request) =>
request.url.endsWith("/prompt")
? json({
data: {
admittedSeq: 1,
id: "msg_1",
sessionID: "ses_1",
timeCreated: 1,
type: "user",
data: { text: "hello" },
delivery: "steer",
},
})
: new Response(null, { status: 204 }),
)
await setupResult.backend.common.sessions.prompt({
sessionID: "ses_1",
id: "msg_1",
text: "hello",
selection: { agent: "build", model: { id: "model", providerID: "provider", variant: "high" } },
files: [{ uri: "data:text/plain;base64,aGk=", name: "hi.txt", source: { text: "hi", start: 0, end: 2 } }],
})
expect(setupResult.requests.map((item) => new URL(item.url).pathname)).toEqual([
"/api/session/ses_1/agent",
"/api/session/ses_1/model",
"/api/session/ses_1/prompt",
])
expect(await setupResult.requests[2].json()).toEqual({
id: "msg_1",
text: "hello",
files: [
{
uri: "data:text/plain;base64,aGk=",
name: "hi.txt",
mention: { start: 0, end: 2, text: "hi" },
},
],
})
})
test("requests file staging when selected revert files are present", async () => {
const setupResult = setup(() => json({ data: { messageID: "msg_1", files: [] } }))
await setupResult.backend.capabilities.sessionExtrasV2?.stageRevert({
sessionID: "ses_1",
messageID: "msg_1",
files: ["a.txt"],
})
expect(await setupResult.requests[0].json()).toEqual({ messageID: "msg_1", files: true })
})
test("normalizes retry events before message projection refresh", async () => {
const setupResult = setup(() =>
new Response(
`data: ${JSON.stringify({
id: "evt_retry",
created: 1,
type: "session.retry.scheduled",
durable: { aggregateID: "ses_1", seq: 3, version: 1 },
location: { directory: "/repo" },
data: {
sessionID: "ses_1",
assistantMessageID: "msg_1",
attempt: 2,
at: 1234,
error: { type: "rate_limit", message: "try again" },
},
})}\n\n`,
{ headers: { "content-type": "text/event-stream" } },
),
)
const result = await setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]().next()
expect(result.value).toEqual({
location: { directory: "/repo" },
event: {
type: "session.activity",
sessionID: "ses_1",
activity: { type: "retry", attempt: 2, message: "try again", next: 1234 },
},
})
expect(setupResult.requests).toHaveLength(1)
})
test("normalizes durable session log events through the event mapper", async () => {
const setupResult = setup(() =>
new Response(
`data: ${JSON.stringify({
id: "evt_started",
created: 1,
type: "session.execution.started",
durable: { aggregateID: "ses_1", seq: 7, version: 1 },
data: { sessionID: "ses_1" },
})}\n\n`,
{ headers: { "content-type": "text/event-stream" } },
),
)
const capability = setupResult.backend.capabilities.sessionExtrasV2
if (!capability) throw new Error("Missing V2 session capability")
const result = await capability.log({ sessionID: "ses_1" })[Symbol.asyncIterator]().next()
expect(result.value).toEqual({
sequence: 7,
event: { type: "session.activity", sessionID: "ses_1", activity: { type: "running" } },
})
expect(new URL(setupResult.requests[0].url).pathname).toBe("/api/experimental/session/ses_1/log")
})
test("refreshes message projections for streamed V2 fragments", async () => {
const setupResult = setup((request) => {
if (new URL(request.url).pathname === "/api/event") {
return new Response(
`data: ${JSON.stringify({
id: "evt_1",
created: 1,
type: "session.text.delta",
location: { directory: "/repo" },
data: { sessionID: "ses_1", assistantMessageID: "msg_1", ordinal: 0, delta: "hi" },
})}\n\n`,
{ headers: { "content-type": "text/event-stream" } },
)
}
return json({
data: {
id: "msg_1",
type: "assistant",
time: { created: 1 },
agent: "build",
model: { id: "model", providerID: "provider" },
content: [{ type: "text", text: "hello" }],
},
})
})
const result = await setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]().next()
expect(result.value).toEqual({
location: { directory: "/repo" },
event: {
type: "timeline.updated",
item: {
type: "assistant",
id: "msg_1",
sessionID: "ses_1",
created: 1,
completed: undefined,
content: [{ type: "text", id: "msg_1:text:0", text: "hello" }],
agent: "build",
model: { id: "model", providerID: "provider" },
tokens: undefined,
error: undefined,
raw: {
id: "msg_1",
type: "assistant",
time: { created: 1 },
agent: "build",
model: { id: "model", providerID: "provider" },
content: [{ type: "text", text: "hello" }],
},
},
},
})
expect(new URL(setupResult.requests[1].url).pathname).toBe("/api/session/ses_1/message/msg_1")
})
})
+970
View File
@@ -0,0 +1,970 @@
import type {
AgentInfo,
FormCreateInput,
FormInfo,
IntegrationInfo,
McpServer,
ModelInfo,
OpenCodeClient,
OpenCodeEvent,
PermissionV2Request,
Project,
QuestionV2Request,
SessionInfo,
SessionMessageAssistant,
SessionMessageInfo,
SessionPendingInfo,
ShellInfo1,
} from "@opencode-ai/client"
import type {
AppAgent,
AppClient,
AppEvent,
AppFileDiff,
AppMcpServer,
AppModel,
AppPermissionRequest,
AppProject,
AppQuestionRequest,
AppSession,
FormField,
IntegrationAttempt,
IntegrationAttemptStatus,
IntegrationConnection,
IntegrationMethod,
LocationInput,
LocationRef,
PendingSessionInput,
PromptFile,
RequestOptions,
SessionActivity,
SessionLogItem,
ShellProcess,
TimelineContent,
TimelineItem,
} from "./backend"
export function createV2Backend(client: OpenCodeClient, defaultLocation?: LocationRef): AppClient {
const request = (input?: RequestOptions) => ({ signal: input?.signal })
const location = (input?: LocationInput) => toLocation(input?.location ?? defaultLocation)
const loadSession = async (sessionID: string, options?: RequestOptions) =>
toSession(await client.session.get({ sessionID }, request(options)))
const loadMessage = async (sessionID: string, messageID: string, options?: RequestOptions) =>
toTimelineItem(await client.session.message({ sessionID, messageID }, request(options)), sessionID)
return {
version: "v2",
common: {
health: { get: (options) => client.health.get(request(options)) },
projects: {
list: async (options) => (await client.project.list(request(options))).map(toProject),
current: (input, options) => client.project.current({ location: location(input) }, request(options)),
},
catalog: {
providers: async (input, options) => {
const params = { location: location(input) }
const [providers, models, selected] = await Promise.all([
client.provider.list(params, request(options)),
client.model.list(params, request(options)),
client.model.default(params, request(options)),
])
return {
providers: new Map(
providers.data.map((provider) => [
provider.id,
{
id: provider.id,
name: provider.name,
models: Object.fromEntries(
models.data
.filter((model) => model.providerID === provider.id && model.status !== "deprecated")
.map((model) => [model.id, toModel(model)]),
),
},
]),
),
connected: providers.data.filter((provider) => !provider.disabled).map((provider) => provider.id),
defaults: selected.data ? { [selected.data.providerID]: selected.data.id } : {},
}
},
agents: async (input, options) =>
(await client.agent.list({ location: location(input) }, request(options))).data.map(toAgent),
},
commands: {
list: async (input, options) =>
(await client.command.list({ location: location(input) }, request(options))).data.map((item) => ({
name: item.name,
description: item.description,
})),
},
references: {
list: async (input, options) =>
(await client.reference.list({ location: location(input) }, request(options))).data.map((item) => ({
name: item.name,
path: item.path,
description: item.description,
hidden: item.hidden,
source: item.source,
})),
},
sessions: {
list: async (input, options) => {
const result = await client.session.list(
{
workspace: input?.location?.workspaceID ?? defaultLocation?.workspaceID,
directory: input?.location?.directory ?? defaultLocation?.directory,
parentID: input?.roots ? null : undefined,
limit: input?.limit,
search: input?.search,
cursor: input?.cursor,
},
request(options),
)
return {
items: result.data.map(toSession),
previous: result.cursor.previous ?? undefined,
next: result.cursor.next ?? undefined,
}
},
create: async (input, options) =>
toSession(
await client.session.create(
{ location: input?.location ?? defaultLocation, agent: input?.agent, model: input?.model },
request(options),
),
),
get: (input, options) => loadSession(input.sessionID, options),
remove: async (input, options) => {
await client.session.remove({ sessionID: input.sessionID }, request(options))
},
fork: async (input, options) =>
toSession(await client.session.fork({ sessionID: input.sessionID, messageID: input.messageID }, request(options))),
rename: async (input, options) => {
await client.session.rename({ sessionID: input.sessionID, title: input.title }, request(options))
},
interrupt: async (input, options) => {
await client.session.interrupt({ sessionID: input.sessionID }, request(options))
},
activity: async (_input, options) => {
const result = await client.session.active(request(options))
return Object.fromEntries(Object.keys(result).map((sessionID) => [sessionID, { type: "running" } as const]))
},
history: async (input, options) => {
const result = await client.message.list(
{ sessionID: input.sessionID, limit: input.limit, cursor: input.cursor },
request(options),
)
return {
items: result.data.map((item) => toTimelineItem(item, input.sessionID)),
previous: result.cursor.previous ?? undefined,
next: result.cursor.next ?? undefined,
}
},
message: (input, options) => loadMessage(input.sessionID, input.messageID, options),
prompt: async (input, options) => {
if (input.selection?.agent)
await client.session.switchAgent(
{ sessionID: input.sessionID, agent: input.selection.agent },
request(options),
)
if (input.selection?.model)
await client.session.switchModel(
{ sessionID: input.sessionID, model: input.selection.model },
request(options),
)
await client.session.prompt(
{
sessionID: input.sessionID,
id: input.id,
text: input.text,
files: input.files?.map(toPromptFile),
agents: input.agents?.map((agent) => ({
name: agent.name,
mention:
agent.start === undefined || agent.end === undefined || agent.text === undefined
? undefined
: { start: agent.start, end: agent.end, text: agent.text },
})),
delivery: input.delivery,
},
request(options),
)
},
command: async (input, options) => {
await client.session.command(
{
sessionID: input.sessionID,
id: input.id,
command: input.command,
arguments: input.arguments,
agent: input.agent,
model: input.model,
files: input.files?.map(toPromptFile),
delivery: input.delivery,
},
request(options),
)
},
},
files: {
list: async (input, options) =>
(await client.file.list({ location: location(input), path: input.path }, request(options))).data,
find: async (input, options) =>
(
await client.file.find(
{ location: location(input), query: input.query, type: input.type, limit: input.limit },
request(options),
)
).data,
read: async (input, options) => ({
bytes: await client.file.read({ location: location(input), path: input.path }, request(options)),
}),
},
permissions: {
pending: async (input, options) =>
(await client.permission.request.list({ location: location(input) }, request(options))).data.map(toPermission),
reply: async (input, options) => {
await client.permission.reply(input, request(options))
},
},
questions: {
pending: async (input, options) =>
(await client.question.request.list({ location: location(input) }, request(options))).data.map(toQuestion),
reply: async (input, options) => {
await client.question.reply({ ...input, answers: input.answers.map((answer) => [...answer]) }, request(options))
},
reject: async (input, options) => {
await client.question.reject(input, request(options))
},
},
vcs: {
status: async (input, options) =>
(await client.vcs.status({ location: location(input) }, request(options))).data,
diff: async (input, options) =>
(await client.vcs.diff({ ...input, location: location(input) }, request(options))).data.map(toFileDiff),
},
mcp: {
list: async (input, options) =>
(await client.mcp.list({ location: location(input) }, request(options))).data.map(toMcpServer),
resources: async (input, options) =>
(await client.mcp.resource.catalog({ location: location(input) }, request(options))).data,
},
pty: {
list: async (input, options) =>
(await client.pty.list({ location: location(input) }, request(options))).data.map(toPty),
create: async (input, options) =>
toPty(
(
await client.pty.create(
{
location: location(input),
title: input.title,
command: input.command,
args: input.args,
cwd: input.cwd,
env: input.env,
},
request(options),
)
).data,
),
get: async (input, options) =>
toPty((await client.pty.get({ ptyID: input.ptyID, location: location(input) }, request(options))).data),
update: async (input, options) =>
toPty(
(
await client.pty.update(
{ ptyID: input.ptyID, location: location(input), title: input.title, size: input.size },
request(options),
)
).data,
),
remove: async (input, options) => {
await client.pty.remove({ ptyID: input.ptyID, location: location(input) }, request(options))
},
},
events: {
subscribe: (options) => ({
async *[Symbol.asyncIterator]() {
for await (const input of client.event.subscribe(request(options))) {
yield {
location: input.location,
event: await toEvent(input, loadSession, loadMessage, options),
}
}
},
}),
},
disposeLocation: async (input, options) => {
await client.debug.location.evict({ location: location(input) }, request(options))
},
},
capabilities: {
integrationsV2: {
list: async (input, options) =>
(await client.integration.list({ location: location(input) }, request(options))).data.map(toIntegration),
get: async (input, options) => {
const result = await client.integration.get(
{ integrationID: input.integrationID, location: location(input) },
request(options),
)
return result.data ? toIntegration(result.data) : null
},
connectKey: async (input, options) => {
await client.integration.connect.key({ ...input, location: location(input) }, request(options))
},
connectOauth: async (input, options) =>
toIntegrationAttempt(
(
await client.integration.connect.oauth(
{
integrationID: input.integrationID,
methodID: input.methodID,
inputs: input.values,
label: input.label,
location: location(input),
},
request(options),
)
).data,
),
attemptStatus: async (input, options) =>
toAttemptStatus(
(
await client.integration.attempt.status(
{ attemptID: input.attemptID, location: location(input) },
request(options),
)
).data,
),
completeAttempt: async (input, options) => {
await client.integration.attempt.complete({ ...input, location: location(input) }, request(options))
},
cancelAttempt: async (input, options) => {
await client.integration.attempt.cancel({ ...input, location: location(input) }, request(options))
},
renameCredential: async (input, options) => {
await client.credential.update({ ...input, location: location(input) }, request(options))
},
removeCredential: async (input, options) => {
await client.credential.remove({ ...input, location: location(input) }, request(options))
},
},
sessionExtrasV2: {
switchAgent: async (input, options) => {
await client.session.switchAgent(input, request(options))
},
switchModel: async (input, options) => {
await client.session.switchModel(input, request(options))
},
move: async (input, options) => {
await client.session.move(
{ sessionID: input.sessionID, destination: { directory: input.directory }, moveChanges: input.moveChanges },
request(options),
)
},
skill: async (input, options) => {
await client.session.skill(input, request(options))
},
synthetic: async (input, options) =>
toPending(
await client.session.synthetic(
{ ...input, metadata: input.metadata && toClientRecord(input.metadata) },
request(options),
),
),
shell: async (input, options) => {
await client.session.shell(input, request(options))
},
compact: async (input, options) => toPending(await client.session.compact(input, request(options))),
wait: async (input, options) => {
await client.session.wait(input, request(options))
},
context: async (input, options) =>
(await client.session.context(input, request(options))).map((item) => toTimelineItem(item, input.sessionID)),
pending: async (input, options) =>
(await client.session.pending.list(input, request(options))).map(toPending),
instructionEntries: (input, options) => client.session.instructions.entry.list(input, request(options)),
putInstructionEntry: async (input, options) => {
await client.session.instructions.entry.put({ ...input, value: toClientJson(input.value) }, request(options))
},
removeInstructionEntry: async (input, options) => {
await client.session.instructions.entry.remove(input, request(options))
},
log: (input, options) => ({
async *[Symbol.asyncIterator]() {
for await (const item of client.session.log(input, request(options))) {
yield {
sequence: "durable" in item ? item.durable.seq : (item.seq ?? 0),
event: await toLogEvent(item, loadSession, loadMessage, options),
} satisfies SessionLogItem
}
},
}),
background: async (input, options) => {
await client.session.background(input, request(options))
},
stageRevert: async (input, options) => {
const result = await client.session.revert.stage(
{ sessionID: input.sessionID, messageID: input.messageID, files: input.files?.length ? true : undefined },
request(options),
)
return { messageID: result.messageID }
},
clearRevert: async (input, options) => {
await client.session.revert.clear(input, request(options))
},
commitRevert: async (input, options) => {
await client.session.revert.commit(input, request(options))
},
},
projectCopiesV2: {
directories: (input, options) =>
client.project.directories({ projectID: input.projectID, location: location(input) }, request(options)),
create: (input, options) => client.projectCopy.create({ ...input, location: location(input) }, request(options)),
remove: async (input, options) => {
await client.projectCopy.remove({ ...input, location: location(input) }, request(options))
},
refresh: async (input, options) => {
await client.projectCopy.refresh({ ...input, location: location(input) }, request(options))
},
},
formsV2: {
pending: async (input, options) =>
(await client.form.request.list({ location: location(input) }, request(options))).data.map(toForm),
list: async (input, options) => (await client.form.list(input, request(options))).map(toForm),
create: async (input, options) => {
if (input.fields.length === 0) throw new Error("A form requires at least one field")
const fields: FormCreateInput["fields"] = [
fromFormField(input.fields[0]),
...input.fields.slice(1).map(fromFormField),
]
return toForm(
await client.form.create(
{ ...input, metadata: input.metadata && toClientRecord(input.metadata), fields },
request(options),
),
)
},
get: async (input, options) => toForm(await client.form.get(input, request(options))),
state: (input, options) => client.form.state(input, request(options)),
reply: async (input, options) => {
await client.form.reply({ ...input, answer: { ...input.answer } }, request(options))
},
cancel: async (input, options) => {
await client.form.cancel(input, request(options))
},
},
savedPermissionsV2: {
list: (input, options) => client.permission.saved.list(input, request(options)),
remove: async (input, options) => {
await client.permission.saved.remove(input, request(options))
},
},
shellsV2: {
list: async (input, options) =>
(await client.shell.list({ location: location(input) }, request(options))).data.map(toShell),
create: async (input, options) =>
toShell(
(
await client.shell.create(
{
...input,
metadata: input.metadata && toClientRecord(input.metadata),
location: location(input),
},
request(options),
)
).data,
),
get: async (input, options) =>
toShell((await client.shell.get({ id: input.id, location: location(input) }, request(options))).data),
setTimeout: async (input, options) =>
toShell(
(
await client.shell.timeout(
{ id: input.id, timeout: input.timeout, location: location(input) },
request(options),
)
).data,
),
output: async (input, options) =>
(
await client.shell.output(
{ id: input.id, cursor: input.cursor, limit: input.limit, location: location(input) },
request(options),
)
).data,
remove: async (input, options) => {
await client.shell.remove({ id: input.id, location: location(input) }, request(options))
},
},
discoveryV2: {
server: (options) => client.server.get(request(options)),
location: (input, options) => client.location.get({ location: location(input) }, request(options)),
plugins: async (input, options) =>
(await client.plugin.list({ location: location(input) }, request(options))).data,
skills: async (input, options) => {
const result = await client.skill.list({ location: location(input) }, request(options))
return result.data.map((item) => ({
...item,
location: { directory: item.location },
}))
},
models: async (input, options) =>
(await client.model.list({ location: location(input) }, request(options))).data.map(toModel),
defaultModel: async (input, options) => {
const result = await client.model.default({ location: location(input) }, request(options))
return result.data ? toModel(result.data) : null
},
generateText: async (input, options) =>
(await client.generate.text({ ...input, location: location(input) }, request(options))).text,
loadedLocations: (options) => client.debug.location.list(request(options)),
},
},
}
}
function toLocation(input?: LocationRef) {
if (!input) return
return { directory: input.directory, workspace: input.workspaceID }
}
function toProject(input: Project): AppProject {
return {
id: input.id,
worktree: input.worktree,
name: input.name,
icon: input.icon,
commands: input.commands,
sandboxes: input.sandboxes,
}
}
function toSession(input: SessionInfo): AppSession {
return {
id: input.id,
parentID: input.parentID,
projectID: input.projectID,
location: input.location,
title: input.title,
cost: input.cost,
tokens: input.tokens,
time: input.time,
revert: input.revert && { messageID: input.revert.messageID },
}
}
function toModel(input: ModelInfo): AppModel {
const cost = input.cost[0]
return {
id: input.id,
providerID: input.providerID,
name: input.name,
family: input.family,
releaseDate: new Date(input.time.released).toISOString().slice(0, 10),
cost: cost && { input: cost.input, output: cost.output, cacheRead: cost.cache.read, cacheWrite: cost.cache.write },
capabilities: {
reasoning: input.capabilities.output.includes("reasoning"),
input: {
text: input.capabilities.input.includes("text"),
image: input.capabilities.input.includes("image"),
audio: input.capabilities.input.includes("audio"),
video: input.capabilities.input.includes("video"),
pdf: input.capabilities.input.includes("pdf"),
},
},
limit: { context: input.limit.context, output: input.limit.output },
variants: Object.fromEntries(input.variants.map((variant) => [variant.id, variant])),
}
}
function toAgent(input: AgentInfo): AppAgent {
return {
id: input.id,
name: input.name,
description: input.description,
mode: input.mode,
hidden: input.hidden,
color: input.color,
model: input.model,
}
}
function toTimelineItem(input: SessionMessageInfo, sessionID: string): TimelineItem {
if (input.type === "user") {
return {
type: "user",
id: input.id,
sessionID,
created: input.time.created,
content: [
{ type: "text", id: `${input.id}:text`, text: input.text },
...(input.files?.map((file, index) => ({
type: "file" as const,
id: `${input.id}:file:${index}`,
uri: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
name: file.name,
})) ?? []),
...(input.agents?.map((agent, index) => ({
type: "agent" as const,
id: `${input.id}:agent:${index}`,
name: agent.name,
source: agent.mention,
})) ?? []),
],
raw: input,
}
}
if (input.type === "assistant") return toAssistant(input, sessionID)
const type =
input.type === "agent-switched"
? "agent-switch"
: input.type === "model-switched"
? "model-switch"
: input.type
return { type, id: input.id, sessionID, created: input.time.created, raw: input }
}
function toAssistant(input: SessionMessageAssistant, sessionID: string): TimelineItem {
return {
type: "assistant",
id: input.id,
sessionID,
created: input.time.created,
completed: input.time.completed,
content: input.content.flatMap((item, index): TimelineContent[] => {
if (item.type === "text") return [{ type: "text", id: `${input.id}:text:${index}`, text: item.text }]
if (item.type === "reasoning")
return [{ type: "reasoning", id: `${input.id}:reasoning:${index}`, text: item.text }]
if (item.state.status === "streaming")
return [
{
type: "tool",
id: item.id,
tool: item.name,
state: { status: "pending", input: {}, raw: item.state.input },
},
]
if (item.state.status === "running")
return [
{
type: "tool",
id: item.id,
tool: item.name,
state: { status: "running", input: item.state.input, metadata: item.state.structured },
},
]
if (item.state.status === "error")
return [
{
type: "tool",
id: item.id,
tool: item.name,
state: {
status: "error",
input: item.state.input,
error: item.state.error.message,
metadata: item.state.structured,
},
},
]
return [
{
type: "tool",
id: item.id,
tool: item.name,
state: {
status: "completed",
input: item.state.input,
output: item.state.content
.map((content) => (content.type === "text" ? content.text : content.uri))
.join("\n"),
metadata: item.state.structured,
},
},
]
}),
agent: input.agent,
model: input.model,
tokens: input.tokens,
error: input.error,
raw: input,
}
}
function toPromptFile(input: PromptFile) {
return {
uri: input.uri,
name: input.name,
mention: input.source && { start: input.source.start, end: input.source.end, text: input.source.text },
}
}
function toPermission(input: PermissionV2Request): AppPermissionRequest {
return {
id: input.id,
sessionID: input.sessionID,
action: input.action,
resources: input.resources,
metadata: input.metadata,
}
}
function toQuestion(input: QuestionV2Request): AppQuestionRequest {
return { id: input.id, sessionID: input.sessionID, questions: input.questions }
}
function toFileDiff(input: { file: string; patch: string; additions: number; deletions: number; status: "added" | "deleted" | "modified" }): AppFileDiff {
return input
}
function toMcpServer(input: McpServer): AppMcpServer {
return { name: input.name, status: input.status, integrationID: input.integrationID }
}
function toPty(input: { id: string; title: string }) {
return { id: input.id, title: input.title }
}
function toIntegration(input: IntegrationInfo) {
return {
id: input.id,
name: input.name,
methods: input.methods.map((method): IntegrationMethod => {
if (method.type === "oauth") return method
if (method.type === "key") return { type: "key", label: method.label ?? "API key" }
return { type: "environment", label: method.names.join(", ") }
}),
connections: input.connections.map((connection): IntegrationConnection =>
connection.type === "credential"
? { id: connection.id, label: connection.label }
: { id: connection.name, label: connection.name },
),
}
}
function toIntegrationAttempt(input: {
attemptID: string
url: string
instructions: string
mode: "auto" | "code"
time: { created: number | string; expires: number | string }
}): IntegrationAttempt {
return {
...input,
time: { created: Number(input.time.created), expires: Number(input.time.expires) },
}
}
function toAttemptStatus(input: import("@opencode-ai/client").IntegrationAttemptStatus): IntegrationAttemptStatus {
if (input.status === "failed") return { status: input.status, error: input.message }
return { status: input.status }
}
function toPending(input: SessionPendingInfo): PendingSessionInput {
return {
id: input.id,
sessionID: input.sessionID,
sequence: input.admittedSeq,
created: input.timeCreated,
delivery: "delivery" in input ? input.delivery : undefined,
raw: input,
}
}
function toForm(input: FormInfo) {
return {
id: input.id,
sessionID: input.sessionID,
title: input.title,
metadata: input.metadata,
fields: input.fields.map((field): FormField => {
if (field.type === "external")
return {
type: field.type,
key: field.key,
label: field.title ?? field.key,
url: field.url,
description: field.description,
}
if (field.type === "multiselect")
return {
type: field.type,
key: field.key,
label: field.title ?? field.key,
options: field.options.map((option) => option.value),
required: field.required,
description: field.description,
}
return {
type: field.type,
key: field.key,
label: field.title ?? field.key,
required: field.required,
description: field.description,
}
}),
}
}
function fromFormField(input: FormField): FormCreateInput["fields"][number] {
if (input.type === "external")
return { type: input.type, key: input.key, title: input.label, url: input.url, description: input.description }
if (input.type === "multiselect")
return {
type: input.type,
key: input.key,
title: input.label,
options: input.options.map((option) => ({ value: option, label: option })),
required: input.required,
description: input.description,
}
return {
type: input.type,
key: input.key,
title: input.label,
required: input.type === "boolean" ? undefined : input.required,
description: input.description,
}
}
function toClientRecord(input: Readonly<Record<string, import("./backend").JsonValue>>) {
return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, toClientJson(value)]))
}
function toClientJson(input: import("./backend").JsonValue): import("@opencode-ai/client").JsonValue {
if (isJsonArray(input)) return input.map(toClientJson)
if (input && typeof input === "object") return toClientRecord(input)
return input
}
function isJsonArray(input: import("./backend").JsonValue): input is readonly import("./backend").JsonValue[] {
return Array.isArray(input)
}
function toShell(input: ShellInfo1): ShellProcess {
return {
id: input.id,
command: input.command,
cwd: input.cwd,
status: input.status === "running" ? "running" : "exited",
created: input.time.started,
exitCode: input.exit,
metadata: input.metadata,
}
}
async function toEvent(
input: OpenCodeEvent,
loadSession: (sessionID: string, options?: RequestOptions) => Promise<AppSession>,
loadMessage: (sessionID: string, messageID: string, options?: RequestOptions) => Promise<TimelineItem>,
options?: RequestOptions,
): Promise<AppEvent> {
if (input.type === "server.connected") return { type: input.type }
if (input.type === "session.deleted") return { type: input.type, sessionID: input.data.sessionID }
if (input.type === "session.error") return { type: input.type, sessionID: input.data.sessionID, error: input.data.error }
if (input.type === "session.status") {
const activity = toActivity(input.data.status)
return activity
? { type: "session.activity", sessionID: input.data.sessionID, activity }
: { type: "unknown", raw: input }
}
if (input.type === "session.execution.started")
return { type: "session.activity", sessionID: input.data.sessionID, activity: { type: "running" } }
if (input.type === "session.retry.scheduled")
return {
type: "session.activity",
sessionID: input.data.sessionID,
activity: {
type: "retry",
attempt: input.data.attempt,
message: input.data.error.message,
next: input.data.at,
},
}
if (input.type === "session.execution.failed")
return { type: "session.error", sessionID: input.data.sessionID, error: input.data.error }
if (input.type === "session.idle") return { type: "unknown", raw: input }
if (input.type === "permission.v2.asked") return { type: "permission.requested", request: toPermission(input.data) }
if (input.type === "permission.v2.replied")
return { type: "permission.replied", sessionID: input.data.sessionID, requestID: input.data.requestID }
if (input.type === "question.v2.asked") return { type: "question.requested", request: toQuestion(input.data) }
if (input.type === "question.v2.replied" || input.type === "question.v2.rejected")
return {
type: input.type === "question.v2.replied" ? "question.replied" : "question.rejected",
sessionID: input.data.sessionID,
requestID: input.data.requestID,
}
if (input.type === "filesystem.changed")
return { type: "file.changed", path: input.data.file, change: input.data.event }
if (input.type === "vcs.branch.updated") return { type: input.type, branch: input.data.branch }
if (input.type === "pty.exited") return { type: input.type, ptyID: input.data.id }
if (input.type === "message.removed")
return { type: "timeline.removed", sessionID: input.data.sessionID, itemID: input.data.messageID }
if (input.type === "message.updated")
return {
type: "timeline.updated",
item: await loadMessage(input.data.sessionID, input.data.info.id, options),
}
if (input.type === "message.part.updated")
return {
type: "timeline.updated",
item: await loadMessage(input.data.sessionID, input.data.part.messageID, options),
}
const sessionID = eventSessionID(input)
const messageID = eventMessageID(input)
if (sessionID && messageID)
return { type: "timeline.updated", item: await loadMessage(sessionID, messageID, options) }
if (sessionID && isSessionProjectionEvent(input.type)) {
const session = await loadSession(sessionID, options)
return { type: input.type === "session.created" ? "session.created" : "session.updated", session }
}
return { type: "unknown", raw: input }
}
function toActivity(input: import("@opencode-ai/client").SessionStatus): SessionActivity | undefined {
if (input.type === "idle") return
if (input.type === "busy") return { type: "running" }
return input
}
function eventSessionID(input: OpenCodeEvent) {
if (!("data" in input) || typeof input.data !== "object" || input.data === null || !("sessionID" in input.data)) return
return typeof input.data.sessionID === "string" ? input.data.sessionID : undefined
}
function eventMessageID(input: OpenCodeEvent) {
if (!("data" in input) || typeof input.data !== "object" || input.data === null) return
if ("assistantMessageID" in input.data && typeof input.data.assistantMessageID === "string")
return input.data.assistantMessageID
if ("messageID" in input.data && typeof input.data.messageID === "string") return input.data.messageID
if (input.type === "session.input.promoted") return input.data.inputID
}
function isSessionProjectionEvent(type: string) {
return [
"session.created",
"session.updated",
"session.agent.selected",
"session.model.selected",
"session.moved",
"session.renamed",
"session.usage.updated",
"session.revert.staged",
"session.revert.cleared",
"session.revert.committed",
].includes(type)
}
async function toLogEvent(
input: import("@opencode-ai/client").SessionLogOutput,
loadSession: (sessionID: string, options?: RequestOptions) => Promise<AppSession>,
loadMessage: (sessionID: string, messageID: string, options?: RequestOptions) => Promise<TimelineItem>,
options?: RequestOptions,
): Promise<AppEvent> {
if (input.type === "log.synced") return { type: "unknown", raw: input }
return toEvent(input, loadSession, loadMessage, options)
}
File diff suppressed because it is too large Load Diff