diff --git a/packages/app/src/context/backend-v2.test.ts b/packages/app/src/context/backend-v2.test.ts new file mode 100644 index 0000000000..5256002a55 --- /dev/null +++ b/packages/app/src/context/backend-v2.test.ts @@ -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) { + 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") + }) +}) diff --git a/packages/app/src/context/backend-v2.ts b/packages/app/src/context/backend-v2.ts new file mode 100644 index 0000000000..3eea631c12 --- /dev/null +++ b/packages/app/src/context/backend-v2.ts @@ -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>) { + 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, + loadMessage: (sessionID: string, messageID: string, options?: RequestOptions) => Promise, + options?: RequestOptions, +): Promise { + 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, + loadMessage: (sessionID: string, messageID: string, options?: RequestOptions) => Promise, + options?: RequestOptions, +): Promise { + if (input.type === "log.synced") return { type: "unknown", raw: input } + return toEvent(input, loadSession, loadMessage, options) +}