chore: merge dev into v2 (#39290)

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

View file

@ -1,14 +1,41 @@
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, 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"
type ProjectApi = ServerApi["project"]
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>({
@ -30,6 +57,7 @@ function directoryState() {
return this.session_status[id]?.type !== "idle"
},
session_diff: {},
todo: {},
permission: {},
question: {},
mcp_ready: true,
@ -40,20 +68,21 @@ function directoryState() {
vcs: undefined,
limit: 5,
message: {},
session_message: {},
part: {},
part_text_accum_delta: {},
})
}
describe("bootstrapDirectory", () => {
test("marks a loading directory partial during bootstrap and complete after success", async () => {
test("uses legacy MCP endpoints while refreshing a v1 directory", async () => {
const mcpReads: string[] = []
const [store, setStore] = directoryState()
await bootstrapDirectory({
directory: "/project",
scope: ServerScope.local,
mcp: false,
mcp: true,
global: {
config: {} satisfies Config,
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
@ -80,14 +109,24 @@ describe("bootstrapDirectory", () => {
return { data: {} }
},
},
experimental: {
resource: {
list: async () => {
mcpReads.push("resource")
return { data: {} }
},
},
},
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
} as unknown as OpencodeClient,
api,
store,
setStore,
vcsCache: { setStore() {} } as unknown as VcsCache,
loadSessions() {},
translate: (key) => key,
queryClient: new QueryClient(),
protocol: Promise.resolve("v1"),
})
expect(store.status).toBe("partial")
@ -95,81 +134,110 @@ describe("bootstrapDirectory", () => {
await new Promise((resolve) => setTimeout(resolve, 80))
expect(store.status).toBe("complete")
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()
expect(mcpReads.sort()).toEqual(["command", "resource", "status"])
})
})
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)
})
})

View file

@ -9,6 +9,20 @@ import type {
ReferenceInfo,
Session,
} from "@opencode-ai/sdk/v2/client"
import type {
AgentListInput,
AgentListOutput,
CatalogApi,
CommandInfo,
CommandListInput,
CommandListOutput,
ProjectCurrentInput,
ProjectCurrentOutput,
ProjectListOutput,
ReferenceListInput,
ReferenceListOutput,
SessionApi,
} 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 +30,21 @@ 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"
import type { ServerApi } from "@/utils/server"
type GlobalStore = {
ready: boolean
@ -88,15 +111,26 @@ 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 McpApi = ServerApi["mcp"]
type PermissionApi = ServerApi["permission"]
type QuestionApi = ServerApi["question"]
type VcsApi = ServerApi["vcs"]
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 +139,8 @@ export const loadProjectsQuery = (scope: ServerScope, sdk: OpencodeClient) =>
export async function bootstrapGlobal(input: {
serverSDK: OpencodeClient
serverAPI: CatalogApi & { readonly project: ProjectApi }
protocol?: Promise<ServerProtocol>
scope: ServerScope
requestFailedTitle: string
translate: (key: string, vars?: Record<string, string | number>) => string
@ -114,11 +150,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.serverSDK, input.protocol)),
() =>
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 +201,126 @@ 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,
sdk: OpencodeClient,
protocol?: Promise<ServerProtocol>,
) =>
queryOptions<Path>({
queryKey: [scope, directory, "path"],
queryFn: () => retry(() => sdk.path.get().then((x) => x.data!)),
queryFn: async () => {
if ((await protocol) !== "v1")
return { state: "", config: "", worktree: "", directory: directory ?? "", home: "" }
return retry(() => sdk.path.get({ directory: directory ?? undefined }).then((result) => result.data!))
},
})
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 +329,17 @@ export async function bootstrapDirectory(input: {
scope: ServerScope
mcp: boolean
sdk: OpencodeClient
api: CatalogApi & {
readonly agent: AgentListApi
readonly command: CommandListApi
readonly mcp: McpApi
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 +353,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,13 +373,15 @@ 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) => {
(async () => {
if ((await input.protocol) !== "v1") return
const x = await input.sdk.session.status()
if (!input.session) {
input.setStore("session_status", x.data!)
return
@ -264,42 +399,57 @@ export async function bootstrapDirectory(input: {
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.sdk, input.protocol))
.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
retry(async () => {
if ((await input.protocol) !== "v1") return
return input.sdk.vcs.get().then((result) => {
const next = { branch: result.data?.branch, default_branch: result.data?.default_branch }
input.setStore("vcs", next)
if (next) input.vcsCache.setStore("value", next)
}),
})
}),
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),
),
input.mcp && (() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])))),
() => input.queryClient.fetchQuery(loadReferencesQuery(input.scope, input.directory, input.sdk)),
() =>
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 +473,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 +508,27 @@ 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.sdk, input.protocol),
)),
input.mcp &&
(() =>
input.queryClient.fetchQuery(
loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp, input.sdk, input.protocol),
)),
() =>
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()

View file

@ -221,4 +221,56 @@ describe("createChildStoreManager", () => {
dispose()
}
})
test("keeps non-bootstrapping children passive until a real directory access", () => {
let manager: ReturnType<typeof createChildStoreManager> | undefined
const offset = querySingles.length
const bootstraps: string[] = []
const dispose = createOwner((owner) => {
manager = createChildStoreManager({
owner,
scope: ServerScope.local,
persist,
isBooting: () => false,
isLoadingSessions: () => false,
onBootstrap(directory) {
bootstraps.push(directory)
},
onMcp() {},
onDispose() {},
translate: (key) => key,
queryOptions: queryOptionsApi,
global: { provider },
})
})
try {
if (!manager) throw new Error("manager required")
const [store] = manager.child("/project", { bootstrap: false })
const queries = querySingles.slice(offset)
expect(queries).toHaveLength(6)
expect(queries[0]?.().enabled).toBe(false)
expect(queries[3]?.().enabled).toBe(false)
expect(queries[4]?.().enabled).toBe(false)
expect(queries[5]?.().enabled).toBe(false)
expect(store.path.directory).toBe("/project")
expect(store.provider_ready).toBe(false)
expect(store.lsp_ready).toBe(false)
expect(bootstraps).toEqual([])
manager.child("/project")
expect(queries[0]?.().enabled).toBe(true)
expect(queries[3]?.().enabled).toBe(true)
expect(queries[4]?.().enabled).toBe(true)
expect(queries[5]?.().enabled).toBe(true)
expect(bootstraps).toEqual(["/project"])
manager.child("/project", { bootstrap: false })
expect(queries[0]?.().enabled).toBe(true)
} finally {
dispose()
}
})
})

View file

@ -45,6 +45,8 @@ export function createChildStoreManager(input: {
const disposers = new Map<string, () => void>()
const mcpDirectories = new Set<string>()
const mcpToggles = new Map<string, (enabled: boolean) => void>()
const activeDirectories = new Set<string>()
const activationToggles = new Map<string, (enabled: boolean) => void>()
const markKey = (key: DirectoryKey) => {
if (!key) return
@ -118,6 +120,8 @@ export function createChildStoreManager(input: {
lifecycle.delete(key)
mcpDirectories.delete(key)
mcpToggles.delete(key)
activeDirectories.delete(key)
activationToggles.delete(key)
const dispose = disposers.get(key)
if (dispose) {
dispose()
@ -182,20 +186,27 @@ export function createChildStoreManager(input: {
const initialMeta = meta[0].value
const initialIcon = icon[0].value
const [mcpEnabled, setMcpEnabled] = createSignal(false)
const [instanceQueriesEnabled, setInstanceQueriesEnabled] = createSignal(false)
const pathQuery = useQuery(() => input.queryOptions.path(key))
const pathQuery = useQuery(() => ({ ...input.queryOptions.path(key), enabled: instanceQueriesEnabled() }))
const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() }))
const mcpResourceQuery = useQuery(() => ({ ...input.queryOptions.mcpResources(key), enabled: mcpEnabled() }))
const lspQuery = useQuery(() => input.queryOptions.lsp(key))
const providerQuery = useQuery(() => input.queryOptions.providers(key))
const referenceQuery = useQuery(() => input.queryOptions.references(key))
const lspQuery = useQuery(() => ({ ...input.queryOptions.lsp(key), enabled: instanceQueriesEnabled() }))
const providerQuery = useQuery(() => ({
...input.queryOptions.providers(key),
enabled: instanceQueriesEnabled(),
}))
const referenceQuery = useQuery(() => ({
...input.queryOptions.references(key),
enabled: instanceQueriesEnabled(),
}))
const child = createStore<State>({
project: "",
projectMeta: initialMeta,
icon: initialIcon,
get provider_ready() {
return !providerQuery.isLoading
return instanceQueriesEnabled() && !providerQuery.isLoading
},
get provider() {
const EMPTY = { all: new Map(), connected: [], default: {} }
@ -223,6 +234,7 @@ export function createChildStoreManager(input: {
return (type ?? "idle") !== "idle"
},
session_diff: {},
todo: {},
permission: {},
question: {},
get mcp_ready() {
@ -235,7 +247,7 @@ export function createChildStoreManager(input: {
return mcpResourceQuery.isLoading ? {} : (mcpResourceQuery.data ?? {})
},
get lsp_ready() {
return !lspQuery.isLoading
return instanceQueriesEnabled() && !lspQuery.isLoading
},
get lsp() {
return lspQuery.isLoading ? [] : (lspQuery.data ?? [])
@ -243,12 +255,14 @@ export function createChildStoreManager(input: {
vcs: vcsStore.value,
limit: 5,
message: {},
session_message: {},
part: {},
part_text_accum_delta: {},
})
children[key] = child
disposers.set(key, dispose)
mcpToggles.set(key, setMcpEnabled)
activationToggles.set(key, setInstanceQueriesEnabled)
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
if (!(init instanceof Promise)) return
@ -289,6 +303,7 @@ export function createChildStoreManager(input: {
pinForOwner(key)
if (options.mcp) enableMcp(directory, key, childStore)
const shouldBootstrap = options.bootstrap ?? true
if (shouldBootstrap) activate(key)
if (shouldBootstrap && childStore[0].status === "loading") {
input.onBootstrap(directory)
}
@ -300,6 +315,7 @@ export function createChildStoreManager(input: {
const childStore = ensureChild(directory)
if (options.mcp) enableMcp(directory, key, childStore)
const shouldBootstrap = options.bootstrap ?? true
if (shouldBootstrap) activate(key)
if (shouldBootstrap && childStore[0].status === "loading") {
input.onBootstrap(directory)
}
@ -313,6 +329,16 @@ export function createChildStoreManager(input: {
if (childStore[0].status !== "loading") input.onMcp(directory, childStore[1])
}
// Passive Home/project metadata reads must not initialize the directory.
// A real directory access enables these queries once for the store lifetime.
// TODO(v2): After Home switches to v2.project.list and root-filtered,
// updated-time v2.session.list, remove any Home-only passive child creation.
function activate(key: DirectoryKey) {
if (activeDirectories.has(key)) return
activeDirectories.add(key)
activationToggles.get(key)?.(true)
}
function disableMcp(directory: string) {
const key = directoryKey(directory)
if (!mcpDirectories.delete(key)) return
@ -359,6 +385,7 @@ export function createChildStoreManager(input: {
unpin,
pinned,
mcp: (directory: string) => mcpDirectories.has(directoryKey(directory)),
active: (directory: string) => activeDirectories.has(directoryKey(directory)),
disableMcp,
disposeDirectory,
runEviction,

View file

@ -72,6 +72,7 @@ const baseState = (input: Partial<State> = {}) =>
sessionTotal: 0,
session_status: {},
session_diff: {},
todo: {},
permission: {},
question: {},
mcp: {},
@ -79,6 +80,7 @@ const baseState = (input: Partial<State> = {}) =>
vcs: undefined,
limit: 10,
message: {},
session_message: {},
part: {},
part_text_accum_delta: {},
...input,
@ -215,6 +217,7 @@ describe("applyDirectoryEvent", () => {
message: { ses_1: [message] },
part: { [message.id]: [textPart("prt_1", "ses_1", message.id)] },
session_diff: { ses_1: [] },
todo: { ses_1: [] },
permission: { ses_1: [] },
question: { ses_1: [] },
session_status: { ses_1: { type: "busy" } },
@ -235,15 +238,32 @@ describe("applyDirectoryEvent", () => {
expect(store.message.ses_1).toBeUndefined()
expect(store.part[message.id]).toBeUndefined()
expect(store.session_diff.ses_1).toBeUndefined()
expect(store.todo.ses_1).toBeUndefined()
expect(store.permission.ses_1).toBeUndefined()
expect(store.question.ses_1).toBeUndefined()
expect(store.session_status.ses_1).toBeUndefined()
})
test("ignores an archived session absent from a passive directory store", () => {
const [store, setStore] = createStore(baseState({ session: [], sessionTotal: 0 }))
applyDirectoryEvent({
event: { type: "session.updated", properties: { info: rootSession({ id: "missing", archived: 10 }) } },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
expect(store.session).toEqual([])
expect(store.sessionTotal).toBe(0)
})
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) {
@ -259,6 +279,7 @@ describe("applyDirectoryEvent", () => {
message: { [item.info.id]: [message] },
part: { [message.id]: [textPart("prt_1", item.info.id, message.id)] },
session_diff: { [item.info.id]: [] },
todo: { [item.info.id]: [] },
permission: { [item.info.id]: [] },
question: { [item.info.id]: [] },
session_status: { [item.info.id]: { type: "busy" } },
@ -266,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() {},
@ -279,6 +303,7 @@ describe("applyDirectoryEvent", () => {
expect(store.message[item.info.id]).toBeUndefined()
expect(store.part[message.id]).toBeUndefined()
expect(store.session_diff[item.info.id]).toBeUndefined()
expect(store.todo[item.info.id]).toBeUndefined()
expect(store.permission[item.info.id]).toBeUndefined()
expect(store.question[item.info.id]).toBeUndefined()
expect(store.session_status[item.info.id]).toBeUndefined()
@ -289,6 +314,7 @@ describe("applyDirectoryEvent", () => {
const dropped = rootSession({ id: "ses_b" })
const kept = rootSession({ id: "ses_a" })
const message = userMessage("msg_1", dropped.id)
const todos: string[] = []
const [store, setStore] = createStore(
baseState({
limit: 1,
@ -296,6 +322,7 @@ describe("applyDirectoryEvent", () => {
message: { [dropped.id]: [message] },
part: { [message.id]: [textPart("prt_1", dropped.id, message.id)] },
session_diff: { [dropped.id]: [] },
todo: { [dropped.id]: [] },
permission: { [dropped.id]: [] },
question: { [dropped.id]: [] },
session_status: { [dropped.id]: { type: "busy" } },
@ -309,15 +336,21 @@ describe("applyDirectoryEvent", () => {
push() {},
directory: "/tmp",
loadLsp() {},
setSessionTodo(sessionID, value) {
if (value !== undefined) return
todos.push(sessionID)
},
})
expect(store.session.map((x) => x.id)).toEqual([kept.id])
expect(store.message[dropped.id]).toBeUndefined()
expect(store.part[message.id]).toBeUndefined()
expect(store.session_diff[dropped.id]).toBeUndefined()
expect(store.todo[dropped.id]).toBeUndefined()
expect(store.permission[dropped.id]).toBeUndefined()
expect(store.question[dropped.id]).toBeUndefined()
expect(store.session_status[dropped.id]).toBeUndefined()
expect(todos).toEqual([dropped.id])
})
test("cleanupDroppedSessionCaches clears part-only orphan state", () => {

View file

@ -8,8 +8,9 @@ import type {
QuestionRequest,
Session,
SessionStatus,
Todo,
} from "@opencode-ai/sdk/v2/client"
import type { SessionDiff } from "@/utils/diffs"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { State, VcsCache } from "./types"
import { trimSessions } from "./session-trim"
import { dropSessionCaches } from "./session-cache"
@ -18,6 +19,7 @@ import { diffs as list, message as clean } from "@/utils/diffs"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const SESSION_CONTENT_EVENTS = new Set([
"session.diff",
"todo.updated",
"session.status",
"message.updated",
"message.removed",
@ -60,8 +62,13 @@ export function applyGlobalEvent(input: {
)
}
function cleanupSessionCaches(setStore: SetStoreFunction<State>, sessionID: string) {
function cleanupSessionCaches(
setStore: SetStoreFunction<State>,
sessionID: string,
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
) {
if (!sessionID) return
setSessionTodo?.(sessionID, undefined)
setStore(
produce((draft) => {
dropSessionCaches(draft, [sessionID])
@ -69,11 +76,17 @@ function cleanupSessionCaches(setStore: SetStoreFunction<State>, sessionID: stri
)
}
export function cleanupDroppedSessionCaches(store: Store<State>, setStore: SetStoreFunction<State>, next: Session[]) {
export function cleanupDroppedSessionCaches(
store: Store<State>,
setStore: SetStoreFunction<State>,
next: Session[],
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
) {
const keep = new Set(next.map((item) => item.id))
const stale = [
...Object.keys(store.message),
...Object.keys(store.session_diff),
...Object.keys(store.todo),
...Object.keys(store.permission),
...Object.keys(store.question),
...Object.keys(store.session_status),
@ -82,6 +95,9 @@ export function cleanupDroppedSessionCaches(store: Store<State>, setStore: SetSt
.filter((sessionID): sessionID is string => !!sessionID),
].filter((sessionID, index, list) => !keep.has(sessionID) && list.indexOf(sessionID) === index)
if (stale.length === 0) return
for (const sessionID of stale) {
setSessionTodo?.(sessionID, undefined)
}
setStore(
produce((draft) => {
dropSessionCaches(draft, stale)
@ -98,6 +114,7 @@ export function applyDirectoryEvent(input: {
loadLsp: () => void
loadReferences?: () => void
vcsCache?: VcsCache
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void
retainedLimit?: number
sessionContent?: boolean
permission?: State["permission"]
@ -121,7 +138,7 @@ export function applyDirectoryEvent(input: {
next.splice(result.index, 0, info)
const trimmed = trimSessions(next, { limit, permission: input.permission ?? input.store.permission })
input.setStore("session", reconcile(trimmed, { key: "id" }))
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed)
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1)
break
}
@ -129,16 +146,15 @@ export function applyDirectoryEvent(input: {
const info = (event.properties as { info: Session }).info
const result = Binary.search(input.store.session, info.id, (s) => s.id)
if (info.time.archived) {
if (!result.found) break
if (input.store.session[result.index]!.time.archived === info.time.archived) break
if (result.found) {
input.setStore(
"session",
produce((draft) => {
draft.splice(result.index, 1)
}),
)
}
cleanupSessionCaches(input.setStore, info.id)
input.setStore(
"session",
produce((draft) => {
draft.splice(result.index, 1)
}),
)
cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo)
if (info.parentID) break
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
break
@ -151,12 +167,15 @@ export function applyDirectoryEvent(input: {
next.splice(result.index, 0, info)
const trimmed = trimSessions(next, { limit, permission: input.permission ?? input.store.permission })
input.setStore("session", reconcile(trimmed, { key: "id" }))
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed)
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
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",
@ -165,14 +184,83 @@ export function applyDirectoryEvent(input: {
}),
)
}
cleanupSessionCaches(input.setStore, info.id)
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: SessionDiff[] }
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": {
const props = event.properties as { sessionID: string; todos: Todo[] }
input.setStore("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
input.setSessionTodo?.(props.sessionID, props.todos)
break
}
case "session.status": {

View file

@ -0,0 +1,154 @@
import { describe, expect, test } from "bun:test"
import type { SessionV2Info } from "@opencode-ai/sdk/v2/client"
import {
applyHomeSessionEvent,
appendHomeSessionEvent,
HOME_V2_SESSION_PAGE_LIMIT,
loadHomeSessionIndex,
homeSessionIndexSessions,
homeSessionIndexRefresh,
parseHomeSessionIndex,
retainHomeSessions,
} from "./home-session-index"
const session = (input: {
id: string
directory?: string
parentID?: string
archived?: number
updated?: number
}) => ({
id: input.id,
parentID: input.parentID,
projectID: "project",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: input.updated ?? 1, archived: input.archived },
title: input.id,
location: { directory: input.directory ?? "/project" },
})
describe("Home V2 session index", () => {
test("loads the Home index with one global V2 request", async () => {
const calls: unknown[] = []
const result = await loadHomeSessionIndex(async (input) => {
calls.push(input)
return { data: { data: [session({ id: "root" })], cursor: {} } }
})
expect(result.sessions).toHaveLength(1)
expect(calls).toEqual([{ limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc" }])
})
test("loads subsequent pages until the session index is complete", async () => {
const calls: unknown[] = []
const controller = new AbortController()
const result = await loadHomeSessionIndex(
async (input, options) => {
calls.push({ input, signal: options.signal })
if (!("cursor" in input)) {
return {
data: {
data: Array.from({ length: HOME_V2_SESSION_PAGE_LIMIT }, (_, index) =>
session({ id: `page-1-${index}` }),
),
cursor: { next: "next-page" },
},
}
}
return { data: { data: [session({ id: "page-2" })], cursor: {} } }
},
0,
controller.signal,
)
expect(result.sessions).toHaveLength(HOME_V2_SESSION_PAGE_LIMIT + 1)
expect(calls).toEqual([
{ input: { limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc" }, signal: controller.signal },
{
input: { limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc", cursor: "next-page" },
signal: controller.signal,
},
])
})
test("maps visible roots to Home session summaries", () => {
const activeNull = {
...session({ id: "active-null", updated: 20 }),
time: { created: 1, updated: 20, archived: null },
} as unknown as SessionV2Info
const result = parseHomeSessionIndex([
session({ id: "root", updated: 30 }),
activeNull,
session({ id: "child", parentID: "root", updated: 40 }),
session({ id: "archived", archived: 50, updated: 50 }),
])
expect(result).toEqual([
expect.objectContaining({
id: "root",
slug: "root",
version: "",
directory: "/project",
projectID: "project",
title: "root",
time: { created: 1, updated: 30 },
}),
expect.objectContaining({
id: "active-null",
time: { created: 1, updated: 20, archived: null },
}),
])
})
test("preserves the per-directory Home retention limit", () => {
const now = 10 * 60 * 60 * 1000
const sessions = Array.from({ length: 80 }, (_, index) => ({
...parseHomeSessionIndex([session({ id: `session-${index}`, updated: index + 1 })])[0],
directory: index % 2 === 0 ? "/one" : "/two",
}))
const retained = retainHomeSessions(sessions, 10, now)
expect(retained.filter((item) => item.directory === "/one")).toHaveLength(10)
expect(retained.filter((item) => item.directory === "/two")).toHaveLength(10)
})
test("replays session events over the loaded index", () => {
const initial = parseHomeSessionIndex([session({ id: "old" })])
const created = { ...initial[0], id: "new", slug: "new", title: "new", time: { created: 2, updated: 2 } }
const afterCreate = applyHomeSessionEvent(initial, {
type: "session.created",
properties: { sessionID: created.id, info: created },
})
expect(
applyHomeSessionEvent(afterCreate, {
type: "session.deleted",
properties: { sessionID: initial[0]!.id, info: initial[0]! },
}),
).toEqual([created])
})
test("applies only events newer than the index baseline", () => {
const initial = parseHomeSessionIndex([session({ id: "old" })])
const stale = { ...initial[0], title: "stale" }
const current = { ...initial[0], title: "current" }
const first = appendHomeSessionEvent(undefined, {
type: "session.updated",
properties: { sessionID: stale.id, info: stale },
})
const events = appendHomeSessionEvent(first, {
type: "session.updated",
properties: { sessionID: current.id, info: current },
})
expect(homeSessionIndexSessions({ sessions: initial, eventSequence: 1 }, events)[0]?.title).toBe("current")
})
test("refetches after reconnect, disposal, and session moves", () => {
expect(homeSessionIndexRefresh("server.connected", false)).toEqual({ connected: true, refetch: false })
expect(homeSessionIndexRefresh("server.connected", true)).toEqual({ connected: true, refetch: true })
expect(homeSessionIndexRefresh("global.disposed", true).refetch).toBe(true)
expect(homeSessionIndexRefresh("session.next.moved", true).refetch).toBe(true)
})
})

View file

@ -0,0 +1,174 @@
import type { Event, Session, SessionV2Info, V2SessionListResponse } from "@opencode-ai/sdk/v2/client"
import type { QueryClient } from "@tanstack/solid-query"
import { trimSessions } from "./session-trim"
import { pathKey } from "@/utils/path-key"
export const HOME_V2_SESSION_PAGE_LIMIT = 5_000
export type HomeSessionEvent = {
type: "session.created" | "session.updated" | "session.deleted"
properties: { sessionID: string; info: Session }
}
export type HomeSessionEvents = {
sequence: number
entries: Array<{ sequence: number; event: HomeSessionEvent }>
}
export type HomeSessionIndex = {
sessions: Session[]
eventSequence: number
}
export const homeSessionIndexKey = (server: string) => ["home", "session-index", server] as const
export const homeSessionEventsKey = (server: string) => ["home", "session-events", server] as const
type HomeSessionPage = { data?: V2SessionListResponse }
export async function loadHomeSessionIndex(
list: (
input: { limit: number; order: "desc"; cursor?: string },
options: { signal?: AbortSignal },
) => Promise<HomeSessionPage>,
eventSequence = 0,
signal?: AbortSignal,
) {
const data: SessionV2Info[] = []
let cursor: string | undefined
for (;;) {
const response = await list(
{
limit: HOME_V2_SESSION_PAGE_LIMIT,
order: "desc",
...(cursor ? { cursor } : {}),
},
{ signal },
)
const page = response.data!
data.push(...page.data)
if (page.data.length < HOME_V2_SESSION_PAGE_LIMIT || !page.cursor.next)
return { sessions: parseHomeSessionIndex(data), eventSequence }
cursor = page.cursor.next
}
}
export function appendHomeSessionEvent(current: HomeSessionEvents | undefined, event: HomeSessionEvent) {
const sequence = (current?.sequence ?? 0) + 1
return {
sequence,
entries: [...(current?.entries ?? []), { sequence, event }],
}
}
export function trimHomeSessionEvents(current: HomeSessionEvents | undefined, sequence: number): HomeSessionEvents {
return {
sequence: current?.sequence ?? sequence,
entries: (current?.entries ?? []).filter((entry) => entry.sequence > sequence),
}
}
export function homeSessionIndexSessions(index: HomeSessionIndex | undefined, events: HomeSessionEvents | undefined) {
if (!index) return []
return (events?.entries ?? [])
.filter((entry) => entry.sequence > index.eventSequence)
.reduce((sessions, entry) => applyHomeSessionEvent(sessions, entry.event), index.sessions)
}
export function homeSessionIndexRefresh(event: Event["type"], connected: boolean) {
if (event === "server.connected") return { connected: true, refetch: connected }
return {
connected,
refetch: event === "global.disposed" || event === "session.next.moved",
}
}
export function createHomeSessionIndexCache(queryClient: QueryClient, server: string) {
const indexKey = homeSessionIndexKey(server)
const eventsKey = homeSessionEventsKey(server)
let connected = false
return {
indexKey,
eventsKey,
eventSequence() {
return queryClient.getQueryData<HomeSessionEvents>(eventsKey)?.sequence ?? 0
},
complete(sequence: number) {
// Keep events received after the fetch began so its response cannot overwrite them.
queryClient.setQueryData<HomeSessionEvents>(eventsKey, (current) => trimHomeSessionEvents(current, sequence))
},
sessions(index: HomeSessionIndex | undefined, events: HomeSessionEvents | undefined) {
return homeSessionIndexSessions(index, events)
},
apply(event: HomeSessionEvent) {
if (!queryClient.getQueryState(indexKey)) return
const next = appendHomeSessionEvent(queryClient.getQueryData<HomeSessionEvents>(eventsKey), event)
if (queryClient.isFetching({ queryKey: indexKey, exact: true }) > 0) {
queryClient.setQueryData(eventsKey, next)
return
}
const index = queryClient.getQueryData<HomeSessionIndex>(indexKey)
if (index) {
queryClient.setQueryData<HomeSessionIndex>(indexKey, {
sessions: homeSessionIndexSessions(index, next),
eventSequence: next.sequence,
})
}
queryClient.setQueryData<HomeSessionEvents>(eventsKey, { sequence: next.sequence, entries: [] })
},
refresh(event: Event["type"]) {
const result = homeSessionIndexRefresh(event, connected)
connected = result.connected
if (!result.refetch) return
void queryClient.refetchQueries({ queryKey: indexKey, exact: true, type: "active" })
},
}
}
// TODO(v2): This deliberately dumb full-table scan is necessary because the
// current V2 API orders by creation time and cannot filter roots, archives, or
// multiple directories. A bounded page could omit an old session updated today.
// Once released, use client.v2.project.list() and client.v2.session.list({
// parentID: null, order: "desc" }), then remove this adapter and its V1 fields.
export function parseHomeSessionIndex(sessions: SessionV2Info[]): Session[] {
return sessions.flatMap((item) => {
if (item.parentID || typeof item.time.archived === "number") return []
return [toLegacySummary(item)]
})
}
export function retainHomeSessions(sessions: Session[], limit: number, now: number) {
const grouped = Map.groupBy(sessions, (session) => pathKey(session.directory))
return [...grouped.values()].flatMap((items) => trimSessions(items, { limit, permission: {}, now }))
}
export function applyHomeSessionEvent(sessions: Session[], event: HomeSessionEvent) {
const info = event.properties.info
const index = sessions.findIndex((session) => session.id === info.id)
if (event.type === "session.deleted" || info.parentID || typeof info.time.archived === "number") {
if (index === -1) return sessions
return sessions.toSpliced(index, 1)
}
if (event.type !== "session.created" && event.type !== "session.updated") return sessions
if (index === -1) return [...sessions, info]
return sessions.with(index, info)
}
function toLegacySummary(session: SessionV2Info): Session {
return {
id: session.id,
slug: session.id,
projectID: session.projectID,
workspaceID: session.location.workspaceID,
directory: session.location.directory,
path: session.subpath,
parentID: session.parentID,
cost: session.cost,
tokens: session.tokens,
title: session.title,
agent: session.agent,
model: session.model,
version: "",
time: session.time,
}
}

View file

@ -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([])
})
})

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,

View file

@ -1,12 +1,6 @@
import { describe, expect, test } from "bun:test"
import type {
Message,
Part,
PermissionRequest,
QuestionRequest,
SessionStatus,
} from "@opencode-ai/sdk/v2/client"
import type { SessionDiff } from "@/utils/diffs"
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) =>
@ -32,8 +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, SessionDiff[] | 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>
@ -41,7 +37,9 @@ describe("app session cache", () => {
} = {
session_status: { ses_1: { type: "busy" } as SessionStatus },
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[] },
@ -53,6 +51,7 @@ describe("app session cache", () => {
expect(store.message.ses_1).toBeUndefined()
expect(store.part.msg_1).toBeUndefined()
expect(store.part_text_accum_delta.prt_1).toBeUndefined()
expect(store.todo.ses_1).toBeUndefined()
expect(store.session_diff.ses_1).toBeUndefined()
expect(store.session_status.ses_1).toBeUndefined()
expect(store.permission.ses_1).toBeUndefined()
@ -63,8 +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, SessionDiff[] | 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>
@ -72,7 +73,9 @@ describe("app session cache", () => {
} = {
session_status: {},
session_diff: {},
todo: {},
message: { ses_1: [m] },
session_message: {},
part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
permission: {},
question: {},

View file

@ -1,18 +1,15 @@
import type {
Message,
Part,
PermissionRequest,
QuestionRequest,
SessionStatus,
} from "@opencode-ai/sdk/v2/client"
import type { SessionDiff } from "@/utils/diffs"
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, SessionDiff[] | 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>
@ -34,6 +31,8 @@ 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]

View file

@ -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
}
}

View file

@ -1,10 +1,7 @@
import type {
Agent,
Command,
Config,
LspStatus,
McpResource,
McpStatus,
Message,
Part,
Path,
@ -13,10 +10,12 @@ import type {
ReferenceInfo,
Session,
SessionStatus,
Todo,
VcsInfo,
} from "@opencode-ai/sdk/v2/client"
import type { SessionDiff } from "@/utils/diffs"
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"
@ -34,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
@ -50,7 +49,10 @@ export type State = {
}
session_working(id: string): boolean
session_diff: {
[sessionID: string]: SessionDiff[]
[sessionID: string]: FileDiffInfo[]
}
todo: {
[sessionID: string]: Todo[]
}
permission: {
[sessionID: string]: PermissionRequest[]
@ -60,7 +62,7 @@ export type State = {
}
mcp_ready: boolean
mcp: {
[name: string]: McpStatus
[name: string]: McpServer["status"]
}
mcp_resource: {
[key: string]: McpResource
@ -72,6 +74,9 @@ export type State = {
message: {
[sessionID: string]: Message[]
}
session_message: {
[sessionID: string]: SessionMessageInfo[]
}
part: {
[messageID: string]: Part[]
}
@ -124,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

View file

@ -1,36 +1,117 @@
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: {} },
})
})
})

View file

@ -1,39 +1,154 @@
import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
import type {
AgentListOutput,
ModelDefaultOutput,
ModelListOutput,
PermissionRequest,
ProviderListOutput,
} from "@opencode-ai/client/promise"
import type { Agent, Event, 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)
}
type LegacyPermissionRequest = Extract<Event, { type: "permission.asked" }>["properties"]
export function normalizeProviderList(input: ProviderListResponse): NormalizedProviderListResponse {
export function normalizePermissionRequest(input: PermissionRequest | LegacyPermissionRequest): LegacyPermissionRequest {
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 +164,10 @@ export function sanitizeProject(project: Project) {
},
}
}
export function normalizeProjectInfo(project: Project | CurrentProject): Project {
return {
...project,
vcs: project.vcs === "git" ? "git" : undefined,
}
}