Compare commits

...
5 Commits
50 changed files with 4265 additions and 620 deletions
@@ -41,7 +41,12 @@ const assistants = Array.from({ length: 14 }, (_, index) => {
const messages = [user, ...assistants]
const target = fixture.sessions.find((session) => session.id === fixture.targetID)!
const lastID = userID
const lastPartID = assistants.at(-1)!.parts.at(-1)!.id
const lastAssistant = assistants.at(-1)!
const lastPart = lastAssistant.parts.at(-1)!
const lastPartID =
lastPart.type === "tool"
? lastPart.id
: `${lastAssistant.info.id}:${lastPart.type}:${lastAssistant.parts.filter((part) => part.type === lastPart.type).length - 1}`
benchmark("hydrates an orphaned latest turn after a cold session click", async ({ browser, report }, testInfo) => {
benchmark.setTimeout(180_000)
@@ -107,9 +112,25 @@ async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
return { items: items.slice(start, end), cursor: start > 0 ? items[start]!.info.id : undefined }
},
})
await page.route(`**/session/${fixture.targetID}`, (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(target) }),
)
await page.route(`**/session/${fixture.targetID}`, (route) => {
const current = new URL(route.request().url()).pathname.startsWith("/api/")
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(
current
? {
data: {
...target,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
location: { directory: target.directory },
},
}
: target,
),
})
})
await installStressSessionTabs(page, { sessionIDs: [fixture.sourceID] })
await page.goto(stressSessionHref(fixture.sourceID))
await expectSessionTitle(page, fixture.expected.sourceTitle)
@@ -144,8 +165,8 @@ async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
parent: requests.filter((request) => request.type === "parent").length,
}
if (mode === "candidate") {
expect(requestCounts.parent).toBe(1)
expect(historyGates).toBe(1)
expect(requestCounts.parent).toBe(0)
expect(historyGates).toBe(0)
}
return { metrics, requestCounts, historyGateCount: historyGates }
}
@@ -89,7 +89,7 @@ test("reconnects after a stream error", async ({ page }) => {
expect((await timeline.transport.connections())[0]?.endedBy).toBe("error")
})
test("records event IDs and reconnect Last-Event-ID headers", async ({ page }) => {
test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => {
const timeline = await setupTimeline(page, { eventRetry: 10 })
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
id: "timeline-event-7",
@@ -100,7 +100,7 @@ test("records event IDs and reconnect Last-Event-ID headers", async ({ page }) =
const connection = await timeline.transport.waitForConnection({ after: first.connectionID })
expect(first.eventID).toBe("timeline-event-7")
expect(connection.headers["last-event-id"]).toBe("timeline-event-7")
expect(connection.headers["last-event-id"]).toBeUndefined()
})
test("passes through non-event fetches", async ({ page }) => {
+242 -9
View File
@@ -54,14 +54,18 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
if (url.port !== targetPort && url.port !== appPort) return route.fallback()
const path = url.pathname
if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
if (path === "/global/event" || path === "/event" || path === "/api/event") {
const events = config.events?.()
return sse(
route,
path === "/api/event"
? [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events?.map(currentEvent) ?? [])]
: events,
config.eventRetry,
)
}
if (path === "/global/health") return json(route, { healthy: true })
if (path === "/api/session")
return json(route, {
data: config.sessions.map((session) => v2Session(session, config.directory)),
cursor: {},
})
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: false })
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true })
if (path === "/permission")
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
if (path === "/question")
@@ -89,10 +93,122 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
},
data: [],
})
if (path === "/api/agent")
return json(route, {
location: location(config),
data: [
{
id: "build",
name: "Build",
mode: "primary",
hidden: false,
request: { settings: {}, headers: {}, body: {} },
permissions: [],
},
],
})
if (path === "/api/command") return json(route, { location: location(config), data: [] })
if (path === "/api/mcp") return json(route, { location: location(config), data: [] })
if (path === "/api/mcp/resource")
return json(route, { location: location(config), data: { resources: [], templates: [] } })
const integration = path.match(/^\/api\/integration\/([^/]+)$/)?.[1]
if (integration && route.request().method() === "GET")
return json(route, {
location: location(config),
data: { id: integration, name: integration, methods: [{ type: "key", label: "API key" }], connections: [] },
})
if (/^\/api\/integration\/[^/]+\/connect\/key$/.test(path) && route.request().method() === "POST")
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
if (path === "/api/project") return json(route, [config.project])
if (path === "/api/project/current")
return json(route, { id: (config.project as { id?: string }).id, directory: config.directory })
if (path.startsWith("/api/project/") && route.request().method() === "PATCH") return json(route, config.project)
if (path === "/api/path")
return json(route, {
state: config.directory,
config: config.directory,
worktree: config.directory,
directory: config.directory,
home: "C:/OpenCode",
})
if (path === "/api/permission/request")
return json(route, {
location: location(config),
data: (typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])).map(
currentPermission,
),
})
if (path === "/api/question/request")
return json(route, {
location: location(config),
data: typeof config.questions === "function" ? config.questions() : (config.questions ?? []),
})
if (path === "/api/vcs")
return json(route, { location: location(config), data: { branch: "main", defaultBranch: "main" } })
if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] })
if (path === "/api/vcs/diff") return json(route, { location: location(config), data: config.vcsDiff ?? [] })
if (path === "/api/pty/shells") return json(route, { location: location(config), data: [] })
if (/^\/api\/pty\/[^/]+\/connect-token$/.test(path))
return json(route, { location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } })
if (emptyObject.has(path)) return json(route, {})
if (emptyList.has(path)) return json(route, [])
if (path === "/api/session") {
const directory = url.searchParams.get("directory")
const parentID = url.searchParams.get("parentID")
const limit = Number(url.searchParams.get("limit") ?? 50)
const offset = Number(url.searchParams.get("cursor") ?? 0)
const sessions = config.sessions
.filter((session) => !directory || session.directory === directory)
.filter((session) => parentID !== "null" || session.parentID === undefined)
.filter((session) => {
const search = url.searchParams.get("search")?.toLowerCase()
return !search || String(session.title ?? "").toLowerCase().includes(search)
})
const ordered = url.searchParams.get("order") === "asc" ? sessions.toReversed() : sessions
const data = ordered.slice(offset, offset + limit)
const next = offset + limit < ordered.length ? String(offset + limit) : undefined
return json(route, {
data: data.map((session) => currentSession(session, config.directory)),
cursor: { next },
})
}
if (path === "/api/session/active") {
const statuses = (config.sessionStatus ?? {}) as Record<string, { type?: string }>
return json(route, {
data: Object.fromEntries(
Object.entries(statuses).flatMap(([id, status]) => (status.type === "idle" ? [] : [[id, { type: "running" }]])),
),
})
}
if (/^\/api\/session\/[^/]+\/shell$/.test(path) && route.request().method() === "POST") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (/^\/api\/session\/[^/]+\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (
/^\/api\/session\/[^/]+\/(archive|rename|interrupt|revert\/clear|revert\/commit)$/.test(path) &&
route.request().method() === "POST"
) {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (/^\/api\/session\/[^/]+$/.test(path) && route.request().method() === "DELETE") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (path in staticRoutes) return json(route, staticRoutes[path])
const currentSessionMatch = path.match(/^\/api\/session\/([^/]+)$/)
if (currentSessionMatch) {
const session = config.sessions.find((item) => item.id === currentSessionMatch[1])
if (!session) return json(route, { error: "Session not found" }, undefined, 404)
return json(route, {
data: currentSession(session, config.directory),
})
}
const sessionMatch = path.match(/^\/session\/([^/]+)$/)
if (sessionMatch) {
const session = config.sessions.find((s) => s.id === sessionMatch[1])
@@ -115,6 +231,24 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? [])
if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, [])
const currentMessagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/)
if (currentMessagesMatch) {
const token = url.searchParams.get("cursor") ?? undefined
const before = token ? cursors.get(token) : undefined
if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400)
config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "start" })
await config.beforeMessagesResponse?.({ sessionID: currentMessagesMatch[1]!, before })
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
const pageData = config.pageMessages(currentMessagesMatch[1], Number(url.searchParams.get("limit") ?? 50), before)
config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "end" })
const cursor = pageData.cursor ? `cursor_${++nextCursor}` : undefined
if (cursor) cursors.set(cursor, pageData.cursor!)
return json(route, {
data: pageData.items.map(currentMessage).reverse(),
cursor: { next: cursor },
})
}
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
if (messagesMatch) {
const token = url.searchParams.get("before") ?? undefined
@@ -137,12 +271,36 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
})
}
function v2Session(session: { id: string } & Record<string, unknown>, fallbackDirectory: string) {
function location(config: MockServerConfig) {
return {
directory: config.directory,
project: { id: (config.project as { id?: string }).id, directory: config.directory },
}
}
function currentPermission(value: unknown) {
const permission = value as Record<string, unknown>
if (permission.action) return permission
const tool = permission.tool as { messageID?: string; callID?: string } | undefined
return {
id: permission.id,
sessionID: permission.sessionID,
action: permission.permission,
resources: permission.patterns ?? [],
save: permission.always,
metadata: permission.metadata,
source: tool?.messageID && tool.callID ? { type: "tool", messageID: tool.messageID, callID: tool.callID } : undefined,
}
}
export function currentSession(session: { id: string } & Record<string, unknown>, fallbackDirectory?: string) {
const time = session.time && typeof session.time === "object" ? session.time : {}
return {
id: session.id,
parentID: session.parentID,
projectID: session.projectID ?? "project",
agent: session.agent ?? "build",
model: session.model ?? { id: "mock-model", providerID: "mock-provider" },
cost: session.cost ?? 0,
tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: {
@@ -157,7 +315,67 @@ function v2Session(session: { id: string } & Record<string, unknown>, fallbackDi
directory: typeof session.directory === "string" ? session.directory : fallbackDirectory,
...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}),
},
...(typeof session.path === "string" ? { subpath: session.path } : {}),
subpath: session.path,
revert: session.revert,
}
}
function currentMessage(value: unknown) {
const item = value as {
info: Record<string, unknown> & { id: string; role: "user" | "assistant"; time: { created: number } }
parts: Array<Record<string, unknown> & { type: string }>
}
if (item.info.role === "user") {
return {
id: item.info.id,
type: "user",
time: item.info.time,
text: item.parts
.flatMap((part) => (part.type === "text" && typeof part.text === "string" ? [part.text] : []))
.join("\n"),
}
}
return {
id: item.info.id,
type: "assistant",
time: item.info.time,
agent: item.info.agent ?? "build",
model: { id: item.info.modelID ?? "model", providerID: item.info.providerID ?? "provider" },
cost: item.info.cost,
tokens: item.info.tokens,
error: item.info.error,
content: item.parts.flatMap<unknown>((part) => {
if (part.type === "text" || part.type === "reasoning") return [{ type: part.type, text: part.text ?? "" }]
if (part.type !== "tool") return []
const state = part.state as Record<string, unknown>
return [
{
type: "tool",
id: part.id,
name: part.tool,
time: state.time ?? { created: item.info.time.created },
state:
state.status === "pending"
? { status: "streaming", input: state.raw ?? JSON.stringify(state.input ?? {}) }
: state.status === "completed"
? {
status: "completed",
input: state.input ?? {},
structured: state.metadata ?? {},
content: [{ type: "text", text: state.output ?? "" }],
}
: state.status === "error"
? {
status: "error",
input: state.input ?? {},
structured: state.metadata ?? {},
content: [],
error: { type: "ToolError", message: state.error ?? "Tool failed" },
}
: { status: "running", input: state.input ?? {}, structured: state.metadata ?? {}, content: [] },
},
]
}),
}
}
@@ -181,3 +399,18 @@ function sse(route: Route, events?: unknown[], retry?: number) {
body: `${retry === undefined ? "" : `retry: ${retry}\n\n`}${events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n"}`,
})
}
function currentEvent(input: unknown) {
if (!input || typeof input !== "object" || !("payload" in input)) return input
const envelope = input as { directory?: string; payload?: unknown }
if (!envelope.payload || typeof envelope.payload !== "object") return input
const payload = envelope.payload as { id?: string; type?: string; properties?: unknown }
if (!payload.type) return input
return {
id: payload.id ?? `evt_mock_${Date.now()}`,
created: Date.now(),
type: payload.type,
data: payload.properties ?? {},
location: envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined,
}
}
+30 -7
View File
@@ -3,7 +3,7 @@ import type { Page } from "@playwright/test"
export type SseConnectionRecord = {
id: number
url: string
path: "/global/event" | "/event"
path: "/global/event" | "/event" | "/api/event"
headers: Record<string, string>
openedAt: number
endedAt?: number
@@ -93,6 +93,20 @@ export async function installSseTransport<T>(
eventOptions.retry === undefined ? "" : `retry: ${eventOptions.retry}\n`,
`data: ${JSON.stringify(payload)}\n\n`,
].join("")
const currentEvent = (input: unknown) => {
if (!input || typeof input !== "object" || !("payload" in input)) return input
const envelope = input as { directory?: string; payload?: unknown }
if (!envelope.payload || typeof envelope.payload !== "object") return input
const payload = envelope.payload as { id?: string; type?: string; properties?: unknown }
if (!payload.type) return input
return {
id: payload.id ?? `evt_mock_${Date.now()}`,
created: Date.now(),
type: payload.type,
data: payload.properties ?? {},
location: envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined,
}
}
const acknowledge = (
connection: Connection,
bytes: number,
@@ -140,14 +154,14 @@ export async function installSseTransport<T>(
output.forEach((chunk) => connection.controller.enqueue(chunk))
return acknowledge(connection, input.bytes.length, output.length)
}
const encoded = input.deliveries.map((delivery) => ({
delivery,
bytes: encoder.encode(frame(delivery.payload, delivery.options)),
}))
const encoded = input.deliveries.map((delivery) => {
const payload = connection.path === "/api/event" ? currentEvent(delivery.payload) : delivery.payload
return { delivery, payload, bytes: encoder.encode(frame(payload, delivery.options)) }
})
encoded.forEach((item) => marker(item.delivery.options?.marker))
if (input.burst) {
const bytes = encoder.encode(
encoded.map((item) => frame(item.delivery.payload, item.delivery.options)).join(""),
encoded.map((item) => frame(item.payload, item.delivery.options)).join(""),
)
connection.controller.enqueue(bytes)
return encoded.map((item) => acknowledge(connection, item.bytes.byteLength, 1, item.delivery.options?.id))
@@ -161,7 +175,10 @@ export async function installSseTransport<T>(
const fetch = (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init)
const url = new URL(request.url)
if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event"))
if (
url.origin !== server ||
(url.pathname !== "/global/event" && url.pathname !== "/event" && url.pathname !== "/api/event")
)
return originalFetch(request)
const id = ++nextConnectionID
@@ -177,6 +194,12 @@ export async function installSseTransport<T>(
record.controller = controller
connections.push(record)
if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`))
if (url.pathname === "/api/event")
controller.enqueue(
encoder.encode(
frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} }),
),
)
request.signal.addEventListener(
"abort",
() => {
@@ -310,7 +310,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
)
const resources = createMemo(() =>
Object.values(sync().data.mcp_resource).map((resource) => ({
id: `resource:${resource.client}:${resource.uri}`,
id: `resource:${resource.server}:${resource.uri}`,
kind: "resource" as const,
label: `@${resource.name}`,
path: resource.uri,
@@ -327,7 +327,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
source: {
type: "resource" as const,
text: { value: `@${resource.name}`, start: 0, end: resource.name.length + 1 },
clientName: resource.client,
clientName: resource.server,
uri: resource.uri,
},
},
+1 -1
View File
@@ -591,7 +591,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
type: "resource",
name: resource.name,
uri: resource.uri,
client: resource.client,
client: resource.server,
display: resource.name,
description: resource.description,
mime: resource.mimeType,
@@ -20,6 +20,8 @@ import { setCursorPosition } from "./editor-dom"
import { formatServerError } from "@/utils/server-errors"
import { ScopedKey } from "@/utils/server-scope"
import { createPromptSubmissionState } from "./submission-state"
import { normalizeSessionInfo } from "@/utils/session"
import { Event } from "@opencode-ai/schema/event"
type PendingPrompt = {
abort: AbortController
@@ -39,7 +41,7 @@ export type FollowupDraft = {
}
type FollowupSendInput = {
client: DirectorySDK["client"]
api: DirectorySDK["api"]["session"]
serverSync: ServerSync
sync: DirectorySync
draft: FollowupDraft
@@ -81,19 +83,21 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
return false
}
await input.client.session.command({
const messageID = Identifier.ascending("message")
await input.api.command({
sessionID: input.draft.sessionID,
id: messageID,
command: cmd,
arguments: tail.join(" "),
agent: input.draft.agent,
model: `${input.draft.model.providerID}/${input.draft.model.modelID}`,
variant: input.draft.variant,
parts: images.map((attachment) => ({
id: Identifier.ascending("part"),
type: "file" as const,
mime: attachment.mime,
url: attachment.dataUrl,
filename: attachment.filename,
model: {
id: input.draft.model.modelID,
providerID: input.draft.model.providerID,
variant: input.draft.variant,
},
files: images.map((attachment) => ({
uri: attachment.dataUrl,
name: attachment.filename,
})),
})
return true
@@ -152,13 +156,36 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
return false
}
await input.client.session.promptAsync({
await input.api.prompt({
sessionID: input.draft.sessionID,
id: messageID,
agent: input.draft.agent,
model: input.draft.model,
messageID,
parts: requestParts,
variant: input.draft.variant,
text: requestParts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"),
files: requestParts.flatMap((part) => {
if (part.type !== "file") return []
const text = part.source?.text
return [
{
uri: part.url,
name: part.filename,
mention: text ? { start: text.start, end: text.end, text: text.value } : undefined,
},
]
}),
agents: requestParts.flatMap((part) =>
part.type === "agent"
? [
{
name: part.name,
mention: part.source
? { start: part.source.start, end: part.source.end, text: part.source.value }
: undefined,
},
]
: [],
),
})
return true
} catch (err) {
@@ -210,6 +237,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID)
const errorMessage = (err: unknown) => {
if (err && typeof err === "object" && "message" in err && typeof err.message === "string") return err.message
if (err && typeof err === "object" && "data" in err) {
const data = (err as { data?: { message?: string } }).data
if (data?.message) return data.message
@@ -235,9 +263,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
return Promise.resolve()
}
return sdk()
.client.session.abort({
sessionID,
})
.api.session.interrupt({ sessionID })
.catch(() => {})
}
@@ -364,9 +390,13 @@ export function createPromptSubmit(input: PromptSubmitInput) {
let session = input.info()
if (!session && isNewSession) {
const created = await client.session
.create()
.then((x) => x.data ?? undefined)
const created = await sdk()
.api.session.create({
agent: currentAgent.name,
model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
location: { directory: sessionDirectory },
})
.then(normalizeSessionInfo)
.catch((err) => {
showToast({
title: language.t("prompt.toast.sessionCreateFailed.title"),
@@ -450,12 +480,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
if (mode === "shell") {
clearInput()
client.session
.shell({
const eventID = Event.ID.create()
sdk()
.api.session.shell({
sessionID: session.id,
id: eventID,
command: text,
agent,
model,
command: text,
})
.catch((err) => {
showToast({
@@ -473,23 +505,23 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const customCommand = sync().data.command.find((c) => c.name === commandName)
if (customCommand) {
clearInput()
client.session
.command({
const messageID = Identifier.ascending("message")
serverSync().session.set("session_status", session.id, { type: "busy" })
sdk()
.api.session.command({
sessionID: session.id,
id: messageID,
command: commandName,
arguments: args.join(" "),
agent,
model: `${model.providerID}/${model.modelID}`,
variant,
parts: images.map((attachment) => ({
id: Identifier.ascending("part"),
type: "file" as const,
mime: attachment.mime,
url: attachment.dataUrl,
filename: attachment.filename,
model: { id: model.modelID, providerID: model.providerID, variant },
files: images.map((attachment) => ({
uri: attachment.dataUrl,
name: attachment.filename,
})),
})
.catch((err) => {
serverSync().session.set("session_status", session.id, { type: "idle" })
showToast({
title: language.t("prompt.toast.commandSendFailed.title"),
description: formatServerError(err, language.t, language.t("common.requestFailed")),
@@ -573,7 +605,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}
void sendFollowupDraft({
client,
api: sdk().api.session,
sync: sync(),
serverSync: serverSync(),
draft,
@@ -26,7 +26,7 @@ describe("hasNonBlockingServiceIssue", () => {
expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["needs_client_registration"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["connected", "disabled"], lsp: [] })).toBe(false)
expect(hasNonBlockingServiceIssue({ mcp: ["connected", "pending", "disabled"], lsp: [] })).toBe(false)
})
test("detects LSP failures that do not block chatting", () => {
@@ -1,11 +1,12 @@
import type { LspStatus, McpStatus } from "@opencode-ai/sdk/v2/client"
import type { LspStatus } from "@opencode-ai/sdk/v2/client"
import type { McpServer } from "@opencode-ai/client/promise"
export function hasNonBlockingServiceIssue(input: {
mcp: Array<McpStatus["status"]>
mcp: Array<McpServer["status"]["status"]>
lsp: Array<LspStatus["status"]>
}) {
return (
input.mcp.some((status) => status !== "connected" && status !== "disabled") ||
input.mcp.some((status) => status !== "connected" && status !== "pending" && status !== "disabled") ||
input.lsp.some((status) => status === "error")
)
}
+6 -5
View File
@@ -5,6 +5,7 @@ import { produce, reconcile, type SetStoreFunction } from "solid-js/store"
import type { createServerSdkContext } from "./server-sdk"
import type { createServerSyncContextInner } from "./server-sync"
import type { State } from "./global-sync/types"
import { normalizeSessionInfo } from "@/utils/session"
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
const sessionFields = new Set([
@@ -15,6 +16,7 @@ const sessionFields = new Set([
"permission",
"question",
"message",
"session_message",
"part",
"part_text_accum_delta",
])
@@ -114,7 +116,6 @@ export const createDirSyncContext = (
await serverSync.session.sync(sessionID, options)
index(sessionID)
},
diff: serverSync.session.diff,
todo: serverSync.session.todo,
history: serverSync.session.history,
evict(sessionID: string) {
@@ -123,9 +124,9 @@ export const createDirSyncContext = (
fetch: async (count = 10) => {
const [store, setStore] = current()
setStore("limit", (value) => value + count)
const response = await client.session.list()
const sessions = (response.data ?? [])
.filter((session) => !!session?.id)
const response = await serverSDK.api.session.list({ directory, limit: store.limit, order: "desc" })
const sessions = response.data
.map(normalizeSessionInfo)
.sort((a, b) => cmp(a.id, b.id))
.slice(0, store.limit)
sessions.forEach(serverSync.session.remember)
@@ -133,7 +134,7 @@ export const createDirSyncContext = (
},
more: createMemo(() => current()[0].session.length >= current()[0].limit),
archive: async (sessionID: string) => {
await serverSDK.client.session.update({ sessionID, time: { archived: Date.now() } })
await serverSDK.api.session.archive({ sessionID, directory })
current()[1](
"session",
produce((draft) => {
@@ -1,14 +1,39 @@
import { describe, expect, test } from "bun:test"
import { createStore } from "solid-js/store"
import { QueryClient } from "@tanstack/solid-query"
import type { Config, OpencodeClient, Project, Session } from "@opencode-ai/sdk/v2/client"
import type { Config, OpencodeClient, Project } from "@opencode-ai/sdk/v2/client"
import type { AgentApi, CatalogApi, CommandApi, ProjectApi, ReferenceApi } from "@opencode-ai/client/promise"
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import { bootstrapDirectory, loadPathQuery, loadProvidersQuery } from "./bootstrap"
import {
bootstrapDirectory,
loadAgentsQuery,
loadCommands,
loadPathQuery,
loadProjectsQuery,
loadProvidersQuery,
loadReferencesQuery,
} from "./bootstrap"
import type { State, VcsCache } from "./types"
import { createServerSession } from "../server-session"
import { ServerScope } from "@/utils/server-scope"
import type { ServerApi } from "@/utils/server"
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
const api = {
agent: { list: async () => ({ location: {}, data: [] }) },
provider: { list: async () => ({ location: {}, data: [] }) },
model: {
list: async () => ({ location: {}, data: [] }),
default: async () => ({ location: {}, data: null }),
},
permission: { request: { list: async () => ({ location: {}, data: [] }) } },
project: {
list: async () => [],
current: async () => ({ id: "project", directory: "/project" }),
},
question: { request: { list: async () => ({ location: {}, data: [] }) } },
reference: { list: async () => ({ location: {}, data: [] }) },
vcs: { get: async () => ({ location: {}, data: {} }) },
} as unknown as ServerApi
function directoryState() {
return createStore<State>({
@@ -41,6 +66,7 @@ function directoryState() {
vcs: undefined,
limit: 5,
message: {},
session_message: {},
part: {},
part_text_accum_delta: {},
})
@@ -64,7 +90,6 @@ describe("bootstrapDirectory", () => {
sdk: {
app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
config: { get: async () => ({ data: {} }) },
session: { status: async () => ({ data: {} }) },
vcs: { get: async () => ({ data: undefined }) },
command: {
list: async () => {
@@ -83,6 +108,7 @@ describe("bootstrapDirectory", () => {
},
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
} as unknown as OpencodeClient,
api,
store,
setStore,
vcsCache: { setStore() {} } as unknown as VcsCache,
@@ -99,78 +125,108 @@ describe("bootstrapDirectory", () => {
expect(mcpReads).toEqual([])
})
test("seeds session status even while warming session info stalls", async () => {
const [store, setStore] = directoryState()
const stalled = Promise.withResolvers<never>()
const client = {
app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
config: { get: async () => ({ data: {} }) },
session: {
status: async () => ({ data: { ses_busy: { type: "busy" } } }),
get: () => stalled.promise,
},
vcs: { get: async () => ({ data: undefined }) },
command: { list: async () => ({ data: [] }) },
permission: { list: async () => ({ data: [] }) },
question: { list: async () => ({ data: [] }) },
v2: { reference: { list: async () => ({ data: { data: [] } }) } },
mcp: { status: async () => ({ data: {} }) },
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
} as unknown as OpencodeClient
const session = createServerSession(client)
const stale: Session = {
id: "ses_stale",
slug: "ses_stale",
projectID: "project",
directory: "/project",
title: "stale",
version: "1",
time: { created: 1, updated: 1 },
}
session.remember(stale)
session.set("session_status", stale.id, { type: "busy" })
await bootstrapDirectory({
directory: "/project",
scope: ServerScope.local,
mcp: false,
global: {
config: {} satisfies Config,
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
project: [{ id: "project", worktree: "/project" } as Project],
provider,
},
sdk: client,
store,
setStore,
vcsCache: { setStore() {} } as unknown as VcsCache,
loadSessions() {},
translate: (key) => key,
queryClient: new QueryClient(),
session,
})
const deadline = Date.now() + 500
while (!session.data.session_working("ses_busy") && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 10))
}
expect(session.data.session_status["ses_busy"]?.type).toBe("busy")
expect(session.data.session_status[stale.id]).toBeUndefined()
})
})
describe("query keys", () => {
test("partitions identical directories by server scope", () => {
const client = {} as OpencodeClient
const client = {} as Parameters<typeof loadPathQuery>[2]
const api = {} as CatalogApi
const remote = "https://debian.example" as typeof ServerScope.local
expect([...loadPathQuery(ServerScope.local, "/repo", client).queryKey]).toEqual(["local", "/repo", "path"])
expect([...loadPathQuery(remote, "/repo", client).queryKey]).toEqual(["https://debian.example", "/repo", "path"])
expect([...loadProvidersQuery(remote, null, client).queryKey]).toEqual([
"https://debian.example",
null,
"providers",
expect([...loadProvidersQuery(remote, null, api).queryKey]).toEqual(["https://debian.example", null, "providers"])
})
test("loads the current provider and model catalog", async () => {
const calls: unknown[] = []
const api = {
provider: {
list: async (input: unknown) => {
calls.push(["provider", input])
return { location: {}, data: [{ id: "openai", name: "OpenAI", package: "@ai-sdk/openai" }] }
},
},
model: {
list: async (input: unknown) => {
calls.push(["model", input])
return { location: {}, data: [] }
},
default: async (input: unknown) => {
calls.push(["default", input])
return { location: {}, data: null }
},
},
} as unknown as CatalogApi
const result = await new QueryClient().fetchQuery(loadProvidersQuery(ServerScope.local, "/repo", api))
expect(calls).toEqual([
["provider", { location: { directory: "/repo" } }],
["model", { location: { directory: "/repo" } }],
["default", { location: { directory: "/repo" } }],
])
expect(result.connected).toEqual(["openai"])
})
test("loads agents from the current location-scoped endpoint", async () => {
const calls: unknown[] = []
const api = {
list: async (input: unknown) => {
calls.push(input)
return { location: {}, data: [] }
},
} as unknown as AgentApi
const result = await new QueryClient().fetchQuery(loadAgentsQuery(ServerScope.local, "/repo", api))
expect(calls).toEqual([{ location: { directory: "/repo" } }])
expect(result).toEqual([])
})
test("loads commands from the current location-scoped endpoint", async () => {
const calls: unknown[] = []
const api = {
list: async (input: unknown) => {
calls.push(input)
return {
location: {},
data: [{ name: "review", template: "Review files", source: "command" as const }],
}
},
} as unknown as CommandApi
const result = await loadCommands("/repo", api)
expect(calls).toEqual([{ location: { directory: "/repo" } }])
expect(result).toEqual([{ name: "review", template: "Review files", source: "command" }])
})
test("loads projects from the current endpoint", async () => {
const api = {
list: async () => [
{ id: "b", worktree: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
{ id: "a", worktree: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
],
} as unknown as ProjectApi
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api))
expect(result.map((project) => project.id)).toEqual(["a", "b"])
})
test("loads references from the current location-scoped endpoint", async () => {
const calls: unknown[] = []
const api = {
list: async (input: unknown) => {
calls.push(input)
return { location: {}, data: [{ name: "AGENTS.md", path: "/repo/AGENTS.md", source: "instructions" }] }
},
} as unknown as ReferenceApi
const result = await new QueryClient().fetchQuery(loadReferencesQuery(ServerScope.local, "/repo", api))
expect(calls).toEqual([{ location: { directory: "/repo" } }])
expect(result).toHaveLength(1)
})
})
+205 -77
View File
@@ -9,6 +9,26 @@ import type {
ReferenceInfo,
Session,
} from "@opencode-ai/sdk/v2/client"
import type {
AgentListInput,
AgentListOutput,
CatalogApi,
CommandInfo,
CommandListInput,
CommandListOutput,
McpApi,
PathGetInput,
PathGetOutput,
PermissionApi,
ProjectCurrentInput,
ProjectCurrentOutput,
ProjectListOutput,
QuestionApi,
ReferenceListInput,
ReferenceListOutput,
SessionApi,
VcsApi,
} from "@opencode-ai/client/promise"
import { showToast } from "@/utils/toast"
import { getFilename } from "@opencode-ai/core/util/path"
import { retry } from "@opencode-ai/core/util/retry"
@@ -16,12 +36,20 @@ import { batch } from "solid-js"
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import type { State, VcsCache } from "./types"
import type { ServerSession } from "../server-session"
import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
import {
cmp,
normalizeAgentList,
normalizePermissionRequest,
normalizeProjectInfo,
normalizeProviderList,
} from "./utils"
import { formatServerError } from "@/utils/server-errors"
import { QueryClient, queryOptions } from "@tanstack/solid-query"
import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
import { normalizeSessionInfo } from "@/utils/session"
import type { ServerProtocol } from "@/utils/server-protocol"
type GlobalStore = {
ready: boolean
@@ -88,15 +116,25 @@ export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient) =
queryFn: () => retry(() => sdk.global.config.get().then((x) => x.data!)),
})
export const loadProjectsQuery = (scope: ServerScope, sdk: OpencodeClient) =>
type ProjectApi = {
readonly list: () => Promise<ProjectListOutput>
readonly current: (input?: ProjectCurrentInput) => Promise<ProjectCurrentOutput>
}
type PathApi = {
readonly get: (input?: PathGetInput) => Promise<PathGetOutput>
}
export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) =>
queryOptions({
queryKey: [scope, "project"],
queryFn: () =>
retry(() =>
sdk.project.list().then((x) => {
return (x.data ?? [])
api.list().then((projects) => {
return projects
.filter((p) => !!p?.id)
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
.map(normalizeProjectInfo)
.slice()
.sort((a, b) => cmp(a.id, b.id))
}),
@@ -105,6 +143,8 @@ export const loadProjectsQuery = (scope: ServerScope, sdk: OpencodeClient) =>
export async function bootstrapGlobal(input: {
serverSDK: OpencodeClient
serverAPI: CatalogApi & { readonly path: PathApi; readonly project: ProjectApi }
protocol?: Promise<ServerProtocol>
scope: ServerScope
requestFailedTitle: string
translate: (key: string, vars?: Record<string, string | number>) => string
@@ -114,11 +154,14 @@ export async function bootstrapGlobal(input: {
}) {
const slow = [
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK)),
() => input.queryClient.fetchQuery(loadProvidersQuery(input.scope, null, input.serverSDK)),
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverSDK)),
() =>
input.queryClient.fetchQuery(
loadProvidersQuery(input.scope, null, input.serverAPI, input.serverSDK, input.protocol),
),
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverAPI.path)),
() =>
input.queryClient
.fetchQuery(loadProjectsQuery(input.scope, input.serverSDK))
.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project))
.then((data) => input.setGlobalStore("project", data)),
]
await runAll(slow)
@@ -162,44 +205,117 @@ function warmSessions(input: {
ids: string[]
store: Store<State>
setStore: SetStoreFunction<State>
sdk: OpencodeClient
api: SessionApi
}) {
const known = new Set(input.store.session.map((item) => item.id))
const ids = [...new Set(input.ids)].filter((id) => !!id && !known.has(id))
if (ids.length === 0) return Promise.resolve()
return Promise.all(
ids.map((sessionID) =>
retry(() => input.sdk.session.get({ sessionID })).then((x) => {
const session = x.data
if (!session?.id) return
mergeSession(input.setStore, session)
}),
retry(() => input.api.get({ sessionID })).then((session) =>
mergeSession(input.setStore, normalizeSessionInfo(session)),
),
),
).then(() => undefined)
}
export const loadProvidersQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
export const loadProvidersQuery = (
scope: ServerScope,
directory: string | null,
sdk: CatalogApi,
legacy?: OpencodeClient,
protocol?: Promise<ServerProtocol>,
) =>
queryOptions({
queryKey: [scope, directory, "providers"],
queryFn: () => retry(() => sdk.provider.list().then((x) => normalizeProviderList(x.data!))),
queryFn: () =>
retry(async () => {
if ((await protocol) === "v1" && legacy) {
const result = await legacy.provider.list()
return normalizeProviderList(result.data!)
}
const location = directory ? { location: { directory } } : undefined
const [providers, models, defaultModel] = await Promise.all([
sdk.provider.list(location),
sdk.model.list(location),
sdk.model.default(location),
])
return normalizeProviderList(providers.data, models.data, defaultModel.data)
}),
})
export const loadAgentsQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
type AgentListApi = {
readonly list: (input?: AgentListInput) => Promise<AgentListOutput>
}
type CommandListApi = {
readonly list: (input?: CommandListInput) => Promise<CommandListOutput>
}
type ReferenceListApi = {
readonly list: (input?: ReferenceListInput) => Promise<ReferenceListOutput>
}
export const loadAgentsQuery = (
scope: ServerScope,
directory: string,
sdk: AgentListApi,
legacy?: OpencodeClient,
protocol?: Promise<ServerProtocol>,
) =>
queryOptions({
queryKey: [scope, directory, "agents"],
queryFn: () => retry(() => sdk.app.agents().then((x) => normalizeAgentList(x.data))),
queryFn: () =>
retry(async () => {
if ((await protocol) === "v1" && legacy) return normalizeAgentList((await legacy.app.agents()).data ?? [])
return sdk.list({ location: { directory } }).then((result) => normalizeAgentList(result.data))
}),
})
export const loadPathQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
export const loadCommands = (
directory: string,
api: CommandListApi,
legacy?: OpencodeClient,
protocol?: Promise<ServerProtocol>,
): Promise<CommandInfo[]> =>
retry(async () => {
if ((await protocol) === "v1" && legacy) {
return ((await legacy.command.list()).data ?? []).map((command) => {
const [providerID, id] = command.model?.split("/") ?? []
return {
name: command.name,
template: command.template,
description: command.description,
agent: command.agent,
model: providerID && id ? { providerID, id } : undefined,
subtask: command.subtask,
source: command.source === "skill" ? undefined : command.source,
}
})
}
return api.list({ location: { directory } }).then((result) => result.data)
})
export const loadPathQuery = (scope: ServerScope, directory: string | null, api: PathApi) =>
queryOptions<Path>({
queryKey: [scope, directory, "path"],
queryFn: () => retry(() => sdk.path.get().then((x) => x.data!)),
queryFn: () => retry(() => api.get(directory ? { location: { directory } } : undefined)),
})
export const loadReferencesQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
export const loadReferencesQuery = (
scope: ServerScope,
directory: string,
api: ReferenceListApi,
legacy?: OpencodeClient,
protocol?: Promise<ServerProtocol>,
) =>
queryOptions<ReferenceInfo[]>({
queryKey: [scope, directory, "references"] as const,
queryFn: () => retry(() => sdk.v2.reference.list().then((x) => x.data?.data ?? [])).catch(() => []),
queryFn: () =>
retry(async () => {
if ((await protocol) === "v1" && legacy) return (await legacy.v2.reference.list()).data?.data ?? []
return api.list({ location: { directory } }).then((result) => result.data)
}).catch(() => []),
placeholderData: [],
})
@@ -208,6 +324,18 @@ export async function bootstrapDirectory(input: {
scope: ServerScope
mcp: boolean
sdk: OpencodeClient
api: CatalogApi & {
readonly agent: AgentListApi
readonly command: CommandListApi
readonly mcp: McpApi
readonly path: PathApi
readonly permission: PermissionApi
readonly project: ProjectApi
readonly question: QuestionApi
readonly reference: ReferenceListApi
readonly session: SessionApi
readonly vcs: VcsApi
}
store: Store<State>
setStore: SetStoreFunction<State>
vcsCache: VcsCache
@@ -221,6 +349,7 @@ export async function bootstrapDirectory(input: {
}
queryClient: QueryClient
session?: ServerSession
protocol?: Promise<ServerProtocol>
}) {
const loading = input.store.status !== "complete"
const seededProject = projectID(input.directory, input.global.project)
@@ -240,66 +369,55 @@ export async function bootstrapDirectory(input: {
() => Promise.resolve(input.loadSessions(input.directory)),
() =>
input.queryClient
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.sdk))
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.api.agent, input.sdk, input.protocol))
.then((data) => input.setStore("agent", data)),
() =>
retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))),
() =>
retry(() =>
input.sdk.session.status().then(async (x) => {
if (!input.session) {
input.setStore("session_status", x.data!)
return
}
const statuses = x.data ?? {}
input.session.set(
"session_status",
produce((draft) => {
for (const sessionID of Object.keys(draft)) {
if (statuses[sessionID]) continue
if (input.session?.get(sessionID)?.directory === input.directory) delete draft[sessionID]
}
}),
)
for (const [sessionID, status] of Object.entries(statuses)) {
input.session.set("session_status", sessionID, reconcile(status))
}
// Warm session info only after seeding statuses so a stalled session
// fetch cannot park busy indicators behind it, mirroring how live
// session.status events apply first and resolve info in the background.
await Promise.all(
Object.keys(statuses).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined)),
)
}),
),
!seededProject &&
(() => retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id))),
(() =>
retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) =>
input.setStore("project", project.id),
)),
!seededPath &&
(() =>
input.queryClient.ensureQueryData(loadPathQuery(input.scope, input.directory, input.sdk)).then((data) => {
const next = projectID(data.directory ?? input.directory, input.global.project)
if (next) input.setStore("project", next)
})),
input.queryClient
.ensureQueryData(loadPathQuery(input.scope, input.directory, input.api.path))
.then((data) => {
const next = projectID(data.directory ?? input.directory, input.global.project)
if (next) input.setStore("project", next)
})),
() =>
retry(() =>
input.sdk.vcs.get().then((x) => {
const next = x.data ?? input.store.vcs
input.api.vcs.get({ location: { directory: input.directory } }).then((result) => {
const next = { branch: result.data.branch, default_branch: result.data.defaultBranch }
input.setStore("vcs", next)
if (next) input.vcsCache.setStore("value", next)
}),
),
input.mcp && (() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])))),
() => input.queryClient.fetchQuery(loadReferencesQuery(input.scope, input.directory, input.sdk)),
input.mcp &&
(() =>
loadCommands(input.directory, input.api.command, input.sdk, input.protocol).then((commands) =>
input.setStore("command", commands),
)),
() =>
input.queryClient.fetchQuery(
loadReferencesQuery(input.scope, input.directory, input.api.reference, input.sdk, input.protocol),
),
() =>
retry(() =>
input.sdk.permission.list().then((x) => {
const ids = (x.data ?? []).map((perm) => perm?.sessionID).filter((id): id is string => !!id)
(async () => {
if ((await input.protocol) === "v1") return (await input.sdk.permission.list()).data ?? []
return input.api.permission.request
.list({ location: { directory: input.directory } })
.then((result) => result.data.map(normalizePermissionRequest))
})().then((permissions) => {
const ids = permissions.map((permission) => permission.sessionID)
const grouped = groupBySession(
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
permissions.filter((permission) => !!permission.id && !!permission.sessionID),
)
const warm = input.session
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
: warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk })
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
return warm.then(() =>
batch(() => {
const current = input.session?.data.permission ?? input.store.permission
@@ -323,12 +441,19 @@ export async function bootstrapDirectory(input: {
),
() =>
retry(() =>
input.sdk.question.list().then((x) => {
const ids = (x.data ?? []).map((question) => question?.sessionID).filter((id): id is string => !!id)
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
(async () => {
if ((await input.protocol) === "v1") return (await input.sdk.question.list()).data ?? []
return input.api.question.request
.list({ location: { directory: input.directory } })
.then((result) => result.data)
})().then((questions) => {
const ids = questions.map((question) => question.sessionID)
const grouped = groupBySession(
questions.filter((question) => !!question.id && !!question.sessionID) as QuestionRequest[],
)
const warm = input.session
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
: warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk })
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
return warm.then(() =>
batch(() => {
const current = input.session?.data.question ?? input.store.question
@@ -351,17 +476,20 @@ export async function bootstrapDirectory(input: {
}),
),
() => Promise.resolve(input.loadSessions(input.directory)),
input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.sdk))),
input.mcp && (() => input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, input.directory, input.sdk))),
input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.api.mcp))),
input.mcp &&
(() => input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp))),
() =>
input.queryClient.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.sdk)).catch((err) => {
const project = getFilename(input.directory)
showToast({
variant: "error",
title: input.translate("toast.project.reloadFailed.title", { project }),
description: formatServerError(err, input.translate),
})
}),
input.queryClient
.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.api, input.sdk, input.protocol))
.catch((err) => {
const project = getFilename(input.directory)
showToast({
variant: "error",
title: input.translate("toast.project.reloadFailed.title", { project }),
description: formatServerError(err, input.translate),
})
}),
].filter(Boolean) as (() => Promise<any>)[]
await waitForPaint()
@@ -255,6 +255,7 @@ export function createChildStoreManager(input: {
vcs: vcsStore.value,
limit: 5,
message: {},
session_message: {},
part: {},
part_text_accum_delta: {},
})
@@ -80,6 +80,7 @@ const baseState = (input: Partial<State> = {}) =>
vcs: undefined,
limit: 10,
message: {},
session_message: {},
part: {},
part_text_accum_delta: {},
...input,
@@ -261,8 +262,8 @@ describe("applyDirectoryEvent", () => {
test("cleans session caches when deleted and decrements only root totals", () => {
const cases = [
{ info: rootSession({ id: "ses_1" }), expectedTotal: 1 },
{ info: rootSession({ id: "ses_2", parentID: "ses_1" }), expectedTotal: 2 },
{ info: rootSession({ id: "ses_1" }), expectedTotal: 1, current: false },
{ info: rootSession({ id: "ses_2", parentID: "ses_1" }), expectedTotal: 2, current: true },
]
for (const item of cases) {
@@ -286,7 +287,10 @@ describe("applyDirectoryEvent", () => {
)
applyDirectoryEvent({
event: { type: "session.deleted", properties: { info: item.info } },
event: {
type: "session.deleted",
properties: item.current ? { sessionID: item.info.id } : { info: item.info },
},
store,
setStore,
push() {},
@@ -8,9 +8,9 @@ import type {
QuestionRequest,
Session,
SessionStatus,
SnapshotFileDiff,
Todo,
} from "@opencode-ai/sdk/v2/client"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { State, VcsCache } from "./types"
import { trimSessions } from "./session-trim"
import { dropSessionCaches } from "./session-cache"
@@ -171,8 +171,11 @@ export function applyDirectoryEvent(input: {
break
}
case "session.deleted": {
const info = (event.properties as { info: Session }).info
const result = Binary.search(input.store.session, info.id, (s) => s.id)
const properties = event.properties as { sessionID?: string; info?: Session }
const sessionID = properties.info?.id ?? properties.sessionID
if (!sessionID) break
const result = Binary.search(input.store.session, sessionID, (s) => s.id)
const info = properties.info ?? (result.found ? input.store.session[result.index] : undefined)
if (result.found) {
input.setStore(
"session",
@@ -181,14 +184,77 @@ export function applyDirectoryEvent(input: {
}),
)
}
cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo)
if (info.parentID) break
cleanupSessionCaches(input.setStore, sessionID, input.setSessionTodo)
if (info?.parentID) break
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
break
}
case "session.renamed": {
const properties = event.properties as { sessionID: string; title: string }
const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
if (!result.found) break
input.setStore("session", result.index, (session) => ({
...session,
title: properties.title,
time: { ...session.time, updated: Date.now() },
}))
break
}
case "session.usage.updated": {
const properties = event.properties as Pick<Session, "cost" | "tokens"> & { sessionID: string }
const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
if (!result.found) break
input.setStore("session", result.index, (session) => ({
...session,
cost: properties.cost,
tokens: properties.tokens,
}))
break
}
case "session.archived": {
const properties = event.properties as { sessionID: string }
const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
if (!result.found) break
const info = input.store.session[result.index]
input.setStore(
"session",
produce((draft) => void draft.splice(result.index, 1)),
)
cleanupSessionCaches(input.setStore, properties.sessionID)
if (!info?.parentID) input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
break
}
case "session.moved": {
const properties = event.properties as {
sessionID: string
location: { directory: string; workspaceID?: string }
projectID?: string
subpath?: string
}
const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
if (!result.found) break
if (properties.location.directory === input.directory) {
input.setStore("session", result.index, (session) => ({
...session,
projectID: properties.projectID ?? session.projectID,
workspaceID: properties.location.workspaceID,
directory: properties.location.directory,
path: properties.subpath,
time: { ...session.time, updated: Date.now() },
}))
break
}
const info = input.store.session[result.index]
input.setStore(
"session",
produce((draft) => void draft.splice(result.index, 1)),
)
if (!info?.parentID) input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
break
}
case "session.diff": {
const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] }
input.setStore("session_diff", props.sessionID, reconcile(list(props.diff), { key: "file" }))
const props = event.properties as { sessionID: string; diff: FileDiffInfo[] }
input.setStore("session_diff", props.sessionID, reconcile(list(props.diff) as FileDiffInfo[], { key: "file" }))
break
}
case "todo.updated": {
@@ -31,4 +31,24 @@ describe("toggleMcp", () => {
await toggleMcp(input("disabled"))
expect(calls).toEqual(["connect", "refresh"])
})
test("does not toggle a server while its connection is pending", async () => {
const calls: string[] = []
await toggleMcp({
status: "pending",
connect: async () => {
calls.push("connect")
},
disconnect: async () => {
calls.push("disconnect")
},
authenticate: async () => {
calls.push("authenticate")
},
refresh: async () => {
calls.push("refresh")
},
})
expect(calls).toEqual([])
})
})
+3 -2
View File
@@ -1,12 +1,13 @@
import type { McpStatus } from "@opencode-ai/sdk/v2/client"
import type { McpServer } from "@opencode-ai/client/promise"
export async function toggleMcp(input: {
status: McpStatus["status"]
status: McpServer["status"]["status"]
connect: () => Promise<void>
disconnect: () => Promise<void>
authenticate: () => Promise<void>
refresh: () => Promise<void>
}) {
if (input.status === "pending") return
await {
connected: input.disconnect,
needs_auth: input.authenticate,
@@ -1,13 +1,6 @@
import { describe, expect, test } from "bun:test"
import type {
Message,
Part,
PermissionRequest,
QuestionRequest,
SessionStatus,
SnapshotFileDiff,
Todo,
} from "@opencode-ai/sdk/v2/client"
import type { Message, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@opencode-ai/sdk/v2/client"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
const msg = (id: string, sessionID: string) =>
@@ -33,9 +26,10 @@ describe("app session cache", () => {
test("dropSessionCaches clears orphaned parts without message rows", () => {
const store: {
session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, SnapshotFileDiff[] | undefined>
session_diff: Record<string, FileDiffInfo[] | undefined>
todo: Record<string, Todo[] | undefined>
message: Record<string, Message[] | undefined>
session_message: Record<string, never[] | undefined>
part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined>
question: Record<string, QuestionRequest[] | undefined>
@@ -45,6 +39,7 @@ describe("app session cache", () => {
session_diff: { ses_1: [] },
todo: { ses_1: [] as Todo[] },
message: {},
session_message: {},
part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
permission: { ses_1: [] as PermissionRequest[] },
question: { ses_1: [] as QuestionRequest[] },
@@ -67,9 +62,10 @@ describe("app session cache", () => {
const m = msg("msg_1", "ses_1")
const store: {
session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, SnapshotFileDiff[] | undefined>
session_diff: Record<string, FileDiffInfo[] | undefined>
todo: Record<string, Todo[] | undefined>
message: Record<string, Message[] | undefined>
session_message: Record<string, never[] | undefined>
part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined>
question: Record<string, QuestionRequest[] | undefined>
@@ -79,6 +75,7 @@ describe("app session cache", () => {
session_diff: {},
todo: {},
message: { ses_1: [m] },
session_message: {},
part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
permission: {},
question: {},
@@ -1,20 +1,15 @@
import type {
Message,
Part,
PermissionRequest,
QuestionRequest,
SessionStatus,
SnapshotFileDiff,
Todo,
} from "@opencode-ai/sdk/v2/client"
import type { Message, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@opencode-ai/sdk/v2/client"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
export const SESSION_CACHE_LIMIT = 40
type SessionCache = {
session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, SnapshotFileDiff[] | undefined>
session_diff: Record<string, FileDiffInfo[] | undefined>
todo: Record<string, Todo[] | undefined>
message: Record<string, Message[] | undefined>
session_message: Record<string, SessionMessageInfo[] | undefined>
part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined>
question: Record<string, QuestionRequest[] | undefined>
@@ -37,6 +32,7 @@ export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<stri
for (const sessionID of stale) {
delete store.message[sessionID]
delete store.todo[sessionID]
delete store.session_message[sessionID]
delete store.session_diff[sessionID]
delete store.session_status[sessionID]
delete store.permission[sessionID]
@@ -1,20 +1,28 @@
import type { RootLoadArgs } from "./types"
import type { SessionApi } from "@opencode-ai/client/promise"
import { normalizeSessionInfo } from "@/utils/session"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
export async function loadRootSessionsWithFallback(input: RootLoadArgs) {
export async function loadRootSessions(input: { api: Pick<SessionApi, "list">; directory: string; limit: number }) {
const result = await input.api.list({
directory: input.directory,
parentID: null,
limit: input.limit,
order: "desc",
})
return {
data: result.data.map(normalizeSessionInfo),
limit: input.limit,
limited: true,
} as const
}
export async function loadRootSessionsV1(input: { client: OpencodeClient; directory: string; limit: number }) {
try {
const result = await input.list({ directory: input.directory, roots: true, limit: input.limit })
return {
data: result.data,
limit: input.limit,
limited: true,
} as const
const result = await input.client.session.list({ directory: input.directory, roots: true, limit: input.limit })
return { data: result.data, limit: input.limit, limited: true } as const
} catch {
const result = await input.list({ directory: input.directory, roots: true })
return {
data: result.data,
limit: input.limit,
limited: false,
} as const
const result = await input.client.session.list({ directory: input.directory, roots: true })
return { data: result.data, limit: input.limit, limited: false } as const
}
}
+8 -19
View File
@@ -1,10 +1,7 @@
import type {
Agent,
Command,
Config,
LspStatus,
McpResource,
McpStatus,
Message,
Part,
Path,
@@ -13,11 +10,12 @@ import type {
ReferenceInfo,
Session,
SessionStatus,
SnapshotFileDiff,
Todo,
VcsInfo,
} from "@opencode-ai/sdk/v2/client"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import type { CommandInfo, McpResource, McpServer, SessionMessageInfo } from "@opencode-ai/client/promise"
import type { Accessor } from "solid-js"
import type { SetStoreFunction, Store } from "solid-js/store"
@@ -35,7 +33,7 @@ export type ProjectMeta = {
export type State = {
status: "loading" | "partial" | "complete"
agent: Agent[]
command: Command[]
command: CommandInfo[]
reference: ReferenceInfo[]
project: string
projectMeta: ProjectMeta | undefined
@@ -51,7 +49,7 @@ export type State = {
}
session_working(id: string): boolean
session_diff: {
[sessionID: string]: SnapshotFileDiff[]
[sessionID: string]: FileDiffInfo[]
}
todo: {
[sessionID: string]: Todo[]
@@ -64,7 +62,7 @@ export type State = {
}
mcp_ready: boolean
mcp: {
[name: string]: McpStatus
[name: string]: McpServer["status"]
}
mcp_resource: {
[key: string]: McpResource
@@ -76,6 +74,9 @@ export type State = {
message: {
[sessionID: string]: Message[]
}
session_message: {
[sessionID: string]: SessionMessageInfo[]
}
part: {
[messageID: string]: Part[]
}
@@ -128,18 +129,6 @@ export type DisposeCheck = {
loadingSessions: boolean
}
export type RootLoadArgs = {
directory: string
limit: number
list: (query: { directory: string; roots: true; limit?: number }) => Promise<{ data?: Session[] }>
}
export type RootLoadResult = {
data?: Session[]
limit: number
limited: boolean
}
export const MAX_DIR_STORES = 30
export const DIR_IDLE_TTL_MS = 20 * 60 * 1000
export const SESSION_RECENT_WINDOW = 4 * 60 * 60 * 1000
@@ -1,36 +1,112 @@
import { describe, expect, test } from "bun:test"
import type { Agent } from "@opencode-ai/sdk/v2/client"
import { directoryKey, normalizeAgentList } from "./utils"
const agent = (name = "build") =>
({
name,
mode: "primary",
permission: {},
options: {},
}) as Agent
import type { AgentListOutput, ModelDefaultOutput, ModelListOutput, ProviderListOutput } from "@opencode-ai/client/promise"
import { directoryKey, normalizeAgentList, normalizePermissionRequest, normalizeProviderList } from "./utils"
describe("normalizeAgentList", () => {
test("keeps array payloads", () => {
expect(normalizeAgentList([agent("build"), agent("docs")])).toEqual([agent("build"), agent("docs")])
})
test("adapts current agents to the app agent shape", () => {
const result = normalizeAgentList([
{
id: "build",
name: "Build",
mode: "primary",
hidden: false,
color: "primary",
model: { id: "gpt-5", providerID: "openai", variant: "high" },
request: { settings: { temperature: 0.2, topP: 0.9 }, headers: {}, body: {} },
system: "Build software",
permissions: [{ action: "read", resource: "*", effect: "allow" }],
},
] as AgentListOutput["data"])
test("wraps a single agent payload", () => {
expect(normalizeAgentList(agent("docs"))).toEqual([agent("docs")])
expect(result).toEqual([
{
name: "build",
description: undefined,
mode: "primary",
hidden: false,
temperature: 0.2,
topP: 0.9,
color: "primary",
permission: [{ permission: "read", pattern: "*", action: "allow" }],
model: { providerID: "openai", modelID: "gpt-5" },
variant: "high",
prompt: "Build software",
options: { temperature: 0.2, topP: 0.9 },
steps: undefined,
},
])
})
})
test("extracts agents from keyed objects", () => {
describe("normalizePermissionRequest", () => {
test("adapts the current permission request to app state", () => {
expect(
normalizeAgentList({
build: agent("build"),
docs: agent("docs"),
normalizePermissionRequest({
id: "permission-1",
sessionID: "session-1",
action: "read",
resources: ["README.md"],
save: ["*.md"],
metadata: { path: "README.md" },
source: { type: "tool", messageID: "message-1", callID: "call-1" },
}),
).toEqual([agent("build"), agent("docs")])
).toEqual({
id: "permission-1",
sessionID: "session-1",
permission: "read",
patterns: ["README.md"],
always: ["*.md"],
metadata: { path: "README.md" },
tool: { messageID: "message-1", callID: "call-1" },
})
})
})
test("drops invalid payloads", () => {
expect(normalizeAgentList({ name: "AbortError" })).toEqual([])
expect(normalizeAgentList([{ name: "build" }, agent("docs")])).toEqual([agent("docs")])
describe("normalizeProviderList", () => {
test("groups current models into the app provider catalog", () => {
const result = normalizeProviderList(
[{ id: "openai", name: "OpenAI", package: "@ai-sdk/openai" }] as ProviderListOutput["data"],
[
{
id: "gpt-5",
modelID: "gpt-5",
providerID: "openai",
name: "GPT-5",
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
variants: [{ id: "high" }],
time: { released: 1 },
cost: [{ input: 1, output: 2, cache: { read: 0.1, write: 0.2 } }],
status: "active",
enabled: true,
limit: { context: 128_000, output: 8_192 },
},
{
id: "gpt-old",
modelID: "gpt-old",
providerID: "openai",
name: "GPT Old",
capabilities: { tools: false, input: ["text"], output: ["text"] },
variants: [],
time: { released: 0 },
cost: [],
status: "deprecated",
enabled: true,
limit: { context: 1, output: 1 },
},
] as ModelListOutput["data"],
{ id: "gpt-5", providerID: "openai" } as ModelDefaultOutput["data"],
)
expect(result.connected).toEqual(["openai"])
expect(result.default).toEqual({ openai: "gpt-5" })
expect(result.all.get("openai")?.models["gpt-old"]).toBeUndefined()
expect(result.all.get("openai")?.models["gpt-5"]).toMatchObject({
id: "gpt-5",
providerID: "openai",
capabilities: { toolcall: true, attachment: true },
cost: { input: 1, output: 2 },
variants: { high: {} },
})
})
})
+147 -27
View File
@@ -1,39 +1,152 @@
import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
import type {
AgentListOutput,
ModelDefaultOutput,
ModelListOutput,
PermissionV2Request,
ProviderListOutput,
} from "@opencode-ai/client/promise"
import type { Agent, PermissionRequest, Project, Provider, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
import type { Project as CurrentProject } from "@opencode-ai/client/promise"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key"
export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
function isAgent(input: unknown): input is Agent {
if (!input || typeof input !== "object") return false
const item = input as { name?: unknown; mode?: unknown }
if (typeof item.name !== "string") return false
return item.mode === "subagent" || item.mode === "primary" || item.mode === "all"
export function normalizeAgentList(input: AgentListOutput["data"] | Agent[]): Agent[] {
if (input.every((agent) => !("request" in agent))) return input as Agent[]
return (input as AgentListOutput["data"]).map((agent) => ({
name: agent.id,
description: agent.description,
mode: agent.mode,
hidden: agent.hidden,
temperature:
typeof agent.request.settings.temperature === "number" ? agent.request.settings.temperature : undefined,
topP: typeof agent.request.settings.topP === "number" ? agent.request.settings.topP : undefined,
color: agent.color,
permission: agent.permissions.map((rule) => ({
permission: rule.action,
pattern: rule.resource,
action: rule.effect,
})),
model: agent.model && { providerID: agent.model.providerID, modelID: agent.model.id },
variant: agent.model?.variant,
prompt: agent.system,
options: agent.request.settings,
steps: agent.steps,
}))
}
export function normalizeAgentList(input: unknown): Agent[] {
if (Array.isArray(input)) return input.filter(isAgent)
if (isAgent(input)) return [input]
if (!input || typeof input !== "object") return []
return Object.values(input).filter(isAgent)
}
export function normalizeProviderList(input: ProviderListResponse): NormalizedProviderListResponse {
export function normalizePermissionRequest(input: PermissionV2Request | PermissionRequest): PermissionRequest {
if ("permission" in input) return input
return {
...input,
all: new Map(
input.all.map(
(provider) =>
[
provider.id,
{
...provider,
models: Object.fromEntries(
Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated"),
),
},
] as const,
id: input.id,
sessionID: input.sessionID,
permission: input.action,
patterns: input.resources,
always: input.save ?? [],
metadata: input.metadata ?? {},
tool:
input.source?.type === "tool" ? { messageID: input.source.messageID, callID: input.source.callID } : undefined,
}
}
export function normalizeProviderList(
providers: ProviderListOutput["data"] | ProviderListResponse,
models?: ModelListOutput["data"],
defaultModel?: ModelDefaultOutput["data"],
): NormalizedProviderListResponse {
if (!Array.isArray(providers)) {
return {
...providers,
all: new Map(
providers.all.map((provider) => [
provider.id,
{
...provider,
models: Object.fromEntries(
Object.entries(provider.models).filter(([, model]) => model.status !== "deprecated"),
),
},
]),
),
}
}
const all = new Map<string, Provider>()
for (const provider of providers) {
all.set(provider.id, {
id: provider.id,
name: provider.name,
source: "custom",
env: [],
options: provider.settings ?? {},
models: {},
})
}
for (const model of models ?? []) {
const provider = all.get(model.providerID)
if (!provider || model.status === "deprecated") continue
const cost = model.cost.find((item) => item.tier === undefined) ?? model.cost[0]
provider.models[model.id] = {
id: model.id,
providerID: model.providerID,
api: {
id: model.modelID,
url: "",
npm: model.package ?? provider.id,
},
name: model.name,
family: model.family,
capabilities: {
temperature: false,
reasoning: false,
attachment: model.capabilities.input.some((item) => item !== "text"),
toolcall: model.capabilities.tools,
input: {
text: model.capabilities.input.includes("text"),
audio: model.capabilities.input.includes("audio"),
image: model.capabilities.input.includes("image"),
video: model.capabilities.input.includes("video"),
pdf: model.capabilities.input.includes("pdf"),
},
output: {
text: model.capabilities.output.includes("text"),
audio: model.capabilities.output.includes("audio"),
image: model.capabilities.output.includes("image"),
video: model.capabilities.output.includes("video"),
pdf: model.capabilities.output.includes("pdf"),
},
interleaved: false,
},
cost: {
input: cost?.input ?? 0,
output: cost?.output ?? 0,
cache: {
read: cost?.cache.read ?? 0,
write: cost?.cache.write ?? 0,
},
},
limit: model.limit,
status: model.status,
options: model.settings ?? {},
headers: model.headers ?? {},
release_date: new Date(model.time.released).toISOString().slice(0, 10),
variants: Object.fromEntries(model.variants.map((variant) => [variant.id, variant.settings ?? {}])),
}
}
return {
all,
connected: providers.map((provider) => provider.id),
default: Object.fromEntries(
providers.flatMap((provider) => {
const model =
defaultModel?.providerID === provider.id
? defaultModel
: models?.find((item) => item.providerID === provider.id && item.status !== "deprecated")
return model ? [[provider.id, model.id]] : []
}),
),
}
}
@@ -49,3 +162,10 @@ export function sanitizeProject(project: Project) {
},
}
}
export function normalizeProjectInfo(project: Project | CurrentProject): Project {
return {
...project,
vcs: project.vcs === "git" ? "git" : undefined,
}
}
+36 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import { coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import type { Event } from "@opencode-ai/sdk/v2/client"
describe("resumeStreamAfterPageShow", () => {
@@ -14,6 +15,23 @@ describe("resumeStreamAfterPageShow", () => {
})
})
describe("adaptServerEvent", () => {
test("preserves V2 events while adapting permission requests for existing consumers", () => {
const current = {
id: "evt_1",
created: 1,
type: "permission.v2.asked",
data: { id: "perm_1", sessionID: "ses_1", action: "read", resources: ["src/**"] },
} as OpenCodeEvent
expect(adaptServerEvent(current)).toMatchObject({
type: "permission.asked",
properties: { id: "perm_1", sessionID: "ses_1", permission: "read", patterns: ["src/**"] },
current,
})
})
})
describe("coalesceServerEvents", () => {
const delta = (value: string, field = "text", partID = "part") => ({
directory: "/repo",
@@ -34,6 +52,23 @@ describe("coalesceServerEvents", () => {
expect(result[0]?.payload).toMatchObject({ id: "second", properties: { delta: "hello world" } })
})
test("merges adjacent current text deltas", () => {
const current = (id: string, value: string) => adaptServerEvent({
id,
created: 1,
type: "session.text.delta",
location: { directory: "/repo" },
data: { sessionID: "ses", assistantMessageID: "msg", ordinal: 0, delta: value },
} as OpenCodeEvent)
const result = coalesceServerEvents([
{ directory: "/repo", payload: current("evt_1", "hello ") },
{ directory: "/repo", payload: current("evt_2", "world") },
])
expect(result).toHaveLength(1)
expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { delta: "hello world" } })
})
test("preserves event boundaries and distinct fields", () => {
const status = {
directory: "/repo",
+123 -49
View File
@@ -1,21 +1,60 @@
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import type { Event } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { makeEventListener } from "@solid-primitives/event-listener"
import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js"
import { createSdkForServer } from "@/utils/server"
import { createApiForServer, createSdkForServer } from "@/utils/server"
import { useLanguage } from "./language"
import { usePlatform } from "./platform"
import { ServerConnection, useServer } from "./server"
import { createRefCountMap } from "@/utils/refcount"
import { useGlobal } from "./global"
import { ServerScope } from "@/utils/server-scope"
import { detectServerProtocol } from "@/utils/server-protocol"
import { createCompatibleApi } from "@/utils/server-compat"
const isAbortError = (error: unknown) =>
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true
type QueuedServerEvent = { directory: string; payload: Event }
export type ServerEvent = Event & { current?: OpenCodeEvent }
type QueuedServerEvent = { directory: string; payload: ServerEvent }
type CurrentDelta = Extract<
OpenCodeEvent,
{ type: "session.text.delta" | "session.reasoning.delta" | "session.tool.input.delta" | "session.compaction.delta" }
>
export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
if (event.type === "permission.v2.asked") {
return {
id: event.id,
type: "permission.asked",
properties: {
id: event.data.id,
sessionID: event.data.sessionID,
permission: event.data.action,
patterns: event.data.resources,
always: event.data.save ?? [],
metadata: event.data.metadata ?? {},
tool:
event.data.source?.type === "tool"
? { messageID: event.data.source.messageID, callID: event.data.source.callID }
: undefined,
},
current: event,
} as ServerEvent
}
if (event.type === "permission.v2.replied")
return { id: event.id, type: "permission.replied", properties: event.data, current: event } as ServerEvent
if (event.type === "question.v2.asked")
return { id: event.id, type: "question.asked", properties: event.data, current: event } as ServerEvent
if (event.type === "question.v2.replied")
return { id: event.id, type: "question.replied", properties: event.data, current: event } as ServerEvent
if (event.type === "question.v2.rejected")
return { id: event.id, type: "question.rejected", properties: event.data, current: event } as ServerEvent
return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
}
const coalescedKey = (event: QueuedServerEvent) => {
if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}`
@@ -40,6 +79,34 @@ export function enqueueServerEvent(queue: QueuedServerEvent[], event: QueuedServ
export function coalesceServerEvents(events: QueuedServerEvent[]) {
const output: QueuedServerEvent[] = []
events.forEach((event) => {
const current = currentDelta(event.payload.current)
if (current) {
const previous = output[output.length - 1]
const prior = currentDelta(previous?.payload.current)
if (
previous &&
prior &&
previous.directory === event.directory &&
currentDeltaKey(prior) === currentDeltaKey(current)
) {
const fragment = currentDeltaFragment(prior) + currentDeltaFragment(current)
const data =
current.type === "session.compaction.delta"
? { ...current.data, text: fragment }
: { ...current.data, delta: fragment }
output[output.length - 1] = {
directory: event.directory,
payload: {
...event.payload,
properties: data,
current: { ...current, data } as CurrentDelta,
} as ServerEvent,
}
return
}
output.push(event)
return
}
if (event.payload.type !== "message.part.delta") {
output.push(event)
return
@@ -71,6 +138,27 @@ export function coalesceServerEvents(events: QueuedServerEvent[]) {
return output
}
function currentDelta(event: OpenCodeEvent | undefined): CurrentDelta | undefined {
if (
event?.type === "session.text.delta" ||
event?.type === "session.reasoning.delta" ||
event?.type === "session.tool.input.delta" ||
event?.type === "session.compaction.delta"
)
return event
}
function currentDeltaKey(event: CurrentDelta) {
if (event.type === "session.tool.input.delta")
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.callID}`
if (event.type === "session.compaction.delta") return `${event.type}:${event.data.sessionID}`
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.ordinal}`
}
function currentDeltaFragment(event: CurrentDelta) {
return event.type === "session.compaction.delta" ? event.data.text : event.data.delta
}
export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: () => unknown) {
if (!event.persisted) return
start()
@@ -91,13 +179,15 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
}
})()
const eventApi = createApiForServer({ server: server.http, fetch: eventFetch })
const eventSdk = createSdkForServer({
signal: abort.signal,
fetch: eventFetch,
server: server.http,
})
const protocol = detectServerProtocol(server.http, platform.fetch ?? globalThis.fetch)
const emitter = createGlobalEmitter<{
[key: string]: Event
[key: string]: ServerEvent
}>()
type Queued = QueuedServerEvent
@@ -142,21 +232,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
let run: Promise<void> | undefined
let started = false
let generation = 0
const HEARTBEAT_TIMEOUT_MS = 15_000
let lastEventAt = Date.now()
let heartbeat: ReturnType<typeof setTimeout> | undefined
const resetHeartbeat = () => {
lastEventAt = Date.now()
if (heartbeat) clearTimeout(heartbeat)
heartbeat = setTimeout(() => {
attempt?.abort()
}, HEARTBEAT_TIMEOUT_MS)
}
const clearHeartbeat = () => {
if (!heartbeat) return
clearTimeout(heartbeat)
heartbeat = undefined
}
const start = () => {
if (started) return run
@@ -168,35 +243,24 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
// oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit
while (!abort.signal.aborted && started && generation === active) {
attempt = new AbortController()
lastEventAt = Date.now()
const onAbort = () => {
attempt?.abort()
}
abort.signal.addEventListener("abort", onAbort)
try {
const events = await eventSdk.global.event({
signal: attempt.signal,
onSseError: (error) => {
if (isStreamClosed(error, attempt?.signal)) return
if (streamErrorLogged) return
streamErrorLogged = true
console.error("[global-sdk] event stream error", {
url: server.http.url,
fetch: eventFetch ? "platform" : "webview",
error,
})
},
})
const kind = await protocol
const events =
kind === "v1"
? (await eventSdk.global.event({ signal: attempt.signal })).stream
: eventApi.event.subscribe({ signal: attempt.signal })
let yielded = Date.now()
resetHeartbeat()
for await (const event of events.stream) {
resetHeartbeat()
for await (const event of events) {
streamErrorLogged = false
if (event.payload.type !== "sync") {
const directory = event.directory ?? "global"
const payload = event.payload as Event
if (enqueueServerEvent(queue, { directory, payload })) schedule()
}
const legacy = "payload" in event
if (legacy && event.payload.type === "sync") continue
const directory = legacy ? (event.directory ?? "global") : (event.location?.directory ?? "global")
const payload = legacy ? (event.payload as Event) : adaptServerEvent(event)
if (enqueueServerEvent(queue, { directory, payload })) schedule()
if (Date.now() - yielded < STREAM_YIELD_MS) continue
yielded = Date.now()
@@ -214,7 +278,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
} finally {
abort.signal.removeEventListener("abort", onAbort)
attempt = undefined
clearHeartbeat()
}
if (abort.signal.aborted || !started || generation !== active) return
@@ -233,18 +296,11 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
started = false
generation++
attempt?.abort()
clearHeartbeat()
}
onMount(() => {
makeEventListener(window, "pagehide", stop)
makeEventListener(window, "pageshow", (event) => resumeStreamAfterPageShow(event, start))
makeEventListener(document, "visibilitychange", () => {
if (document.visibilityState !== "visible") return
if (!started) return
if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return
attempt?.abort()
})
})
onCleanup(() => {
@@ -258,12 +314,24 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
fetch: platform.fetch,
throwOnError: true,
})
const currentApi = createApiForServer({ server: server.http, fetch: platform.fetch })
const legacy = (directory?: string) =>
createSdkForServer({
server: server.http,
fetch: platform.fetch,
throwOnError: true,
directory,
})
const api = createCompatibleApi({ protocol, current: currentApi, legacy })
return {
server,
scope,
protocol,
url: server.http.url,
client: sdk,
api,
currentApi,
event: {
on: emitter.on.bind(emitter),
listen: emitter.listen.bind(emitter),
@@ -309,7 +377,7 @@ export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleCo
})
type SDKEventMap = {
[key in Event["type"]]: Extract<Event, { type: key }>
[key in Event["type"]]: Extract<ServerEvent, { type: key }>
}
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
@@ -329,6 +397,12 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
scope: serverSDK.scope,
directory,
client,
api: createCompatibleApi({
protocol: serverSDK.protocol,
current: serverSDK.currentApi,
legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }),
directory,
}),
event: emitter,
get url() {
return serverSDK.url
@@ -0,0 +1,148 @@
import { describe, expect, test } from "bun:test"
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode-ai/client/promise"
import { createV2SessionReducer } from "./server-session-v2-reducer"
const event = (input: object) => input as OpenCodeEvent
const base = { created: 1, location: { directory: "/repo" }, durable: { aggregateID: "ses_1", seq: 1, version: 1 } }
describe("v2 session reducer", () => {
test("projects promoted input and streaming assistant content", () => {
const reducer = createV2SessionReducer()
let messages: SessionMessageInfo[] = []
const apply = (input: object) => {
const result = reducer.reduce(messages, event(input))
if (result) messages = result.messages
return result
}
apply({
...base,
id: "evt_admitted",
type: "session.input.admitted",
data: {
sessionID: "ses_1",
inputID: "msg_user",
input: { type: "user", delivery: "steer", data: { text: "hello" } },
},
})
apply({ ...base, id: "evt_promoted", type: "session.input.promoted", data: { sessionID: "ses_1", inputID: "msg_user" } })
apply({
...base,
id: "evt_step",
type: "session.step.started",
data: {
sessionID: "ses_1",
assistantMessageID: "msg_assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
},
})
apply({
...base,
id: "evt_text_start",
type: "session.text.started",
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", ordinal: 0 },
})
apply({
...base,
id: "evt_text_delta",
type: "session.text.delta",
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", ordinal: 0, delta: "hel" },
})
apply({
...base,
id: "evt_text_end",
type: "session.text.ended",
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", ordinal: 0, text: "hello" },
})
expect(messages[0]).toMatchObject({ id: "msg_user", type: "user", text: "hello" })
expect(messages[1]).toMatchObject({
id: "msg_assistant",
type: "assistant",
content: [{ type: "text", text: "hello" }],
})
})
test("folds tool, retry, and completion events", () => {
const reducer = createV2SessionReducer()
let messages: SessionMessageInfo[] = []
const apply = (input: object) => {
const result = reducer.reduce(messages, event(input))
if (result) messages = result.messages
}
apply({
...base,
id: "evt_step",
type: "session.step.started",
data: {
sessionID: "ses_1",
assistantMessageID: "msg_assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
},
})
apply({
...base,
id: "evt_tool_start",
type: "session.tool.input.started",
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", callID: "call_1", name: "bash" },
})
apply({
...base,
id: "evt_tool_delta",
type: "session.tool.input.delta",
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", callID: "call_1", delta: "{}" },
})
apply({
...base,
id: "evt_tool_called",
type: "session.tool.called",
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", callID: "call_1", input: {}, executed: true },
})
apply({
...base,
id: "evt_tool_success",
type: "session.tool.success",
data: {
sessionID: "ses_1",
assistantMessageID: "msg_assistant",
callID: "call_1",
structured: {},
content: [{ type: "text", text: "done" }],
executed: true,
},
})
apply({
...base,
id: "evt_retry",
type: "session.retry.scheduled",
data: {
sessionID: "ses_1",
assistantMessageID: "msg_assistant",
attempt: 2,
at: 10,
error: { type: "ProviderError", message: "retry" },
},
})
apply({ ...base, id: "evt_done", type: "session.execution.succeeded", data: { sessionID: "ses_1" } })
expect(messages[0]).toMatchObject({
type: "assistant",
retry: undefined,
content: [{ type: "tool", id: "call_1", state: { status: "completed", content: [{ text: "done" }] } }],
})
})
test("requests hydration when promotion admission was missed", () => {
const result = createV2SessionReducer().reduce([], event({
...base,
id: "evt_promoted",
type: "session.input.promoted",
data: { sessionID: "ses_1", inputID: "msg_user" },
}))
expect(result).toMatchObject({ sessionID: "ses_1", missing: "msg_user", touched: [] })
})
})
@@ -0,0 +1,434 @@
import type { OpenCodeEvent, SessionMessageInfo, SessionPendingMessage } from "@opencode-ai/client/promise"
type Assistant = Extract<SessionMessageInfo, { type: "assistant" }>
type Compaction = Extract<SessionMessageInfo, { type: "compaction" }>
type Shell = Extract<SessionMessageInfo, { type: "shell" }>
export type V2SessionReduction = {
sessionID: string
messages: SessionMessageInfo[]
touched: string[]
missing?: string
}
export function createV2SessionReducer() {
const pending = new Map<string, SessionPendingMessage>()
const reduce = (source: readonly SessionMessageInfo[], event: OpenCodeEvent): V2SessionReduction | undefined => {
if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return
const sessionID = event.data.sessionID
const result = (messages: SessionMessageInfo[], touched: string[] = []): V2SessionReduction => ({
sessionID,
messages,
touched,
})
const append = (message: SessionMessageInfo) =>
result(source.some((item) => item.id === message.id) ? [...source] : [...source, message], [message.id])
switch (event.type) {
case "session.input.admitted":
pending.set(key(sessionID, event.data.inputID), event.data.input)
return result([...source])
case "session.input.promoted": {
const input = pending.get(key(sessionID, event.data.inputID))
pending.delete(key(sessionID, event.data.inputID))
if (!input) return { ...result([...source]), missing: event.data.inputID }
if (input.type === "user")
return append({
id: event.data.inputID,
type: "user",
metadata: input.data.metadata,
text: input.data.text,
files: input.data.files,
agents: input.data.agents,
time: { created: event.created },
})
return append({
id: event.data.inputID,
type: "synthetic",
metadata: input.data.metadata,
text: input.data.text,
description: input.data.description,
time: { created: event.created },
})
}
case "session.agent.selected":
return append({
id: messageID(event.id),
type: "agent-switched",
metadata: event.metadata,
agent: event.data.agent,
time: { created: event.created },
})
case "session.model.selected":
return append({
id: messageID(event.id),
type: "model-switched",
metadata: event.metadata,
model: event.data.model,
previous: source.findLast(
(item): item is Extract<SessionMessageInfo, { type: "model-switched" | "assistant" }> =>
item.type === "model-switched" || item.type === "assistant",
)?.model,
time: { created: event.created },
})
case "session.synthetic":
return append({
id: messageID(event.id),
type: "synthetic",
metadata: event.data.metadata,
text: event.data.text,
description: event.data.description,
time: { created: event.created },
})
case "session.skill.activated":
return append({
id: messageID(event.id),
type: "skill",
metadata: event.metadata,
skill: event.data.id,
name: event.data.name,
text: event.data.text,
time: { created: event.created },
})
case "session.shell.started":
return append({
id: messageID(event.id),
type: "shell",
metadata: event.metadata,
shellID: event.data.shell.id,
command: event.data.shell.command,
status: event.data.shell.status,
exit: event.data.shell.exit,
time: { created: event.created },
})
case "session.shell.ended":
return updateMessage<Shell>(source, (item): item is Shell => item.type === "shell" && item.shellID === event.data.shell.id, (item) => ({
...item,
status: event.data.shell.status,
exit: event.data.shell.exit,
output: event.data.output,
time: { ...item.time, completed: event.created },
}), sessionID)
case "session.step.started": {
const current = source.findLast((item): item is Assistant => item.type === "assistant" && !item.time.completed)
const completed = current && current.id !== event.data.assistantMessageID
? update(source, current.id, (item) => item.type === "assistant" ? { ...item, retry: undefined, time: { ...item.time, completed: event.created } } : item)
: [...source]
const existing = completed.find((item) => item.id === event.data.assistantMessageID)
if (existing?.type === "assistant")
return result(update(completed, existing.id, (item) => item.type === "assistant" ? {
...item,
agent: event.data.agent,
model: event.data.model,
retry: undefined,
error: undefined,
finish: undefined,
snapshot: event.data.snapshot ? { ...item.snapshot, start: event.data.snapshot } : item.snapshot,
time: { ...item.time, completed: undefined },
} : item), current && current.id !== existing.id ? [current.id, existing.id] : [existing.id])
return result([...completed, {
id: event.data.assistantMessageID,
type: "assistant",
metadata: event.metadata,
agent: event.data.agent,
model: event.data.model,
content: [],
snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined,
time: { created: event.created },
}], current ? [current.id, event.data.assistantMessageID] : [event.data.assistantMessageID])
}
case "session.step.ended":
return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({
...item,
finish: event.data.finish,
cost: event.data.cost,
tokens: event.data.tokens,
snapshot: event.data.snapshot || event.data.files
? { ...item.snapshot, end: event.data.snapshot, files: event.data.files }
: item.snapshot,
time: { ...item.time, completed: event.created },
}))
case "session.step.failed":
return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({
...item,
finish: "error",
error: event.data.error,
retry: undefined,
cost: event.data.cost ?? item.cost,
tokens: event.data.tokens ?? item.tokens,
snapshot: event.data.snapshot || event.data.files
? { ...item.snapshot, end: event.data.snapshot, files: event.data.files }
: item.snapshot,
time: { ...item.time, completed: event.created },
}))
case "session.text.started":
return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({
...item,
content: insertOrdinal(item.content, "text", event.data.ordinal, { type: "text", text: "" }),
}))
case "session.text.delta":
return updateContent(source, event.data.assistantMessageID, sessionID, "text", event.data.ordinal, (item) => ({
...item,
text: item.text + event.data.delta,
}))
case "session.text.ended":
return updateContent(source, event.data.assistantMessageID, sessionID, "text", event.data.ordinal, (item) => ({
...item,
text: event.data.text,
}))
case "session.reasoning.started":
return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({
...item,
content: insertOrdinal(item.content, "reasoning", event.data.ordinal, {
type: "reasoning",
text: "",
state: event.data.state,
time: { created: event.created },
}),
}))
case "session.reasoning.delta":
return updateContent(source, event.data.assistantMessageID, sessionID, "reasoning", event.data.ordinal, (item) => ({
...item,
text: item.text + event.data.delta,
}))
case "session.reasoning.ended":
return updateContent(source, event.data.assistantMessageID, sessionID, "reasoning", event.data.ordinal, (item) => ({
...item,
text: event.data.text,
state: event.data.state ?? item.state,
time: { created: item.time?.created ?? event.created, completed: event.created },
}))
case "session.tool.input.started":
return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({
...item,
content: item.content.some((content) => content.type === "tool" && content.id === event.data.callID)
? item.content
: [...item.content, {
type: "tool",
id: event.data.callID,
name: event.data.name,
state: { status: "streaming", input: "" },
time: { created: event.created },
}],
}))
case "session.tool.input.delta":
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) =>
tool.state.status === "streaming"
? { ...tool, state: { ...tool.state, input: tool.state.input + event.data.delta } }
: tool,
)
case "session.tool.input.ended":
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) =>
tool.state.status === "streaming" ? { ...tool, state: { ...tool.state, input: event.data.text } } : tool,
)
case "session.tool.called":
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => ({
...tool,
executed: event.data.executed,
providerState: event.data.state,
state: { status: "running", input: event.data.input, structured: {}, content: [] },
time: { ...tool.time, ran: event.created },
}))
case "session.tool.progress":
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) =>
tool.state.status === "running"
? { ...tool, state: { ...tool.state, structured: event.data.structured, content: event.data.content } }
: tool,
)
case "session.tool.success":
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => {
if (tool.state.status !== "running") return tool
return {
...tool,
executed: event.data.executed || tool.executed === true,
providerResultState: event.data.resultState,
state: {
status: "completed",
input: tool.state.input,
structured: event.data.structured,
content: event.data.content,
result: event.data.result,
},
time: { ...tool.time, completed: event.created },
}
})
case "session.tool.failed":
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => {
if (tool.state.status !== "streaming" && tool.state.status !== "running") return tool
return {
...tool,
executed: event.data.executed || tool.executed === true,
providerResultState: event.data.resultState,
state: {
status: "error",
input: typeof tool.state.input === "string" ? {} : tool.state.input,
structured: tool.state.status === "running" ? tool.state.structured : {},
content: tool.state.status === "running" ? tool.state.content : [],
error: event.data.error,
result: event.data.result,
},
time: { ...tool.time, completed: event.created },
}
})
case "session.retry.scheduled":
return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({
...item,
retry: { attempt: event.data.attempt, at: event.data.at, error: event.data.error },
}))
case "session.execution.succeeded":
case "session.execution.failed":
case "session.execution.interrupted": {
const current = source.findLast((item): item is Assistant => item.type === "assistant" && !item.time.completed)
if (!current?.retry) return result([...source])
return updateAssistant(source, current.id, sessionID, (item) => ({ ...item, retry: undefined }))
}
case "session.compaction.started":
return append({
id: event.data.inputID ?? messageID(event.id),
type: "compaction",
status: "running",
metadata: event.metadata,
reason: event.data.reason,
summary: "",
recent: event.data.recent,
time: { created: event.created },
})
case "session.compaction.delta":
return updateMessage<Extract<Compaction, { status: "running" }>>(source, (item): item is Extract<Compaction, { status: "running" }> => item.type === "compaction" && item.status === "running", (item) => ({
...item,
summary: item.summary + event.data.text,
}), sessionID)
case "session.compaction.ended": {
const current = source.findLast((item): item is Extract<Compaction, { status: "running" }> => item.type === "compaction" && item.status === "running")
if (!current)
return append({
id: messageID(event.id),
type: "compaction",
status: "completed",
metadata: event.metadata,
reason: event.data.reason,
summary: event.data.text,
recent: event.data.recent,
time: { created: event.created },
})
return result(update(source, current.id, () => ({
...current,
status: "completed",
reason: event.data.reason,
summary: event.data.text,
recent: event.data.recent,
})), [current.id])
}
case "session.compaction.failed": {
const current = source.findLast((item): item is Extract<Compaction, { status: "running" }> => item.type === "compaction" && item.status === "running")
const failed: Extract<Compaction, { status: "failed" }> = {
id: current?.id ?? event.data.inputID ?? messageID(event.id),
type: "compaction",
status: "failed",
metadata: current?.metadata ?? event.metadata,
reason: event.data.reason,
error: event.data.error,
time: current?.time ?? { created: event.created },
}
if (!current) return append(failed)
return result(update(source, current.id, () => failed), [failed.id])
}
default:
return
}
}
return {
reduce,
clear(sessionID: string) {
for (const id of pending.keys()) {
if (id.startsWith(`${sessionID}:`)) pending.delete(id)
}
},
}
}
function key(sessionID: string, inputID: string) {
return `${sessionID}:${inputID}`
}
function messageID(eventID: string) {
return eventID.replace(/^evt_/, "msg_")
}
function update(
source: readonly SessionMessageInfo[],
id: string,
apply: (item: SessionMessageInfo) => SessionMessageInfo,
) {
return source.map((item) => item.id === id ? apply(item) : item)
}
function updateMessage<T extends SessionMessageInfo>(
source: readonly SessionMessageInfo[],
matches: (item: SessionMessageInfo) => item is T,
apply: (item: T) => T,
sessionID: string,
): V2SessionReduction {
const current = source.findLast(matches)
if (!current) return { sessionID, messages: [...source], touched: [] }
return { sessionID, messages: update(source, current.id, (item) => matches(item) ? apply(item) : item), touched: [current.id] }
}
function updateAssistant(
source: readonly SessionMessageInfo[],
id: string,
sessionID: string,
apply: (item: Assistant) => Assistant,
): V2SessionReduction {
return {
sessionID,
messages: update(source, id, (item) => item.type === "assistant" ? apply(item) : item),
touched: source.some((item) => item.id === id && item.type === "assistant") ? [id] : [],
}
}
function updateContent<T extends "text" | "reasoning">(
source: readonly SessionMessageInfo[],
messageID: string,
sessionID: string,
type: T,
ordinal: number,
apply: (item: Extract<Assistant["content"][number], { type: T }>) => Extract<Assistant["content"][number], { type: T }>,
) {
return updateAssistant(source, messageID, sessionID, (assistant) => {
let index = -1
return {
...assistant,
content: assistant.content.map((item) => {
if (item.type !== type || ++index !== ordinal) return item
return apply(item as Extract<Assistant["content"][number], { type: T }>)
}),
}
})
}
function updateTool(
source: readonly SessionMessageInfo[],
messageID: string,
callID: string,
sessionID: string,
apply: (item: Extract<Assistant["content"][number], { type: "tool" }>) => Extract<Assistant["content"][number], { type: "tool" }>,
) {
return updateAssistant(source, messageID, sessionID, (assistant) => ({
...assistant,
content: assistant.content.map((item) => item.type === "tool" && item.id === callID ? apply(item) : item),
}))
}
function insertOrdinal<T extends Assistant["content"][number]["type"]>(
source: Assistant["content"],
type: T,
ordinal: number,
item: Extract<Assistant["content"][number], { type: T }>,
) {
const matches = source.filter((content) => content.type === type)
if (matches[ordinal]) return source
return [...source, item]
}
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { retry } from "@opencode-ai/core/util/retry"
import type { MessageApi, OpenCodeEvent, SessionApi } from "@opencode-ai/client/promise"
import type { Message, OpencodeClient, Part, Session } from "@opencode-ai/sdk/v2/client"
import { createServerSession } from "./server-session"
@@ -158,6 +159,57 @@ function setup(sessions: Record<string, Session>) {
}
describe("server session", () => {
test("projects V2 session events into current and legacy message state", () => {
const ctx = setup({ child: session("child") })
ctx.store.remember(session("child"))
ctx.store.set("session_message", "child", [
{
id: "msg_1_user",
type: "user",
text: "hello",
time: { created: 1 },
},
])
const apply = (input: object) => ctx.store.applyV2(input as OpenCodeEvent)
apply({
id: "evt_step",
created: 2,
type: "session.step.started",
durable: { aggregateID: "child", seq: 1, version: 1 },
location: { directory: "/repo" },
data: {
sessionID: "child",
assistantMessageID: "msg_2_assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
},
})
apply({
id: "evt_text_start",
created: 3,
type: "session.text.started",
durable: { aggregateID: "child", seq: 2, version: 1 },
location: { directory: "/repo" },
data: { sessionID: "child", assistantMessageID: "msg_2_assistant", ordinal: 0 },
})
apply({
id: "evt_text_delta",
created: 4,
type: "session.text.delta",
location: { directory: "/repo" },
data: { sessionID: "child", assistantMessageID: "msg_2_assistant", ordinal: 0, delta: "world" },
})
expect(ctx.store.data.session_message.child?.at(-1)).toMatchObject({
id: "msg_2_assistant",
type: "assistant",
content: [{ type: "text", text: "world" }],
})
expect(ctx.store.data.message.child?.map((message) => message.id)).toEqual(["msg_1_user", "msg_2_assistant"])
expect(ctx.store.data.part.msg_2_assistant).toMatchObject([{ type: "text", text: "world" }])
})
test("resolves lineage by session ID without directory", async () => {
const ctx = setup({ child: session("child", "root"), root: session("root") })
@@ -178,6 +230,111 @@ describe("server session", () => {
expect(ctx.store.data.message.root).toEqual([])
})
test("loads current session content through the current message API", async () => {
const requests: unknown[] = []
const user = { id: "msg_z_user", type: "user", text: "hello", time: { created: 1 } }
const assistant = {
id: "msg_a_assistant",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [{ type: "text", text: "hi" }],
time: { created: 2, completed: 3 },
}
const client = {
session: {
messages: () => {
throw new Error("legacy message endpoint called")
},
},
} as unknown as OpencodeClient
const messageApi = {
list: async (input: unknown) => {
requests.push(input)
return { data: [assistant, user], cursor: { previous: null, next: null } }
},
} as unknown as MessageApi
const store = createServerSession(client, {} as SessionApi, messageApi)
store.remember(session("root"))
await store.sync("root")
expect(requests).toEqual([{ sessionID: "root", limit: 20, order: "desc" }])
expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
})
test("reprojects current assistants when an older page supplies their user", async () => {
const user = { id: "msg_1_user", type: "user", text: "hello", time: { created: 1 } } as const
const assistant = (id: string, created: number) => ({
id,
type: "assistant" as const,
agent: "build",
model: { id: "model", providerID: "provider" },
content: [{ type: "text" as const, text: id }],
time: { created, completed: created },
})
const assistants = [
assistant("msg_2_assistant", 2),
assistant("msg_3_assistant", 3),
assistant("msg_4_assistant", 4),
]
const pages = [
{ data: assistants.slice(1).toReversed(), cursor: { previous: null, next: "older" } },
{ data: [assistants[0], user], cursor: { previous: null, next: null } },
]
const messageApi = {
list: async () => pages.shift()!,
} as unknown as MessageApi
const store = createServerSession({} as OpencodeClient, {} as SessionApi, messageApi)
store.remember(session("root"))
await store.sync("root")
expect(store.data.message.root).toEqual([])
await store.history.loadMore("root")
expect(store.data.message.root.map((message) => message.id)).toEqual([
user.id,
...assistants.map((item) => item.id),
])
expect(assistants.map((item) => store.data.part[item.id]?.[0]?.type)).toEqual(["text", "text", "text"])
})
test("indexes V1 messages for the current timeline projection", async () => {
const user = userMessage("message-1", { sessionID: "root" })
const assistant = assistantMessage("message-2", user.id, { sessionID: "root" })
const client = messageClient(
response([
{ info: user, parts: [textPart(user.id, { sessionID: "root" })] },
{ info: assistant, parts: [textPart(assistant.id, { sessionID: "root" })] },
]),
)
const messageApi = {
list: () => {
throw new Error("current message endpoint called")
},
} as unknown as MessageApi
const store = createServerSession(client, {} as SessionApi, messageApi, {
protocol: Promise.resolve("v1"),
})
store.remember(session("root"))
await store.sync("root")
expect(store.data.message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
expect(store.data.session_message.root).toMatchObject([
{ id: user.id, type: "user", text: "text" },
{ id: assistant.id, type: "assistant" },
])
const next = userMessage("message-3", { sessionID: "root" })
store.apply({ type: "message.updated", properties: { info: next } })
expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id, next.id])
store.apply({ type: "message.removed", properties: { sessionID: "root", messageID: next.id } })
expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
})
test("backfills an assistant-only initial page through its user root", async () => {
const user = userMessage("message-1")
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
+247 -38
View File
@@ -1,5 +1,6 @@
import { Binary } from "@opencode-ai/core/util/binary"
import { retry } from "@opencode-ai/core/util/retry"
import type { MessageApi, OpenCodeEvent, SessionApi, SessionMessageInfo } from "@opencode-ai/client/promise"
import type {
Message,
OpencodeClient,
@@ -8,15 +9,18 @@ import type {
QuestionRequest,
Session,
SessionStatus,
SnapshotFileDiff,
Todo,
} from "@opencode-ai/sdk/v2/client"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { batch } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { diffs as cleanDiffs, message as cleanMessage } from "@/utils/diffs"
import { message as cleanMessage } from "@/utils/diffs"
import { sessionNotFoundError } from "@/utils/server-errors"
import { rootSession } from "@/utils/session-route"
import { normalizeSessionInfo } from "@/utils/session"
import { normalizeSessionMessages } from "@/utils/session-message"
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
import { createV2SessionReducer, type V2SessionReduction } from "./server-session-v2-reducer"
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
const cmpMessage = (a: Message, b: Message) => a.time.created - b.time.created || cmp(a.id, b.id)
@@ -36,10 +40,37 @@ type OptimisticItem = {
type MessagePage = {
session: Message[]
part: { id: string; part: Part[] }[]
source?: SessionMessageInfo[]
sourceMode?: "latest" | "older"
projectSource?: boolean
cursor?: string
complete: boolean
}
function legacyMessageSource(items: { info: Message; parts: Part[] }[]): SessionMessageInfo[] {
return items
.slice()
.sort((a, b) => cmp(a.info.id, b.info.id))
.map((item) => {
if (item.info.role === "user") {
return {
id: item.info.id,
type: "user" as const,
text: item.parts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"),
time: item.info.time,
}
}
return {
id: item.info.id,
type: "assistant" as const,
agent: item.info.agent ?? item.info.mode,
model: { id: item.info.modelID, providerID: item.info.providerID, variant: item.info.variant },
content: [],
time: item.info.time,
}
})
}
// Most markers describe the current HTTP attempt; deltaParts persists non-durable stream state across retries.
type MessageLoadState = {
touchedMessages: Set<string>
@@ -52,6 +83,7 @@ type MessageLoadState = {
optimisticParts: Map<string, Set<string>>
orphanParents: Set<string>
clearedMessageParts: Set<string>
touchedSource: Set<string>
}
type MessageLoadBaseline = Pick<
@@ -137,15 +169,25 @@ function reconcileFetched<T extends { id: string }>(
return [...result.values()].sort((a, b) => cmp(a.id, b.id))
}
export function createServerSession(client: OpencodeClient, options?: { retry?: typeof retry }) {
type ServerSessionOptions = { retry?: typeof retry; protocol?: Promise<"v1" | "v2"> }
export function createServerSession(
client: OpencodeClient,
sessionApiOrOptions?: SessionApi | ServerSessionOptions,
messageApi?: MessageApi,
currentOptions?: ServerSessionOptions,
) {
const sessionApi = messageApi ? (sessionApiOrOptions as SessionApi) : undefined
const options = messageApi ? currentOptions : (sessionApiOrOptions as ServerSessionOptions | undefined)
const [data, setData] = createStore({
info: {} as Record<string, Session | undefined>,
session_status: {} as Record<string, SessionStatus>,
session_diff: {} as Record<string, SnapshotFileDiff[]>,
session_diff: {} as Record<string, FileDiffInfo[]>,
todo: {} as Record<string, Todo[]>,
permission: {} as Record<string, PermissionRequest[]>,
question: {} as Record<string, QuestionRequest[]>,
message: {} as Record<string, Message[]>,
session_message: {} as Record<string, SessionMessageInfo[]>,
part: {} as Record<string, Part[]>,
part_text_accum_delta: {} as Record<string, string>,
session_working(id: string) {
@@ -154,9 +196,9 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
})
const requests = new Map<string, Promise<Session>>()
const inflight = new Map<string, Promise<void>>()
const inflightDiff = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>()
const optimistic = new Map<string, Map<string, OptimisticItem>>()
const v2 = createV2SessionReducer()
const messageLoads = new Map<string, MessageLoadState>()
const pendingParts = new Map<string, Map<string, Set<string>>>()
const orphanParts = new Map<string, Set<string>>()
@@ -191,6 +233,16 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
at: {} as Record<string, number | undefined>,
})
const indexLegacyMessage = (message: Message) => {
const current = data.session_message[message.sessionID] ?? []
if (current.some((item) => item.id === message.id)) return
setData(
"session_message",
message.sessionID,
reconcile([...current, ...legacyMessageSource([{ info: message, parts: [] }])]),
)
}
const remember = (session: Session) => {
setData("info", session.id, reconcile(session))
infoSeen.delete(session.id)
@@ -200,7 +252,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
...pinned.keys(),
...requests.keys(),
...inflight.keys(),
...inflightDiff.keys(),
...inflightTodo.keys(),
...messageLoads.keys(),
...optimistic.keys(),
@@ -242,27 +293,31 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
const pending = requests.get(sessionID)
if (pending) return pending
const active = generation(sessionID)
const request = client.session.get({ sessionID }).then((result) => {
if (!result.data) throw sessionNotFoundError(sessionID)
if (generations.get(sessionID) !== active) return result.data
return remember(result.data)
const request = sessionApi
? sessionApi.get({ sessionID }).then(normalizeSessionInfo)
: client.session.get({ sessionID }).then((result) => {
if (!result.data) throw sessionNotFoundError(sessionID)
return result.data
})
const resolved = request.then((result) => {
if (generations.get(sessionID) !== active) return result
return remember(result)
})
requests.set(sessionID, request)
requests.set(sessionID, resolved)
const cleanup = () => {
if (requests.get(sessionID) === request) requests.delete(sessionID)
if (requests.get(sessionID) === resolved) requests.delete(sessionID)
if (
generations.get(sessionID) === active &&
!data.info[sessionID] &&
!requests.has(sessionID) &&
!messageLoads.has(sessionID) &&
!inflight.has(sessionID) &&
!inflightDiff.has(sessionID) &&
!inflightTodo.has(sessionID)
)
generations.delete(sessionID)
}
void request.then(cleanup, cleanup)
return request
void resolved.then(cleanup, cleanup)
return resolved
}
const peekLineage = (sessionID: string) => {
@@ -419,9 +474,9 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
clearOptimistic(sessionID)
requests.delete(sessionID)
inflight.delete(sessionID)
inflightDiff.delete(sessionID)
inflightTodo.delete(sessionID)
messageLoads.delete(sessionID)
v2.clear(sessionID)
pendingParts.delete(sessionID)
orphanParts.delete(sessionID)
removedMessages.delete(sessionID)
@@ -449,7 +504,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
...pinned.keys(),
...requests.keys(),
...inflight.keys(),
...inflightDiff.keys(),
...inflightTodo.keys(),
...messageLoads.keys(),
...optimistic.keys(),
@@ -470,6 +524,25 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
)
const fetchMessages = async (sessionID: string, limit: number, before?: string, onAttempt?: () => void) => {
if (messageApi && (await options?.protocol) !== "v1") {
const response = await (options?.retry ?? retry)(() => {
onAttempt?.()
return messageApi.list(before ? { sessionID, limit, cursor: before } : { sessionID, limit, order: "desc" })
})
const source = [...response.data].reverse()
const normalized = normalizeSessionMessages(sessionID, source)
return {
session: normalized.messages.sort((a, b) => cmp(a.id, b.id)),
part: [...normalized.parts.entries()]
.map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) }))
.sort((a, b) => cmp(a.id, b.id)),
source,
sourceMode: before ? ("older" as const) : ("latest" as const),
projectSource: true,
cursor: response.cursor.next ?? undefined,
complete: response.data.length === 0,
}
}
const response = await (options?.retry ?? retry)(() => {
onAttempt?.()
return client.session.messages({ sessionID, limit, before })
@@ -481,12 +554,24 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
id: item.info.id,
part: item.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)),
})),
source: legacyMessageSource(items),
sourceMode: before ? ("older" as const) : ("latest" as const),
cursor: response.response.headers.get("x-next-cursor") ?? undefined,
complete: !response.response.headers.get("x-next-cursor"),
}
}
const fetchMessage = async (sessionID: string, messageID: string, onAttempt?: () => void) => {
if (sessionApi && (await options?.protocol) !== "v1") {
const response = await (options?.retry ?? retry)(() => {
onAttempt?.()
return sessionApi.message({ sessionID, messageID })
})
const normalized = normalizeSessionMessages(sessionID, [response])
const message = normalized.messages[0]
if (!message) throw new Error(`Message not found: ${messageID}`)
return { message, parts: normalized.parts.get(messageID) ?? [] }
}
const response = await (options?.retry ?? retry)(() => {
onAttempt?.()
return client.session.message({ sessionID, messageID })
@@ -571,7 +656,31 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
preserveUnfetched: boolean | ((message: Message) => boolean),
cleanupOrphans: boolean,
) => {
const merged = mergeOptimisticPage(page, [...(optimistic.get(sessionID)?.values() ?? [])])
const source = page.source
? (() => {
const incoming = new Map(page.source.map((message) => [message.id, message]))
const existing = data.session_message[sessionID] ?? []
const current = existing.filter((message) => !incoming.has(message.id))
const live = new Map(existing.map((message) => [message.id, message]))
return (page.sourceMode === "older" ? [...page.source, ...current] : [...current, ...page.source]).map(
(message) => (load?.touchedSource.has(message.id) ? (live.get(message.id) ?? message) : message),
)
})()
: undefined
const projected =
page.projectSource && source
? (() => {
const normalized = normalizeSessionMessages(sessionID, source)
return {
...page,
session: normalized.messages.sort((a, b) => cmp(a.id, b.id)),
part: [...normalized.parts.entries()]
.map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) }))
.sort((a, b) => cmp(a.id, b.id)),
}
})()
: page
const merged = mergeOptimisticPage(projected, [...(optimistic.get(sessionID)?.values() ?? [])])
merged.observed.forEach((item) => {
if (!load?.clearedMessageParts.has(item.messageID)) confirmOptimistic(sessionID, item.messageID, item.parts)
})
@@ -583,6 +692,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
preserveUnfetched,
})
batch(() => {
if (source) setData("session_message", sessionID, reconcile(source))
const messageIDs = replaceMessages(sessionID, messages)
replaceParts(sessionID, merged.part, messageIDs, load)
const orphans = orphanParts.get(sessionID)
@@ -613,6 +723,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
optimisticParts: new Map(),
orphanParents: new Set(),
clearedMessageParts: new Set(),
touchedSource: new Set(),
}
messageLoads.set(sessionID, load)
setMeta("loading", sessionID, true)
@@ -747,6 +858,109 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
return properties.part.sessionID
}
const projectV2 = (reduction: V2SessionReduction) => {
reduction.touched.forEach((messageID) => messageLoads.get(reduction.sessionID)?.touchedSource.add(messageID))
setData("session_message", reduction.sessionID, reconcile(reduction.messages))
if (reduction.touched.length === 0) return
const touched = new Set(reduction.touched)
let parentID: string | undefined
for (const message of reduction.messages) {
if (message.type === "user" || (message.type === "synthetic" && message.description?.trim()))
parentID = message.id
if (message.type === "shell") {
if (touched.has(message.id)) touched.add(`${message.id}:assistant`)
parentID = undefined
}
if (message.type === "assistant" && touched.has(message.id) && parentID) touched.add(parentID)
if (message.type === "compaction" && touched.has(message.id) && parentID) touched.add(parentID)
}
const normalized = normalizeSessionMessages(reduction.sessionID, reduction.messages)
batch(() => {
for (const message of normalized.messages) {
if (!touched.has(message.id)) continue
apply({ type: "message.updated", properties: { sessionID: reduction.sessionID, info: message } })
}
for (const messageID of touched) {
const next = normalized.parts.get(messageID) ?? []
const nextIDs = new Set(next.map((part) => part.id))
for (const part of next) {
apply({ type: "message.part.updated", properties: { sessionID: reduction.sessionID, part } })
}
for (const part of data.part[messageID] ?? []) {
if (nextIDs.has(part.id)) continue
apply({
type: "message.part.removed",
properties: { sessionID: reduction.sessionID, messageID, partID: part.id },
})
}
}
})
}
const hydrateV2Message = (sessionID: string, messageID: string) => {
if (!sessionApi) return
void sessionApi
.message({ sessionID, messageID })
.then((message) => {
const current = data.session_message[sessionID] ?? []
const messages = [...current.filter((item) => item.id !== message.id), message].sort((a, b) => cmp(a.id, b.id))
projectV2({ sessionID, messages, touched: [message.id] })
})
.catch(() => {})
}
const applyV2 = (event: OpenCodeEvent) => {
if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return
const sessionID = event.data.sessionID
const reduction = v2.reduce(data.session_message[sessionID] ?? [], event)
if (reduction) {
projectV2(reduction)
if (reduction.missing) hydrateV2Message(sessionID, reduction.missing)
}
const info = data.info[sessionID]
if (event.type === "session.renamed" && info)
remember({ ...info, title: event.data.title, time: { ...info.time, updated: event.created } })
if (event.type === "session.moved" && info)
remember({
...info,
projectID: event.data.projectID ?? info.projectID,
workspaceID: event.data.location.workspaceID,
directory: event.data.location.directory,
path: event.data.subpath,
time: { ...info.time, updated: event.created },
})
if (event.type === "session.usage.updated" && info)
remember({ ...info, cost: event.data.cost, tokens: event.data.tokens })
if (event.type === "session.archived") {
if (info) remember({ ...info, time: { ...info.time, archived: event.created, updated: event.created } })
evict([sessionID])
}
if (event.type === "session.execution.started") setData("session_status", sessionID, { type: "busy" })
if (
event.type === "session.execution.succeeded" ||
event.type === "session.execution.failed" ||
event.type === "session.execution.interrupted"
)
setData("session_status", sessionID, { type: "idle" })
if (event.type === "session.retry.scheduled")
setData("session_status", sessionID, {
type: "retry",
attempt: event.data.attempt,
message: event.data.error.message,
next: event.data.at,
})
if (event.type === "session.forked") void resolve(sessionID, { force: true }).catch(() => {})
if (
event.type === "session.revert.staged" ||
event.type === "session.revert.cleared" ||
event.type === "session.revert.committed"
)
void resolve(sessionID, { force: true }).catch(() => {})
}
const apply = (event: { type: string; properties?: unknown }) => {
const eventID = eventSessionID(event)
if (eventID) {
@@ -770,7 +984,9 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
return
}
case "session.deleted": {
const sessionID = (event.properties as { info: Session }).info.id
const properties = event.properties as { sessionID?: string; info?: Session }
const sessionID = properties.info?.id ?? properties.sessionID
if (!sessionID) return
infoSeen.delete(sessionID)
setData(
"info",
@@ -779,11 +995,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
evict([sessionID])
return
}
case "session.diff": {
const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] }
setData("session_diff", props.sessionID, reconcile(cleanDiffs(props.diff), { key: "file" }))
return
}
case "todo.updated": {
const props = event.properties as { sessionID: string; todos: Todo[] }
setData("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
@@ -796,6 +1007,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
}
case "message.updated": {
const info = cleanMessage((event.properties as { info: Message }).info)
indexLegacyMessage(info)
const load = messageLoads.get(info.sessionID)
load?.touchedMessages.add(info.id)
load?.removedMessages.delete(info.id)
@@ -828,6 +1040,9 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
}
case "message.removed": {
const props = event.properties as { sessionID: string; messageID: string }
setData("session_message", props.sessionID, (messages) =>
messages?.filter((message) => message.id !== props.messageID),
)
const load = messageLoads.get(props.sessionID)
load?.touchedMessages.add(props.messageID)
load?.removedMessages.add(props.messageID)
@@ -1140,23 +1355,16 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
setData(produce((draft) => deleteMessageParts(draft, input.messageID)))
},
},
diff(sessionID: string, options?: { force?: boolean }) {
async todo(sessionID: string, request?: { force?: boolean }) {
touch(sessionID)
if (data.session_diff[sessionID] !== undefined && !options?.force) return Promise.resolve()
return runInflight(inflightDiff, sessionID, () => {
const active = generation(sessionID)
return retry(() => client.session.diff({ sessionID })).then((result) => {
if (generations.get(sessionID) !== active) return
setData("session_diff", sessionID, reconcile(cleanDiffs(result.data), { key: "file" }))
})
})
},
todo(sessionID: string, options?: { force?: boolean }) {
touch(sessionID)
if (data.todo[sessionID] !== undefined && !options?.force) return Promise.resolve()
if (data.todo[sessionID] !== undefined && !request?.force) return
if ((await options?.protocol) === "v2") {
setData("todo", sessionID, [])
return
}
return runInflight(inflightTodo, sessionID, () => {
const active = generation(sessionID)
return retry(() => client.session.todo({ sessionID })).then((result) => {
return (options?.retry ?? retry)(() => client.session.todo({ sessionID })).then((result) => {
if (generations.get(sessionID) !== active) return
setData("todo", sessionID, reconcile(result.data ?? [], { key: "id" }))
})
@@ -1190,6 +1398,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
if (count && count > 1) pinned.set(sessionID, count - 1)
},
apply,
applyV2,
}
}
+143 -30
View File
@@ -1,6 +1,108 @@
import { describe, expect, test } from "bun:test"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
import type {
McpApi,
McpListInput,
McpResourceCatalogInput,
SessionApi,
SessionInfo,
SessionListInput,
} from "@opencode-ai/client/promise"
import { QueryClient } from "@tanstack/solid-query"
import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/session-load"
import {
loadActiveSessionsQuery,
loadMcpQuery,
loadMcpResourcesQuery,
seedActiveSessionStatuses,
} from "./server-sync"
import { ServerScope } from "@/utils/server-scope"
import { createServerSession } from "./server-session"
describe("MCP queries", () => {
test("loads current servers for the requested location", async () => {
const calls: unknown[] = []
const queryClient = new QueryClient()
const result = await queryClient.fetchQuery(
loadMcpQuery(ServerScope.local, "/project", {
list: async (input: McpListInput = {}) => {
calls.push(input)
return {
location: { directory: "/project", project: { id: "project", directory: "/project" } },
data: [
{ name: "docs", status: { status: "connected" } },
{ name: "search", status: { status: "pending" } },
],
}
},
} as unknown as McpApi),
)
expect(calls).toEqual([{ location: { directory: "/project" } }])
expect(result).toEqual({ docs: { status: "connected" }, search: { status: "pending" } })
})
test("loads and keys the current resource catalog", async () => {
const calls: unknown[] = []
const queryClient = new QueryClient()
const result = await queryClient.fetchQuery(
loadMcpResourcesQuery(ServerScope.local, "/project", {
resource: {
catalog: async (input: McpResourceCatalogInput = {}) => {
calls.push(input)
return {
location: { directory: "/project", project: { id: "project", directory: "/project" } },
data: {
resources: [{ server: "docs", name: "Guide", uri: "docs://guide" }],
templates: [],
},
}
},
},
} as unknown as McpApi),
)
expect(calls).toEqual([{ location: { directory: "/project" } }])
expect(result).toEqual({ "docs:docs://guide": { server: "docs", name: "Guide", uri: "docs://guide" } })
})
})
describe("active session query", () => {
test("loads active sessions once per server cache", async () => {
let calls = 0
const queryClient = new QueryClient()
const options = loadActiveSessionsQuery(ServerScope.local, {
active: async () => {
calls++
return { ses_running: { type: "running" } }
},
})
expect(await queryClient.fetchQuery(options)).toEqual({ ses_running: { type: "running" } })
expect(await queryClient.fetchQuery(options)).toEqual({ ses_running: { type: "running" } })
expect(calls).toBe(1)
expect([...options.queryKey]).toEqual([ServerScope.local, "activeSessions"])
})
test("does not overwrite statuses already written by events", () => {
const session = createServerSession({} as OpencodeClient)
session.set("session_status", "ses_retry", { type: "retry", attempt: 2, message: "retrying", next: 10 })
seedActiveSessionStatuses(session, {
ses_running: { type: "running" },
ses_retry: { type: "running" },
})
expect(session.data.session_status.ses_running).toEqual({ type: "busy" })
expect(session.data.session_status.ses_retry).toEqual({
type: "retry",
attempt: 2,
message: "retrying",
next: 10,
})
})
})
describe("pickDirectoriesToEvict", () => {
test("keeps pinned stores and evicts idle stores", () => {
@@ -23,46 +125,57 @@ describe("pickDirectoriesToEvict", () => {
})
})
describe("loadRootSessionsWithFallback", () => {
test("uses limited roots query when supported", async () => {
const calls: Array<{ directory: string; roots: true; limit?: number }> = []
describe("loadRootSessions", () => {
test("loads and normalizes a limited page of root sessions", async () => {
const calls: SessionListInput[] = []
const result = await loadRootSessionsWithFallback({
const result = await loadRootSessions({
api: {
list: async (query = {}) => {
calls.push(query)
return { data: [sessionInfo("session-1")], cursor: {} }
},
} satisfies Pick<SessionApi, "list">,
directory: "dir",
limit: 10,
list: async (query) => {
calls.push(query)
return { data: [] }
},
})
expect(result.data).toEqual([])
expect(result.data).toEqual([
expect.objectContaining({ id: "session-1", directory: "dir", slug: "session-1", version: "" }),
])
expect(result.limited).toBe(true)
expect(calls).toEqual([{ directory: "dir", roots: true, limit: 10 }])
expect(calls).toEqual([{ directory: "dir", parentID: null, limit: 10, order: "desc" }])
})
test("falls back to full roots query on limited-query failure", async () => {
const calls: Array<{ directory: string; roots: true; limit?: number }> = []
const result = await loadRootSessionsWithFallback({
directory: "dir",
limit: 25,
list: async (query) => {
calls.push(query)
if (query.limit) throw new Error("unsupported")
return { data: [] }
},
})
expect(result.data).toEqual([])
expect(result.limited).toBe(false)
expect(calls).toEqual([
{ directory: "dir", roots: true, limit: 25 },
{ directory: "dir", roots: true },
])
test("propagates list failures", () => {
expect(
loadRootSessions({
api: {
list: async () => {
throw new Error("failed")
},
} satisfies Pick<SessionApi, "list">,
directory: "dir",
limit: 25,
}),
).rejects.toThrow("failed")
})
})
function sessionInfo(id: string) {
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 },
title: id,
location: { directory: "dir" },
} as SessionInfo
}
describe("estimateRootSessionTotal", () => {
test("keeps exact total for full fetches", () => {
expect(estimateRootSessionTotal({ count: 42, limit: 10, limited: false })).toBe(42)
+232 -39
View File
@@ -1,10 +1,10 @@
import type {
Config,
McpResource,
OpencodeClient,
Path,
Project,
ProviderAuthResponse,
SessionStatus,
} from "@opencode-ai/sdk/v2/client"
import { showToast } from "@/utils/toast"
import { getFilename } from "@opencode-ai/core/util/path"
@@ -18,6 +18,7 @@ import {
bootstrapGlobal,
clearProviderRev,
loadAgentsQuery,
loadCommands,
loadGlobalConfigQuery,
loadPathQuery,
loadProjectsQuery,
@@ -26,12 +27,13 @@ import {
} from "./global-sync/bootstrap"
import { createChildStoreManager } from "./global-sync/child-store"
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
import { estimateRootSessionTotal, loadRootSessions, loadRootSessionsV1 } from "./global-sync/session-load"
import { trimSessions } from "./global-sync/session-trim"
import type { ProjectMeta } from "./global-sync/types"
import { SESSION_RECENT_LIMIT } from "./global-sync/types"
import { formatServerError } from "@/utils/server-errors"
import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query"
import type { SolidQueryOptions } from "@tanstack/solid-query"
import { createRefreshQueue } from "./global-sync/queue"
import { directoryKey } from "./global-sync/utils"
import { PathKey } from "@/utils/path-key"
@@ -45,8 +47,18 @@ import { retry } from "@opencode-ai/core/util/retry"
import type { ServerScope } from "@/utils/server-scope"
import { createHomeSessionIndexCache } from "./global-sync/home-session-index"
import { persisted } from "@/utils/persist"
import type { ServerApi } from "@/utils/server"
import type {
McpListInput,
McpListOutput,
McpResource,
McpResourceCatalogInput,
McpResourceCatalogOutput,
McpServer,
SessionActiveOutput,
} from "@opencode-ai/client/promise"
import { toggleMcp } from "./global-sync/mcp"
import { createServerSession } from "./server-session"
import { createServerSession, type ServerSession } from "./server-session"
type GlobalStore = {
ready: boolean
@@ -59,16 +71,76 @@ type GlobalStore = {
reload: undefined | "pending" | "complete"
}
export const loadMcpQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
queryOptions({
type McpListApi = {
readonly list: (input?: McpListInput) => Promise<McpListOutput>
}
type McpResourceApi = {
readonly resource: {
readonly catalog: (input?: McpResourceCatalogInput) => Promise<McpResourceCatalogOutput>
}
}
type ApiQueryOptions<T, K extends readonly unknown[]> = SolidQueryOptions<T, Error, T, K> & {
initialData?: undefined
queryKey: K
}
type SessionActiveApi = {
readonly active: () => Promise<SessionActiveOutput>
}
export const loadMcpQuery = (
scope: ServerScope,
directory: string,
api: McpListApi,
legacy?: OpencodeClient,
protocol?: Promise<"v1" | "v2">,
): ApiQueryOptions<Record<string, McpServer["status"]>, readonly [ServerScope, string, "mcp"]> =>
queryOptions<
Record<string, McpServer["status"]>,
Error,
Record<string, McpServer["status"]>,
readonly [ServerScope, string, "mcp"]
>({
queryKey: [scope, directory, "mcp"] as const,
queryFn: () => sdk.mcp.status().then((r) => r.data ?? {}),
queryFn: async () => {
if ((await protocol) === "v1" && legacy) return (await legacy.mcp.status()).data ?? {}
return api
.list({ location: { directory } })
.then((result) => Object.fromEntries(result.data.map((server) => [server.name, server.status])))
},
})
export const loadMcpResourcesQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
queryOptions<Record<string, McpResource>>({
export const loadMcpResourcesQuery = (
scope: ServerScope,
directory: string,
api: McpResourceApi,
legacy?: OpencodeClient,
protocol?: Promise<"v1" | "v2">,
): ApiQueryOptions<Record<string, McpResource>, readonly [ServerScope, string, "mcpResources"]> =>
queryOptions<
Record<string, McpResource>,
Error,
Record<string, McpResource>,
readonly [ServerScope, string, "mcpResources"]
>({
queryKey: [scope, directory, "mcpResources"] as const,
queryFn: () => sdk.experimental.resource.list().then((r) => r.data ?? {}),
queryFn: async () => {
if ((await protocol) === "v1" && legacy) {
return Object.fromEntries(
Object.entries((await legacy.experimental.resource.list()).data ?? {}).map(([key, resource]) => [
key,
{ ...resource, server: resource.client },
]),
)
}
return api.resource
.catalog({ location: { directory } })
.then((result) =>
Object.fromEntries(result.data.resources.map((resource) => [`${resource.server}:${resource.uri}`, resource])),
)
},
placeholderData: {},
})
@@ -78,22 +150,51 @@ export const loadLspQuery = (scope: ServerScope, directory: string, sdk: Opencod
queryFn: () => sdk.lsp.status().then((r) => r.data ?? []),
})
export const loadActiveSessionsQuery = (
scope: ServerScope,
api: SessionActiveApi,
): ApiQueryOptions<SessionActiveOutput, readonly [ServerScope, "activeSessions"]> =>
queryOptions<SessionActiveOutput, Error, SessionActiveOutput, readonly [ServerScope, "activeSessions"]>({
queryKey: [scope, "activeSessions"] as const,
queryFn: () => api.active(),
enabled: false,
staleTime: Number.POSITIVE_INFINITY,
gcTime: Number.POSITIVE_INFINITY,
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
})
export function seedActiveSessionStatuses(
session: Pick<ServerSession, "data" | "set">,
active: SessionActiveOutput | Record<string, SessionStatus>,
) {
for (const sessionID of Object.keys(active)) {
if (session.data.session_status[sessionID] !== undefined) continue
const status = active[sessionID]
session.set("session_status", sessionID, status?.type === "running" ? { type: "busy" } : status)
}
}
function makeQueryOptionsApi(
scope: ServerScope,
serverSDK: () => OpencodeClient,
serverAPI: ServerApi,
sdkFor: (dir: PathKey) => OpencodeClient,
protocol: Promise<"v1" | "v2">,
) {
return {
globalConfig: () => loadGlobalConfigQuery(scope, serverSDK()),
projects: () => loadProjectsQuery(scope, serverSDK()),
projects: () => loadProjectsQuery(scope, serverAPI.project),
providers: (directory: PathKey | null) =>
loadProvidersQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)),
path: (directory: PathKey | null) =>
loadPathQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)),
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, sdkFor(directory)),
references: (directory: PathKey) => loadReferencesQuery(scope, directory, sdkFor(directory)),
mcp: (directory: PathKey) => loadMcpQuery(scope, directory, sdkFor(directory)),
mcpResources: (directory: PathKey) => loadMcpResourcesQuery(scope, directory, sdkFor(directory)),
loadProvidersQuery(scope, directory, serverAPI, directory ? sdkFor(directory) : serverSDK(), protocol),
path: (directory: PathKey | null) => loadPathQuery(scope, directory, serverAPI.path),
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, serverAPI.agent, sdkFor(directory), protocol),
references: (directory: PathKey) =>
loadReferencesQuery(scope, directory, serverAPI.reference, sdkFor(directory), protocol),
mcp: (directory: PathKey) => loadMcpQuery(scope, directory, serverAPI.mcp, sdkFor(directory), protocol),
mcpResources: (directory: PathKey) =>
loadMcpResourcesQuery(scope, directory, serverAPI.mcp, sdkFor(directory), protocol),
lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)),
sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
}
@@ -122,11 +223,44 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
return sdk
}
const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, () => serverSDK.client, sdkFor)
const session = createServerSession(serverSDK.client, serverSDK.api.session, serverSDK.api.message, {
protocol: serverSDK.protocol,
})
const queryOptionsApi = makeQueryOptionsApi(
serverSDK.scope,
() => serverSDK.client,
serverSDK.api,
sdkFor,
serverSDK.protocol,
)
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)],
}))
const activeSessionsQuery = useQuery(() =>
loadActiveSessionsQuery(serverSDK.scope, {
active: async () => {
if ((await serverSDK.protocol) === "v1") {
const statuses = (await serverSDK.client.session.status()).data ?? {}
for (const [sessionID, status] of Object.entries(statuses)) {
session.set("session_status", sessionID, reconcile(status))
void session.resolve(sessionID).catch(() => undefined)
}
return Object.fromEntries(
Object.entries(statuses).flatMap(([sessionID, status]) =>
status.type === "idle" ? [] : [[sessionID, { type: "running" as const }]],
),
)
}
const active = await serverSDK.api.session.active()
seedActiveSessionStatuses(session, active)
for (const sessionID of Object.keys(active)) {
void session.resolve(sessionID).catch(() => undefined)
}
return active
},
}),
)
const [globalStore, setGlobalStore] = createStore<GlobalStore>({
get ready() {
@@ -183,6 +317,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
queryFn: async () => {
await bootstrapGlobal({
serverSDK: serverSDK.client,
serverAPI: serverSDK.api,
protocol: serverSDK.protocol,
scope: serverSDK.scope,
requestFailedTitle: language.t("common.requestFailed"),
translate: language.t,
@@ -212,8 +348,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
bootstrapInstance,
})
const session = createServerSession(serverSDK.client)
const children = createChildStoreManager({
owner,
scope: serverSDK.scope,
@@ -224,17 +358,15 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
void bootstrapInstance(directory)
},
onMcp: (directory, setStore) => {
void retry(() =>
sdkFor(directory)
.command.list()
.then((x) => setStore("command", x.data ?? [])),
).catch((err) => {
showToast({
variant: "error",
title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }),
description: formatServerError(err, language.t),
void loadCommands(directory, serverSDK.api.command, sdkFor(directory), serverSDK.protocol)
.then((commands) => setStore("command", commands))
.catch((err) => {
showToast({
variant: "error",
title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }),
description: formatServerError(err, language.t),
})
})
})
},
onDispose: (directory) => {
const key = directoryKey(directory)
@@ -279,11 +411,12 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
.fetchQuery({
...queryOptionsApi.sessions(key),
queryFn: () =>
loadRootSessionsWithFallback({
directory,
limit,
list: (query) => serverSDK.client.session.list(query),
})
serverSDK.protocol
.then((protocol) =>
protocol === "v1"
? loadRootSessionsV1({ client: sdkFor(directory), directory, limit })
: loadRootSessions({ api: serverSDK.api.session, directory, limit }),
)
.then((x) => {
const nonArchived = (x.data ?? [])
.filter((s) => !!s?.id)
@@ -353,6 +486,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
provider: globalStore.provider,
},
sdk,
api: serverSDK.api,
store: child[0],
setStore: child[1],
vcsCache: cache,
@@ -360,6 +494,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
translate: language.t,
queryClient,
session,
protocol: serverSDK.protocol,
})
})
@@ -371,12 +506,31 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
return promise
}
const indexSession = (info: Parameters<typeof session.remember>[0]) => {
const key = directoryKey(info.directory)
const existing = children.children[key]
if (!existing) return
applyDirectoryEvent({
event: { type: "session.created", properties: { info } },
directory: key,
store: existing[0],
setStore: existing[1],
push: queue.push,
retainedLimit: sessionMeta.get(key)?.limit,
sessionContent: false,
permission: session.data.permission,
loadLsp() {},
})
}
const unsub = serverSDK.event.listen((e) => {
const directory = e.name
const key = directoryKey(directory)
const event = e.details
const eventType: string = event.type
const recent = bootingRoot || Date.now() - bootedAt < 1500
if (event.current) session.applyV2(event.current)
session.apply(event)
if (event.type === "session.created" || event.type === "session.updated" || event.type === "session.deleted") {
homeSessions.apply(event)
@@ -384,6 +538,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
homeSessions.refresh(event.type)
if (directory === "global") {
if (eventType === "server.connected" && activeSessionsQuery.data === undefined && !activeSessionsQuery.isFetching)
void activeSessionsQuery.refetch()
applyGlobalEvent({
event,
project: globalStore.project,
@@ -393,7 +549,14 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
},
setGlobalProject: setProjects,
})
if (event.type === "server.connected" || event.type === "global.disposed") {
if (
eventType === "config.updated" ||
eventType === "catalog.updated" ||
eventType === "agent.updated" ||
eventType === "project.directories.updated"
)
bootstrap.refetch()
if (eventType === "server.connected" || eventType === "global.disposed") {
if (recent) return
for (const directory of Object.keys(children.children)) {
if (!children.active(directory)) continue
@@ -403,9 +566,30 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
return
}
if (event.current?.type === "session.moved") {
const info = session.get(event.current.data.sessionID)
if (info) indexSession(info)
}
if (event.current?.type === "session.forked")
void session
.resolve(event.current.data.sessionID, { force: true })
.then(indexSession)
.catch(() => {})
const existing = children.children[key]
if (!existing) return
children.mark(key)
if (
event.current?.type === "session.moved" ||
event.current?.type === "session.archived" ||
event.current?.type === "session.forked" ||
eventType === "command.updated" ||
eventType === "config.updated" ||
eventType === "agent.updated"
)
queue.push(key)
if (eventType === "mcp.status.changed") void queryClient.invalidateQueries(queryOptionsApi.mcp(key))
if (eventType === "mcp.resources.changed") void queryClient.invalidateQueries(queryOptionsApi.mcpResources(key))
const [store, setStore] = existing
applyDirectoryEvent({
event,
@@ -502,14 +686,23 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
toggle: async (directory: string, name: string) => {
const key = directoryKey(directory)
const sdk = sdkFor(key)
const status = children.child(key, { bootstrap: false })[0].mcp[name].status
const status = children.child(key, { bootstrap: false })[0].mcp[name]?.status
if (!status) return
await toggleMcp({
status,
connect: async () => {
await sdk.mcp.connect({ name })
if ((await serverSDK.protocol) === "v1") {
await sdk.mcp.connect({ name })
return
}
await serverSDK.api.mcp.connect({ server: name, location: { directory: key } })
},
disconnect: async () => {
await sdk.mcp.disconnect({ name })
if ((await serverSDK.protocol) === "v1") {
await sdk.mcp.disconnect({ name })
return
}
await serverSDK.api.mcp.disconnect({ server: name, location: { directory: key } })
},
authenticate: async () => {
await sdk.mcp.auth.authenticate({ name })
+21 -59
View File
@@ -532,7 +532,6 @@ export default function Page() {
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
const isChildSession = createMemo(() => !!info()?.parentID)
const diffs = createMemo(() => (params.id ? list(sync().data.session_diff[params.id]) : []))
const canReview = createMemo(() => !!sync().project)
const reviewTab = createMemo(() => isDesktop())
const tabState = createSessionTabs({
@@ -690,8 +689,8 @@ export default function Page() {
queryFn: mode
? () =>
sdk()
.client.vcs.diff({ mode })
.then((result) => list(result.data))
.api.vcs.diff({ location: { directory: sdk().directory }, mode: mode === "git" ? "working" : mode })
.then((result) => result.data)
.catch((error) => {
console.debug("[session-review] failed to load vcs diff", { mode, error })
return []
@@ -738,8 +737,12 @@ export default function Page() {
retry: 2,
queryFn: () =>
sdk()
.client.vcs.diff({ mode, directory: scope, context })
.then((result) => result.data ?? []),
.api.vcs.diff({
location: { directory: scope },
mode: mode === "git" ? "working" : mode,
context,
})
.then((result) => result.data),
})
.then((diffs) => diffs.find((diff) => diff.file === file))
@@ -946,10 +949,11 @@ export default function Page() {
)
const stopVcs = sdk().event.listen((evt) => {
if (evt.details.type !== "file.watcher.updated") return
const details = evt.details as { type: string; properties?: unknown }
if (details.type !== "file.watcher.updated" && details.type !== "filesystem.changed") return
const props =
typeof evt.details.properties === "object" && evt.details.properties
? (evt.details.properties as Record<string, unknown>)
typeof details.properties === "object" && details.properties
? (details.properties as Record<string, unknown>)
: undefined
const file = typeof props?.file === "string" ? props.file : undefined
if (!file || file.startsWith(".git/")) return
@@ -1464,44 +1468,6 @@ export default function Page() {
requestAnimationFrame(() => attempt(0))
})
createEffect(() => {
const id = params.id
if (!id) return
if (!wantsReview()) return
if (sync().data.session_diff[id] !== undefined) return
if (sync().status === "loading") return
void sync().session.diff(id)
})
createEffect(
on(
() => [sessionKey(), wantsReview()] as const,
([key, wants]) => {
if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
if (diffTimer !== undefined) window.clearTimeout(diffTimer)
diffFrame = undefined
diffTimer = undefined
if (!wants) return
const id = params.id
if (!id) return
if (!untrack(() => sync().data.session_diff[id] !== undefined)) return
diffFrame = requestAnimationFrame(() => {
diffFrame = undefined
diffTimer = window.setTimeout(() => {
diffTimer = undefined
if (sessionKey() !== key) return
void sync().session.diff(id, { force: true })
}, 0)
})
},
{ defer: true },
),
)
let treeDir: string | undefined
createEffect(() => {
const dir = sdk().directory
@@ -1757,7 +1723,7 @@ export default function Page() {
setFollowup("failed", input.sessionID, undefined)
const ok = await sendFollowupDraft({
client: sdk().client,
api: sdk().api.session,
sync: sync(),
serverSync: serverSync(),
draft: item,
@@ -1853,13 +1819,13 @@ export default function Page() {
const halt = (sessionID: string) =>
busy(sessionID)
? sdk()
.client.session.abort({ sessionID })
.api.session.interrupt({ sessionID })
.catch(() => {})
: Promise.resolve()
const revertMutation = useMutation(() => ({
mutationFn: async (input: { sessionID: string; messageID: string }) => {
const client = sdk().client
const session = sdk().api.session
const target = sync()
const last = target.session.get(input.sessionID)?.revert
const value = draft(input.messageID)
@@ -1869,10 +1835,8 @@ export default function Page() {
roll(input.sessionID, { messageID: input.messageID }, target)
prompt.set(value)
},
request: () => halt(input.sessionID).then(() => client.session.revert(input)),
complete: (result) => {
if (result.data) merge(result.data, target)
},
request: () => halt(input.sessionID).then(() => session.revert.stage(input)),
complete: () => undefined,
rollback: () => roll(input.sessionID, last, target),
fail,
})
@@ -1884,7 +1848,7 @@ export default function Page() {
const sessionID = params.id
if (!sessionID) return
const client = sdk().client
const session = sdk().api.session
const target = sync()
const next = userMessages().find((item) => item.id > id)
const last = target.session.get(sessionID)?.revert
@@ -1901,11 +1865,9 @@ export default function Page() {
},
request: () =>
!next
? halt(sessionID).then(() => client.session.unrevert({ sessionID }))
: halt(sessionID).then(() => client.session.revert({ sessionID, messageID: next.id })),
complete: (result) => {
if (result.data) merge(result.data, target)
},
? halt(sessionID).then(() => session.revert.clear({ sessionID }))
: halt(sessionID).then(() => session.revert.stage({ sessionID, messageID: next.id }).then(() => undefined)),
complete: () => undefined,
rollback: () => roll(sessionID, last, target),
fail,
})
@@ -283,6 +283,14 @@ export function MessageTimeline(props: {
return sync().data.session_status[id] ?? idle
})
const sessionMessages = createMemo(() => (sessionID() ? (sync().data.message[sessionID()!] ?? []) : []))
const projectedMessages = createMemo(() => {
const id = sessionID()
if (!id) return []
const visible = new Set(props.userMessages.map((message) => message.id))
const boundary = sessionMessages().find((message) => message.role === "user" && !visible.has(message.id))?.id
const messages = sync().data.session_message[id] ?? []
return boundary ? messages.filter((message) => message.id < boundary) : messages
})
const info = createMemo(() => {
const id = sessionID()
if (!id) return
@@ -324,7 +332,7 @@ export function MessageTimeline(props: {
const showHeader = createMemo(() => !!(titleValue() || parentID()))
const projection = createTimelineProjection({
messages: sessionMessages,
userMessages: () => props.userMessages,
sessionMessages: projectedMessages,
parts: getMsgParts,
status: sessionStatus,
showReasoningSummaries: settings.general.showReasoningSummaries,
@@ -664,8 +672,7 @@ export function MessageTimeline(props: {
}))
const titleMutation = useMutation(() => ({
mutationFn: (input: { id: string; title: string }) =>
sdk().client.session.update({ sessionID: input.id, title: input.title }),
mutationFn: (input: { id: string; title: string }) => sdk().api.session.rename({ sessionID: input.id, title: input.title }),
onSuccess: (_, input) => {
sync().set(
produce((draft) => {
@@ -809,7 +816,7 @@ export function MessageTimeline(props: {
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
await sdk()
.client.session.update({ sessionID, time: { archived: Date.now() } })
.api.session.archive({ sessionID })
.then(() => {
sync().set(
produce((draft) => {
@@ -838,8 +845,8 @@ export function MessageTimeline(props: {
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
const result = await sdk()
.client.session.delete({ sessionID })
.then((x) => x.data)
.api.session.remove({ sessionID })
.then(() => true)
.catch((err) => {
showToast({
title: language.t("session.delete.failed.title"),
@@ -1,16 +1,14 @@
import { Binary } from "@opencode-ai/core/util/binary"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import type { AssistantMessage, Message, Part, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2"
import { createMemo, mapArray, type Accessor } from "solid-js"
import { createMemo, type Accessor } from "solid-js"
import { reuseTimelineRows } from "./row-reconciliation"
import { Timeline, TimelineRow } from "./rows"
export { reuseTimelineRows } from "./row-reconciliation"
const emptyAssistantMessages: AssistantMessage[] = []
export function createTimelineProjection(input: {
messages: Accessor<Message[]>
userMessages: Accessor<UserMessage[]>
sessionMessages: Accessor<SessionMessageInfo[]>
parts: (messageID: string) => Part[]
status: Accessor<SessionStatus>
showReasoningSummaries: Accessor<boolean>
@@ -30,47 +28,19 @@ export function createTimelineProjection(input: {
})
return result
})
const activeMessageID = createMemo(() => {
const parentID = input
.messages()
.findLast(
(message): message is AssistantMessage =>
message.role === "assistant" && typeof message.time.completed !== "number",
)?.parentID
if (parentID) {
const messages = input.messages()
const result = Binary.search(messages, parentID, (message) => message.id)
const message = result.found ? messages[result.index] : messages.find((item) => item.id === parentID)
if (message?.role === "user") return message.id
}
if (input.status().type === "idle") return
return input.messages().findLast((message) => message.role === "user")?.id
})
const messageRowMemos = createMemo(
mapArray(input.userMessages, (userMessage, indexAccessor) =>
createMemo((previous: TimelineRow.TimelineRow[] | undefined) =>
reuseTimelineRows(
previous,
Timeline.constructMessageRows(
userMessage,
input.parts,
assistantMessagesByParent().get(userMessage.id) ?? emptyAssistantMessages,
indexAccessor(),
input.showReasoningSummaries(),
input.status().type,
activeMessageID() === userMessage.id,
input.inlineComments(),
),
),
),
const projection = createMemo(() =>
Timeline.constructSessionMessageRows(
input.sessionMessages(),
(messageID) => messageByID().get(messageID) as UserMessage | AssistantMessage | undefined,
input.parts,
input.showReasoningSummaries(),
input.status().type,
input.inlineComments(),
),
)
const activeMessageID = createMemo(() => projection().activeMessageID)
const rows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) =>
reuseTimelineRows(
previous,
messageRowMemos().flatMap((memo) => memo()),
),
reuseTimelineRows(previous, projection().rows),
)
const rowByKey = createMemo(() => new Map(rows().map((row) => [TimelineRow.key(row), row] as const)))
const messageRowIndex = createMemo(() => {
@@ -0,0 +1,92 @@
import { describe, expect, mock, test } from "bun:test"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { normalizeSessionMessages } from "@/utils/session-message"
mock.module("@opencode-ai/session-ui/message-part", () => ({
renderable: () => true,
groupParts: (refs: Array<{ messageID: string; part: { id: string } }>) =>
refs.map((ref) => ({
type: "part" as const,
key: ref.part.id,
ref: { messageID: ref.messageID, partID: ref.part.id },
})),
}))
const { Timeline, TimelineRow } = await import("./rows")
describe("current session timeline rows", () => {
test("derives turns and tagged rows from chronological current messages", () => {
const source = [
{ id: "msg_1", type: "user", text: "first", time: { created: 1 } },
{
id: "msg_2",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [{ type: "text", text: "answer" }],
time: { created: 2, completed: 3 },
},
{ id: "msg_3", type: "user", text: "second", time: { created: 4 } },
{
id: "msg_4",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [{ type: "reasoning", text: "working" }],
time: { created: 5 },
},
] satisfies SessionMessageInfo[]
const normalized = normalizeSessionMessages("ses_1", source)
const messages = new Map(normalized.messages.map((message) => [message.id, message]))
const result = Timeline.constructSessionMessageRows(
source,
(messageID) => messages.get(messageID),
(messageID) => normalized.parts.get(messageID) ?? [],
true,
"busy",
true,
)
expect(result.activeMessageID).toBe("msg_3")
expect(result.rows.map(TimelineRow.key)).toEqual([
"user-message:msg_1",
"assistant-part:msg_1:msg_2:text:0",
"turn-gap:msg_3",
"user-message:msg_3",
"assistant-part:msg_3:msg_4:reasoning:0",
])
})
test("renders a current shell message as a standalone turn", () => {
const source = [
{
id: "msg_shell",
type: "shell",
shellID: "shell_1",
command: "pwd",
status: "exited",
exit: 0,
output: { output: "/repo", cursor: 5, size: 5, truncated: false },
time: { created: 1, completed: 2 },
},
] satisfies SessionMessageInfo[]
const normalized = normalizeSessionMessages("ses_1", source)
const messages = new Map(normalized.messages.map((message) => [message.id, message]))
const result = Timeline.constructSessionMessageRows(
source,
(messageID) => messages.get(messageID),
(messageID) => normalized.parts.get(messageID) ?? [],
true,
"idle",
true,
)
expect(result.activeMessageID).toBe("msg_shell")
expect(result.rows.map(TimelineRow.key)).toEqual([
"user-message:msg_shell",
"assistant-part:msg_shell:msg_shell:tool",
])
})
})
@@ -1,4 +1,5 @@
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { AssistantMessage, Part, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2"
import { groupParts, renderable, type PartGroup } from "@opencode-ai/session-ui/message-part"
import { TimelineRow, type SummaryDiff } from "./timeline-row"
@@ -31,6 +32,47 @@ export type TimelineRowMap = {
}
export namespace Timeline {
export function constructSessionMessageRows(
messages: SessionMessageInfo[],
getMessage: (messageID: string) => UserMessage | AssistantMessage | undefined,
getMessageParts: (messageID: string) => Part[],
showReasoning: boolean,
status: SessionStatus["type"],
inlineComments: boolean,
) {
const turns = messages.reduce<{ user: UserMessage; assistants: AssistantMessage[] }[]>((result, message) => {
const projected = getMessage(message.id)
if (message.type === "shell" && projected?.role === "user") {
const assistant = getMessage(`${message.id}:assistant`)
result.push({ user: projected, assistants: assistant?.role === "assistant" ? [assistant] : [] })
return result
}
if (projected?.role === "user") {
result.push({ user: projected, assistants: [] })
return result
}
if (projected?.role !== "assistant") return result
result.at(-1)?.assistants.push(projected)
return result
}, [])
const activeMessageID = turns.at(-1)?.user.id
return {
activeMessageID,
rows: turns.flatMap((turn, index) =>
constructMessageRows(
turn.user,
getMessageParts,
turn.assistants,
index,
showReasoning,
status,
turn.user.id === activeMessageID,
inlineComments,
),
),
}
}
export function constructMessageRows(
userMessage: UserMessage,
getMessageParts: (messageID: string) => Part[],
@@ -0,0 +1,95 @@
import { describe, expect, test } from "bun:test"
import { createApiForServer, createSdkForServer } from "./server"
import { createCompatibleApi } from "./server-compat"
function setup(protocol: "v1" | "v2") {
const requests: Request[] = []
const fetcher = Object.assign(
async (input: string | URL | Request, init?: RequestInit) => {
const request = new Request(input, init)
requests.push(request)
if (request.method === "PATCH") {
return Response.json({
id: "ses_1",
slug: "ses_1",
projectID: "project",
directory: "/repo",
title: "Session",
version: "1",
time: { created: 1, updated: 1 },
})
}
if (request.method === "POST" && request.url.endsWith("/prompt_async"))
return new Response(undefined, { status: 204 })
if (request.method === "POST" && request.url.endsWith("/prompt")) {
return Response.json({
admittedSeq: 1,
id: "msg_1",
sessionID: "ses_1",
timeCreated: 1,
type: "user",
data: { text: "hello" },
delivery: "steer",
})
}
if (request.method === "GET") return Response.json([])
return new Response(undefined, { status: 204 })
},
{ preconnect: globalThis.fetch.preconnect },
)
const server = { url: "http://localhost:4096" }
const api = createCompatibleApi({
protocol: Promise.resolve(protocol),
current: createApiForServer({ server, fetch: fetcher }),
legacy: (directory) => createSdkForServer({ server, fetch: fetcher, directory, throwOnError: true }),
directory: "/repo",
})
return { api, requests }
}
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" })
const url = new URL(requests[0]!.url)
expect(url.pathname).toBe("/session/ses_1")
expect(requests[0]!.headers.get("x-opencode-directory")).toBe("%2Frepo")
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",
agent: "build",
model: { providerID: "provider", modelID: "model" },
})
expect(new URL(requests[0]!.url).pathname).toBe("/session/ses_1/prompt_async")
expect(await requests[0]!.json()).toMatchObject({
messageID: "msg_1",
agent: "build",
model: { providerID: "provider", modelID: "model" },
parts: [{ type: "text", text: "hello" }],
})
})
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")
})
})
+496
View File
@@ -0,0 +1,496 @@
import type { ServerApi } from "./server"
import type { ServerProtocol } from "./server-protocol"
import type { OpencodeClient, Session } from "@opencode-ai/sdk/v2/client"
import type {
Project,
ProjectCurrent,
SessionApi,
SessionCommandInput,
SessionCommandOutput,
SessionCompactInput,
SessionCompactOutput,
SessionInfo,
SessionPromptInput,
SessionPromptOutput,
SessionShellInput,
SessionShellOutput,
} from "@opencode-ai/client/promise"
type LegacyClient = OpencodeClient
type LegacyFor = (directory?: string) => LegacyClient
type CompatibleSessionApi = Omit<
SessionApi,
"prompt" | "command" | "shell" | "compact" | "rename" | "archive" | "remove"
> & {
prompt: (input: SessionPromptInput & LegacyPrompt) => Promise<SessionPromptOutput>
command: (input: SessionCommandInput) => Promise<SessionCommandOutput>
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"]>
remove: (input: Parameters<SessionApi["remove"]>[0] & LegacyLocation) => ReturnType<SessionApi["remove"]>
}
export type CompatibleApi = Omit<ServerApi, "session"> & { readonly session: CompatibleSessionApi }
type LegacyPrompt = {
agent?: string
model?: { providerID: string; modelID: string }
variant?: string
}
type LegacyLocation = { directory?: string }
function mime(uri: string) {
const match = /^data:([^;,]+)/.exec(uri)
return match?.[1] ?? "application/octet-stream"
}
function sessionInfo(session: Session): SessionInfo {
return {
id: session.id,
parentID: session.parentID,
projectID: session.projectID,
agent: session.agent,
model: session.model && {
id: session.model.id,
providerID: session.model.providerID,
variant: session.model.variant,
},
cost: session.cost ?? 0,
tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: session.time,
title: session.title,
location: { directory: session.directory, workspaceID: session.workspaceID },
subpath: session.path,
revert: session.revert && {
messageID: session.revert.messageID,
partID: session.revert.partID,
snapshot: session.revert.snapshot,
},
}
}
export function createCompatibleApi(input: {
protocol: Promise<ServerProtocol>
current: ServerApi
legacy: LegacyFor
directory?: string
}): CompatibleApi {
const directory = (location?: { directory?: string }) => location?.directory ?? input.directory
const legacy = (location?: { directory?: string }) => input.legacy(directory(location))
const isV1 = () => input.protocol.then((protocol) => protocol === "v1")
const located = <T>(data: T, value?: { directory?: string }) => ({
location: {
directory: directory(value) ?? "",
project: { id: "", directory: directory(value) ?? "" },
},
data,
})
return {
...input.current,
session: {
...input.current.session,
async list(
value?: Parameters<ServerApi["session"]["list"]>[0],
options?: Parameters<ServerApi["session"]["list"]>[1],
) {
if (!(await isV1())) return input.current.session.list(value, options)
if (!value?.directory && value?.search !== undefined) {
const result = await legacy().experimental.session.list(
{
roots: value.parentID === null ? true : undefined,
search: value.search,
limit: value.limit,
},
options,
)
return { data: (result.data ?? []).map(sessionInfo), cursor: {} }
}
const result = await legacy({ directory: value?.directory }).session.list({
directory: value?.directory,
roots: value?.parentID === null ? true : undefined,
search: value?.search,
limit: value?.limit,
})
return { data: (result.data ?? []).map(sessionInfo), cursor: {} }
},
async create(value?: Parameters<ServerApi["session"]["create"]>[0]) {
if (!(await isV1())) return input.current.session.create(value)
const result = await legacy(value?.location ?? undefined).session.create({
directory: directory(value?.location ?? undefined),
})
if (!result.data) throw new Error("Failed to create session")
return sessionInfo(result.data)
},
async get(value: Parameters<ServerApi["session"]["get"]>[0]) {
if (!(await isV1())) return input.current.session.get(value)
const result = await legacy().session.get(value)
if (!result.data) throw new Error(`Session not found: ${value.sessionID}`)
return sessionInfo(result.data)
},
async active() {
if (!(await isV1())) return input.current.session.active()
const result = await legacy().session.status()
return Object.fromEntries(
Object.entries(result.data ?? {}).flatMap(([sessionID, status]) =>
status.type === "idle" ? [] : [[sessionID, { type: "running" as const }]],
),
)
},
async rename(value: Parameters<ServerApi["session"]["rename"]>[0] & LegacyLocation) {
if (!(await isV1())) return input.current.session.rename(value)
await legacy(value).session.update({ sessionID: value.sessionID, title: value.title })
},
async archive(value: Parameters<ServerApi["session"]["archive"]>[0] & LegacyLocation) {
if (!(await isV1())) return input.current.session.archive(value)
await legacy(value).session.update({ sessionID: value.sessionID, time: { archived: Date.now() } })
},
async remove(value: Parameters<ServerApi["session"]["remove"]>[0] & LegacyLocation) {
if (!(await isV1())) return input.current.session.remove(value)
await legacy(value).session.delete(value)
},
async fork(value: Parameters<ServerApi["session"]["fork"]>[0]) {
if (!(await isV1())) return input.current.session.fork(value)
const result = await legacy().session.fork(value)
if (!result.data) throw new Error("Failed to fork session")
return sessionInfo(result.data)
},
async interrupt(value: Parameters<ServerApi["session"]["interrupt"]>[0]) {
if (!(await isV1())) return input.current.session.interrupt(value)
await legacy().session.abort(value)
},
async prompt(value: SessionPromptInput & LegacyPrompt) {
if (!(await isV1())) return input.current.session.prompt(value)
await legacy().session.promptAsync({
sessionID: value.sessionID,
messageID: value.id ?? undefined,
agent: value.agent,
model: value.model,
variant: value.variant,
parts: [
{ type: "text", text: value.text },
...(value.files ?? []).map((file) => ({
type: "file" as const,
mime: mime(file.uri),
url: file.uri,
filename: file.name,
})),
...(value.agents ?? []).map((agent) => ({
type: "agent" as const,
name: agent.name,
source: agent.mention
? { value: agent.mention.text, start: agent.mention.start, end: agent.mention.end }
: undefined,
})),
],
})
return {
admittedSeq: 0,
id: value.id ?? "",
sessionID: value.sessionID,
timeCreated: Date.now(),
type: "user",
data: { text: value.text },
delivery: value.delivery ?? "steer",
}
},
async command(value: SessionCommandInput) {
if (!(await isV1())) return input.current.session.command(value)
await legacy().session.command({
sessionID: value.sessionID,
messageID: value.id ?? undefined,
command: value.command,
arguments: value.arguments ?? "",
agent: value.agent ?? undefined,
model: value.model ? `${value.model.providerID}/${value.model.id}` : undefined,
variant: value.model?.variant,
parts: value.files?.map((file) => ({
type: "file" as const,
mime: mime(file.uri),
url: file.uri,
filename: file.name,
})),
})
return {
admittedSeq: 0,
id: value.id ?? "",
sessionID: value.sessionID,
timeCreated: Date.now(),
type: "user",
data: { text: `/${value.command} ${value.arguments ?? ""}`.trim() },
delivery: value.delivery ?? "steer",
}
},
async shell(value: SessionShellInput & LegacyPrompt) {
if (!(await isV1())) return input.current.session.shell(value)
await legacy().session.shell({
sessionID: value.sessionID,
command: value.command,
agent: value.agent,
model: value.model,
})
},
compact: async (value: SessionCompactInput & { model?: LegacyPrompt["model"] }) => {
if (!(await isV1())) return input.current.session.compact(value)
if (!value.model) throw new Error("A model is required to compact a V1 session")
await legacy().session.summarize({
sessionID: value.sessionID,
providerID: value.model.providerID,
modelID: value.model.modelID,
})
return {
admittedSeq: 0,
id: value.id ?? "",
sessionID: value.sessionID,
timeCreated: Date.now(),
type: "compaction",
}
},
revert: {
stage: async (value: Parameters<ServerApi["session"]["revert"]["stage"]>[0]) => {
if (!(await isV1())) return input.current.session.revert.stage(value)
await legacy().session.revert(value)
return { messageID: value.messageID }
},
clear: async (value: Parameters<ServerApi["session"]["revert"]["clear"]>[0]) => {
if (!(await isV1())) return input.current.session.revert.clear(value)
await legacy().session.unrevert(value)
},
commit: input.current.session.revert.commit,
},
},
project: {
...input.current.project,
async list() {
if (!(await isV1())) return input.current.project.list()
return ((await legacy().project.list()).data ?? []) as Project[]
},
async current(value?: Parameters<ServerApi["project"]["current"]>[0]) {
if (!(await isV1())) return input.current.project.current(value)
const result = await legacy(value?.location).project.current()
if (!result.data) throw new Error("Project not found")
return { id: result.data.id, directory: result.data.worktree } satisfies ProjectCurrent
},
async update(value: Parameters<ServerApi["project"]["update"]>[0]) {
if (!(await isV1())) return input.current.project.update(value)
const project = (await legacy().project.list()).data?.find((item) => item.id === value.projectID)
const result = await legacy({ directory: project?.worktree }).project.update({
...value,
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]) {
if (!(await isV1())) return input.current.project.directories(value)
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]) {
if (!(await isV1())) return input.current.path.get(value)
const result = await legacy(value?.location).path.get()
if (!result.data) throw new Error("Path unavailable")
return result.data
},
},
vcs: {
...input.current.vcs,
async get(value?: Parameters<ServerApi["vcs"]["get"]>[0]) {
if (!(await isV1())) return input.current.vcs.get(value)
const result = await legacy(value?.location).vcs.get()
return located({ branch: result.data?.branch, defaultBranch: undefined }, value?.location)
},
async status(value?: Parameters<ServerApi["vcs"]["status"]>[0]) {
if (!(await isV1())) return input.current.vcs.status(value)
const result = await legacy(value?.location).vcs.status()
return located(result.data ?? [], value?.location)
},
async diff(value: Parameters<ServerApi["vcs"]["diff"]>[0]) {
if (!(await isV1())) return input.current.vcs.diff(value)
const result = await legacy(value.location).vcs.diff({
mode: value.mode === "working" ? "git" : value.mode,
context: value.context,
})
return located(
(result.data ?? []).map((file) => ({
file: file.file,
patch: file.patch ?? "",
additions: file.additions,
deletions: file.deletions,
status: file.status ?? "modified",
})),
value.location,
)
},
},
file: {
...input.current.file,
async list(value?: Parameters<ServerApi["file"]["list"]>[0]) {
if (!(await isV1())) return input.current.file.list(value)
const result = await legacy(value?.location).file.list({ path: value?.path ?? "" })
return located(result.data ?? [], value?.location)
},
async find(value: Parameters<ServerApi["file"]["find"]>[0]) {
if (!(await isV1())) return input.current.file.find(value)
const result = await legacy(value.location).find.files({
query: value.query,
type: value.type,
limit: value.limit,
})
return located(
(result.data ?? []).map((path) => ({ path, type: value.type ?? "file" })),
value.location,
)
},
},
integration: {
...input.current.integration,
async get(value: Parameters<ServerApi["integration"]["get"]>[0]) {
if (!(await isV1())) return input.current.integration.get(value)
const methods = ((await legacy(value.location).provider.auth()).data?.[value.integrationID] ?? []).map(
(method, index) =>
method.type === "api"
? { type: "key" as const, label: method.label }
: { type: "oauth" as const, id: String(index), label: method.label, prompts: method.prompts },
)
return located(
{
id: value.integrationID,
name: value.integrationID,
methods,
connections: [],
},
value.location,
)
},
connect: {
...input.current.integration.connect,
key: async (value: Parameters<ServerApi["integration"]["connect"]["key"]>[0]) => {
if (!(await isV1())) return input.current.integration.connect.key(value)
await legacy(value.location).auth.set({
providerID: value.integrationID,
auth: { type: "api", key: value.key },
})
},
},
oauth: {
...input.current.integration.oauth,
connect: async (value: Parameters<ServerApi["integration"]["oauth"]["connect"]>[0]) => {
if (!(await isV1())) return input.current.integration.oauth.connect(value)
const method = Number(value.methodID)
const result = await legacy(value.location).provider.oauth.authorize(
{ providerID: value.integrationID, method, inputs: value.inputs },
{ throwOnError: true },
)
if (!result.data) throw new Error("Failed to start OAuth authorization")
return located(
{
attemptID: `${value.integrationID}:${method}`,
url: result.data.url,
instructions: result.data.instructions,
mode: result.data.method,
time: { created: Date.now(), expires: Date.now() + 10 * 60 * 1000 },
},
value.location,
)
},
complete: async (value: Parameters<ServerApi["integration"]["oauth"]["complete"]>[0]) => {
if (!(await isV1())) return input.current.integration.oauth.complete(value)
const method = Number(value.attemptID.split(":").at(-1))
await legacy(value.location).provider.oauth.callback(
{ providerID: value.integrationID, method, code: value.code },
{ throwOnError: true },
)
},
status: async (value: Parameters<ServerApi["integration"]["oauth"]["status"]>[0]) => {
if (!(await isV1())) return input.current.integration.oauth.status(value)
const method = Number(value.attemptID.split(":").at(-1))
await legacy(value.location).provider.oauth.callback(
{ providerID: value.integrationID, method },
{ throwOnError: true },
)
return located(
{ status: "complete" as const, time: { created: Date.now(), expires: Date.now() } },
value.location,
)
},
},
},
pty: {
...input.current.pty,
async shells(value?: Parameters<ServerApi["pty"]["shells"]>[0]) {
if (!(await isV1())) return input.current.pty.shells(value)
return located((await legacy(value?.location).pty.shells()).data ?? [], value?.location)
},
async list(value?: Parameters<ServerApi["pty"]["list"]>[0]) {
if (!(await isV1())) return input.current.pty.list(value)
return located((await legacy(value?.location).pty.list()).data ?? [], value?.location)
},
async create(value?: Parameters<ServerApi["pty"]["create"]>[0]) {
if (!(await isV1())) return input.current.pty.create(value)
const result = await legacy(value?.location).pty.create({
command: value?.command,
args: value?.args ? [...value.args] : undefined,
cwd: value?.cwd,
title: value?.title,
env: value?.env,
})
if (!result.data) throw new Error("Failed to create terminal")
return located(result.data, value?.location)
},
async get(value: Parameters<ServerApi["pty"]["get"]>[0]) {
if (!(await isV1())) return input.current.pty.get(value)
const result = await legacy(value.location).pty.get({ ptyID: value.ptyID })
if (!result.data) throw new Error(`Terminal not found: ${value.ptyID}`)
return located(result.data, value.location)
},
async update(value: Parameters<ServerApi["pty"]["update"]>[0]) {
if (!(await isV1())) return input.current.pty.update(value)
const result = await legacy(value.location).pty.update({
ptyID: value.ptyID,
title: value.title,
size: value.size,
})
if (!result.data) throw new Error(`Terminal not found: ${value.ptyID}`)
return located(result.data, value.location)
},
async remove(value: Parameters<ServerApi["pty"]["remove"]>[0]) {
if (!(await isV1())) return input.current.pty.remove(value)
await legacy(value.location).pty.remove({ ptyID: value.ptyID })
},
async connectToken(value: Parameters<ServerApi["pty"]["connectToken"]>[0]) {
if (!(await isV1())) return input.current.pty.connectToken(value)
const result = await legacy(value.location).pty.connectToken({ ptyID: value.ptyID })
if (!result.data) throw new Error(`Failed to connect terminal: ${value.ptyID}`)
return located(result.data, value.location)
},
},
permission: {
...input.current.permission,
async reply(value: Parameters<ServerApi["permission"]["reply"]>[0]) {
if (!(await isV1())) return input.current.permission.reply(value)
await legacy().permission.respond({
sessionID: value.sessionID,
permissionID: value.requestID,
response: value.reply,
})
},
},
question: {
...input.current.question,
async reply(value: Parameters<ServerApi["question"]["reply"]>[0]) {
if (!(await isV1())) return input.current.question.reply(value)
await legacy().question.reply({
requestID: value.requestID,
answers: value.answers.map((answer) => [...answer]),
})
},
async reject(value: Parameters<ServerApi["question"]["reject"]>[0]) {
if (!(await isV1())) return input.current.question.reject(value)
await legacy().question.reject({ requestID: value.requestID })
},
},
}
}
+21 -4
View File
@@ -14,15 +14,32 @@ function abortFromInput(input: RequestInfo | URL, init?: RequestInit) {
describe("checkServerHealth", () => {
test("returns healthy response with version", async () => {
const fetch = (async () =>
new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), {
let request: URL | undefined
const fetch = (async (input: RequestInfo | URL) => {
request = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input)
return new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), {
status: 200,
headers: { "content-type": "application/json" },
})) as unknown as typeof globalThis.fetch
})
}) as unknown as typeof globalThis.fetch
const result = await checkServerHealth(server, fetch)
expect(result).toEqual({ healthy: true, version: "1.2.3" })
expect(request?.pathname).toBe("/api/health")
})
test("falls back to the V1 health endpoint", async () => {
const paths: string[] = []
const fetch = (async (input: RequestInfo | URL) => {
const url = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input)
paths.push(url.pathname)
if (url.pathname === "/api/health") return new Response(undefined, { status: 404 })
return Response.json({ healthy: true, version: "1.18.4" })
}) as unknown as typeof globalThis.fetch
expect(await checkServerHealth(server, fetch)).toEqual({ healthy: true, version: "1.18.4" })
expect(paths).toEqual(["/api/health", "/global/health"])
})
test("allows slow servers thirty seconds by default", async () => {
@@ -142,7 +159,7 @@ describe("checkServerHealth", () => {
retryDelayMs: 1,
})
expect(count).toBe(3)
expect(count).toBe(6)
expect(result).toEqual({ healthy: false })
})
})
+19 -5
View File
@@ -1,6 +1,7 @@
import { usePlatform } from "@/context/platform"
import { ServerConnection } from "@/context/server"
import { createSdkForServer } from "./server"
import { authTokenFromCredentials, createSdkForServer } from "./server"
import { ClientError, OpenCode } from "@opencode-ai/client"
import { Accessor, createEffect, onCleanup } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
@@ -61,6 +62,7 @@ function wait(ms: number, signal?: AbortSignal) {
function retryable(error: unknown, signal?: AbortSignal) {
if (signal?.aborted) return false
if (error instanceof ClientError) return error.reason === "Transport"
if (!(error instanceof Error)) return false
if (error.name === "AbortError" || error.name === "TimeoutError") return false
if (error instanceof TypeError) return true
@@ -82,15 +84,27 @@ export async function checkServerHealth(
.then(() => attempt(count + 1))
.catch(() => ({ healthy: false }))
}
const attempt = (count: number): Promise<ServerHealth> =>
createSdkForServer({
server,
const attempt = async (count: number): Promise<ServerHealth> => {
const current = await OpenCode.make({
baseUrl: server.url,
fetch,
signal,
headers: server.password
? {
Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`,
}
: undefined,
})
.health.get({ signal })
.then((x) => ({ data: { healthy: x.healthy === true, version: x.version } }))
.catch((error) => ({ error }))
if ("data" in current) return current.data
if (signal?.aborted) return { healthy: false }
return createSdkForServer({ server, fetch, signal })
.global.health()
.then((x) => (x.error ? next(count, x.error) : { healthy: x.data?.healthy === true, version: x.data?.version }))
.catch((error) => next(count, error))
}
return attempt(0).finally(() => timeout?.clear?.())
}
@@ -0,0 +1,40 @@
import { describe, expect, test } from "bun:test"
import { detectServerProtocol } from "./server-protocol"
const server = { url: "http://localhost:4096" }
const json = (value: unknown, status = 200) =>
new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } })
const mockFetch = (run: (input: string | URL | Request) => Promise<Response>) =>
Object.assign(run, { preconnect: globalThis.fetch.preconnect })
describe("detectServerProtocol", () => {
test("prefers the legacy health endpoint when both API generations exist", async () => {
const fetcher = mockFetch((input) => {
const path = new URL(input instanceof Request ? input.url : input).pathname
if (path === "/global/health") return Promise.resolve(json({ healthy: true, version: "1.18.4" }))
return Promise.resolve(json({ healthy: true, version: "2.0.0", pid: 123 }))
})
expect(await detectServerProtocol(server, fetcher)).toBe("v1")
})
test("recognizes V2 health by its process identifier", async () => {
const fetcher = mockFetch((input) => {
const path = new URL(input instanceof Request ? input.url : input).pathname
if (path === "/global/health") return Promise.resolve(json({}, 404))
return Promise.resolve(json({ healthy: true, version: "2.0.0", pid: 123 }))
})
expect(await detectServerProtocol(server, fetcher)).toBe("v2")
})
test("recognizes the transitional V1 API health response", async () => {
const fetcher = mockFetch((input) => {
const path = new URL(input instanceof Request ? input.url : input).pathname
if (path === "/global/health") return Promise.resolve(json({}, 404))
return Promise.resolve(json({ healthy: true }))
})
expect(await detectServerProtocol(server, fetcher)).toBe("v1")
})
})
+35
View File
@@ -0,0 +1,35 @@
import type { ServerConnection } from "@/context/server"
import { authTokenFromCredentials } from "./server"
export type ServerProtocol = "v1" | "v2"
function headers(server: ServerConnection.HttpBase) {
if (!server.password) return
return {
Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`,
}
}
async function probe(server: ServerConnection.HttpBase, fetch: typeof globalThis.fetch, path: string) {
const response = await fetch(new URL(path, server.url), {
headers: headers(server),
signal: AbortSignal.timeout(5_000),
})
if (!response.ok || !response.headers.get("content-type")?.includes("application/json")) return
const value: unknown = await response.json()
if (!value || typeof value !== "object") return
return value
}
export async function detectServerProtocol(
server: ServerConnection.HttpBase,
fetch: typeof globalThis.fetch,
): Promise<ServerProtocol> {
const legacy = await probe(server, fetch, "/global/health").catch(() => undefined)
if (legacy && "healthy" in legacy && legacy.healthy === true) return "v1"
const current = await probe(server, fetch, "/api/health").catch(() => undefined)
if (current && "pid" in current && typeof current.pid === "number") return "v2"
if (current && "healthy" in current && current.healthy === true) return "v1"
return "v2"
}
+55
View File
@@ -1,4 +1,23 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { OpenCode } from "@opencode-ai/client/promise"
import type {
AgentApi,
CatalogApi,
CommandApi,
EventApi,
FileApi,
IntegrationApi,
McpApi,
MessageApi,
PathApi,
PermissionApi,
ProjectApi,
PtyApi,
QuestionApi,
ReferenceApi,
SessionApi,
VcsApi,
} from "@opencode-ai/client/promise"
import type { ServerConnection } from "@/context/server"
import { decode64 } from "@/utils/base64"
@@ -39,3 +58,39 @@ export function createSdkForServer({
baseUrl: server.url,
})
}
export interface ServerApi extends CatalogApi {
readonly agent: AgentApi
readonly command: CommandApi
readonly event: EventApi
readonly file: FileApi
readonly integration: IntegrationApi
readonly mcp: McpApi
readonly message: MessageApi
readonly path: PathApi
readonly permission: PermissionApi
readonly project: ProjectApi
readonly pty: PtyApi
readonly question: QuestionApi
readonly reference: ReferenceApi
readonly session: SessionApi
readonly vcs: VcsApi
}
export function createApiForServer(input: {
server: ServerConnection.HttpBase
fetch?: typeof globalThis.fetch
}): ServerApi {
return OpenCode.make({
baseUrl: input.server.url,
fetch: input.fetch,
headers: input.server.password
? {
Authorization: `Basic ${authTokenFromCredentials({
username: input.server.username,
password: input.server.password,
})}`,
}
: undefined,
})
}
@@ -0,0 +1,200 @@
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 this",
files: [
{
data: "aGVsbG8=",
mime: "text/plain",
name: "note.txt",
source: { type: "inline" },
},
],
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" },
structured: { 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:agent:0",
"msg_5:compaction",
])
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" }],
structured: {
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,
},
}),
}),
}),
])
})
})
+348
View File
@@ -0,0 +1,348 @@
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}`,
}),
),
...(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),
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),
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,
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),
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 },
}
}
+94
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
}
+37
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()
}
+12 -11
View File
@@ -182,9 +182,9 @@ export async function spawnLocalServer(
}
export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
let healthUrl: URL
let healthUrls: URL[]
try {
healthUrl = new URL("/global/health", url)
healthUrls = [new URL("/api/health", url), new URL("/global/health", url)]
} catch {
return false
}
@@ -195,16 +195,17 @@ export async function checkHealth(url: string, password?: string | null): Promis
headers.set("authorization", `Basic ${auth}`)
}
try {
const res = await fetch(healthUrl, {
method: "GET",
headers,
signal: AbortSignal.timeout(3000),
})
return res.ok
} catch {
return false
for (const healthUrl of healthUrls) {
try {
const res = await fetch(healthUrl, {
method: "GET",
headers,
signal: AbortSignal.timeout(3000),
})
if (res.ok) return true
} catch {}
}
return false
}
function createSidecarEnv(): Record<string, string> {
@@ -521,6 +521,7 @@ export function getToolInfo(
}
}
case "bash":
case "shell":
return {
icon: "console",
title: i18n.t("ui.tool.shell"),
@@ -538,6 +539,7 @@ export function getToolInfo(
title: i18n.t("ui.messagePart.title.write"),
subtitle: input.filePath ? getFilename(input.filePath) : undefined,
}
case "patch":
case "apply_patch":
return {
icon: "code-lines",
@@ -729,8 +731,8 @@ export function renderable(part: PartType, showReasoningSummaries = true) {
}
function toolDefaultOpen(tool: string, shell = false, edit = false) {
if (tool === "bash") return shell
if (tool === "edit" || tool === "write" || tool === "apply_patch") return edit
if (tool === "bash" || tool === "shell") return shell
if (tool === "edit" || tool === "write" || tool === "patch" || tool === "apply_patch") return edit
}
export function partDefaultOpen(part: PartType, shell = false, edit = false) {
@@ -1506,7 +1508,7 @@ export function registerTool(input: { name: string; render?: ToolComponent }) {
}
export function getTool(name: string) {
return state[name]?.render
return state[name === "apply_patch" ? "patch" : name === "bash" ? "shell" : name]?.render
}
export const ToolRegistry = {
@@ -2101,7 +2103,7 @@ ToolRegistry.register({
})
ToolRegistry.register({
name: "bash",
name: "shell",
render(props) {
const i18n = useI18n()
const pending = () => props.status === "pending" || props.status === "running"
@@ -2337,7 +2339,7 @@ ToolRegistry.register({
})
ToolRegistry.register({
name: "apply_patch",
name: "patch",
render(props) {
const i18n = useI18n()
const fileComponent = useFileComponent()
@@ -51,6 +51,8 @@ export function ToolErrorCard(props: ToolErrorCardProps) {
webfetch: "ui.tool.webfetch",
websearch: "ui.tool.websearch",
bash: "ui.tool.shell",
shell: "ui.tool.shell",
patch: "ui.tool.patch",
apply_patch: "ui.tool.patch",
question: "ui.tool.questions",
}