chore: merge dev into v2 (#39290)

Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
Co-authored-by: BB84 <110078428+BB-84C@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Jay V <air@live.ca>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: Jack <jack@anoma.ly>
Co-authored-by: Sebastian <hasta84@gmail.com>
Co-authored-by: Jérôme Benoit <jerome.benoit@sap.com>
Co-authored-by: Test User <test@test.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
Co-authored-by: Rahul A Mistry <149420892+ProdigyRahul@users.noreply.github.com>
Co-authored-by: Qiping Li <liqiping1991@gmail.com>
Co-authored-by: liqiping <liqiping@msh.team>
Co-authored-by: OpeOginni <107570612+OpeOginni@users.noreply.github.com>
Co-authored-by: Matthias Reso <13337103+mreso@users.noreply.github.com>
Co-authored-by: tobwen <1864057+tobwen@users.noreply.github.com>
Co-authored-by: Daniel Polito <danielbpolito@gmail.com>
Co-authored-by: opencode <noreply@opencode.ai>
Co-authored-by: Devin R Leopold <devin.leopold@gmail.com>
Co-authored-by: Zach Bruggeman <mail@bruggie.com>
Co-authored-by: Zach Bruggeman <zbruggeman@ramp.com>
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: David Siewert <david1gruppenplan@gmail.com>
Co-authored-by: Andrei Dziahel <develop7@develop7.info>
Co-authored-by: adityachaudhary99 <adityaachaudhary2003@gmail.com>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
Co-authored-by: Matt Carey <mcarey@cloudflare.com>
This commit is contained in:
opencode-agent[bot] 2026-07-28 18:36:55 +10:00 committed by GitHub
commit 302e9b45ab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
263 changed files with 12516 additions and 4822 deletions

View file

@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { Message } from "@opencode-ai/sdk/v2/client"
import { diffs, message } from "./diffs"
@ -9,7 +10,7 @@ const item = {
additions: 1,
deletions: 1,
status: "modified",
} satisfies SnapshotFileDiff
} satisfies FileDiffInfo & SnapshotFileDiff
describe("diffs", () => {
test("keeps valid arrays", () => {

View file

@ -1,9 +1,10 @@
import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2"
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { Message } from "@opencode-ai/sdk/v2/client"
export type SessionDiff = SnapshotFileDiff & { file: string; patch: string }
type Diff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff
function diff(value: unknown): value is SessionDiff {
function diff(value: unknown): value is Diff {
if (!value || typeof value !== "object" || Array.isArray(value)) return false
if (!("file" in value) || typeof value.file !== "string") return false
if (!("patch" in value) || typeof value.patch !== "string") return false
@ -17,7 +18,7 @@ function object(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
}
export function diffs(value: unknown): SessionDiff[] {
export function diffs(value: unknown): Diff[] {
if (Array.isArray(value) && value.every(diff)) return value
if (Array.isArray(value)) return value.filter(diff)
if (diff(value)) return [value]

View file

@ -0,0 +1,30 @@
/** Coordinates focus restoration and actions that must run after menu content unmounts. */
export function createMenuDismissController(content: () => HTMLElement | undefined) {
let restoreTrigger = true
return {
/** Allows the menu primitive to restore focus to its trigger when closing. */
allowTriggerRestore() {
restoreTrigger = true
},
/** Keeps focus at its current or next destination instead of returning it to the trigger. */
preventTriggerRestore() {
restoreTrigger = false
},
/** Applies the current restoration policy during the menu primitive's close-focus event. */
onCloseAutoFocus(event: Event) {
if (!restoreTrigger) event.preventDefault()
},
/** Runs an action after the menu unmounts and its focus-close work has settled. */
afterClose(callback: () => void) {
const complete = () => {
if (content()?.isConnected) {
requestAnimationFrame(complete)
return
}
requestAnimationFrame(() => requestAnimationFrame(callback))
}
requestAnimationFrame(complete)
},
}
}

View file

@ -2,7 +2,10 @@ import { describe, expect, test } from "bun:test"
import { createApiForServer, createSdkForServer } from "./server"
import { createCompatibleApi } from "./server-compat"
function setup(protocol: "v1" | "v2" | Promise<"v1" | "v2">) {
function setup(
protocol: "v1" | "v2" | Promise<"v1" | "v2">,
responses?: { vcs?: { branch: string; default_branch: string } },
) {
const requests: Request[] = []
const fetcher = Object.assign(
async (input: string | URL | Request, init?: RequestInit) => {
@ -32,6 +35,8 @@ function setup(protocol: "v1" | "v2" | Promise<"v1" | "v2">) {
delivery: "steer",
})
}
if (request.method === "GET" && new URL(request.url).pathname === "/vcs")
return Response.json(responses?.vcs ?? {})
if (request.method === "GET") return Response.json([])
return new Response(undefined, { status: 204 })
},
@ -48,6 +53,7 @@ function setup(protocol: "v1" | "v2" | Promise<"v1" | "v2">) {
}
describe("createCompatibleApi", () => {
/*
test("routes V1 archive through the legacy session update", async () => {
const { api, requests } = setup("v1")
await api.session.archive({ sessionID: "ses_1", directory: "/repo" })
@ -58,32 +64,69 @@ describe("createCompatibleApi", () => {
expect(requests[0]!.method).toBe("PATCH")
expect(await requests[0]!.json()).toMatchObject({ time: { archived: expect.any(Number) } })
})
*/
test("converts current prompts to the V1 prompt contract", async () => {
const { api, requests } = setup("v1")
await api.session.prompt({
sessionID: "ses_1",
id: "msg_1",
text: "hello",
text: "hello @src/index.ts",
agent: "build",
model: { providerID: "provider", modelID: "model" },
files: [
{ uri: "file:///repo/src/index.ts", name: "index.ts", mention: { text: "@src/index.ts", start: 6, end: 19 } },
{ uri: "data:text/plain;base64,aGVsbG8=", name: "notes.txt" },
],
})
expect(new URL(requests[0]!.url).pathname).toBe("/session/ses_1/prompt_async")
expect(await requests[0]!.json()).toMatchObject({
const body = await requests[0]!.json()
expect(body).toMatchObject({
messageID: "msg_1",
agent: "build",
model: { providerID: "provider", modelID: "model" },
parts: [{ type: "text", text: "hello" }],
parts: [
{ type: "text", text: "hello @src/index.ts" },
{
type: "file",
mime: "text/plain",
url: "file:///repo/src/index.ts",
filename: "index.ts",
source: {
type: "file",
text: { value: "@src/index.ts", start: 6, end: 19 },
path: "file:///repo/src/index.ts",
},
},
{
type: "file",
mime: "text/plain",
url: "data:text/plain;base64,aGVsbG8=",
filename: "notes.txt",
},
],
})
expect(body.parts[2]).not.toHaveProperty("source")
})
test("keeps V2 session actions on the current API", async () => {
const { api, requests } = setup("v2")
await api.session.archive({ sessionID: "ses_1" })
test("preserves original parts for V1 optimistic reconciliation", async () => {
const { api, requests } = setup("v1")
await api.session.prompt({
sessionID: "ses_1",
id: "msg_1",
text: "look",
files: [{ uri: "data:image/png;base64,AAAA", name: "image.png" }],
legacyParts: [
{ id: "prt_text", type: "text", text: "look" },
{ id: "prt_image", type: "file", mime: "image/png", url: "data:image/png;base64,AAAA", filename: "image.png" },
],
})
expect(new URL(requests[0]!.url).pathname).toBe("/api/session/ses_1/archive")
expect(requests[0]!.method).toBe("POST")
expect((await requests[0]!.json()).parts).toEqual([
{ id: "prt_text", type: "text", text: "look" },
{ id: "prt_image", type: "file", mime: "image/png", url: "data:image/png;base64,AAAA", filename: "image.png" },
])
})
test("resolves protocol detection once across implementation methods", async () => {
@ -98,16 +141,96 @@ describe("createCompatibleApi", () => {
})
const { api } = setup(protocol)
await api.session.archive({ sessionID: "ses_1" })
await api.session.list()
await api.session.list()
expect(detections).toBe(1)
})
/*
test("keeps V2 session actions on the current API", async () => {
const { api, requests } = setup("v2")
await api.session.archive({ sessionID: "ses_1" })
expect(new URL(requests[0]!.url).pathname).toBe("/api/session/ses_1/archive")
expect(requests[0]!.method).toBe("POST")
})
*/
test("uses the global V1 session search endpoint", async () => {
const { api, requests } = setup("v1")
await api.session.list({ parentID: null, search: "session", limit: 50 })
expect(new URL(requests[0]!.url).pathname).toBe("/experimental/session")
})
/*
test("projects the V1 default branch", async () => {
const { api } = setup("v1", { vcs: { branch: "feature", default_branch: "dev" } })
expect(await api.vcs.get({ location: { directory: "/repo" } })).toMatchObject({
data: { branch: "feature", defaultBranch: "dev" },
})
})
*/
test("translates current file searches to the V1 dirs parameter", async () => {
const { api, requests } = setup("v1")
await api.file.find({ location: { directory: "/repo" }, query: "src", type: "file", limit: 20 })
const url = new URL(requests[0]!.url)
expect(url.pathname).toBe("/find/file")
expect(url.searchParams.get("dirs")).toBe("false")
expect(url.searchParams.get("limit")).toBe("20")
})
test("routes V1 permission replies through the requested directory", async () => {
const { api, requests } = setup("v1")
await api.permission.reply({
sessionID: "ses_1",
requestID: "permission_1",
reply: "once",
location: { directory: "/other" },
})
expect(new URL(requests[0]!.url).pathname).toBe("/session/ses_1/permissions/permission_1")
expect(new URL(requests[0]!.url).searchParams.get("directory")).toBe("/other")
})
test("disposes the V1 instance after connecting a provider", async () => {
const { api, requests } = setup("v1")
await api.integration.connect.key({
integrationID: "openrouter",
key: "secret",
location: { directory: "/repo" },
})
expect(requests.map((request) => new URL(request.url).pathname)).toEqual([
"/auth/openrouter",
"/instance/dispose",
"/instance/dispose",
])
expect(requests[1]!.headers.get("x-opencode-directory")).toBe("%2Frepo")
expect(requests[2]!.headers.get("x-opencode-directory")).toBeNull()
})
test("disposes the V1 instance after completing provider OAuth", async () => {
const { api, requests } = setup("v1")
await api.integration.oauth.complete({
integrationID: "openrouter",
attemptID: "openrouter:0",
code: "code",
location: { directory: "/repo" },
})
expect(requests.map((request) => new URL(request.url).pathname)).toEqual([
"/provider/openrouter/oauth/callback",
"/instance/dispose",
"/instance/dispose",
])
expect(requests[1]!.headers.get("x-opencode-directory")).toBe("%2Frepo")
expect(requests[2]!.headers.get("x-opencode-directory")).toBeNull()
})
})

View file

@ -1,6 +1,6 @@
import type { ServerApi } from "./server"
import type { ServerProtocol } from "./server-protocol"
import type { OpencodeClient, Session } from "@opencode-ai/sdk/v2/client"
import type { AgentPartInput, FilePartInput, OpencodeClient, Session, TextPartInput } from "@opencode-ai/sdk/v2/client"
import type {
Project,
ProjectCurrent,
@ -27,14 +27,23 @@ type CompatibleSessionApi = Omit<
shell: (input: SessionShellInput & LegacyPrompt) => Promise<SessionShellOutput>
compact: (input: SessionCompactInput & { model?: LegacyPrompt["model"] }) => Promise<SessionCompactOutput>
rename: (input: Parameters<SessionApi["rename"]>[0] & LegacyLocation) => ReturnType<SessionApi["rename"]>
archive: (input: Parameters<SessionApi["archive"]>[0] & LegacyLocation) => ReturnType<SessionApi["archive"]>
// archive: (input: Parameters<SessionApi["archive"]>[0] & LegacyLocation) => ReturnType<SessionApi["archive"]>
remove: (input: Parameters<SessionApi["remove"]>[0] & LegacyLocation) => ReturnType<SessionApi["remove"]>
}
export type CompatibleApi = Omit<ServerApi, "session"> & { readonly session: CompatibleSessionApi }
type CompatiblePermissionApi = Omit<ServerApi["permission"], "reply"> & {
reply: (
input: Parameters<ServerApi["permission"]["reply"]>[0] & { location?: { directory?: string } },
) => ReturnType<ServerApi["permission"]["reply"]>
}
export type CompatibleApi = Omit<ServerApi, "session" | "permission"> & {
readonly session: CompatibleSessionApi
readonly permission: CompatiblePermissionApi
}
type LegacyPrompt = {
agent?: string
model?: { providerID: string; modelID: string }
variant?: string
legacyParts?: (TextPartInput | FilePartInput | AgentPartInput)[]
}
type LegacyLocation = { directory?: string }
type CompatibleInput = {
@ -174,9 +183,9 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
async rename(value: Parameters<ServerApi["session"]["rename"]>[0] & LegacyLocation) {
await legacy(value).session.update({ sessionID: value.sessionID, title: value.title })
},
async archive(value: Parameters<ServerApi["session"]["archive"]>[0] & LegacyLocation) {
await legacy(value).session.update({ sessionID: value.sessionID, time: { archived: Date.now() } })
},
// async archive(value: Parameters<ServerApi["session"]["archive"]>[0] & LegacyLocation) {
// await legacy(value).session.update({ sessionID: value.sessionID, time: { archived: Date.now() } })
// },
async remove(value: Parameters<ServerApi["session"]["remove"]>[0] & LegacyLocation) {
await legacy(value).session.delete(value)
},
@ -195,13 +204,20 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
agent: value.agent,
model: value.model,
variant: value.variant,
parts: [
parts: value.legacyParts ?? [
{ type: "text", text: value.text },
...(value.files ?? []).map((file) => ({
type: "file" as const,
mime: mime(file.uri),
mime: file.mention ? "text/plain" : mime(file.uri),
url: file.uri,
filename: file.name,
source: file.mention
? {
type: "file" as const,
text: { value: file.mention.text, start: file.mention.start, end: file.mention.end },
path: file.uri,
}
: undefined,
})),
...(value.agents ?? []).map((agent) => ({
type: "agent" as const,
@ -292,34 +308,34 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
if (!result.data) throw new Error("Project not found")
return { id: result.data.id, directory: result.data.worktree } satisfies ProjectCurrent
},
async update(value: Parameters<ServerApi["project"]["update"]>[0]) {
const project = (await legacy().project.list()).data?.find((item) => item.id === value.projectID)
const result = await legacy({ directory: project?.worktree }).project.update({
...value,
directory: project?.worktree,
})
if (!result.data) throw new Error(`Project not found: ${value.projectID}`)
return result.data as Project
},
// async update(value: Parameters<ServerApi["project"]["update"]>[0]) {
// const project = (await legacy().project.list()).data?.find((item) => item.id === value.projectID)
// const result = await legacy({ directory: project?.worktree }).project.update({
// ...value,
// directory: project?.worktree,
// })
// if (!result.data) throw new Error(`Project not found: ${value.projectID}`)
// return result.data as Project
// },
async directories(value: Parameters<ServerApi["project"]["directories"]>[0]) {
const result = await legacy(value.location).worktree.list()
return (result.data ?? []).map((item) => ({ directory: item }))
},
},
path: {
...input.current.path,
async get(value?: Parameters<ServerApi["path"]["get"]>[0]) {
const result = await legacy(value?.location).path.get()
if (!result.data) throw new Error("Path unavailable")
return result.data
},
},
// path: {
// ...input.current.path,
// async get(value?: Parameters<ServerApi["path"]["get"]>[0]) {
// const result = await legacy(value?.location).path.get()
// if (!result.data) throw new Error("Path unavailable")
// return result.data
// },
// },
vcs: {
...input.current.vcs,
async get(value?: Parameters<ServerApi["vcs"]["get"]>[0]) {
const result = await legacy(value?.location).vcs.get()
return located({ branch: result.data?.branch, defaultBranch: undefined }, value?.location)
},
// async get(value?: Parameters<ServerApi["vcs"]["get"]>[0]) {
// const result = await legacy(value?.location).vcs.get()
// return located({ branch: result.data?.branch, defaultBranch: result.data?.default_branch }, value?.location)
// },
async status(value?: Parameters<ServerApi["vcs"]["status"]>[0]) {
const result = await legacy(value?.location).vcs.status()
return located(result.data ?? [], value?.location)
@ -350,7 +366,7 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
async find(value: Parameters<ServerApi["file"]["find"]>[0]) {
const result = await legacy(value.location).find.files({
query: value.query,
type: value.type,
dirs: value.type === undefined ? undefined : value.type === "directory" ? "true" : "false",
limit: value.limit,
})
return located(
@ -385,6 +401,8 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
providerID: value.integrationID,
auth: { type: "api", key: value.key },
})
await legacy(value.location).instance.dispose()
await input.legacy().instance.dispose()
},
},
oauth: {
@ -413,6 +431,8 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
{ providerID: value.integrationID, method, code: value.code },
{ throwOnError: true },
)
await legacy(value.location).instance.dispose()
await input.legacy().instance.dispose()
},
status: async (value: Parameters<ServerApi["integration"]["oauth"]["status"]>[0]) => {
const method = Number(value.attemptID.split(":").at(-1))
@ -420,6 +440,8 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
{ providerID: value.integrationID, method },
{ throwOnError: true },
)
await legacy(value.location).instance.dispose()
await input.legacy().instance.dispose()
return located(
{ status: "complete" as const, time: { created: Date.now(), expires: Date.now() } },
value.location,
@ -429,9 +451,9 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
},
pty: {
...input.current.pty,
async shells(value?: Parameters<ServerApi["pty"]["shells"]>[0]) {
return located((await legacy(value?.location).pty.shells()).data ?? [], value?.location)
},
// async shells(value?: Parameters<ServerApi["pty"]["shells"]>[0]) {
// return located((await legacy(value?.location).pty.shells()).data ?? [], value?.location)
// },
async list(value?: Parameters<ServerApi["pty"]["list"]>[0]) {
return located((await legacy(value?.location).pty.list()).data ?? [], value?.location)
},
@ -463,19 +485,20 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
async remove(value: Parameters<ServerApi["pty"]["remove"]>[0]) {
await legacy(value.location).pty.remove({ ptyID: value.ptyID })
},
async connectToken(value: Parameters<ServerApi["pty"]["connectToken"]>[0]) {
const result = await legacy(value.location).pty.connectToken({ ptyID: value.ptyID })
if (!result.data) throw new Error(`Failed to connect terminal: ${value.ptyID}`)
return located(result.data, value.location)
},
// async connectToken(value: Parameters<ServerApi["pty"]["connectToken"]>[0]) {
// const result = await legacy(value.location).pty.connectToken({ ptyID: value.ptyID })
// if (!result.data) throw new Error(`Failed to connect terminal: ${value.ptyID}`)
// return located(result.data, value.location)
// },
},
permission: {
...input.current.permission,
async reply(value: Parameters<ServerApi["permission"]["reply"]>[0]) {
await legacy().permission.respond({
async reply(value: Parameters<ServerApi["permission"]["reply"]>[0] & { location?: { directory?: string } }) {
await legacy(value.location).permission.respond({
sessionID: value.sessionID,
permissionID: value.requestID,
response: value.reply,
directory: directory(value.location),
})
},
},

View file

@ -0,0 +1,214 @@
import { describe, expect, test } from "bun:test"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { normalizeSessionMessages } from "./session-message"
describe("normalizeSessionMessages", () => {
test("projects current turns into stable legacy rendering records", () => {
const source = [
{ id: "msg_1", type: "agent-switched", agent: "build", time: { created: 1 } },
{
id: "msg_2",
type: "model-switched",
model: { id: "claude", providerID: "anthropic", variant: "high" },
time: { created: 2 },
},
{
id: "msg_3",
type: "user",
text: "inspect @src/client.ts",
files: [
{
data: "aGVsbG8=",
mime: "text/plain",
name: "note.txt",
source: { type: "inline" },
},
{
data: "ZXhwb3J0IHt9",
mime: "text/plain",
name: "client.ts",
source: { type: "inline" },
mention: { text: "@src/client.ts", start: 8, end: 22 },
},
],
agents: [{ name: "review", mention: { text: "@review", start: 0, end: 7 } }],
time: { created: 3 },
},
{
id: "msg_4",
type: "assistant",
agent: "build",
model: { id: "claude", providerID: "anthropic", variant: "high" },
content: [
{ type: "reasoning", text: "Thinking", time: { created: 4, completed: 5 } },
{ type: "text", text: "Result" },
{
type: "tool",
id: "call_1",
name: "read",
state: {
status: "completed",
input: { filePath: "note.txt" },
metadata: { title: "note.txt" },
content: [{ type: "text", text: "hello" }],
},
time: { created: 5, ran: 6, completed: 7 },
},
],
cost: 0.1,
tokens: { input: 10, output: 5, reasoning: 2, cache: { read: 1, write: 0 } },
time: { created: 4, completed: 7 },
},
{
id: "msg_5",
type: "compaction",
status: "completed",
reason: "auto",
summary: "summary",
recent: "recent",
time: { created: 8 },
},
] satisfies SessionMessageInfo[]
const result = normalizeSessionMessages("ses_1", source)
expect(result.messages).toHaveLength(2)
expect(result.messages[0]).toMatchObject({
id: "msg_3",
role: "user",
agent: "build",
model: { providerID: "anthropic", modelID: "claude", variant: "high" },
})
expect(result.messages[1]).toMatchObject({ id: "msg_4", role: "assistant", parentID: "msg_3", cost: 0.1 })
expect(result.parts.get("msg_3")?.map((part) => part.id)).toEqual([
"msg_3:text:0",
"msg_3:file:0",
"msg_3:file:1",
"msg_3:agent:0",
"msg_5:compaction",
])
expect(result.parts.get("msg_3")?.[2]).toMatchObject({
type: "file",
source: {
type: "file",
path: "src/client.ts",
text: { value: "@src/client.ts", start: 8, end: 22 },
},
})
expect(result.parts.get("msg_4")?.map((part) => part.id)).toEqual(["msg_4:reasoning:0", "msg_4:text:0", "call_1"])
expect(result.parts.get("msg_4")?.[2]).toMatchObject({
type: "tool",
tool: "read",
state: { status: "completed", output: "hello" },
})
})
test("does not invent a parent for an assistant-only page", () => {
const source = [
{
id: "msg_2",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [{ type: "text", text: "orphan" }],
time: { created: 2 },
},
] satisfies SessionMessageInfo[]
expect(normalizeSessionMessages("ses_1", source).messages).toEqual([])
})
test("projects a current shell message into a renderable standalone turn", () => {
const source = [
{
id: "msg_shell",
type: "shell",
shellID: "shell_1",
command: "printf hello",
status: "exited",
exit: 0,
output: { output: "hello", cursor: 5, size: 5, truncated: false },
time: { created: 1, completed: 2 },
},
] satisfies SessionMessageInfo[]
const result = normalizeSessionMessages("ses_1", source)
expect(result.messages).toEqual([
expect.objectContaining({ id: "msg_shell", role: "user" }),
expect.objectContaining({ id: "msg_shell:assistant", role: "assistant", parentID: "msg_shell" }),
])
expect(result.parts.get("msg_shell")).toEqual([expect.objectContaining({ type: "text", text: "printf hello" })])
expect(result.parts.get("msg_shell:assistant")).toEqual([
expect.objectContaining({
type: "tool",
tool: "bash",
state: expect.objectContaining({
status: "completed",
input: { command: "printf hello" },
output: "hello",
title: "Shell",
}),
}),
])
})
test("adapts current edit fields for the legacy edit renderer", () => {
const source = [
{ id: "msg_user", type: "user", text: "edit it", time: { created: 1 } },
{
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [
{
type: "tool",
id: "call_edit",
name: "edit",
state: {
status: "completed",
input: { path: "/repo/README.md", oldString: "old", newString: "new" },
content: [{ type: "text", text: "Edited file successfully" }],
metadata: {
files: [
{
file: "README.md",
patch: "@@ -1 +1 @@\n-old\n+new",
additions: 1,
deletions: 1,
status: "modified",
},
],
replacements: 1,
},
},
time: { created: 2, ran: 3, completed: 4 },
},
],
time: { created: 2, completed: 4 },
},
] satisfies SessionMessageInfo[]
const result = normalizeSessionMessages("ses_1", source)
expect(result.parts.get("msg_assistant")).toEqual([
expect.objectContaining({
type: "tool",
tool: "edit",
state: expect.objectContaining({
status: "completed",
input: expect.objectContaining({ path: "/repo/README.md", filePath: "/repo/README.md" }),
metadata: expect.objectContaining({
filediff: {
file: "README.md",
patch: "@@ -1 +1 @@\n-old\n+new",
additions: 1,
deletions: 1,
},
}),
}),
}),
])
})
})

View file

@ -0,0 +1,358 @@
import type {
SessionMessageAssistant,
SessionMessageAssistantTool,
SessionMessageInfo,
SessionMessageShell,
SessionMessageUser,
} from "@opencode-ai/client/promise"
import type { AssistantMessage, FilePart, Message, Part, ToolPart, UserMessage } from "@opencode-ai/sdk/v2"
import { Option, Schema } from "effect"
const emptyTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
const emptyModel: { id: string; providerID: string; variant?: string } = { id: "", providerID: "" }
const decodeToolInput = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
function record(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
}
function normalizeToolInput(name: string, input: Record<string, unknown>) {
if (!["edit", "write"].includes(name) || typeof input.path !== "string" || typeof input.filePath === "string")
return input
return { ...input, filePath: input.path }
}
function normalizeToolMetadata(name: string, metadata: Record<string, unknown>) {
if (name !== "edit" || !Array.isArray(metadata.files)) return metadata
const file = metadata.files.find(record)
if (!file || typeof file.file !== "string") return metadata
return {
...metadata,
filediff: {
file: file.file,
patch: typeof file.patch === "string" ? file.patch : undefined,
additions: typeof file.additions === "number" ? file.additions : 0,
deletions: typeof file.deletions === "number" ? file.deletions : 0,
},
}
}
export function normalizeSessionMessages(sessionID: string, source: readonly SessionMessageInfo[]) {
const messages: Message[] = []
const parts = new Map<string, Part[]>()
let agent = ""
let model = emptyModel
let parentID: string | undefined
source.forEach((message) => {
if (message.type === "agent-switched") {
agent = message.agent
return
}
if (message.type === "model-switched") {
model = message.model
return
}
if (message.type === "user") {
parentID = message.id
messages.push(userMessage(sessionID, message, agent, model))
parts.set(message.id, userParts(sessionID, message))
return
}
if (message.type === "synthetic" && message.description?.trim()) {
parentID = message.id
messages.push({
id: message.id,
sessionID,
role: "user",
time: message.time,
agent,
model: { providerID: model.providerID, modelID: model.id, variant: model.variant },
})
parts.set(message.id, [textPart(sessionID, message.id, 0, message.description, true)])
return
}
if (message.type === "shell") {
messages.push(...shellMessages(sessionID, message, agent, model))
parts.set(message.id, [textPart(sessionID, message.id, 0, message.command)])
parts.set(`${message.id}:assistant`, [shellPart(sessionID, message)])
parentID = undefined
return
}
if (message.type === "assistant") {
agent = message.agent
model = message.model
if (!parentID) return
const parent = messages.findLast((item) => item.id === parentID)
if (parent?.role === "user") {
parent.agent = message.agent
parent.model = {
providerID: message.model.providerID,
modelID: message.model.id,
variant: message.model.variant,
}
}
messages.push(assistantMessage(sessionID, parentID, message))
parts.set(message.id, assistantParts(sessionID, message))
return
}
if (message.type !== "compaction" || !parentID) return
parts.set(parentID, [
...(parts.get(parentID) ?? []),
{
id: `${message.id}:compaction`,
sessionID,
messageID: parentID,
type: "compaction",
auto: message.reason === "auto",
},
])
})
return { messages, parts }
}
function shellMessages(
sessionID: string,
message: SessionMessageShell,
agent: string,
model: { id: string; providerID: string; variant?: string },
): [UserMessage, AssistantMessage] {
return [
{
id: message.id,
sessionID,
role: "user",
time: { created: message.time.created },
agent,
model: { providerID: model.providerID, modelID: model.id, variant: model.variant },
},
{
id: `${message.id}:assistant`,
sessionID,
role: "assistant",
time: message.time,
parentID: message.id,
modelID: model.id,
providerID: model.providerID,
variant: model.variant,
mode: agent,
agent,
path: { cwd: "", root: "" },
cost: 0,
tokens: emptyTokens,
},
]
}
function shellPart(sessionID: string, message: SessionMessageShell): ToolPart {
const input = { command: message.command }
const start = message.time.created
const state: ToolPart["state"] =
message.status === "running"
? { status: "running", input, time: { start } }
: {
status: "completed",
input,
output: message.output?.output ?? "",
title: "Shell",
metadata: {
status: message.status,
exit: message.exit,
truncated: message.output?.truncated,
},
time: { start, end: message.time.completed ?? start },
}
return {
id: `${message.id}:tool`,
sessionID,
messageID: `${message.id}:assistant`,
type: "tool",
callID: message.shellID,
tool: "bash",
state,
}
}
export function sessionMessagePartID(messageID: string, type: "text" | "reasoning", ordinal: number) {
return `${messageID}:${type}:${ordinal}`
}
function userMessage(
sessionID: string,
message: SessionMessageUser,
agent: string,
model: { id: string; providerID: string; variant?: string },
): UserMessage {
return {
id: message.id,
sessionID,
role: "user",
time: message.time,
agent,
model: { providerID: model.providerID, modelID: model.id, variant: model.variant },
}
}
function userParts(sessionID: string, message: SessionMessageUser): Part[] {
return [
textPart(sessionID, message.id, 0, message.text),
...(message.files ?? []).map(
(file, index): FilePart => ({
id: `${message.id}:file:${index}`,
sessionID,
messageID: message.id,
type: "file",
mime: file.mime,
filename: file.name,
url: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
source: file.mention
? {
type: "file",
text: { value: file.mention.text, start: file.mention.start, end: file.mention.end },
path: file.mention.text.startsWith("@") ? file.mention.text.slice(1) : (file.name ?? file.mention.text),
}
: undefined,
}),
),
...(message.agents ?? []).map(
(item, index): Part => ({
id: `${message.id}:agent:${index}`,
sessionID,
messageID: message.id,
type: "agent",
name: item.name,
source: item.mention
? { value: item.mention.text, start: item.mention.start, end: item.mention.end }
: undefined,
}),
),
]
}
function assistantMessage(sessionID: string, parentID: string, message: SessionMessageAssistant): AssistantMessage {
const error = message.error
? message.error.type.toLowerCase().includes("abort") || message.error.type.toLowerCase().includes("interrupt")
? { name: "MessageAbortedError" as const, data: { message: message.error.message } }
: { name: "UnknownError" as const, data: { message: message.error.message } }
: undefined
return {
id: message.id,
sessionID,
role: "assistant",
time: message.time,
error,
parentID,
modelID: message.model.id,
providerID: message.model.providerID,
variant: message.model.variant,
mode: message.agent,
agent: message.agent,
path: { cwd: "", root: "" },
cost: message.cost ?? 0,
tokens: message.tokens ?? emptyTokens,
finish: message.finish,
}
}
function assistantParts(sessionID: string, message: SessionMessageAssistant): Part[] {
const ordinals = { text: 0, reasoning: 0 }
return message.content.flatMap((content): Part[] => {
if (content.type === "text") {
const part = textPart(sessionID, message.id, ordinals.text++, content.text)
return content.text.trim() ? [part] : []
}
if (content.type === "reasoning") {
const part: Part = {
id: sessionMessagePartID(message.id, "reasoning", ordinals.reasoning++),
sessionID,
messageID: message.id,
type: "reasoning",
text: content.text,
metadata: content.state,
time: {
start: content.time?.created ?? message.time.created,
end: content.time?.completed,
},
}
return content.text.trim() ? [part] : []
}
return [toolPart(sessionID, message.id, content)]
})
}
function textPart(sessionID: string, messageID: string, ordinal: number, text: string, synthetic?: boolean): Part {
return {
id: sessionMessagePartID(messageID, "text", ordinal),
sessionID,
messageID,
type: "text",
text,
synthetic,
}
}
function toolPart(sessionID: string, messageID: string, tool: SessionMessageAssistantTool): ToolPart {
const start = tool.time.ran ?? tool.time.created
const state = (() => {
if (tool.state.status === "streaming") {
const value = Option.getOrUndefined(decodeToolInput(tool.state.input))
const input = normalizeToolInput(tool.name, record(value) ? value : {})
return { status: "pending" as const, input, raw: tool.state.input }
}
if (tool.state.status === "running") {
return {
status: "running" as const,
input: normalizeToolInput(tool.name, tool.state.input),
// metadata: normalizeToolMetadata(tool.name, tool.state.structured),
metadata: normalizeToolMetadata(tool.name, tool.state.metadata ?? {}),
time: { start },
}
}
if (tool.state.status === "error") {
return {
status: "error" as const,
input: normalizeToolInput(tool.name, tool.state.input),
error: tool.state.error.message,
// metadata: normalizeToolMetadata(tool.name, tool.state.structured),
metadata: normalizeToolMetadata(tool.name, tool.state.metadata ?? {}),
time: { start, end: tool.time.completed ?? start },
}
}
const attachments = tool.state.content.flatMap((item, index): FilePart[] =>
item.type === "file"
? [
{
id: `${tool.id}:file:${index}`,
sessionID,
messageID,
type: "file",
mime: item.mime,
filename: item.name ?? undefined,
url: item.uri,
},
]
: [],
)
return {
status: "completed" as const,
input: normalizeToolInput(tool.name, tool.state.input),
output: tool.state.content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n"),
title: tool.name,
// metadata: normalizeToolMetadata(tool.name, tool.state.structured),
metadata: normalizeToolMetadata(tool.name, tool.state.metadata ?? {}),
time: { start, end: tool.time.completed ?? start },
attachments: attachments.length ? attachments : undefined,
}
})()
return {
id: tool.id,
sessionID,
messageID,
type: "tool",
callID: tool.id,
tool: tool.name,
state,
metadata: { providerState: tool.providerState, providerResultState: tool.providerResultState },
}
}

View file

@ -0,0 +1,94 @@
import { describe, expect, test } from "bun:test"
import type { SessionApi, SessionInfo, SessionListInput } from "@opencode-ai/client/promise"
import { listAllSessions, normalizeSessionInfo } from "./session"
describe("normalizeSessionInfo", () => {
test("adapts a current session to the app session shape", () => {
const result = normalizeSessionInfo({
id: "session-1",
projectID: "project-1",
agent: "build",
model: { id: "gpt-5", providerID: "openai", variant: "high" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 1 },
title: "New session",
location: { directory: "/repo/worktree", workspaceID: "workspace-1" },
subpath: "worktree",
revert: { messageID: "message-1", partID: "part-1", snapshot: "snapshot", files: [] },
} as SessionInfo)
expect(result).toEqual({
id: "session-1",
slug: "session-1",
projectID: "project-1",
workspaceID: "workspace-1",
directory: "/repo/worktree",
path: "worktree",
parentID: undefined,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
title: "New session",
agent: "build",
model: { id: "gpt-5", providerID: "openai", variant: "high" },
version: "",
time: { created: 1, updated: 1 },
revert: { messageID: "message-1", partID: "part-1", snapshot: "snapshot" },
})
})
})
describe("listAllSessions", () => {
test("loads every page in server order and retains the query", async () => {
const calls: SessionListInput[] = []
const pages = new Map<string | undefined, { data: SessionInfo[]; cursor: { next?: string } }>([
[undefined, { data: [sessionInfo("session-3"), sessionInfo("session-2")], cursor: { next: "next" } }],
["next", { data: [sessionInfo("session-1", true)], cursor: {} }],
])
const api = {
list: async (query = {}) => {
calls.push(query)
return pages.get(query.cursor) ?? { data: [], cursor: {} }
},
} satisfies Pick<SessionApi, "list">
const result = await listAllSessions(api, { directory: "/repo", order: "desc" })
expect(result.map((session) => session.id)).toEqual(["session-3", "session-2", "session-1"])
expect(result[2]?.time.archived).toBe(2)
expect(calls).toEqual([
{ directory: "/repo", order: "desc", limit: 100, cursor: undefined },
{ directory: "/repo", order: "desc", limit: 100, cursor: "next" },
])
})
test("requests the terminal empty page when the server returns a next cursor", async () => {
const cursors: Array<string | undefined> = []
const api = {
list: async (query = {}) => {
cursors.push(query.cursor)
if (query.cursor) return { data: [], cursor: { next: "unused" } }
return { data: [sessionInfo("session-1")], cursor: { next: "terminal" } }
},
} satisfies Pick<SessionApi, "list">
const result = await listAllSessions(api, { directory: "/repo", limit: 25 })
expect(result.map((session) => session.id)).toEqual(["session-1"])
expect(cursors).toEqual([undefined, "terminal"])
})
})
function sessionInfo(id: string, archived = false) {
return {
id,
projectID: "project-1",
agent: "build",
model: { id: "model-1", providerID: "provider-1" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 1, archived: archived ? 2 : undefined },
title: id,
location: { directory: "/repo" },
} as SessionInfo
}

View file

@ -0,0 +1,37 @@
import type { SessionApi, SessionInfo, SessionListInput } from "@opencode-ai/client/promise"
import type { Session } from "@opencode-ai/sdk/v2/client"
export function normalizeSessionInfo(input: SessionInfo | Session): Session {
if (!("location" in input)) return input
return {
id: input.id,
slug: input.id,
projectID: input.projectID,
workspaceID: input.location.workspaceID,
directory: input.location.directory,
path: input.subpath,
parentID: input.parentID,
cost: input.cost,
tokens: input.tokens,
title: input.title,
agent: input.agent,
model: input.model,
version: "",
time: input.time,
revert: input.revert && {
messageID: input.revert.messageID,
partID: input.revert.partID,
snapshot: input.revert.snapshot,
},
}
}
export async function listAllSessions(api: Pick<SessionApi, "list">, input: Omit<SessionListInput, "cursor">) {
const load = async (cursor?: string): Promise<Session[]> => {
const result = await api.list({ ...input, limit: input.limit ?? 100, cursor })
const sessions = result.data.map(normalizeSessionInfo)
if (result.data.length === 0 || !result.cursor.next) return sessions
return [...sessions, ...(await load(result.cursor.next))]
}
return load()
}

View file

@ -2,8 +2,28 @@ import { describe, expect, test } from "bun:test"
import { terminalWebSocketURL } from "./terminal-websocket-url"
describe("terminalWebSocketURL", () => {
test("uses query auth without embedding credentials in websocket URL", () => {
test("uses the current ticketed PTY route", () => {
const url = terminalWebSocketURL({
url: "http://127.0.0.1:49365",
id: "pty_test",
directory: "/tmp/project",
cursor: 0,
ticket: "connect-ticket",
})
expect(url.protocol).toBe("ws:")
expect(url.username).toBe("")
expect(url.password).toBe("")
expect(url.pathname).toBe("/api/pty/pty_test/connect")
expect(url.searchParams.get("location[directory]")).toBe("/tmp/project")
expect(url.searchParams.get("cursor")).toBe("0")
expect(url.searchParams.get("ticket")).toBe("connect-ticket")
expect(url.searchParams.has("auth_token")).toBe(false)
})
test("uses query auth without embedding credentials in websocket URL for v1", () => {
const url = terminalWebSocketURL({
protocol: "v1",
url: "http://127.0.0.1:49365",
id: "pty_test",
directory: "/tmp/project",
@ -16,11 +36,14 @@ describe("terminalWebSocketURL", () => {
expect(url.protocol).toBe("ws:")
expect(url.username).toBe("")
expect(url.password).toBe("")
expect(url.pathname).toBe("/pty/pty_test/connect")
expect(url.searchParams.get("directory")).toBe("/tmp/project")
expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret"))
})
test("omits query auth for same-origin saved credentials", () => {
test("omits query auth for same-origin saved credentials for v1", () => {
const url = terminalWebSocketURL({
protocol: "v1",
url: "https://app.example.test",
id: "pty_test",
directory: "/tmp/project",
@ -31,11 +54,14 @@ describe("terminalWebSocketURL", () => {
})
expect(url.protocol).toBe("wss:")
expect(url.pathname).toBe("/pty/pty_test/connect")
expect(url.searchParams.get("directory")).toBe("/tmp/project")
expect(url.searchParams.has("auth_token")).toBe(false)
})
test("uses query auth for same-origin credentials from auth_token", () => {
test("uses query auth for same-origin credentials from auth_token for v1", () => {
const url = terminalWebSocketURL({
protocol: "v1",
url: "https://app.example.test",
id: "pty_test",
directory: "/tmp/project",
@ -47,6 +73,8 @@ describe("terminalWebSocketURL", () => {
})
expect(url.protocol).toBe("wss:")
expect(url.pathname).toBe("/pty/pty_test/connect")
expect(url.searchParams.get("directory")).toBe("/tmp/project")
expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret"))
})
})

View file

@ -1,6 +1,7 @@
import { authTokenFromCredentials } from "@/utils/server"
export function terminalWebSocketURL(input: {
protocol?: "v1" | "v2"
url: string
id: string
directory: string
@ -11,18 +12,24 @@ export function terminalWebSocketURL(input: {
password?: string
authToken?: boolean
}) {
const next = new URL(`${input.url}/pty/${input.id}/connect`)
next.searchParams.set("directory", input.directory)
const isV1 = input.protocol === "v1"
const next = new URL(`${input.url}${isV1 ? `/pty/${input.id}/connect` : `/api/pty/${input.id}/connect`}`)
if (isV1) {
next.searchParams.set("directory", input.directory)
} else {
next.searchParams.set("location[directory]", input.directory)
}
next.searchParams.set("cursor", String(input.cursor))
next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
if (input.ticket) {
next.searchParams.set("ticket", input.ticket)
return next
}
if (input.password && (!input.sameOrigin || input.authToken))
if (isV1 && input.password && (!input.sameOrigin || input.authToken)) {
next.searchParams.set(
"auth_token",
authTokenFromCredentials({ username: input.username, password: input.password }),
)
}
return next
}