From 4f4aa68db9b5d11f3e3737cf83f4f24d917ac98d Mon Sep 17 00:00:00 2001 From: Frank Date: Thu, 25 Jun 2026 11:55:37 -0400 Subject: [PATCH 01/26] zen: migrate to new inference --- packages/console/app/src/routes/zen/util/handler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index ddeafa5265..37629b31a7 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -196,7 +196,7 @@ export async function handler( providerInfo.modifyHeaders(headers, providerInfo.apiKey, stickyId) Object.entries(providerInfo.headerModifier ?? {}).forEach(([k, v]) => { if (v === "$ip") return headers.set(k, ip) - if (v === "$caller") return headers.set(k, `caller:${ip}`) + if (v === "$caller") return headers.set(k, stickyId) if (v === "$session") return headers.set(k, sessionId) if (v === "$model") return headers.set(k, model) if (v === "$request") return headers.set(k, requestId) From fec087bf37bf33d90643a49424932e248f9f1b34 Mon Sep 17 00:00:00 2001 From: Frank Date: Thu, 25 Jun 2026 12:15:34 -0400 Subject: [PATCH 02/26] zen: new inference --- packages/client/sst-env.d.ts | 10 ++++++++++ packages/console/app/src/routes/zen/util/handler.ts | 1 + packages/httpapi-codegen/sst-env.d.ts | 10 ++++++++++ packages/protocol/sst-env.d.ts | 10 ++++++++++ packages/sdk-next/sst-env.d.ts | 10 ++++++++++ 5 files changed, 41 insertions(+) create mode 100644 packages/client/sst-env.d.ts create mode 100644 packages/httpapi-codegen/sst-env.d.ts create mode 100644 packages/protocol/sst-env.d.ts create mode 100644 packages/sdk-next/sst-env.d.ts diff --git a/packages/client/sst-env.d.ts b/packages/client/sst-env.d.ts new file mode 100644 index 0000000000..64441936d7 --- /dev/null +++ b/packages/client/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index 37629b31a7..41fb3ed5a9 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -201,6 +201,7 @@ export async function handler( if (v === "$model") return headers.set(k, model) if (v === "$request") return headers.set(k, requestId) if (v === "$project") return headers.set(k, projectId) + if (v === "$workspace" && authInfo?.workspaceID) return headers.set(k, authInfo.workspaceID) headers.set(k, v) }) headers.delete("host") diff --git a/packages/httpapi-codegen/sst-env.d.ts b/packages/httpapi-codegen/sst-env.d.ts new file mode 100644 index 0000000000..64441936d7 --- /dev/null +++ b/packages/httpapi-codegen/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/packages/protocol/sst-env.d.ts b/packages/protocol/sst-env.d.ts new file mode 100644 index 0000000000..64441936d7 --- /dev/null +++ b/packages/protocol/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/packages/sdk-next/sst-env.d.ts b/packages/sdk-next/sst-env.d.ts new file mode 100644 index 0000000000..64441936d7 --- /dev/null +++ b/packages/sdk-next/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} \ No newline at end of file From 69f75dff1af4013d3c0d358634d65cb58ebee31a Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Fri, 26 Jun 2026 00:19:20 +0800 Subject: [PATCH 03/26] fix(app): adjust home search and header layout (#33923) --- packages/app/src/pages/home.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index ff62f7fba7..f8b1b9c263 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -957,7 +957,7 @@ function HomeSessionSearch(props: { ) return ( -
+
void }) { const language = useLanguage() return ( -
+
{(onNewSession) => ( From e7c59b17a8f4c0a6d79473622f8dad7b471a532d Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Thu, 25 Jun 2026 18:36:08 +0200 Subject: [PATCH 04/26] fix(tui): load root sessions in session switcher (#33931) Request root sessions before applying the session list limit so child sessions cannot crowd roots out of the switcher. Keep the synchronized cache available while requests are pending or fail, reconcile results with live updates, and retain current and pinned sessions. Preserve selection by session ID when asynchronous results reorder the list. Closes #16270 Closes #32725 --- .../tui/src/component/dialog-session-list.tsx | 72 +++++++++++++++-- packages/tui/src/ui/dialog-select.tsx | 77 ++++++++++++++++++- .../tui/test/cli/cmd/tui/sync-fixture.tsx | 9 ++- packages/tui/test/cli/cmd/tui/sync.test.tsx | 2 + .../component/dialog-session-list.test.ts | 46 +++++++++++ 5 files changed, 193 insertions(+), 13 deletions(-) create mode 100644 packages/tui/test/component/dialog-session-list.test.ts diff --git a/packages/tui/src/component/dialog-session-list.tsx b/packages/tui/src/component/dialog-session-list.tsx index 2965b3692e..c508fc6b7b 100644 --- a/packages/tui/src/component/dialog-session-list.tsx +++ b/packages/tui/src/component/dialog-session-list.tsx @@ -2,7 +2,7 @@ import { useDialog } from "../ui/dialog" import { DialogSelect } from "../ui/dialog-select" import { useRoute } from "../context/route" import { useSync } from "../context/sync" -import { createMemo, createResource, createSignal, onMount } from "solid-js" +import { createMemo, createResource, createSignal, onCleanup, onMount } from "solid-js" import path from "path" import { Locale } from "../util/locale" import { useProject } from "../context/project" @@ -17,6 +17,30 @@ import { Spinner } from "./spinner" import { errorMessage } from "../util/error" import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed" import { useCommandShortcut } from "../keymap" +import { useEvent } from "../context/event" + +type SessionListFilter = { scope?: "project"; path?: string } + +export function createDialogSessionListQuery(input: { search?: string; filter: SessionListFilter }) { + const search = input.search?.trim() + return { + roots: true, + limit: search ? 30 : 100, + ...(search ? { search } : {}), + ...input.filter, + } +} + +export function loadDialogSessionList(input: { + search?: string + filter: SessionListFilter + list: (query: ReturnType) => Promise<{ data?: T[] }> +}) { + return input.list(createDialogSessionListQuery(input)).then( + (result) => result.data, + () => undefined, + ) +} export function DialogSessionList() { const dialog = useDialog() @@ -25,25 +49,54 @@ export function DialogSessionList() { const project = useProject() const { theme } = useTheme() const sdk = useSDK() + const event = useEvent() const local = useLocal() const toast = useToast() const [toDelete, setToDelete] = createSignal() + const [deleted, setDeleted] = createSignal(new Set()) const [search, setSearch] = createDebouncedSignal("", 150) const deleteHint = useCommandShortcut("session.delete") const quickSwitch1 = useCommandShortcut("session.quick_switch.1") const quickSwitch9 = useCommandShortcut("session.quick_switch.9") + const [browseResults, { refetch: refetchBrowse }] = createResource( + () => sync.session.query(), + (filter) => loadDialogSessionList({ filter, list: (query) => sdk.client.session.list(query) }), + ) const [searchResults, { refetch }] = createResource( () => ({ query: search(), filter: sync.session.query() }), - async (input) => { + (input) => { if (!input.query) return undefined - const result = await sdk.client.session.list({ search: input.query, limit: 30, ...input.filter }) - return result.data ?? [] + return loadDialogSessionList({ + search: input.query, + filter: input.filter, + list: (query) => sdk.client.session.list(query), + }) }, ) const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) - const sessions = createMemo(() => searchResults() ?? sync.data.session) + const sessions = createMemo(() => { + const result = searchResults() ?? browseResults() ?? sync.data.session + const synced = new Map(sync.data.session.map((session) => [session.id, session])) + const ids = new Set(result.map((session) => session.id)) + const extra = [currentSessionID(), ...local.session.pinned()].flatMap((id) => { + if (!id || ids.has(id)) return [] + const session = synced.get(id) + if (session) ids.add(id) + return session ? [session] : [] + }) + const query = search().trim().toLowerCase() + return [...result.map((session) => synced.get(session.id) ?? session), ...extra] + .filter((session) => !deleted().has(session.id)) + .filter((session) => !query || session.title.toLowerCase().includes(query)) + }) + + onCleanup( + event.on("session.deleted", (event) => { + setDeleted((current) => new Set(current).add(event.properties.info.id)) + }), + ) function recover(session: NonNullable[number]>) { const workspace = project.workspace.get(session.workspaceID!) @@ -108,6 +161,7 @@ export function DialogSessionList() { } await project.workspace.sync() await sync.session.refresh() + await refetchBrowse() if (search()) await refetch() if (info?.workspaceID === session.workspaceID) { route.navigate({ type: "home" }) @@ -138,7 +192,7 @@ export function DialogSessionList() { .map((x) => x.id) } - const [browseOrder] = createSignal(orderByRecency(sync.data.session)) + const browseOrder = createMemo(() => orderByRecency(browseResults() ?? sync.data.session)) const quickSwitchHint = createMemo(() => { const first = quickSwitch1() @@ -160,7 +214,9 @@ export function DialogSessionList() { ) const searchResult = searchResults() - const displayOrder = searchResult ? orderByRecency(searchResult) : browseOrder() + const order = searchResult ? orderByRecency(sessions()) : browseOrder() + const current = currentSessionID() + const displayOrder = current && sessionMap.has(current) && !order.includes(current) ? [...order, current] : order const pinned = local.session.pinned().filter((id) => sessionMap.has(id)) const pinnedSet = new Set(pinned) @@ -218,6 +274,7 @@ export function DialogSessionList() { title="Sessions" options={options()} skipFilter={true} + preserveSelection={true} current={currentSessionID()} onFilter={setSearch} onMove={() => { @@ -279,6 +336,7 @@ export function DialogSessionList() { if (status && status !== "connected") { await sync.session.refresh() } + await refetchBrowse() if (search()) await refetch() setToDelete(undefined) return diff --git a/packages/tui/src/ui/dialog-select.tsx b/packages/tui/src/ui/dialog-select.tsx index 99299f4e22..dab9103d24 100644 --- a/packages/tui/src/ui/dialog-select.tsx +++ b/packages/tui/src/ui/dialog-select.tsx @@ -9,7 +9,7 @@ import { import type { Binding } from "@opentui/keymap" import { useTheme, selectedForeground } from "../context/theme" import { entries, filter, flatMap, groupBy, pipe } from "remeda" -import { batch, createEffect, createMemo, createSignal, For, Show, type JSX, on } from "solid-js" +import { batch, createEffect, createMemo, createSignal, For, Show, type JSX, on, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { useTerminalDimensions } from "@opentui/solid" import * as fuzzysort from "fuzzysort" @@ -35,6 +35,7 @@ export interface DialogSelectProps { skipFilter?: boolean renderFilter?: boolean locked?: boolean + preserveSelection?: boolean actions?: { command: string title: string @@ -93,6 +94,9 @@ export function DialogSelect(props: DialogSelectProps) { }) const [focusedAction, setFocusedAction] = createSignal() const actionFocused = createMemo(() => focusedAction() !== undefined) + let selection: { value: T; category?: string } | undefined + let resetSelection = false + let visibilityGeneration = 0 createEffect( on( @@ -102,6 +106,7 @@ export function DialogSelect(props: DialogSelectProps) { const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current)) if (currentIndex >= 0) { setStore("selected", currentIndex) + selection = flat()[currentIndex] } } }, @@ -209,11 +214,69 @@ export function DialogSelect(props: DialogSelectProps) { const selected = createMemo(() => flat()[store.selected]) + createEffect( + on( + () => props.options, + () => { + if (!props.preserveSelection) return + if (resetSelection && store.filter.length > 0) { + const option = flat()[0] + if (!option) return + setStore("selected", 0) + selection = option + return + } + if (!selection) { + if (props.current !== undefined) { + const index = flat().findIndex((option) => isDeepEqual(option.value, props.current)) + if (index >= 0) { + setStore("selected", index) + selection = flat()[index] + return + } + } + const option = selected() + if (!option) return + selection = option + return + } + const previous = selection + const index = flat().findIndex((option) => isDeepEqual(option.value, previous.value)) + if (index >= 0) { + const option = flat()[index] + const moved = index !== store.selected || option.category !== previous.category + setStore("selected", index) + selection = option + if (!moved) return + const value = option.value + const generation = ++visibilityGeneration + requestAnimationFrame(() => { + requestAnimationFrame(() => { + if (generation !== visibilityGeneration) return + if (!props.preserveSelection || store.filter.length > 0) return + if (!isDeepEqual(selected()?.value, value)) return + scrollToSelection(false) + }) + }) + return + } + const next = Math.min(store.selected, flat().length - 1) + if (next < 0) return + setStore("selected", next) + selection = flat()[next] + }, + ), + ) + onCleanup(() => { + visibilityGeneration++ + }) + createEffect( on([() => store.filter, () => props.current], ([filter, current]) => { + if (filter.length > 0) resetSelection = true setTimeout(() => { if (filter.length > 0) { - moveTo(0, true) + moveTo(0, true, false) } else if (current) { const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current)) if (currentIndex >= 0) { @@ -233,11 +296,19 @@ export function DialogSelect(props: DialogSelectProps) { moveTo(next, true) } - function moveTo(next: number, center = false) { + function moveTo(next: number, center = false, preserve = true) { setFocusedAction(undefined) setStore("selected", next) const option = selected() + if (option) { + selection = option + resetSelection = !preserve + } if (option) props.onMove?.(option) + scrollToSelection(center) + } + + function scrollToSelection(center: boolean) { if (!scroll) return let remaining = store.selected let index = 0 diff --git a/packages/tui/test/cli/cmd/tui/sync-fixture.tsx b/packages/tui/test/cli/cmd/tui/sync-fixture.tsx index a212f5be1d..5b7872ee30 100644 --- a/packages/tui/test/cli/cmd/tui/sync-fixture.tsx +++ b/packages/tui/test/cli/cmd/tui/sync-fixture.tsx @@ -6,6 +6,7 @@ import { KVProvider, useKV } from "../../../../src/context/kv" import { ProjectProvider, useProject } from "../../../../src/context/project" import { SDKProvider } from "../../../../src/context/sdk" import { SyncProvider, useSync } from "../../../../src/context/sync" +import { ExitProvider } from "../../../../src/context/exit" import { createEventSource, createFetch, type FetchHandler, directory } from "../../../fixture/tui-sdk" import { TestTuiContexts } from "../../../fixture/tui-environment" export { createEventSource, createFetch, directory, eventSource, json, worktree } from "../../../fixture/tui-sdk" @@ -48,9 +49,11 @@ export async function mount(override?: FetchHandler, state?: string) { - - - + {}}> + + + + diff --git a/packages/tui/test/cli/cmd/tui/sync.test.tsx b/packages/tui/test/cli/cmd/tui/sync.test.tsx index a7472ab1d6..6c70295b69 100644 --- a/packages/tui/test/cli/cmd/tui/sync.test.tsx +++ b/packages/tui/test/cli/cmd/tui/sync.test.tsx @@ -25,6 +25,7 @@ describe("tui sync", () => { try { expect(kv.get("session_directory_filter_enabled", true)).toBe(true) + expect(session.at(-1)?.searchParams.get("roots")).toBeNull() expect(session.at(-1)?.searchParams.get("scope")).toBeNull() expect(session.at(-1)?.searchParams.get("path")).toBe("packages/tui") @@ -33,6 +34,7 @@ describe("tui sync", () => { expect(session.at(-1)?.searchParams.get("scope")).toBe("project") expect(session.at(-1)?.searchParams.get("path")).toBeNull() + expect(session.at(-1)?.searchParams.get("roots")).toBeNull() } finally { app.renderer.destroy() } diff --git a/packages/tui/test/component/dialog-session-list.test.ts b/packages/tui/test/component/dialog-session-list.test.ts new file mode 100644 index 0000000000..9779047995 --- /dev/null +++ b/packages/tui/test/component/dialog-session-list.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test" +import { createDialogSessionListQuery, loadDialogSessionList } from "../../src/component/dialog-session-list" + +describe("dialog session list", () => { + test("requests root sessions for the default browse list", () => { + expect(createDialogSessionListQuery({ filter: { path: "packages/tui" } })).toEqual({ + roots: true, + limit: 100, + path: "packages/tui", + }) + }) + + test("requests root sessions for search results", () => { + expect(createDialogSessionListQuery({ search: " deploy ", filter: { scope: "project" } })).toEqual({ + roots: true, + limit: 30, + search: "deploy", + scope: "project", + }) + }) + + test("keeps the cache usable while the root request is pending", async () => { + let resolve!: (result: { data: string[] }) => void + const pending = loadDialogSessionList({ + filter: {}, + list: () => new Promise((done) => (resolve = done)), + }) + + expect(await Promise.race([pending, Promise.resolve("pending")])).toBe("pending") + resolve({ data: ["root"] }) + expect(await pending).toEqual(["root"]) + }) + + test("falls back when the root request returns an error response", async () => { + expect(await loadDialogSessionList({ filter: {}, list: async () => ({}) })).toBeUndefined() + }) + + test("falls back when the root request rejects", async () => { + expect( + await loadDialogSessionList({ + filter: {}, + list: () => Promise.reject(new Error("offline")), + }), + ).toBeUndefined() + }) +}) From 10e4b87d25baa4f2939b80b0d933a6203b93de1b Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Fri, 26 Jun 2026 01:00:19 +0800 Subject: [PATCH 05/26] fix(app): persist home project selection (#33935) --- packages/app/src/context/layout.tsx | 12 +++++++- packages/app/src/pages/home.tsx | 38 ++++++++++++------------ packages/app/src/pages/layout/helpers.ts | 3 +- 3 files changed, 31 insertions(+), 22 deletions(-) diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index edd58ad7a7..44d2787051 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -1,4 +1,4 @@ -import { createStore, produce } from "solid-js/store" +import { createStore, produce, reconcile } from "solid-js/store" import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js" import { useLocation } from "@solidjs/router" import { createSimpleContext } from "@opencode-ai/ui/context" @@ -73,6 +73,7 @@ type TabHandoff = { } export type LocalProject = Partial & { worktree: string; expanded: boolean } +export type HomeProjectSelection = { server: ServerConnection.Key; directory?: string } export type ReviewDiffStyle = "unified" | "split" @@ -290,6 +291,9 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( handoff: { tabs: undefined as TabHandoff | undefined, }, + home: { + selection: { server: server.key } as HomeProjectSelection, + }, }), ) @@ -579,6 +583,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( return { route, ready, + home: { + selection: createMemo(() => store.home.selection), + setSelection(selection: HomeProjectSelection) { + setStore("home", "selection", reconcile(selection)) + }, + }, handoff: { tabs: createMemo(() => store.handoff?.tabs), setTabs(dir: string, id: string) { diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index f8b1b9c263..97b6e572eb 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -1,6 +1,5 @@ import type { Session } from "@opencode-ai/sdk/v2/client" import { - batch, createEffect, createMemo, createRoot, @@ -26,7 +25,12 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" -import { getProjectAvatarVariant, useLayout, type LocalProject } from "@/context/layout" +import { + getProjectAvatarVariant, + useLayout, + type HomeProjectSelection, + type LocalProject, +} from "@/context/layout" import { useNavigate } from "@solidjs/router" import { base64Encode } from "@opencode-ai/core/util/encode" import { Icon } from "@opencode-ai/ui/icon" @@ -47,7 +51,6 @@ import { errorMessage, getProjectAvatarSource, homeProjectDirectories, - type HomeProjectSelection, projectForSession, sortedRootSessions, toggleHomeProjectSelection, @@ -149,12 +152,12 @@ export function NewHome() { let focusSessionSearch: (() => void) | undefined const [state, setState] = createStore({ search: "", - selection: { server: server.key } as HomeProjectSelection, searchFocused: false, }) + const selection = layout.home.selection const focusedServer = createMemo( - () => global.servers.list().find((conn) => ServerConnection.key(conn) === state.selection.server) ?? server.current, + () => global.servers.list().find((conn) => ServerConnection.key(conn) === selection().server) ?? server.current, ) const focusedServerCtx = createMemo(() => { const conn = focusedServer() @@ -163,7 +166,7 @@ export function NewHome() { }) const focusedSync = () => focusedServerCtx()?.sync ?? sync() const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? layout.projects.list()) - const selectedProject = createMemo(() => projects().find((project) => project.worktree === state.selection.directory)) + const selectedProject = createMemo(() => projects().find((project) => project.worktree === selection().directory)) const newSessionProject = createMemo( () => selectedProject() ?? @@ -191,7 +194,7 @@ export function NewHome() { return language.t("home.sessions.search.placeholder") }) const sessionLoad = useQuery(() => ({ - queryKey: ["home", "sessions", state.selection.server, ...projectDirectories()] as const, + queryKey: ["home", "sessions", selection().server, ...projectDirectories()] as const, queryFn: async () => { await Promise.all( projectDirectories().map((directory) => @@ -257,10 +260,7 @@ export function NewHome() { }) function setSelection(next: HomeProjectSelection) { - batch(() => { - if (state.selection.server !== next.server) setState("selection", "server", next.server) - if (state.selection.directory !== next.directory) setState("selection", "directory", next.directory) - }) + layout.home.setSelection(next) } function closeSearch() { @@ -285,7 +285,7 @@ export function NewHome() { createEffect(() => { const list = global.servers.list() - if (list.some((conn) => ServerConnection.key(conn) === state.selection.server)) return + if (list.some((conn) => ServerConnection.key(conn) === selection().server)) return const conn = list.find((conn) => ServerConnection.key(conn) === server.key) ?? list[0] if (conn) setSelection({ server: ServerConnection.key(conn) }) }) @@ -310,7 +310,7 @@ export function NewHome() { .some((project) => project.worktree === directory) ) return - setSelection(toggleHomeProjectSelection(state.selection, key, directory)) + setSelection(toggleHomeProjectSelection(selection(), key, directory)) } function addProjects(conn: ServerConnection.Any, directories: string[]) { @@ -418,7 +418,7 @@ export function NewHome() {
{ const next = closeHomeProject( - state.selection, + selection(), ServerConnection.key(conn), global.ensureServerCtx(conn).projects, directory, @@ -451,8 +451,8 @@ export function NewHome() { loading={sessionLoad.isLoading} results={searchResults()} showProjectName={!selectedProject()} - server={state.selection.server} - activeServer={state.selection.server === server.key} + server={selection().server} + activeServer={selection().server === server.key} noResultsLabel={language.t("home.sessions.search.noResults", { query: search() })} bindFocus={(focus) => { focusSessionSearch = focus @@ -489,8 +489,8 @@ export function NewHome() { diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts index 9421961d21..ce793e282b 100644 --- a/packages/app/src/pages/layout/helpers.ts +++ b/packages/app/src/pages/layout/helpers.ts @@ -2,6 +2,7 @@ import { getFilename } from "@opencode-ai/core/util/path" import { type Session } from "@opencode-ai/sdk/v2/client" import { pathKey } from "@/utils/path-key" import type { ServerConnection } from "@/context/server" +import type { HomeProjectSelection } from "@/context/layout" type SessionStore = { session?: Session[] @@ -56,8 +57,6 @@ export const childSessionOnPath = (sessions: Session[] | undefined, rootID: stri export const displayName = (project: { name?: string; worktree: string }) => project.name || getFilename(project.worktree) || project.worktree -export type HomeProjectSelection = { server: ServerConnection.Key; directory?: string } - export function toggleHomeProjectSelection( current: HomeProjectSelection | undefined, server: ServerConnection.Key, From 5682371d0a08f4d43856bfbcb699b7b627dd1ad0 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 25 Jun 2026 17:03:14 +0000 Subject: [PATCH 06/26] chore: generate --- packages/app/src/pages/home.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 97b6e572eb..6686ab388e 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -25,12 +25,7 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" -import { - getProjectAvatarVariant, - useLayout, - type HomeProjectSelection, - type LocalProject, -} from "@/context/layout" +import { getProjectAvatarVariant, useLayout, type HomeProjectSelection, type LocalProject } from "@/context/layout" import { useNavigate } from "@solidjs/router" import { base64Encode } from "@opencode-ai/core/util/encode" import { Icon } from "@opencode-ai/ui/icon" From 9e9d405d7eb186931c85d396dcd0153a63c2d5f6 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 25 Jun 2026 19:10:23 +0200 Subject: [PATCH 07/26] refactor(schema): tighten public contracts (#33771) --- packages/client/src/generated/types.ts | 347 ++++----- packages/core/src/config/plugin/reference.ts | 12 +- packages/core/src/plugin/host.ts | 24 +- packages/core/src/reference.ts | 18 +- packages/core/src/v1/config/migrate.ts | 5 +- packages/core/test/config/config.test.ts | 1 - packages/core/test/tool-todowrite.test.ts | 4 +- packages/opencode/src/session/todo.ts | 4 +- packages/opencode/src/tool/todo.ts | 13 +- .../test/server/httpapi-exercise/index.ts | 2 +- .../test/server/httpapi-exercise/types.ts | 6 +- .../test/server/httpapi-reference.test.ts | 8 - .../test/session/schema-decoding.test.ts | 2 +- packages/schema/src/agent.ts | 13 +- packages/schema/src/command.ts | 9 +- packages/schema/src/credential.ts | 5 +- packages/schema/src/event.ts | 9 +- packages/schema/src/file-diff.ts | 9 +- packages/schema/src/filesystem.ts | 7 +- packages/schema/src/integration.ts | 35 +- packages/schema/src/llm.ts | 11 +- packages/schema/src/location.ts | 7 +- packages/schema/src/model.ts | 31 +- packages/schema/src/permission-saved.ts | 2 +- packages/schema/src/permission.ts | 11 +- packages/schema/src/project-copy.ts | 9 +- packages/schema/src/project.ts | 13 +- packages/schema/src/prompt.ts | 13 +- packages/schema/src/provider.ts | 23 +- packages/schema/src/pty-ticket.ts | 3 +- packages/schema/src/pty.ts | 32 +- packages/schema/src/question.ts | 27 +- packages/schema/src/reference.ts | 19 +- packages/schema/src/revert.ts | 15 +- packages/schema/src/schema.ts | 2 +- packages/schema/src/session-event.ts | 41 +- packages/schema/src/session-input.ts | 3 +- packages/schema/src/session-message.ts | 51 +- packages/schema/src/session-status-event.ts | 5 +- packages/schema/src/session-todo.ts | 6 +- packages/schema/src/session.ts | 14 +- packages/schema/src/skill.ts | 5 +- packages/schema/src/tui-event.ts | 3 +- packages/schema/src/vcs-event.ts | 3 +- packages/schema/src/workspace-event.ts | 4 +- packages/schema/src/worktree-event.ts | 3 +- packages/schema/test/contract-hygiene.test.ts | 67 ++ packages/sdk/js/src/v2/gen/sdk.gen.ts | 28 +- packages/sdk/js/src/v2/gen/types.gen.ts | 672 +++++++----------- 49 files changed, 759 insertions(+), 897 deletions(-) create mode 100644 packages/schema/test/contract-hygiene.test.ts diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index ac888719f4..f51e742798 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -152,8 +152,8 @@ export type SessionsListOutput = { readonly id: string readonly parentID?: string readonly projectID: string - readonly agent?: string | null - readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string | null } | null + readonly agent?: string + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } readonly cost: number readonly tokens: { readonly input: number @@ -161,23 +161,23 @@ export type SessionsListOutput = { readonly reasoning: number readonly cache: { readonly read: number; readonly write: number } } - readonly time: { readonly created: number; readonly updated: number; readonly archived?: number | null } + readonly time: { readonly created: number; readonly updated: number; readonly archived?: number } readonly title: string - readonly location: { readonly directory: string; readonly workspaceID?: string | null | null } - readonly subpath?: string | null + readonly location: { readonly directory: string; readonly workspaceID?: string } + readonly subpath?: string readonly revert?: { readonly messageID: string - readonly partID?: string | null - readonly snapshot?: string | null - readonly diff?: string | null + readonly partID?: string + readonly snapshot?: string + readonly diff?: string readonly files?: ReadonlyArray<{ readonly path: string readonly status: "added" | "modified" | "deleted" readonly additions: number readonly deletions: number readonly patch: string - }> | null - } | null + }> + } }> readonly cursor: { readonly previous?: string | null; readonly next?: string | null } } @@ -186,26 +186,26 @@ export type SessionsCreateInput = { readonly id?: { readonly id?: string | null readonly agent?: string | null - readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string | null } | null - readonly location?: { readonly directory: string; readonly workspaceID?: string | null | null } | null + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null + readonly location?: { readonly directory: string; readonly workspaceID?: string } | null }["id"] readonly agent?: { readonly id?: string | null readonly agent?: string | null - readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string | null } | null - readonly location?: { readonly directory: string; readonly workspaceID?: string | null | null } | null + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null + readonly location?: { readonly directory: string; readonly workspaceID?: string } | null }["agent"] readonly model?: { readonly id?: string | null readonly agent?: string | null - readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string | null } | null - readonly location?: { readonly directory: string; readonly workspaceID?: string | null | null } | null + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null + readonly location?: { readonly directory: string; readonly workspaceID?: string } | null }["model"] readonly location?: { readonly id?: string | null readonly agent?: string | null - readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string | null } | null - readonly location?: { readonly directory: string; readonly workspaceID?: string | null | null } | null + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null + readonly location?: { readonly directory: string; readonly workspaceID?: string } | null }["location"] } @@ -214,8 +214,8 @@ export type SessionsCreateOutput = { readonly id: string readonly parentID?: string readonly projectID: string - readonly agent?: string | null - readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string | null } | null + readonly agent?: string + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } readonly cost: number readonly tokens: { readonly input: number @@ -223,23 +223,23 @@ export type SessionsCreateOutput = { readonly reasoning: number readonly cache: { readonly read: number; readonly write: number } } - readonly time: { readonly created: number; readonly updated: number; readonly archived?: number | null } + readonly time: { readonly created: number; readonly updated: number; readonly archived?: number } readonly title: string - readonly location: { readonly directory: string; readonly workspaceID?: string | null | null } - readonly subpath?: string | null + readonly location: { readonly directory: string; readonly workspaceID?: string } + readonly subpath?: string readonly revert?: { readonly messageID: string - readonly partID?: string | null - readonly snapshot?: string | null - readonly diff?: string | null + readonly partID?: string + readonly snapshot?: string + readonly diff?: string readonly files?: ReadonlyArray<{ readonly path: string readonly status: "added" | "modified" | "deleted" readonly additions: number readonly deletions: number readonly patch: string - }> | null - } | null + }> + } } }["data"] @@ -250,8 +250,8 @@ export type SessionsGetOutput = { readonly id: string readonly parentID?: string readonly projectID: string - readonly agent?: string | null - readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string | null } | null + readonly agent?: string + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } readonly cost: number readonly tokens: { readonly input: number @@ -259,23 +259,23 @@ export type SessionsGetOutput = { readonly reasoning: number readonly cache: { readonly read: number; readonly write: number } } - readonly time: { readonly created: number; readonly updated: number; readonly archived?: number | null } + readonly time: { readonly created: number; readonly updated: number; readonly archived?: number } readonly title: string - readonly location: { readonly directory: string; readonly workspaceID?: string | null | null } - readonly subpath?: string | null + readonly location: { readonly directory: string; readonly workspaceID?: string } + readonly subpath?: string readonly revert?: { readonly messageID: string - readonly partID?: string | null - readonly snapshot?: string | null - readonly diff?: string | null + readonly partID?: string + readonly snapshot?: string + readonly diff?: string readonly files?: ReadonlyArray<{ readonly path: string readonly status: "added" | "modified" | "deleted" readonly additions: number readonly deletions: number readonly patch: string - }> | null - } | null + }> + } } }["data"] @@ -289,7 +289,7 @@ export type SessionsSwitchAgentOutput = void export type SessionsSwitchModelInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] readonly model: { - readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string | undefined } + readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } }["model"] } @@ -298,96 +298,80 @@ export type SessionsSwitchModelOutput = void export type SessionsPromptInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] readonly id?: { - readonly id?: string | undefined + readonly id?: string | null readonly prompt: { readonly text: string - readonly files?: - | ReadonlyArray<{ - readonly uri: string - readonly mime: string - readonly name?: string | undefined - readonly description?: string | undefined - readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined - }> - | undefined - readonly agents?: - | ReadonlyArray<{ - readonly name: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined - }> - | undefined + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> } - readonly delivery?: "steer" | "queue" | undefined - readonly resume?: boolean | undefined + readonly delivery?: "steer" | "queue" | null + readonly resume?: boolean | null }["id"] readonly prompt: { - readonly id?: string | undefined + readonly id?: string | null readonly prompt: { readonly text: string - readonly files?: - | ReadonlyArray<{ - readonly uri: string - readonly mime: string - readonly name?: string | undefined - readonly description?: string | undefined - readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined - }> - | undefined - readonly agents?: - | ReadonlyArray<{ - readonly name: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined - }> - | undefined + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> } - readonly delivery?: "steer" | "queue" | undefined - readonly resume?: boolean | undefined + readonly delivery?: "steer" | "queue" | null + readonly resume?: boolean | null }["prompt"] readonly delivery?: { - readonly id?: string | undefined + readonly id?: string | null readonly prompt: { readonly text: string - readonly files?: - | ReadonlyArray<{ - readonly uri: string - readonly mime: string - readonly name?: string | undefined - readonly description?: string | undefined - readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined - }> - | undefined - readonly agents?: - | ReadonlyArray<{ - readonly name: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined - }> - | undefined + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> } - readonly delivery?: "steer" | "queue" | undefined - readonly resume?: boolean | undefined + readonly delivery?: "steer" | "queue" | null + readonly resume?: boolean | null }["delivery"] readonly resume?: { - readonly id?: string | undefined + readonly id?: string | null readonly prompt: { readonly text: string - readonly files?: - | ReadonlyArray<{ - readonly uri: string - readonly mime: string - readonly name?: string | undefined - readonly description?: string | undefined - readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined - }> - | undefined - readonly agents?: - | ReadonlyArray<{ - readonly name: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined - }> - | undefined + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> } - readonly delivery?: "steer" | "queue" | undefined - readonly resume?: boolean | undefined + readonly delivery?: "steer" | "queue" | null + readonly resume?: boolean | null }["resume"] } @@ -401,18 +385,18 @@ export type SessionsPromptOutput = { readonly files?: ReadonlyArray<{ readonly uri: string readonly mime: string - readonly name?: string | null - readonly description?: string | null - readonly source?: { readonly start: number; readonly end: number; readonly text: string } | null - }> | null + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> readonly agents?: ReadonlyArray<{ readonly name: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } | null - }> | null + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> } readonly delivery: "steer" | "queue" readonly timeCreated: number - readonly promotedSeq?: number | null + readonly promotedSeq?: number } }["data"] @@ -433,18 +417,16 @@ export type SessionsStageInput = { export type SessionsStageOutput = { readonly data: { readonly messageID: string - readonly partID?: string | undefined - readonly snapshot?: string | undefined - readonly diff?: string | undefined - readonly files?: - | ReadonlyArray<{ - readonly path: string - readonly status: "added" | "modified" | "deleted" - readonly additions: number - readonly deletions: number - readonly patch: string - }> - | undefined + readonly partID?: string + readonly snapshot?: string + readonly diff?: string + readonly files?: ReadonlyArray<{ + readonly path: string + readonly status: "added" | "modified" | "deleted" + readonly additions: number + readonly deletions: number + readonly patch: string + }> } }["data"] @@ -462,39 +444,39 @@ export type SessionsContextOutput = { readonly data: ReadonlyArray< | { readonly id: string - readonly metadata?: { readonly [x: string]: JsonValue } | null + readonly metadata?: { readonly [x: string]: JsonValue } readonly time: { readonly created: number } readonly type: "agent-switched" readonly agent: string } | { readonly id: string - readonly metadata?: { readonly [x: string]: JsonValue } | null + readonly metadata?: { readonly [x: string]: JsonValue } readonly time: { readonly created: number } readonly type: "model-switched" - readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string | null } + readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } } | { readonly id: string - readonly metadata?: { readonly [x: string]: JsonValue } | null + readonly metadata?: { readonly [x: string]: JsonValue } readonly time: { readonly created: number } readonly text: string readonly files?: ReadonlyArray<{ readonly uri: string readonly mime: string - readonly name?: string | null - readonly description?: string | null - readonly source?: { readonly start: number; readonly end: number; readonly text: string } | null - }> | null + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> readonly agents?: ReadonlyArray<{ readonly name: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } | null - }> | null + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> readonly type: "user" } | { readonly id: string - readonly metadata?: { readonly [x: string]: JsonValue } | null + readonly metadata?: { readonly [x: string]: JsonValue } readonly time: { readonly created: number } readonly sessionID: string readonly text: string @@ -502,15 +484,15 @@ export type SessionsContextOutput = { } | { readonly id: string - readonly metadata?: { readonly [x: string]: JsonValue } | null + readonly metadata?: { readonly [x: string]: JsonValue } readonly time: { readonly created: number } readonly type: "system" readonly text: string } | { readonly id: string - readonly metadata?: { readonly [x: string]: JsonValue } | null - readonly time: { readonly created: number; readonly completed?: number | null } + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number; readonly completed?: number } readonly type: "shell" readonly callID: string readonly command: string @@ -518,18 +500,18 @@ export type SessionsContextOutput = { } | { readonly id: string - readonly metadata?: { readonly [x: string]: JsonValue } | null - readonly time: { readonly created: number; readonly completed?: number | null } + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number; readonly completed?: number } readonly type: "assistant" readonly agent: string - readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string | null } + readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } readonly content: ReadonlyArray< | { readonly type: "text"; readonly id: string; readonly text: string } | { readonly type: "reasoning" readonly id: string readonly text: string - readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } | null + readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } } | { readonly type: "tool" @@ -537,23 +519,18 @@ export type SessionsContextOutput = { readonly name: string readonly provider?: { readonly executed: boolean - readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } | null - readonly resultMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } | null - } | null + readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } + readonly resultMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } + } readonly state: | { readonly status: "pending"; readonly input: string } | { readonly status: "running" readonly input: { readonly [x: string]: JsonValue } - readonly structured: { readonly [x: string]: any } + readonly structured: { readonly [x: string]: JsonValue } readonly content: ReadonlyArray< | { readonly type: "text"; readonly text: string } - | { - readonly type: "file" - readonly uri: string - readonly mime: string - readonly name?: string | null - } + | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } > } | { @@ -562,61 +539,47 @@ export type SessionsContextOutput = { readonly attachments?: ReadonlyArray<{ readonly uri: string readonly mime: string - readonly name?: string | null - readonly description?: string | null - readonly source?: { readonly start: number; readonly end: number; readonly text: string } | null - }> | null + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> readonly content: ReadonlyArray< | { readonly type: "text"; readonly text: string } - | { - readonly type: "file" - readonly uri: string - readonly mime: string - readonly name?: string | null - } + | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } > - readonly outputPaths?: ReadonlyArray | null - readonly structured: { readonly [x: string]: any } - readonly result?: JsonValue | null + readonly outputPaths?: ReadonlyArray + readonly structured: { readonly [x: string]: JsonValue } + readonly result?: JsonValue } | { readonly status: "error" readonly input: { readonly [x: string]: JsonValue } readonly content: ReadonlyArray< | { readonly type: "text"; readonly text: string } - | { - readonly type: "file" - readonly uri: string - readonly mime: string - readonly name?: string | null - } + | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } > - readonly structured: { readonly [x: string]: any } + readonly structured: { readonly [x: string]: JsonValue } readonly error: { readonly type: "unknown"; readonly message: string } - readonly result?: JsonValue | null + readonly result?: JsonValue } readonly time: { readonly created: number - readonly ran?: number | null - readonly completed?: number | null - readonly pruned?: number | null + readonly ran?: number + readonly completed?: number + readonly pruned?: number } } > - readonly snapshot?: { - readonly start?: string | null - readonly end?: string | null - readonly files?: ReadonlyArray | null - } | null - readonly finish?: string | null - readonly cost?: number | null + readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray } + readonly finish?: string + readonly cost?: number readonly tokens?: { readonly input: number readonly output: number readonly reasoning: number readonly cache: { readonly read: number; readonly write: number } - } | null - readonly error?: { readonly type: "unknown"; readonly message: string } | null + } + readonly error?: { readonly type: "unknown"; readonly message: string } } | { readonly type: "compaction" @@ -624,7 +587,7 @@ export type SessionsContextOutput = { readonly summary: string readonly recent: string readonly id: string - readonly metadata?: { readonly [x: string]: JsonValue } | null + readonly metadata?: { readonly [x: string]: JsonValue } readonly time: { readonly created: number } } > diff --git a/packages/core/src/config/plugin/reference.ts b/packages/core/src/config/plugin/reference.ts index 0e7b95ac08..d5fa53c411 100644 --- a/packages/core/src/config/plugin/reference.ts +++ b/packages/core/src/config/plugin/reference.ts @@ -25,6 +25,8 @@ export const Plugin = define({ const directory = doc.path ? path.dirname(doc.path) : location.directory for (const [name, entry] of Object.entries(doc.info.references ?? {})) { if (!validAlias(name)) continue + const description = typeof entry === "string" ? undefined : entry.description + const hidden = typeof entry === "string" ? undefined : entry.hidden entries.set( name, local(entry) @@ -33,15 +35,15 @@ export const Plugin = define({ path: AbsolutePath.make( localPath(directory, global.home, typeof entry === "string" ? entry : entry.path), ), - description: typeof entry === "string" ? undefined : entry.description, - hidden: typeof entry === "string" ? undefined : entry.hidden, + ...(description === undefined ? {} : { description }), + ...(hidden === undefined ? {} : { hidden }), }) : Reference.GitSource.make({ type: "git", repository: typeof entry === "string" ? entry : entry.repository, - branch: typeof entry === "string" ? undefined : entry.branch, - description: typeof entry === "string" ? undefined : entry.description, - hidden: typeof entry === "string" ? undefined : entry.hidden, + ...(entry.branch === undefined ? {} : { branch: entry.branch }), + ...(description === undefined ? {} : { description }), + ...(hidden === undefined ? {} : { hidden }), }), ) } diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index 8c35d0f19a..a9d084709e 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -12,8 +12,11 @@ import { ModelV2 } from "../model" import { PluginV2 } from "../plugin" import { ProviderV2 } from "../provider" import { Reference } from "../reference" +import type { DeepMutable } from "../schema" import { SkillV2 } from "../skill" +const mutable = (value: T) => value as DeepMutable + export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Interface) { const agents = yield* AgentV2.Service const aisdk = yield* AISDK.Service @@ -30,8 +33,8 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int transform: (callback) => agents.transform((draft) => callback({ - list: draft.list, - get: (id) => draft.get(AgentV2.ID.make(id)), + list: () => mutable(draft.list()), + get: (id) => mutable(draft.get(AgentV2.ID.make(id))), default: (id) => draft.default(id === undefined ? undefined : AgentV2.ID.make(id)), update: (id, update) => draft.update(AgentV2.ID.make(id), update), remove: (id) => draft.remove(AgentV2.ID.make(id)), @@ -42,7 +45,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int sdk: (callback) => aisdk.hook.sdk((event) => { const output = { - model: event.model, + model: mutable(event.model), package: event.package, options: event.options, sdk: event.sdk, @@ -55,7 +58,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int language: (callback) => aisdk.hook.language((event) => { const output = { - model: event.model, + model: mutable(event.model), sdk: event.sdk, options: event.options, language: event.language, @@ -72,13 +75,14 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int catalog.transform((draft) => callback({ provider: { - list: draft.provider.list, - get: (id) => draft.provider.get(ProviderV2.ID.make(id)), + list: () => mutable(draft.provider.list()), + get: (id) => mutable(draft.provider.get(ProviderV2.ID.make(id))), update: (id, update) => draft.provider.update(ProviderV2.ID.make(id), update), remove: (id) => draft.provider.remove(ProviderV2.ID.make(id)), }, model: { - get: (providerID, modelID) => draft.model.get(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)), + get: (providerID, modelID) => + mutable(draft.model.get(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID))), update: (providerID, modelID, update) => draft.model.update(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID), update), remove: (providerID, modelID) => @@ -108,12 +112,12 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int transform: (callback) => integration.transform((draft) => callback({ - list: draft.list, - get: (id) => draft.get(Integration.ID.make(id)), + list: () => mutable(draft.list()), + get: (id) => mutable(draft.get(Integration.ID.make(id))), update: (id, update) => draft.update(Integration.ID.make(id), update), remove: (id) => draft.remove(Integration.ID.make(id)), method: { - list: (id) => draft.method.list(Integration.ID.make(id)), + list: (id) => mutable(draft.method.list(Integration.ID.make(id))), update: (input) => { if ("authorize" in input) { const methodID = Integration.MethodID.make(input.method.id) diff --git a/packages/core/src/reference.ts b/packages/core/src/reference.ts index 04907ede75..31c23a2161 100644 --- a/packages/core/src/reference.ts +++ b/packages/core/src/reference.ts @@ -65,8 +65,8 @@ export const layer = Layer.effect( new Info({ name, path: source.path, - description: source.description, - hidden: source.hidden, + ...(source.description === undefined ? {} : { description: source.description }), + ...(source.hidden === undefined ? {} : { hidden: source.hidden }), source, }), ) @@ -86,13 +86,13 @@ export const layer = Layer.effect( seen.set(target, source.branch) materialized.set( name, - new Info({ - name, - path: AbsolutePath.make(target), - description: source.description, - hidden: source.hidden, - source, - }), + new Info({ + name, + path: AbsolutePath.make(target), + ...(source.description === undefined ? {} : { description: source.description }), + ...(source.hidden === undefined ? {} : { hidden: source.hidden }), + source, + }), ) yield* cache.ensure({ reference: repository, branch: source.branch, refresh: true }).pipe( Effect.catchCause((cause) => diff --git a/packages/core/src/v1/config/migrate.ts b/packages/core/src/v1/config/migrate.ts index 1583c6c1d5..d0cc465e57 100644 --- a/packages/core/src/v1/config/migrate.ts +++ b/packages/core/src/v1/config/migrate.ts @@ -170,6 +170,7 @@ function providers(info?: Readonly>) { function migrateProvider(info: ConfigProviderV1.Info) { const lowerer = ConfigProviderOptionsV1.get(info.npm) const options = lowerer.provider(info.options ?? {}) + const url = info.api ?? options.url return { name: info.name, env: info.env, @@ -177,7 +178,7 @@ function migrateProvider(info: ConfigProviderV1.Info) { ? { type: "aisdk" as const, package: info.npm, - url: info.api ?? options.url, + ...(url === undefined ? {} : { url }), settings: options.settings ?? {}, } : undefined, @@ -221,7 +222,7 @@ function migrateModel(info: typeof ConfigProviderV1.Model.Type, packageName?: st ...(info.id === undefined ? {} : { id: info.id }), type: "aisdk" as const, package: info.provider.npm, - url: info.provider.api, + ...(info.provider.api === undefined ? {} : { url: info.provider.api }), settings: {}, } : info.id === undefined diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index b092bb5582..4b31835726 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -106,7 +106,6 @@ describe("Config", () => { expect(migrated.providers?.bedrock?.api).toEqual({ type: "aisdk", package: "@ai-sdk/amazon-bedrock", - url: undefined, settings: { region: "us-east-1", profile: "dev" }, }) expect(migrated.providers?.bedrock?.request).toEqual({ diff --git a/packages/core/test/tool-todowrite.test.ts b/packages/core/test/tool-todowrite.test.ts index 38ba1900aa..174d6139c8 100644 --- a/packages/core/test/tool-todowrite.test.ts +++ b/packages/core/test/tool-todowrite.test.ts @@ -77,7 +77,9 @@ describe("TodoWriteTool", () => { yield* setup const registry = yield* ToolRegistry.Service const service = yield* SessionTodo.Service - const todoList = [{ content: "Implement slice", status: "in_progress", priority: "high" }] + const todoList: ReadonlyArray = [ + { content: "Implement slice", status: "in_progress", priority: "high" }, + ] expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual([TodoWriteTool.name]) expect(yield* settleTool(registry, call(todoList))).toEqual({ diff --git a/packages/opencode/src/session/todo.ts b/packages/opencode/src/session/todo.ts index 462613522d..d8cc825dfc 100644 --- a/packages/opencode/src/session/todo.ts +++ b/packages/opencode/src/session/todo.ts @@ -14,7 +14,7 @@ export type Info = SessionTodo.Info export const Event = SessionTodo.Event export interface Interface { - readonly update: (input: { sessionID: SessionID; todos: Info[] }) => Effect.Effect + readonly update: (input: { sessionID: SessionID; todos: ReadonlyArray }) => Effect.Effect readonly get: (sessionID: SessionID) => Effect.Effect } @@ -26,7 +26,7 @@ export const layer = Layer.effect( const events = yield* EventV2Bridge.Service const { db } = yield* Database.Service - const update = Effect.fn("Todo.update")(function* (input: { sessionID: SessionID; todos: Info[] }) { + const update = Effect.fn("Todo.update")(function* (input: { sessionID: SessionID; todos: ReadonlyArray }) { yield* db .transaction((tx) => Effect.gen(function* () { diff --git a/packages/opencode/src/tool/todo.ts b/packages/opencode/src/tool/todo.ts index 18d21cf61e..69fb0550e8 100644 --- a/packages/opencode/src/tool/todo.ts +++ b/packages/opencode/src/tool/todo.ts @@ -3,19 +3,8 @@ import * as Tool from "./tool" import DESCRIPTION_WRITE from "./todowrite.txt" import { Todo } from "../session/todo" -// Todo.Info is still a zod schema (session/todo.ts). Inline the field shape -// here rather than referencing its `.shape` — the LLM-visible JSON Schema is -// identical, and it removes the last zod dependency from this tool. -const TodoItem = Schema.Struct({ - content: Schema.String.annotate({ description: "Brief description of the task" }), - status: Schema.String.annotate({ - description: "Current status of the task: pending, in_progress, completed, cancelled", - }), - priority: Schema.String.annotate({ description: "Priority level of the task: high, medium, low" }), -}) - export const Parameters = Schema.Struct({ - todos: Schema.mutable(Schema.Array(TodoItem)).annotate({ description: "The updated todo list" }), + todos: Schema.mutable(Schema.Array(Todo.Info)).annotate({ description: "The updated todo list" }), }) type Metadata = { diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index ea5f5da94a..d0ffe7f8f1 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -1195,7 +1195,7 @@ const scenarios: Scenario[] = [ .seeded((ctx) => Effect.gen(function* () { const session = yield* ctx.session({ title: "Todo session" }) - const todos = [{ content: "cover session todo", status: "pending", priority: "high" }] + const todos = [{ content: "cover session todo", status: "pending" as const, priority: "high" as const }] yield* ctx.todos(session.id, todos) return { session, todos } }), diff --git a/packages/opencode/test/server/httpapi-exercise/types.ts b/packages/opencode/test/server/httpapi-exercise/types.ts index 0b36946993..acc1898e32 100644 --- a/packages/opencode/test/server/httpapi-exercise/types.ts +++ b/packages/opencode/test/server/httpapi-exercise/types.ts @@ -119,5 +119,9 @@ export type Result = | { status: "skip"; scenario: TodoScenario } export type SessionInfo = { id: SessionID; title: string; parentID?: SessionID } -export type TodoInfo = { content: string; status: string; priority: string } +export type TodoInfo = { + content: string + status: "pending" | "in_progress" | "completed" | "cancelled" + priority: "high" | "medium" | "low" +} export type MessageSeed = { info: SessionV1.User; part: SessionV1.TextPart } diff --git a/packages/opencode/test/server/httpapi-reference.test.ts b/packages/opencode/test/server/httpapi-reference.test.ts index bf61512681..9354d527da 100644 --- a/packages/opencode/test/server/httpapi-reference.test.ts +++ b/packages/opencode/test/server/httpapi-reference.test.ts @@ -44,26 +44,18 @@ describe("reference HttpApi", () => { { name: "docs", path: path.join(tmp.path, "docs"), - description: null, - hidden: null, source: { type: "local", path: path.join(tmp.path, "docs"), - description: null, - hidden: null, }, }, { name: "effect", path: path.join(Global.Path.repos, "github.com", "Effect-TS", "effect"), - description: null, - hidden: null, source: { type: "git", repository: "Effect-TS/effect", branch: "main", - description: null, - hidden: null, }, }, ]) diff --git a/packages/opencode/test/session/schema-decoding.test.ts b/packages/opencode/test/session/schema-decoding.test.ts index 1323c2aba6..3bcbd55d4b 100644 --- a/packages/opencode/test/session/schema-decoding.test.ts +++ b/packages/opencode/test/session/schema-decoding.test.ts @@ -256,7 +256,7 @@ describe("Todo.Info", () => { const decode = decodeUnknown(Todo.Info) test("three-field round-trip", () => { - const input = { content: "do a thing", status: "pending", priority: "high" } + const input = Todo.Info.make({ content: "do a thing", status: "pending", priority: "high" }) expect(decode(input)).toEqual(input) }) }) diff --git a/packages/schema/src/agent.ts b/packages/schema/src/agent.ts index 65dbb04377..adf7aec282 100644 --- a/packages/schema/src/agent.ts +++ b/packages/schema/src/agent.ts @@ -1,6 +1,7 @@ export * as Agent from "./agent" import { Schema } from "effect" +import { optional } from "./schema" import { Model } from "./model" import { Permission } from "./permission" import { Provider } from "./provider" @@ -12,20 +13,20 @@ export type ID = typeof ID.Type export const Color = Schema.Union([ Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)), Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]), -]) +]).annotate({ identifier: "Agent.Color" }) export type Color = typeof Color.Type export interface Info extends Schema.Schema.Type {} export const Info = Schema.Struct({ id: ID, - model: Model.Ref.pipe(Schema.optional), + model: Model.Ref.pipe(optional), request: Provider.Request, - system: Schema.String.pipe(Schema.optional), - description: Schema.String.pipe(Schema.optional), + system: Schema.String.pipe(optional), + description: Schema.String.pipe(optional), mode: Schema.Literals(["subagent", "primary", "all"]), hidden: Schema.Boolean, - color: Color.pipe(Schema.optional), - steps: PositiveInt.pipe(Schema.optional), + color: Color.pipe(optional), + steps: PositiveInt.pipe(optional), permissions: Permission.Ruleset, }) .annotate({ identifier: "AgentV2.Info" }) diff --git a/packages/schema/src/command.ts b/packages/schema/src/command.ts index a038f1e166..2bdf3a95c5 100644 --- a/packages/schema/src/command.ts +++ b/packages/schema/src/command.ts @@ -1,14 +1,15 @@ export * as Command from "./command" import { Schema } from "effect" +import { optional } from "./schema" import { Model } from "./model" export interface Info extends Schema.Schema.Type {} export const Info = Schema.Struct({ name: Schema.String, template: Schema.String, - description: Schema.String.pipe(Schema.optional), - agent: Schema.String.pipe(Schema.optional), - model: Model.Ref.pipe(Schema.optional), - subtask: Schema.Boolean.pipe(Schema.optional), + description: Schema.String.pipe(optional), + agent: Schema.String.pipe(optional), + model: Model.Ref.pipe(optional), + subtask: Schema.Boolean.pipe(optional), }).annotate({ identifier: "CommandV2.Info" }) diff --git a/packages/schema/src/credential.ts b/packages/schema/src/credential.ts index 6bfa6276d8..e54e2f680b 100644 --- a/packages/schema/src/credential.ts +++ b/packages/schema/src/credential.ts @@ -1,6 +1,7 @@ export * as Credential from "./credential" import { Schema } from "effect" +import { optional } from "./schema" import { IntegrationMethodID } from "./integration-id" import { ascending } from "./identifier" import { NonNegativeInt, statics } from "./schema" @@ -18,14 +19,14 @@ export const OAuth = Schema.Struct({ refresh: Schema.String, access: Schema.String, expires: NonNegativeInt, - metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), + metadata: optional(Schema.Record(Schema.String, Schema.Unknown)), }).annotate({ identifier: "Credential.OAuth" }) export interface Key extends Schema.Schema.Type {} export const Key = Schema.Struct({ type: Schema.Literal("key"), key: Schema.String, - metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), + metadata: optional(Schema.Record(Schema.String, Schema.Unknown)), }).annotate({ identifier: "Credential.Key" }) export const Value = Schema.Union([OAuth, Key]) diff --git a/packages/schema/src/event.ts b/packages/schema/src/event.ts index 849ffb8902..da1cadfa7b 100644 --- a/packages/schema/src/event.ts +++ b/packages/schema/src/event.ts @@ -1,6 +1,7 @@ export * as Event from "./event" import { Schema } from "effect" +import { optional } from "./schema" import { ascending } from "./identifier" import { Location } from "./location" import { statics } from "./schema" @@ -53,12 +54,10 @@ export function define< return Object.assign( Schema.Struct({ id: ID, - metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + metadata: optional(Schema.Record(Schema.String, Schema.Unknown)), type: Schema.Literal(input.type), - durable: Schema.optional( - Schema.Struct({ aggregateID: Schema.String, seq: Schema.Number, version: Schema.Number }), - ), - location: Schema.optional(Location.Ref), + durable: optional(Schema.Struct({ aggregateID: Schema.String, seq: Schema.Number, version: Schema.Number })), + location: optional(Location.Ref), data, }).annotate({ identifier: input.type }), { diff --git a/packages/schema/src/file-diff.ts b/packages/schema/src/file-diff.ts index 26bfd4155b..45226df28e 100644 --- a/packages/schema/src/file-diff.ts +++ b/packages/schema/src/file-diff.ts @@ -1,12 +1,13 @@ export * as FileDiff from "./file-diff" import { Schema } from "effect" +import { optional } from "./schema" export const Info = Schema.Struct({ - file: Schema.optional(Schema.String), - patch: Schema.optional(Schema.String), + file: optional(Schema.String), + patch: optional(Schema.String), additions: Schema.Finite, deletions: Schema.Finite, - status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])), + status: optional(Schema.Literals(["added", "deleted", "modified"])), }).annotate({ identifier: "SnapshotFileDiff" }) -export type Info = typeof Info.Type +export interface Info extends Schema.Schema.Type {} diff --git a/packages/schema/src/filesystem.ts b/packages/schema/src/filesystem.ts index 7c8a96d867..1998c1b9fd 100644 --- a/packages/schema/src/filesystem.ts +++ b/packages/schema/src/filesystem.ts @@ -1,6 +1,7 @@ export * as FileSystem from "./filesystem" import { Schema } from "effect" +import { optional } from "./schema" import { define, inventory } from "./event" import { NonNegativeInt, PositiveInt, RelativePath } from "./schema" @@ -21,7 +22,7 @@ export const Submatch = Schema.Struct({ text: Schema.String, start: NonNegativeInt, end: NonNegativeInt, -}) +}).annotate({ identifier: "FileSystem.Submatch" }) export interface Match extends Schema.Schema.Type {} export const Match = Schema.Struct({ @@ -34,6 +35,6 @@ export const Match = Schema.Struct({ export class FindInput extends Schema.Class("FileSystem.FindInput")({ query: Schema.String, - type: Schema.Literals(["file", "directory"]).pipe(Schema.optional), - limit: PositiveInt.pipe(Schema.optional), + type: Schema.Literals(["file", "directory"]).pipe(optional), + limit: PositiveInt.pipe(optional), }) {} diff --git a/packages/schema/src/integration.ts b/packages/schema/src/integration.ts index a948422ac0..1345318da1 100644 --- a/packages/schema/src/integration.ts +++ b/packages/schema/src/integration.ts @@ -1,6 +1,7 @@ export * as Integration from "./integration" import { Schema } from "effect" +import { optional } from "./schema" import { define, inventory } from "./event" import { Connection } from "./connection" import { ascending } from "./identifier" @@ -25,8 +26,8 @@ export const TextPrompt = Schema.Struct({ type: Schema.Literal("text"), key: Schema.String, message: Schema.String, - placeholder: Schema.optional(Schema.String), - when: Schema.optional(When), + placeholder: optional(Schema.String), + when: optional(When), }).annotate({ identifier: "Integration.TextPrompt" }) export interface SelectPrompt extends Schema.Schema.Type {} @@ -34,16 +35,14 @@ export const SelectPrompt = Schema.Struct({ type: Schema.Literal("select"), key: Schema.String, message: Schema.String, - options: Schema.mutable( - Schema.Array( - Schema.Struct({ - label: Schema.String, - value: Schema.String, - hint: Schema.optional(Schema.String), - }), - ), + options: Schema.Array( + Schema.Struct({ + label: Schema.String, + value: Schema.String, + hint: optional(Schema.String), + }), ), - when: Schema.optional(When), + when: optional(When), }).annotate({ identifier: "Integration.SelectPrompt" }) export const Prompt = Schema.Union([TextPrompt, SelectPrompt]).pipe(Schema.toTaggedUnion("type")) @@ -54,19 +53,19 @@ export const OAuthMethod = Schema.Struct({ id: MethodID, type: Schema.Literal("oauth"), label: Schema.String, - prompts: Schema.optional(Schema.mutable(Schema.Array(Prompt))), + prompts: optional(Schema.Array(Prompt)), }).annotate({ identifier: "Integration.OAuthMethod" }) export interface KeyMethod extends Schema.Schema.Type {} export const KeyMethod = Schema.Struct({ type: Schema.Literal("key"), - label: Schema.optional(Schema.String), + label: optional(Schema.String), }).annotate({ identifier: "Integration.KeyMethod" }) export interface EnvMethod extends Schema.Schema.Type {} export const EnvMethod = Schema.Struct({ type: Schema.Literal("env"), - names: Schema.mutable(Schema.Array(Schema.String)), + names: Schema.Array(Schema.String), }).annotate({ identifier: "Integration.EnvMethod" }) export const Method = Schema.Union([OAuthMethod, KeyMethod, EnvMethod]) @@ -96,8 +95,8 @@ export const Ref = Schema.Struct({ export class Info extends Schema.Class("Integration.Info")({ id: ID, name: Schema.String, - methods: Schema.mutable(Schema.Array(Method)), - connections: Schema.mutable(Schema.Array(Connection.Info)), + methods: Schema.Array(Method), + connections: Schema.Array(Connection.Info), }) {} export const AttemptID = Schema.String.pipe( @@ -124,5 +123,7 @@ export const AttemptStatus = Schema.Union([ Schema.Struct({ status: Schema.Literal("complete"), time: AttemptTime }), Schema.Struct({ status: Schema.Literal("failed"), message: Schema.String, time: AttemptTime }), Schema.Struct({ status: Schema.Literal("expired"), time: AttemptTime }), -]).pipe(Schema.toTaggedUnion("status")) +]) + .pipe(Schema.toTaggedUnion("status")) + .annotate({ identifier: "Integration.AttemptStatus" }) export type AttemptStatus = typeof AttemptStatus.Type diff --git a/packages/schema/src/llm.ts b/packages/schema/src/llm.ts index 545d7dbb51..44101dd876 100644 --- a/packages/schema/src/llm.ts +++ b/packages/schema/src/llm.ts @@ -1,8 +1,11 @@ export * as LLM from "./llm" import { Schema } from "effect" +import { optional } from "./schema" -export const ProviderMetadata = Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Unknown)) +export const ProviderMetadata = Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Unknown)).annotate({ + identifier: "LLM.ProviderMetadata", +}) export type ProviderMetadata = Schema.Schema.Type export interface ToolTextContent extends Schema.Schema.Type {} @@ -16,8 +19,10 @@ export const ToolFileContent = Schema.Struct({ type: Schema.Literal("file"), uri: Schema.String, mime: Schema.String, - name: Schema.optional(Schema.String), + name: optional(Schema.String), }).annotate({ identifier: "Tool.FileContent" }) -export const ToolContent = Schema.Union([ToolTextContent, ToolFileContent]).pipe(Schema.toTaggedUnion("type")) +export const ToolContent = Schema.Union([ToolTextContent, ToolFileContent]) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ identifier: "LLM.ToolContent" }) export type ToolContent = Schema.Schema.Type diff --git a/packages/schema/src/location.ts b/packages/schema/src/location.ts index b9421bfd55..c01ce36372 100644 --- a/packages/schema/src/location.ts +++ b/packages/schema/src/location.ts @@ -1,6 +1,6 @@ export * as Location from "./location" -import { Effect, Schema } from "effect" +import { Schema } from "effect" import { AbsolutePath, optional } from "./schema" import { ProjectID } from "./project-id" import { WorkspaceID } from "./workspace-id" @@ -8,10 +8,7 @@ import { WorkspaceID } from "./workspace-id" export interface Ref extends Schema.Schema.Type {} export const Ref = Schema.Struct({ directory: AbsolutePath, - workspaceID: Schema.optional(WorkspaceID).pipe( - Schema.withDecodingDefault(Effect.succeed(undefined)), - Schema.withConstructorDefault(Effect.succeed(undefined)), - ), + workspaceID: optional(WorkspaceID), }).annotate({ identifier: "Location.Ref" }) export class Info extends Schema.Class("Location.Info")({ diff --git a/packages/schema/src/model.ts b/packages/schema/src/model.ts index e6b5823cbc..10fa175e24 100644 --- a/packages/schema/src/model.ts +++ b/packages/schema/src/model.ts @@ -1,6 +1,7 @@ export * as Model from "./model" import { Schema } from "effect" +import { optional } from "./schema" import { Provider } from "./provider" import { statics } from "./schema" @@ -13,9 +14,9 @@ export type VariantID = typeof VariantID.Type export const Ref = Schema.Struct({ id: ID, providerID: Provider.ID, - variant: VariantID.pipe(Schema.optional), -}) -export type Ref = typeof Ref.Type + variant: VariantID.pipe(optional), +}).annotate({ identifier: "Model.Ref" }) +export interface Ref extends Schema.Schema.Type {} export const Family = Schema.String.pipe(Schema.brand("Family")) export type Family = typeof Family.Type @@ -23,23 +24,23 @@ export type Family = typeof Family.Type export interface Capabilities extends Schema.Schema.Type {} export const Capabilities = Schema.Struct({ tools: Schema.Boolean, - input: Schema.String.pipe(Schema.Array, Schema.mutable), - output: Schema.String.pipe(Schema.Array, Schema.mutable), -}) + input: Schema.Array(Schema.String), + output: Schema.Array(Schema.String), +}).annotate({ identifier: "Model.Capabilities" }) export interface Cost extends Schema.Schema.Type {} export const Cost = Schema.Struct({ tier: Schema.Struct({ type: Schema.Literal("context"), size: Schema.Int, - }).pipe(Schema.optional), + }).pipe(optional), input: Schema.Finite, output: Schema.Finite, cache: Schema.Struct({ read: Schema.Finite, write: Schema.Finite, }), -}) +}).annotate({ identifier: "Model.Cost" }) export const Api = Schema.Union([ Schema.Struct({ @@ -50,34 +51,36 @@ export const Api = Schema.Union([ id: ID, ...Provider.Native.fields, }), -]).pipe(Schema.toTaggedUnion("type")) +]) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ identifier: "Model.Api" }) export type Api = typeof Api.Type export interface Info extends Schema.Schema.Type {} export const Info = Schema.Struct({ id: ID, providerID: Provider.ID, - family: Family.pipe(Schema.optional), + family: Family.pipe(optional), name: Schema.String, api: Api, capabilities: Capabilities, request: Schema.Struct({ ...Provider.Request.fields, - variant: Schema.String.pipe(Schema.optional), + variant: Schema.String.pipe(optional), }), variants: Schema.Struct({ id: VariantID, ...Provider.Request.fields, - }).pipe(Schema.Array, Schema.mutable), + }).pipe(Schema.Array), time: Schema.Struct({ released: Schema.Finite, }), - cost: Cost.pipe(Schema.Array, Schema.mutable), + cost: Schema.Array(Cost), status: Schema.Literals(["alpha", "beta", "deprecated", "active"]), enabled: Schema.Boolean, limit: Schema.Struct({ context: Schema.Int, - input: Schema.Int.pipe(Schema.optional), + input: Schema.Int.pipe(optional), output: Schema.Int, }), }) diff --git a/packages/schema/src/permission-saved.ts b/packages/schema/src/permission-saved.ts index 2815d51f95..969a6dc25c 100644 --- a/packages/schema/src/permission-saved.ts +++ b/packages/schema/src/permission-saved.ts @@ -17,4 +17,4 @@ export const Info = Schema.Struct({ action: Schema.String, resource: Schema.String, }).annotate({ identifier: "PermissionSaved.Info" }) -export type Info = typeof Info.Type +export interface Info extends Schema.Schema.Type {} diff --git a/packages/schema/src/permission.ts b/packages/schema/src/permission.ts index 733c1e0b9b..25d776284f 100644 --- a/packages/schema/src/permission.ts +++ b/packages/schema/src/permission.ts @@ -1,6 +1,7 @@ export * as Permission from "./permission" import { Schema } from "effect" +import { optional } from "./schema" import { define, inventory } from "./event" import { ascending } from "./identifier" import { SessionID } from "./session-id" @@ -25,16 +26,16 @@ const RequestFields = { sessionID: SessionID, action: Schema.String, resources: Schema.Array(Schema.String), - save: Schema.Array(Schema.String).pipe(Schema.optional), - metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), - source: Source.pipe(Schema.optional), + save: Schema.Array(Schema.String).pipe(optional), + metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(optional), + source: Source.pipe(optional), } export const Request = Schema.Struct({ id: ID, ...RequestFields, }).annotate({ identifier: "PermissionV2.Request" }) -export type Request = typeof Request.Type +export interface Request extends Schema.Schema.Type {} export const Reply = Schema.Literals(["once", "always", "reject"]).annotate({ identifier: "PermissionV2.Reply" }) export type Reply = typeof Reply.Type @@ -60,5 +61,5 @@ export const Rule = Schema.Struct({ effect: Effect, }).annotate({ identifier: "PermissionV2.Rule" }) -export const Ruleset = Schema.mutable(Schema.Array(Rule)).annotate({ identifier: "PermissionV2.Ruleset" }) +export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionV2.Ruleset" }) export type Ruleset = typeof Ruleset.Type diff --git a/packages/schema/src/project-copy.ts b/packages/schema/src/project-copy.ts index d90c323d59..850b87bcb3 100644 --- a/packages/schema/src/project-copy.ts +++ b/packages/schema/src/project-copy.ts @@ -1,6 +1,7 @@ export * as ProjectCopy from "./project-copy" import { Schema } from "effect" +import { optional } from "./schema" import { ProjectID } from "./project-id" import { AbsolutePath } from "./schema" @@ -12,18 +13,18 @@ export const CreateInput = Schema.Struct({ strategy: StrategyID, sourceDirectory: AbsolutePath, directory: AbsolutePath, - name: Schema.optional(Schema.String), + name: optional(Schema.String), }).annotate({ identifier: "ProjectCopy.CreateInput" }) -export type CreateInput = typeof CreateInput.Type +export interface CreateInput extends Schema.Schema.Type {} export const RemoveInput = Schema.Struct({ projectID: ProjectID, directory: AbsolutePath, force: Schema.Boolean, }).annotate({ identifier: "ProjectCopy.RemoveInput" }) -export type RemoveInput = typeof RemoveInput.Type +export interface RemoveInput extends Schema.Schema.Type {} export const Copy = Schema.Struct({ directory: AbsolutePath, }).annotate({ identifier: "ProjectCopy.Copy" }) -export type Copy = typeof Copy.Type +export interface Copy extends Schema.Schema.Type {} diff --git a/packages/schema/src/project.ts b/packages/schema/src/project.ts index 6cee635c60..7e73530a6d 100644 --- a/packages/schema/src/project.ts +++ b/packages/schema/src/project.ts @@ -8,22 +8,25 @@ import { ProjectID } from "./project-id" export const ID = ProjectID export type ID = typeof ID.Type -export const Vcs = Schema.Literal("git") +export const Vcs = Schema.Literal("git").annotate({ identifier: "Project.Vcs" }) export const Icon = Schema.Struct({ url: optional(Schema.String), override: optional(Schema.String), color: optional(Schema.String), -}) +}).annotate({ identifier: "Project.Icon" }) +export interface Icon extends Schema.Schema.Type {} export const Commands = Schema.Struct({ start: optional( Schema.String.annotate({ description: "Startup script to run when creating a new workspace (worktree)" }), ), -}) +}).annotate({ identifier: "Project.Commands" }) +export interface Commands extends Schema.Schema.Type {} export const Time = Schema.Struct({ created: NonNegativeInt, updated: NonNegativeInt, initialized: optional(NonNegativeInt), -}) +}).annotate({ identifier: "Project.Time" }) +export interface Time extends Schema.Schema.Type {} export const Info = Schema.Struct({ id: ID, @@ -35,7 +38,7 @@ export const Info = Schema.Struct({ time: Time, sandboxes: Schema.Array(Schema.String), }).annotate({ identifier: "Project" }) -export type Info = typeof Info.Type +export interface Info extends Schema.Schema.Type {} const Updated = define({ type: "project.updated", schema: Info.fields }) export const Event = { Updated, Definitions: inventory(Updated) } diff --git a/packages/schema/src/prompt.ts b/packages/schema/src/prompt.ts index 87441bc01e..376700cc7a 100644 --- a/packages/schema/src/prompt.ts +++ b/packages/schema/src/prompt.ts @@ -1,4 +1,5 @@ import { Schema } from "effect" +import { optional } from "./schema" import { statics } from "./schema" export interface Source extends Schema.Schema.Type {} @@ -12,9 +13,9 @@ export interface FileAttachment extends Schema.Schema.Type {} export const AgentAttachment = Schema.Struct({ name: Schema.String, - source: Source.pipe(Schema.optional), + source: Source.pipe(optional), }).annotate({ identifier: "Prompt.AgentAttachment" }) export interface Prompt extends Schema.Schema.Type {} export const Prompt = Schema.Struct({ text: Schema.String, - files: Schema.Array(FileAttachment).pipe(Schema.optional), - agents: Schema.Array(AgentAttachment).pipe(Schema.optional), + files: Schema.Array(FileAttachment).pipe(optional), + agents: Schema.Array(AgentAttachment).pipe(optional), }) .annotate({ identifier: "Prompt" }) .pipe( diff --git a/packages/schema/src/provider.ts b/packages/schema/src/provider.ts index a18800a4b1..51ff4b3791 100644 --- a/packages/schema/src/provider.ts +++ b/packages/schema/src/provider.ts @@ -1,6 +1,7 @@ export * as Provider from "./provider" import { Schema } from "effect" +import { optional } from "./schema" import { Integration } from "./integration" import { statics } from "./schema" @@ -26,32 +27,34 @@ export interface AISDK extends Schema.Schema.Type {} export const AISDK = Schema.Struct({ type: Schema.Literal("aisdk"), package: Schema.String, - url: Schema.String.pipe(Schema.optional), - settings: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), -}) + url: Schema.String.pipe(optional), + settings: Schema.Record(Schema.String, Schema.Unknown).pipe(optional), +}).annotate({ identifier: "Provider.AISDK" }) export interface Native extends Schema.Schema.Type {} export const Native = Schema.Struct({ type: Schema.Literal("native"), - url: Schema.String.pipe(Schema.optional), + url: Schema.String.pipe(optional), settings: Schema.Record(Schema.String, Schema.Unknown), -}) +}).annotate({ identifier: "Provider.Native" }) -export const Api = Schema.Union([AISDK, Native]).pipe(Schema.toTaggedUnion("type")) +export const Api = Schema.Union([AISDK, Native]) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ identifier: "Provider.Api" }) export type Api = typeof Api.Type export interface Request extends Schema.Schema.Type {} export const Request = Schema.Struct({ headers: Schema.Record(Schema.String, Schema.String), - body: Schema.Record(Schema.String, Schema.Any), -}) + body: Schema.Record(Schema.String, Schema.Json), +}).annotate({ identifier: "Provider.Request" }) export interface Info extends Schema.Schema.Type {} export const Info = Schema.Struct({ id: ID, - integrationID: Integration.ID.pipe(Schema.optional), + integrationID: Integration.ID.pipe(optional), name: Schema.String, - disabled: Schema.Boolean.pipe(Schema.optional), + disabled: Schema.Boolean.pipe(optional), api: Api, request: Request, }) diff --git a/packages/schema/src/pty-ticket.ts b/packages/schema/src/pty-ticket.ts index 4d1bdedfb8..9b258575a9 100644 --- a/packages/schema/src/pty-ticket.ts +++ b/packages/schema/src/pty-ticket.ts @@ -6,4 +6,5 @@ import { PositiveInt } from "./schema" export const ConnectToken = Schema.Struct({ ticket: Schema.String, expires_in: PositiveInt, -}) +}).annotate({ identifier: "PtyTicket.ConnectToken" }) +export interface ConnectToken extends Schema.Schema.Type {} diff --git a/packages/schema/src/pty.ts b/packages/schema/src/pty.ts index 9d6120ccde..1392ece55f 100644 --- a/packages/schema/src/pty.ts +++ b/packages/schema/src/pty.ts @@ -1,6 +1,7 @@ export * as Pty from "./pty" import { Schema } from "effect" +import { optional } from "./schema" import { define, inventory } from "./event" import { ascending } from "./identifier" import { NonNegativeInt, PositiveInt, statics } from "./schema" @@ -8,9 +9,13 @@ import { NonNegativeInt, PositiveInt, statics } from "./schema" const IDSchema = Schema.String.check(Schema.isStartsWith("pty")).pipe(Schema.brand("PtyID")) export const ID = IDSchema.pipe( - statics((schema: typeof IDSchema) => ({ - ascending: (id?: string) => schema.make(id ?? "pty_" + ascending()), - })), + statics((schema: typeof IDSchema) => { + const create = () => schema.make("pty_" + ascending()) + return { + create, + ascending: (id?: string) => (id === undefined ? create() : schema.make(id)), + } + }), ) export type ID = typeof ID.Type @@ -22,8 +27,9 @@ export const Info = Schema.Struct({ cwd: Schema.String, status: Schema.Literals(["running", "exited"]), pid: NonNegativeInt, - exitCode: Schema.optional(NonNegativeInt), + exitCode: optional(NonNegativeInt), }).annotate({ identifier: "Pty" }) +export interface Info extends Schema.Schema.Type {} const Created = define({ type: "pty.created", schema: { info: Info } }) const Updated = define({ type: "pty.updated", schema: { info: Info } }) @@ -32,21 +38,21 @@ const Deleted = define({ type: "pty.deleted", schema: { id: ID } }) export const Event = { Created, Updated, Exited, Deleted, Definitions: inventory(Created, Updated, Exited, Deleted) } export const CreateInput = Schema.Struct({ - command: Schema.optional(Schema.String), - args: Schema.optional(Schema.Array(Schema.String)), - cwd: Schema.optional(Schema.String), - title: Schema.optional(Schema.String), - env: Schema.optional(Schema.Record(Schema.String, Schema.String)), + command: optional(Schema.String), + args: optional(Schema.Array(Schema.String)), + cwd: optional(Schema.String), + title: optional(Schema.String), + env: optional(Schema.Record(Schema.String, Schema.String)), }) -export type CreateInput = typeof CreateInput.Type +export interface CreateInput extends Schema.Schema.Type {} export const UpdateInput = Schema.Struct({ - title: Schema.optional(Schema.String), - size: Schema.optional( + title: optional(Schema.String), + size: optional( Schema.Struct({ rows: PositiveInt, cols: PositiveInt, }), ), }) -export type UpdateInput = typeof UpdateInput.Type +export interface UpdateInput extends Schema.Schema.Type {} diff --git a/packages/schema/src/question.ts b/packages/schema/src/question.ts index 0933c11ec1..aba5ebfbaf 100644 --- a/packages/schema/src/question.ts +++ b/packages/schema/src/question.ts @@ -1,6 +1,7 @@ export * as Question from "./question" import { Schema } from "effect" +import { optional } from "./schema" import { define, inventory } from "./event" import { ascending } from "./identifier" import { SessionID } from "./session-id" @@ -8,7 +9,13 @@ import { statics } from "./schema" export const ID = Schema.String.check(Schema.isStartsWith("que")).pipe( Schema.brand("QuestionV2.ID"), - statics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "que_" + ascending()) })), + statics((schema) => { + const create = () => schema.make("que_" + ascending()) + return { + create, + ascending: (id?: string) => (id === undefined ? create() : schema.make(id)), + } + }), ) export type ID = typeof ID.Type @@ -16,39 +23,39 @@ export const Option = Schema.Struct({ label: Schema.String.annotate({ description: "Display text (1-5 words, concise)" }), description: Schema.String.annotate({ description: "Explanation of choice" }), }).annotate({ identifier: "QuestionV2.Option" }) -export type Option = typeof Option.Type +export interface Option extends Schema.Schema.Type {} const base = { question: Schema.String.annotate({ description: "Complete question" }), header: Schema.String.annotate({ description: "Very short label (max 30 chars)" }), options: Schema.Array(Option).annotate({ description: "Available choices" }), - multiple: Schema.Boolean.pipe(Schema.optional).annotate({ description: "Allow selecting multiple choices" }), + multiple: Schema.Boolean.pipe(optional).annotate({ description: "Allow selecting multiple choices" }), } export const Info = Schema.Struct({ ...base, - custom: Schema.Boolean.pipe(Schema.optional).annotate({ + custom: Schema.Boolean.pipe(optional).annotate({ description: "Allow typing a custom answer (default: true)", }), }).annotate({ identifier: "QuestionV2.Info" }) -export type Info = typeof Info.Type +export interface Info extends Schema.Schema.Type {} export const Prompt = Schema.Struct(base).annotate({ identifier: "QuestionV2.Prompt" }) -export type Prompt = typeof Prompt.Type +export interface Prompt extends Schema.Schema.Type {} export const Tool = Schema.Struct({ messageID: Schema.String, callID: Schema.String, }).annotate({ identifier: "QuestionV2.Tool" }) -export type Tool = typeof Tool.Type +export interface Tool extends Schema.Schema.Type {} export const Request = Schema.Struct({ id: ID, sessionID: SessionID, questions: Schema.Array(Info).annotate({ description: "Questions to ask" }), - tool: Tool.pipe(Schema.optional), + tool: Tool.pipe(optional), }).annotate({ identifier: "QuestionV2.Request" }) -export type Request = typeof Request.Type +export interface Request extends Schema.Schema.Type {} export const Answer = Schema.Array(Schema.String).annotate({ identifier: "QuestionV2.Answer" }) export type Answer = typeof Answer.Type @@ -58,7 +65,7 @@ export const Reply = Schema.Struct({ description: "User answers in order of questions (each answer is an array of selected labels)", }), }).annotate({ identifier: "QuestionV2.Reply" }) -export type Reply = typeof Reply.Type +export interface Reply extends Schema.Schema.Type {} const Asked = define({ type: "question.v2.asked", schema: Request.fields }) const Replied = define({ diff --git a/packages/schema/src/reference.ts b/packages/schema/src/reference.ts index ca80ff33f0..84b623a274 100644 --- a/packages/schema/src/reference.ts +++ b/packages/schema/src/reference.ts @@ -1,6 +1,7 @@ export * as Reference from "./reference" import { Schema } from "effect" +import { optional } from "./schema" import { define, inventory } from "./event" import { AbsolutePath } from "./schema" @@ -11,26 +12,28 @@ export interface LocalSource extends Schema.Schema.Type {} export const LocalSource = Schema.Struct({ type: Schema.Literal("local"), path: AbsolutePath, - description: Schema.String.pipe(Schema.optional), - hidden: Schema.Boolean.pipe(Schema.optional), + description: Schema.String.pipe(optional), + hidden: Schema.Boolean.pipe(optional), }).annotate({ identifier: "Reference.LocalSource" }) export interface GitSource extends Schema.Schema.Type {} export const GitSource = Schema.Struct({ type: Schema.Literal("git"), repository: Schema.String, - branch: Schema.String.pipe(Schema.optional), - description: Schema.String.pipe(Schema.optional), - hidden: Schema.Boolean.pipe(Schema.optional), + branch: Schema.String.pipe(optional), + description: Schema.String.pipe(optional), + hidden: Schema.Boolean.pipe(optional), }).annotate({ identifier: "Reference.GitSource" }) -export const Source = Schema.Union([LocalSource, GitSource]).pipe(Schema.toTaggedUnion("type")) +export const Source = Schema.Union([LocalSource, GitSource]) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ identifier: "Reference.Source" }) export type Source = typeof Source.Type export class Info extends Schema.Class("Reference.Info")({ name: Schema.String, path: AbsolutePath, - description: Schema.String.pipe(Schema.optional), - hidden: Schema.Boolean.pipe(Schema.optional), + description: Schema.String.pipe(optional), + hidden: Schema.Boolean.pipe(optional), source: Source, }) {} diff --git a/packages/schema/src/revert.ts b/packages/schema/src/revert.ts index 4ac0977420..05222d5398 100644 --- a/packages/schema/src/revert.ts +++ b/packages/schema/src/revert.ts @@ -1,6 +1,7 @@ export * as Revert from "./revert" import { Schema } from "effect" +import { optional } from "./schema" import { NonNegativeInt, RelativePath } from "./schema" import { SessionMessage } from "./session-message" @@ -11,13 +12,13 @@ export const FileDiff = Schema.Struct({ deletions: NonNegativeInt, patch: Schema.String, }).annotate({ identifier: "File.Diff" }) -export type FileDiff = typeof FileDiff.Type +export interface FileDiff extends Schema.Schema.Type {} export const State = Schema.Struct({ messageID: SessionMessage.ID, - partID: Schema.String.pipe(Schema.optional), - snapshot: Schema.String.pipe(Schema.optional), - diff: Schema.String.pipe(Schema.optional), - files: Schema.Array(FileDiff).pipe(Schema.optional), -}) -export type State = typeof State.Type + partID: Schema.String.pipe(optional), + snapshot: Schema.String.pipe(optional), + diff: Schema.String.pipe(optional), + files: Schema.Array(FileDiff).pipe(optional), +}).annotate({ identifier: "Revert.State" }) +export interface State extends Schema.Schema.Type {} diff --git a/packages/schema/src/schema.ts b/packages/schema/src/schema.ts index 1cadffdf60..d19a39b970 100644 --- a/packages/schema/src/schema.ts +++ b/packages/schema/src/schema.ts @@ -11,7 +11,7 @@ export type AbsolutePath = typeof AbsolutePath.Type export const optional = (schema: S) => Schema.optionalKey(schema).pipe( - Schema.decodeTo(Schema.optional(schema), { + Schema.decodeTo(Schema.optional(Schema.toType(schema)), { decode: SchemaGetter.passthrough({ strict: false }), encode: SchemaGetter.transformOptional(Option.filter((value) => value !== undefined)), }), diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index e94cf94c5b..f58c3dcce0 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -1,6 +1,7 @@ export * as SessionEvent from "./session-event" import { Schema } from "effect" +import { optional } from "./schema" import { Event } from "./event" import { ProviderMetadata, ToolContent } from "./llm" import { Delivery } from "./session-delivery" @@ -21,7 +22,7 @@ export const Source = Schema.Struct({ }).annotate({ identifier: "session.next.event.source", }) -export type Source = typeof Source.Type +export interface Source extends Schema.Schema.Type {} const Base = { timestamp: DateTimeUtcFromMillis, @@ -78,7 +79,7 @@ export const Moved = Event.define({ schema: { ...Base, location: Location.Ref, - subdirectory: RelativePath.pipe(Schema.optional), + subdirectory: RelativePath.pipe(optional), }, }) export type Moved = typeof Moved.Type @@ -153,7 +154,7 @@ export namespace Step { assistantMessageID: SessionMessage.ID, agent: Schema.String, model: Model.Ref, - snapshot: Schema.String.pipe(Schema.optional), + snapshot: Schema.String.pipe(optional), }, }) export type Started = typeof Started.Type @@ -175,8 +176,8 @@ export namespace Step { write: Schema.Finite, }), }), - snapshot: Schema.String.pipe(Schema.optional), - files: Schema.Array(RelativePath).pipe(Schema.optional), + snapshot: Schema.String.pipe(optional), + files: Schema.Array(RelativePath).pipe(optional), }, }) export type Ended = typeof Ended.Type @@ -238,7 +239,7 @@ export namespace Reasoning { ...Base, assistantMessageID: SessionMessage.ID, reasoningID: Schema.String, - providerMetadata: ProviderMetadata.pipe(Schema.optional), + providerMetadata: ProviderMetadata.pipe(optional), }, }) export type Started = typeof Started.Type @@ -263,7 +264,7 @@ export namespace Reasoning { assistantMessageID: SessionMessage.ID, reasoningID: Schema.String, text: Schema.String, - providerMetadata: ProviderMetadata.pipe(Schema.optional), + providerMetadata: ProviderMetadata.pipe(optional), }, }) export type Ended = typeof Ended.Type @@ -317,7 +318,7 @@ export namespace Tool { input: Schema.Record(Schema.String, Schema.Unknown), provider: Schema.Struct({ executed: Schema.Boolean, - metadata: ProviderMetadata.pipe(Schema.optional), + metadata: ProviderMetadata.pipe(optional), }), }, }) @@ -332,7 +333,7 @@ export namespace Tool { ...options, schema: { ...ToolBase, - structured: Schema.Record(Schema.String, Schema.Any), + structured: Schema.Record(Schema.String, Schema.Unknown), content: Schema.Array(ToolContent), }, }) @@ -343,13 +344,13 @@ export namespace Tool { ...options, schema: { ...ToolBase, - structured: Schema.Record(Schema.String, Schema.Any), + structured: Schema.Record(Schema.String, Schema.Unknown), content: Schema.Array(ToolContent), - outputPaths: Schema.Array(Schema.String).pipe(Schema.optional), - result: Schema.Unknown.pipe(Schema.optional), + outputPaths: Schema.Array(Schema.String).pipe(optional), + result: Schema.Unknown.pipe(optional), provider: Schema.Struct({ executed: Schema.Boolean, - metadata: ProviderMetadata.pipe(Schema.optional), + metadata: ProviderMetadata.pipe(optional), }), }, }) @@ -361,10 +362,10 @@ export namespace Tool { schema: { ...ToolBase, error: UnknownError, - result: Schema.Unknown.pipe(Schema.optional), + result: Schema.Unknown.pipe(optional), provider: Schema.Struct({ executed: Schema.Boolean, - metadata: ProviderMetadata.pipe(Schema.optional), + metadata: ProviderMetadata.pipe(optional), }), }, }) @@ -373,15 +374,15 @@ export namespace Tool { export const RetryError = Schema.Struct({ message: Schema.String, - statusCode: Schema.Finite.pipe(Schema.optional), + statusCode: Schema.Finite.pipe(optional), isRetryable: Schema.Boolean, - responseHeaders: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), - responseBody: Schema.String.pipe(Schema.optional), - metadata: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), + responseHeaders: Schema.Record(Schema.String, Schema.String).pipe(optional), + responseBody: Schema.String.pipe(optional), + metadata: Schema.Record(Schema.String, Schema.String).pipe(optional), }).annotate({ identifier: "session.next.retry_error", }) -export type RetryError = typeof RetryError.Type +export interface RetryError extends Schema.Schema.Type {} export const Retried = Event.define({ type: "session.next.retried", diff --git a/packages/schema/src/session-input.ts b/packages/schema/src/session-input.ts index 219dfa10ec..40babac105 100644 --- a/packages/schema/src/session-input.ts +++ b/packages/schema/src/session-input.ts @@ -1,6 +1,7 @@ export * as SessionInput from "./session-input" import { Schema } from "effect" +import { optional } from "./schema" import { Prompt } from "./prompt" import { DateTimeUtcFromMillis, NonNegativeInt } from "./schema" import { SessionDelivery } from "./session-delivery" @@ -18,5 +19,5 @@ export const Admitted = Schema.Struct({ prompt: Prompt, delivery: Delivery, timeCreated: DateTimeUtcFromMillis, - promotedSeq: NonNegativeInt.pipe(Schema.optional), + promotedSeq: NonNegativeInt.pipe(optional), }).annotate({ identifier: "SessionInput.Admitted" }) diff --git a/packages/schema/src/session-message.ts b/packages/schema/src/session-message.ts index c365e08ba6..f32bb0582e 100644 --- a/packages/schema/src/session-message.ts +++ b/packages/schema/src/session-message.ts @@ -1,6 +1,7 @@ export * as SessionMessage from "./session-message" import { Schema } from "effect" +import { optional } from "./schema" import { ProviderMetadata, ToolContent } from "./llm" import { Model } from "./model" import { FileAttachment, Prompt } from "./prompt" @@ -22,7 +23,7 @@ export const UnknownError = Schema.Struct({ const Base = { id: ID, - metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), + metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(optional), time: Schema.Struct({ created: DateTimeUtcFromMillis }), } @@ -73,7 +74,7 @@ export const Shell = Schema.Struct({ output: Schema.String, time: Schema.Struct({ created: DateTimeUtcFromMillis, - completed: DateTimeUtcFromMillis.pipe(Schema.optional), + completed: DateTimeUtcFromMillis.pipe(optional), }), }).annotate({ identifier: "Session.Message.Shell" }) @@ -87,7 +88,7 @@ export interface ToolStateRunning extends Schema.Schema.Type {} @@ -107,9 +108,9 @@ export const ToolStateError = Schema.Struct({ status: Schema.Literal("error"), input: Schema.Record(Schema.String, Schema.Unknown), content: ToolContent.pipe(Schema.Array), - structured: Schema.Record(Schema.String, Schema.Any), + structured: Schema.Record(Schema.String, Schema.Unknown), error: UnknownError, - result: Schema.Unknown.pipe(Schema.optional), + result: Schema.Unknown.pipe(optional), }).annotate({ identifier: "Session.Message.ToolState.Error" }) export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]).pipe( @@ -124,15 +125,15 @@ export const AssistantTool = Schema.Struct({ name: Schema.String, provider: Schema.Struct({ executed: Schema.Boolean, - metadata: ProviderMetadata.pipe(Schema.optional), - resultMetadata: ProviderMetadata.pipe(Schema.optional), - }).pipe(Schema.optional), + metadata: ProviderMetadata.pipe(optional), + resultMetadata: ProviderMetadata.pipe(optional), + }).pipe(optional), state: ToolState, time: Schema.Struct({ created: DateTimeUtcFromMillis, - ran: DateTimeUtcFromMillis.pipe(Schema.optional), - completed: DateTimeUtcFromMillis.pipe(Schema.optional), - pruned: DateTimeUtcFromMillis.pipe(Schema.optional), + ran: DateTimeUtcFromMillis.pipe(optional), + completed: DateTimeUtcFromMillis.pipe(optional), + pruned: DateTimeUtcFromMillis.pipe(optional), }), }).annotate({ identifier: "Session.Message.Assistant.Tool" }) @@ -148,7 +149,7 @@ export const AssistantReasoning = Schema.Struct({ type: Schema.Literal("reasoning"), id: Schema.String, text: Schema.String, - providerMetadata: ProviderMetadata.pipe(Schema.optional), + providerMetadata: ProviderMetadata.pipe(optional), }).annotate({ identifier: "Session.Message.Assistant.Reasoning" }) export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool]).pipe( @@ -164,22 +165,22 @@ export const Assistant = Schema.Struct({ model: Model.Ref, content: AssistantContent.pipe(Schema.Array), snapshot: Schema.Struct({ - start: Schema.String.pipe(Schema.optional), - end: Schema.String.pipe(Schema.optional), - files: Schema.Array(RelativePath).pipe(Schema.optional), - }).pipe(Schema.optional), - finish: Schema.String.pipe(Schema.optional), - cost: Schema.Finite.pipe(Schema.optional), + start: Schema.String.pipe(optional), + end: Schema.String.pipe(optional), + files: Schema.Array(RelativePath).pipe(optional), + }).pipe(optional), + finish: Schema.String.pipe(optional), + cost: Schema.Finite.pipe(optional), tokens: Schema.Struct({ input: Schema.Finite, output: Schema.Finite, reasoning: Schema.Finite, cache: Schema.Struct({ read: Schema.Finite, write: Schema.Finite }), - }).pipe(Schema.optional), - error: UnknownError.pipe(Schema.optional), + }).pipe(optional), + error: UnknownError.pipe(optional), time: Schema.Struct({ created: DateTimeUtcFromMillis, - completed: DateTimeUtcFromMillis.pipe(Schema.optional), + completed: DateTimeUtcFromMillis.pipe(optional), }), }).annotate({ identifier: "Session.Message.Assistant" }) diff --git a/packages/schema/src/session-status-event.ts b/packages/schema/src/session-status-event.ts index 5233f97edb..f6a3022bcb 100644 --- a/packages/schema/src/session-status-event.ts +++ b/packages/schema/src/session-status-event.ts @@ -1,6 +1,7 @@ export * as SessionStatusEvent from "./session-status-event" import { Schema } from "effect" +import { optional } from "./schema" import { Event } from "./event" import { NonNegativeInt } from "./schema" import { SessionID } from "./session-id" @@ -13,14 +14,14 @@ export const Info = Schema.Union([ type: Schema.Literal("retry"), attempt: NonNegativeInt, message: Schema.String, - action: Schema.optional( + action: optional( Schema.Struct({ reason: Schema.String, provider: Schema.String, title: Schema.String, message: Schema.String, label: Schema.String, - link: Schema.optional(Schema.String), + link: optional(Schema.String), }), ), next: NonNegativeInt, diff --git a/packages/schema/src/session-todo.ts b/packages/schema/src/session-todo.ts index 5a05d51096..7f6a72bf00 100644 --- a/packages/schema/src/session-todo.ts +++ b/packages/schema/src/session-todo.ts @@ -9,9 +9,11 @@ export const Info = Schema.Struct({ status: Schema.String.annotate({ description: "Current status of the task: pending, in_progress, completed, cancelled", }), - priority: Schema.String.annotate({ description: "Priority level of the task: high, medium, low" }), + priority: Schema.String.annotate({ + description: "Priority level of the task: high, medium, low", + }), }).annotate({ identifier: "Todo" }) -export type Info = typeof Info.Type +export interface Info extends Schema.Schema.Type {} const Updated = define({ type: "todo.updated", diff --git a/packages/schema/src/session.ts b/packages/schema/src/session.ts index 1db7dd7f7c..937705eeb2 100644 --- a/packages/schema/src/session.ts +++ b/packages/schema/src/session.ts @@ -20,8 +20,8 @@ export const Info = Schema.Struct({ id: ID, parentID: ID.pipe(optional), projectID: Project.ID, - agent: Agent.ID.pipe(Schema.optional), - model: Model.Ref.pipe(Schema.optional), + agent: Agent.ID.pipe(optional), + model: Model.Ref.pipe(optional), cost: Schema.Finite, tokens: Schema.Struct({ input: Schema.Finite, @@ -35,17 +35,17 @@ export const Info = Schema.Struct({ time: Schema.Struct({ created: DateTimeUtcFromMillis, updated: DateTimeUtcFromMillis, - archived: DateTimeUtcFromMillis.pipe(Schema.optional), + archived: DateTimeUtcFromMillis.pipe(optional), }), title: Schema.String, location: Location.Ref, - subpath: RelativePath.pipe(Schema.optional), - revert: Revert.State.pipe(Schema.optional), + subpath: RelativePath.pipe(optional), + revert: Revert.State.pipe(optional), }).annotate({ identifier: "SessionV2.Info" }) export const ListAnchor = Schema.Struct({ id: ID, time: Schema.Finite, direction: Schema.Literals(["previous", "next"]), -}) -export type ListAnchor = typeof ListAnchor.Type +}).annotate({ identifier: "Session.ListAnchor" }) +export interface ListAnchor extends Schema.Schema.Type {} diff --git a/packages/schema/src/skill.ts b/packages/schema/src/skill.ts index 3a39e3be6e..ec299180ed 100644 --- a/packages/schema/src/skill.ts +++ b/packages/schema/src/skill.ts @@ -1,6 +1,7 @@ export * as Skill from "./skill" import { Schema } from "effect" +import { optional } from "./schema" import { AbsolutePath } from "./schema" export interface DirectorySource extends Schema.Schema.Type {} @@ -18,8 +19,8 @@ export const UrlSource = Schema.Struct({ export interface Info extends Schema.Schema.Type {} export const Info = Schema.Struct({ name: Schema.String, - description: Schema.String.pipe(Schema.optional), - slash: Schema.Boolean.pipe(Schema.optional), + description: Schema.String.pipe(optional), + slash: Schema.Boolean.pipe(optional), location: AbsolutePath, content: Schema.String, }).annotate({ identifier: "SkillV2.Info" }) diff --git a/packages/schema/src/tui-event.ts b/packages/schema/src/tui-event.ts index 41fa4dbeb6..800094e61e 100644 --- a/packages/schema/src/tui-event.ts +++ b/packages/schema/src/tui-event.ts @@ -1,6 +1,7 @@ export * as TuiEvent from "./tui-event" import { Effect, Schema } from "effect" +import { optional } from "./schema" import { Event } from "./event" import { PositiveInt } from "./schema" import { SessionID } from "./session-id" @@ -39,7 +40,7 @@ export const CommandExecute = Event.define({ export const ToastShow = Event.define({ type: "tui.toast.show", schema: { - title: Schema.optional(Schema.String), + title: optional(Schema.String), message: Schema.String, variant: Schema.Literals(["info", "success", "warning", "error"]), duration: PositiveInt.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_TOAST_DURATION))).annotate({ diff --git a/packages/schema/src/vcs-event.ts b/packages/schema/src/vcs-event.ts index ac1f74b98b..1c0d720dc4 100644 --- a/packages/schema/src/vcs-event.ts +++ b/packages/schema/src/vcs-event.ts @@ -1,12 +1,13 @@ export * as VcsEvent from "./vcs-event" import { Schema } from "effect" +import { optional } from "./schema" import { Event } from "./event" export const BranchUpdated = Event.define({ type: "vcs.branch.updated", schema: { - branch: Schema.optional(Schema.String), + branch: optional(Schema.String), }, }) diff --git a/packages/schema/src/workspace-event.ts b/packages/schema/src/workspace-event.ts index 687af2cc7c..82e15e0771 100644 --- a/packages/schema/src/workspace-event.ts +++ b/packages/schema/src/workspace-event.ts @@ -7,8 +7,8 @@ import { WorkspaceID } from "./workspace-id" export const ConnectionStatus = Schema.Struct({ workspaceID: WorkspaceID, status: Schema.Literals(["connected", "connecting", "disconnected", "error"]), -}) -export type ConnectionStatus = typeof ConnectionStatus.Type +}).annotate({ identifier: "WorkspaceEvent.ConnectionStatus" }) +export interface ConnectionStatus extends Schema.Schema.Type {} export const Ready = Event.define({ type: "workspace.ready", diff --git a/packages/schema/src/worktree-event.ts b/packages/schema/src/worktree-event.ts index 809fe8d9fe..c42ea5821e 100644 --- a/packages/schema/src/worktree-event.ts +++ b/packages/schema/src/worktree-event.ts @@ -1,13 +1,14 @@ export * as WorktreeEvent from "./worktree-event" import { Schema } from "effect" +import { optional } from "./schema" import { Event } from "./event" export const Ready = Event.define({ type: "worktree.ready", schema: { name: Schema.String, - branch: Schema.optional(Schema.String), + branch: optional(Schema.String), }, }) diff --git a/packages/schema/test/contract-hygiene.test.ts b/packages/schema/test/contract-hygiene.test.ts new file mode 100644 index 0000000000..cf83dbb288 --- /dev/null +++ b/packages/schema/test/contract-hygiene.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from "bun:test" +import { Schema } from "effect" +import { Agent } from "../src/agent" +import { FileSystem } from "../src/filesystem" +import { Model } from "../src/model" +import { Project } from "../src/project" +import { Pty } from "../src/pty" +import { Question } from "../src/question" +import { Session } from "../src/session" +import { SessionEvent } from "../src/session-event" +import { SessionTodo } from "../src/session-todo" +import { optional } from "../src/schema" + +describe("contract hygiene", () => { + test("optional properties preserve transformations and omit undefined while encoding", () => { + const Value = Schema.Struct({ value: optional(Schema.FiniteFromString) }) + expect(Schema.decodeUnknownSync(Value)({ value: "1" })).toEqual({ value: 1 }) + expect(Schema.encodeSync(Value)({ value: 1 })).toEqual({ value: "1" }) + expect(Schema.encodeSync(Value)({ value: undefined })).toEqual({}) + }) + + test("todo status and priority preserve arbitrary strings", () => { + const decode = Schema.decodeUnknownSync(SessionTodo.Info) + expect(decode({ content: "ship", status: "waiting", priority: "urgent" })).toEqual({ + content: "ship", + status: "waiting", + priority: "urgent", + }) + }) + + test("current ID constructors expose create", () => { + expect(Question.ID.create()).toStartWith("que_") + expect(Pty.ID.create()).toStartWith("pty_") + }) + + test("reusable public identifiers are stable and unique", () => { + const identifiers = [ + Agent.Color, + FileSystem.Submatch, + Model.Ref, + Model.Capabilities, + Model.Cost, + Model.Api, + Project.Icon, + Project.Commands, + Project.Time, + Project.Info, + Pty.Info, + Session.ListAnchor, + ].map((schema) => schema.ast.annotations?.identifier) + + expect(identifiers.every((identifier) => typeof identifier === "string")).toBe(true) + expect(new Set(identifiers).size).toBe(identifiers.length) + }) + + test("current source avoids Any and mutable contract wrappers", async () => { + const files = [...new Bun.Glob("*.ts").scanSync(new URL("../src", import.meta.url).pathname)].filter( + (file) => !file.endsWith("-v1.ts"), + ) + const source = await Promise.all( + files.map((file) => Bun.file(new URL(`../src/${file}`, import.meta.url)).text()), + ).then((values) => values.join("\n")) + + expect(source).not.toContain("Schema.Any") + expect(source).not.toContain("Schema.mutable") + }) +}) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 98a0be7b7c..08e93f02a2 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -111,6 +111,7 @@ import type { McpRemoteConfig, McpStatusErrors, McpStatusResponses, + ModelRef, MoveSessionDestination, OutputFormat, Part as Part2, @@ -129,10 +130,12 @@ import type { PermissionRuleset, PermissionV2Reply, PermissionV2Source, + ProjectCommands, ProjectCurrentErrors, ProjectCurrentResponses, ProjectDirectoriesErrors, ProjectDirectoriesResponses, + ProjectIcon, ProjectInitGitErrors, ProjectInitGitResponses, ProjectListErrors, @@ -2616,17 +2619,8 @@ export class Project extends HeyApiClient { directory?: string workspace?: string name?: string - icon?: { - url?: string - override?: string - color?: string - } - commands?: { - /** - * Startup script to run when creating a new workspace (worktree) - */ - start?: string - } + icon?: ProjectIcon + commands?: ProjectCommands }, options?: Options, ) { @@ -5471,11 +5465,7 @@ export class Session3 extends HeyApiClient { parameters?: { id?: string agent?: string - model?: { - id: string - providerID: string - variant?: string - } + model?: ModelRef location?: LocationRef }, options?: Options, @@ -5571,11 +5561,7 @@ export class Session3 extends HeyApiClient { public switchModel( parameters: { sessionID: string - model?: { - id: string - providerID: string - variant?: string - } + model?: ModelRef }, options?: Options, ) { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 2f22dc9e7f..cab35b9c27 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -835,11 +835,7 @@ export type GlobalEvent = { timestamp: number sessionID: string messageID: string - model: { - id: string - providerID: string - variant?: string - } + model: ModelRef } } | { @@ -923,11 +919,7 @@ export type GlobalEvent = { sessionID: string assistantMessageID: string agent: string - model: { - id: string - providerID: string - variant?: string - } + model: ModelRef snapshot?: string } } @@ -1003,11 +995,7 @@ export type GlobalEvent = { sessionID: string assistantMessageID: string reasoningID: string - providerMetadata?: { - [key: string]: { - [key: string]: unknown - } - } + providerMetadata?: LlmProviderMetadata } } | { @@ -1030,11 +1018,7 @@ export type GlobalEvent = { assistantMessageID: string reasoningID: string text: string - providerMetadata?: { - [key: string]: { - [key: string]: unknown - } - } + providerMetadata?: LlmProviderMetadata } } | { @@ -1084,11 +1068,7 @@ export type GlobalEvent = { } provider: { executed: boolean - metadata?: { - [key: string]: { - [key: string]: unknown - } - } + metadata?: LlmProviderMetadata } } } @@ -1103,7 +1083,7 @@ export type GlobalEvent = { structured: { [key: string]: unknown } - content: Array + content: Array } } | { @@ -1117,16 +1097,12 @@ export type GlobalEvent = { structured: { [key: string]: unknown } - content: Array + content: Array outputPaths?: Array result?: unknown provider: { executed: boolean - metadata?: { - [key: string]: { - [key: string]: unknown - } - } + metadata?: LlmProviderMetadata } } } @@ -1142,11 +1118,7 @@ export type GlobalEvent = { result?: unknown provider: { executed: boolean - metadata?: { - [key: string]: { - [key: string]: unknown - } - } + metadata?: LlmProviderMetadata } } } @@ -1198,13 +1170,7 @@ export type GlobalEvent = { properties: { timestamp: number sessionID: string - revert: { - messageID: string - partID?: string - snapshot?: string - diff?: string - files?: Array - } + revert: RevertState } } | { @@ -1516,24 +1482,11 @@ export type GlobalEvent = { properties: { id: string worktree: string - vcs?: "git" + vcs?: ProjectVcs name?: string - icon?: { - url?: string - override?: string - color?: string - } - commands?: { - /** - * Startup script to run when creating a new workspace (worktree) - */ - start?: string - } - time: { - created: number - updated: number - initialized?: number - } + icon?: ProjectIcon + commands?: ProjectCommands + time: ProjectTime sandboxes: Array } } @@ -2466,24 +2419,11 @@ export type McpServerNotFoundError = { export type Project = { id: string worktree: string - vcs?: "git" + vcs?: ProjectVcs name?: string - icon?: { - url?: string - override?: string - color?: string - } - commands?: { - /** - * Startup script to run when creating a new workspace (worktree) - */ - start?: string - } - time: { - created: number - updated: number - initialized?: number - } + icon?: ProjectIcon + commands?: ProjectCommands + time: ProjectTime sandboxes: Array } @@ -2976,6 +2916,12 @@ export type MoveSessionDestination = { directory: string } +export type ModelRef = { + id: string + providerID: string + variant?: string +} + export type LocationRef = { directory: string workspaceID?: string @@ -3005,6 +2951,12 @@ export type SessionErrorUnknown = { message: string } +export type LlmProviderMetadata = { + [key: string]: { + [key: string]: unknown + } +} + export type ToolTextContent = { type: "text" text: string @@ -3017,6 +2969,8 @@ export type ToolFileContent = { name?: string } +export type LlmToolContent = ToolTextContent | ToolFileContent + export type SessionNextRetryError = { message: string statusCode?: number @@ -3038,6 +2992,14 @@ export type FileDiff = { patch: string } +export type RevertState = { + messageID: string + partID?: string + snapshot?: string + diff?: string + files?: Array +} + export type PermissionV2Source = { type: "tool" messageID: string @@ -3081,6 +3043,27 @@ export type QuestionV2Tool = { export type QuestionV2Answer = Array +export type ProjectVcs = "git" + +export type ProjectIcon = { + url?: string + override?: string + color?: string +} + +export type ProjectCommands = { + /** + * Startup script to run when creating a new workspace (worktree) + */ + start?: string +} + +export type ProjectTime = { + created: number + updated: number + initialized?: number +} + export type EventServerInstanceDisposed = { id: string type: "server.instance.disposed" @@ -3225,11 +3208,7 @@ export type SyncEventSessionNextModelSwitched = { timestamp: number sessionID: string messageID: string - model: { - id: string - providerID: string - variant?: string - } + model: ModelRef } } } @@ -3369,11 +3348,7 @@ export type SyncEventSessionNextStepStarted = { sessionID: string assistantMessageID: string agent: string - model: { - id: string - providerID: string - variant?: string - } + model: ModelRef snapshot?: string } } @@ -3473,11 +3448,7 @@ export type SyncEventSessionNextReasoningStarted = { sessionID: string assistantMessageID: string reasoningID: string - providerMetadata?: { - [key: string]: { - [key: string]: unknown - } - } + providerMetadata?: LlmProviderMetadata } } } @@ -3496,11 +3467,7 @@ export type SyncEventSessionNextReasoningEnded = { assistantMessageID: string reasoningID: string text: string - providerMetadata?: { - [key: string]: { - [key: string]: unknown - } - } + providerMetadata?: LlmProviderMetadata } } } @@ -3560,11 +3527,7 @@ export type SyncEventSessionNextToolCalled = { } provider: { executed: boolean - metadata?: { - [key: string]: { - [key: string]: unknown - } - } + metadata?: LlmProviderMetadata } } } @@ -3586,7 +3549,7 @@ export type SyncEventSessionNextToolProgress = { structured: { [key: string]: unknown } - content: Array + content: Array } } } @@ -3607,16 +3570,12 @@ export type SyncEventSessionNextToolSuccess = { structured: { [key: string]: unknown } - content: Array + content: Array outputPaths?: Array result?: unknown provider: { executed: boolean - metadata?: { - [key: string]: { - [key: string]: unknown - } - } + metadata?: LlmProviderMetadata } } } @@ -3639,11 +3598,7 @@ export type SyncEventSessionNextToolFailed = { result?: unknown provider: { executed: boolean - metadata?: { - [key: string]: { - [key: string]: unknown - } - } + metadata?: LlmProviderMetadata } } } @@ -3713,13 +3668,7 @@ export type SyncEventSessionNextRevertStaged = { data: { timestamp: number sessionID: string - revert: { - messageID: string - partID?: string - snapshot?: string - diff?: string - files?: Array - } + revert: RevertState } } } @@ -3781,6 +3730,16 @@ export type ProjectDirectories = Array<{ strategy?: string }> +export type PtyTicketConnectToken = { + ticket: string + expires_in: number +} + +export type WorkspaceEventConnectionStatus = { + workspaceID: string + status: "connected" | "connecting" | "disconnected" | "error" +} + export type LocationInfo = { directory: string workspaceID?: string @@ -3790,6 +3749,17 @@ export type LocationInfo = { } } +export type ProviderRequest = { + headers: { + [key: string]: string + } + body: { + [key: string]: unknown + } +} + +export type AgentColor = string | "primary" | "secondary" | "accent" | "success" | "warning" | "error" | "info" + export type PermissionV2Effect = "allow" | "deny" | "ask" export type PermissionV2Rule = { @@ -3802,24 +3772,13 @@ export type PermissionV2Ruleset = Array export type AgentV2Info = { id: string - model?: { - id: string - providerID: string - variant?: string - } - request: { - headers: { - [key: string]: string - } - body: { - [key: string]: unknown - } - } + model?: ModelRef + request: ProviderRequest system?: string description?: string mode: "subagent" | "primary" | "all" hidden: boolean - color?: string | "primary" | "secondary" | "accent" | "success" | "warning" | "error" | "info" + color?: AgentColor steps?: number permissions: PermissionV2Ruleset } @@ -3829,11 +3788,7 @@ export type SessionV2Info = { parentID?: string projectID: string agent?: string - model?: { - id: string - providerID: string - variant?: string - } + model?: ModelRef cost: number tokens: { input: number @@ -3852,13 +3807,7 @@ export type SessionV2Info = { title: string location: LocationRef subpath?: string - revert?: { - messageID: string - partID?: string - snapshot?: string - diff?: string - files?: Array - } + revert?: RevertState } export type SessionInputAdmitted = { @@ -3892,11 +3841,7 @@ export type SessionMessageModelSwitched = { created: number } type: "model-switched" - model: { - id: string - providerID: string - variant?: string - } + model: ModelRef } export type SessionMessageUser = { @@ -3963,11 +3908,7 @@ export type SessionMessageAssistantReasoning = { type: "reasoning" id: string text: string - providerMetadata?: { - [key: string]: { - [key: string]: unknown - } - } + providerMetadata?: LlmProviderMetadata } export type SessionMessageToolStatePending = { @@ -3983,7 +3924,7 @@ export type SessionMessageToolStateRunning = { structured: { [key: string]: unknown } - content: Array + content: Array } export type SessionMessageToolStateCompleted = { @@ -3992,7 +3933,7 @@ export type SessionMessageToolStateCompleted = { [key: string]: unknown } attachments?: Array - content: Array + content: Array outputPaths?: Array structured: { [key: string]: unknown @@ -4005,7 +3946,7 @@ export type SessionMessageToolStateError = { input: { [key: string]: unknown } - content: Array + content: Array structured: { [key: string]: unknown } @@ -4019,16 +3960,8 @@ export type SessionMessageAssistantTool = { name: string provider?: { executed: boolean - metadata?: { - [key: string]: { - [key: string]: unknown - } - } - resultMetadata?: { - [key: string]: { - [key: string]: unknown - } - } + metadata?: LlmProviderMetadata + resultMetadata?: LlmProviderMetadata } state: | SessionMessageToolStatePending @@ -4054,11 +3987,7 @@ export type SessionMessageAssistant = { } type: "assistant" agent: string - model: { - id: string - providerID: string - variant?: string - } + model: ModelRef content: Array snapshot?: { start?: string @@ -4103,34 +4032,51 @@ export type SessionMessage = | SessionMessageAssistant | SessionMessageCompaction +export type ModelApi = + | { + id: string + type: "aisdk" + package: string + url?: string + settings?: { + [key: string]: unknown + } + } + | { + id: string + type: "native" + url?: string + settings: { + [key: string]: unknown + } + } + +export type ModelCapabilities = { + tools: boolean + input: Array + output: Array +} + +export type ModelCost = { + tier?: { + type: "context" + size: number + } + input: number + output: number + cache: { + read: number + write: number + } +} + export type ModelV2Info = { id: string providerID: string family?: string name: string - api: - | { - id: string - type: "aisdk" - package: string - url?: string - settings?: { - [key: string]: unknown - } - } - | { - id: string - type: "native" - url?: string - settings: { - [key: string]: unknown - } - } - capabilities: { - tools: boolean - input: Array - output: Array - } + api: ModelApi + capabilities: ModelCapabilities request: { headers: { [key: string]: string @@ -4152,18 +4098,7 @@ export type ModelV2Info = { time: { released: number } - cost: Array<{ - tier?: { - type: "context" - size: number - } - input: number - output: number - cache: { - read: number - write: number - } - }> + cost: Array status: "alpha" | "beta" | "deprecated" | "active" enabled: boolean limit: { @@ -4173,35 +4108,32 @@ export type ModelV2Info = { } } +export type ProviderAisdk = { + type: "aisdk" + package: string + url?: string + settings?: { + [key: string]: unknown + } +} + +export type ProviderNative = { + type: "native" + url?: string + settings: { + [key: string]: unknown + } +} + +export type ProviderApi = ProviderAisdk | ProviderNative + export type ProviderV2Info = { id: string integrationID?: string name: string disabled?: boolean - api: - | { - type: "aisdk" - package: string - url?: string - settings?: { - [key: string]: unknown - } - } - | { - type: "native" - url?: string - settings: { - [key: string]: unknown - } - } - request: { - headers: { - [key: string]: string - } - body: { - [key: string]: unknown - } - } + api: ProviderApi + request: ProviderRequest } export type IntegrationWhen = { @@ -4278,6 +4210,37 @@ export type IntegrationAttempt = { } } +export type IntegrationAttemptStatus = + | { + status: "pending" + time: { + created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + } + | { + status: "complete" + time: { + created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + } + | { + status: "failed" + message: string + time: { + created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + } + | { + status: "expired" + time: { + created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + } + export type PermissionV2Request = { id: string sessionID: string @@ -4307,11 +4270,7 @@ export type CommandV2Info = { template: string description?: string agent?: string - model?: { - id: string - providerID: string - variant?: string - } + model?: ModelRef subtask?: boolean } @@ -4555,11 +4514,7 @@ export type V2EventSessionNextModelSwitched = { timestamp: number sessionID: string messageID: string - model: { - id: string - providerID: string - variant?: string - } + model: ModelRef } } @@ -4723,11 +4678,7 @@ export type V2EventSessionNextStepStarted = { sessionID: string assistantMessageID: string agent: string - model: { - id: string - providerID: string - variant?: string - } + model: ModelRef snapshot?: string } } @@ -4863,11 +4814,7 @@ export type V2EventSessionNextReasoningStarted = { sessionID: string assistantMessageID: string reasoningID: string - providerMetadata?: { - [key: string]: { - [key: string]: unknown - } - } + providerMetadata?: LlmProviderMetadata } } @@ -4910,11 +4857,7 @@ export type V2EventSessionNextReasoningEnded = { assistantMessageID: string reasoningID: string text: string - providerMetadata?: { - [key: string]: { - [key: string]: unknown - } - } + providerMetadata?: LlmProviderMetadata } } @@ -5004,11 +4947,7 @@ export type V2EventSessionNextToolCalled = { } provider: { executed: boolean - metadata?: { - [key: string]: { - [key: string]: unknown - } - } + metadata?: LlmProviderMetadata } } } @@ -5033,7 +4972,7 @@ export type V2EventSessionNextToolProgress = { structured: { [key: string]: unknown } - content: Array + content: Array } } @@ -5057,16 +4996,12 @@ export type V2EventSessionNextToolSuccess = { structured: { [key: string]: unknown } - content: Array + content: Array outputPaths?: Array result?: unknown provider: { executed: boolean - metadata?: { - [key: string]: { - [key: string]: unknown - } - } + metadata?: LlmProviderMetadata } } } @@ -5092,11 +5027,7 @@ export type V2EventSessionNextToolFailed = { result?: unknown provider: { executed: boolean - metadata?: { - [key: string]: { - [key: string]: unknown - } - } + metadata?: LlmProviderMetadata } } } @@ -5198,13 +5129,7 @@ export type V2EventSessionNextRevertStaged = { data: { timestamp: number sessionID: string - revert: { - messageID: string - partID?: string - snapshot?: string - diff?: string - files?: Array - } + revert: RevertState } } @@ -5846,24 +5771,11 @@ export type V2EventProjectUpdated = { data: { id: string worktree: string - vcs?: "git" + vcs?: ProjectVcs name?: string - icon?: { - url?: string - override?: string - color?: string - } - commands?: { - /** - * Startup script to run when creating a new workspace (worktree) - */ - start?: string - } - time: { - created: number - updated: number - initialized?: number - } + icon?: ProjectIcon + commands?: ProjectCommands + time: ProjectTime sandboxes: Array } } @@ -6150,12 +6062,14 @@ export type ReferenceGitSource = { hidden?: boolean } +export type ReferenceSource = ReferenceLocalSource | ReferenceGitSource + export type ReferenceInfo = { name: string path: string description?: string hidden?: boolean - source: ReferenceLocalSource | ReferenceGitSource + source: ReferenceSource } export type ProjectCopyCopy = { @@ -6277,11 +6191,7 @@ export type EventSessionNextModelSwitched = { timestamp: number sessionID: string messageID: string - model: { - id: string - providerID: string - variant?: string - } + model: ModelRef } } @@ -6373,11 +6283,7 @@ export type EventSessionNextStepStarted = { sessionID: string assistantMessageID: string agent: string - model: { - id: string - providerID: string - variant?: string - } + model: ModelRef snapshot?: string } } @@ -6459,11 +6365,7 @@ export type EventSessionNextReasoningStarted = { sessionID: string assistantMessageID: string reasoningID: string - providerMetadata?: { - [key: string]: { - [key: string]: unknown - } - } + providerMetadata?: LlmProviderMetadata } } @@ -6488,11 +6390,7 @@ export type EventSessionNextReasoningEnded = { assistantMessageID: string reasoningID: string text: string - providerMetadata?: { - [key: string]: { - [key: string]: unknown - } - } + providerMetadata?: LlmProviderMetadata } } @@ -6546,11 +6444,7 @@ export type EventSessionNextToolCalled = { } provider: { executed: boolean - metadata?: { - [key: string]: { - [key: string]: unknown - } - } + metadata?: LlmProviderMetadata } } } @@ -6566,7 +6460,7 @@ export type EventSessionNextToolProgress = { structured: { [key: string]: unknown } - content: Array + content: Array } } @@ -6581,16 +6475,12 @@ export type EventSessionNextToolSuccess = { structured: { [key: string]: unknown } - content: Array + content: Array outputPaths?: Array result?: unknown provider: { executed: boolean - metadata?: { - [key: string]: { - [key: string]: unknown - } - } + metadata?: LlmProviderMetadata } } } @@ -6607,11 +6497,7 @@ export type EventSessionNextToolFailed = { result?: unknown provider: { executed: boolean - metadata?: { - [key: string]: { - [key: string]: unknown - } - } + metadata?: LlmProviderMetadata } } } @@ -6668,13 +6554,7 @@ export type EventSessionNextRevertStaged = { properties: { timestamp: number sessionID: string - revert: { - messageID: string - partID?: string - snapshot?: string - diff?: string - files?: Array - } + revert: RevertState } } @@ -6964,24 +6844,11 @@ export type EventProjectUpdated = { properties: { id: string worktree: string - vcs?: "git" + vcs?: ProjectVcs name?: string - icon?: { - url?: string - override?: string - color?: string - } - commands?: { - /** - * Startup script to run when creating a new workspace (worktree) - */ - start?: string - } - time: { - created: number - updated: number - initialized?: number - } + icon?: ProjectIcon + commands?: ProjectCommands + time: ProjectTime sandboxes: Array } } @@ -8847,17 +8714,8 @@ export type ProjectInitGitResponse = ProjectInitGitResponses[keyof ProjectInitGi export type ProjectUpdateData = { body?: { name?: string - icon?: { - url?: string - override?: string - color?: string - } - commands?: { - /** - * Startup script to run when creating a new workspace (worktree) - */ - start?: string - } + icon?: ProjectIcon + commands?: ProjectCommands } path: { projectID: string @@ -9194,10 +9052,7 @@ export type PtyConnectTokenResponses = { /** * WebSocket connect token */ - 200: { - ticket: string - expires_in: number - } + 200: PtyTicketConnectToken } export type PtyConnectTokenResponse = PtyConnectTokenResponses[keyof PtyConnectTokenResponses] @@ -11222,10 +11077,7 @@ export type ExperimentalWorkspaceStatusResponses = { /** * Workspace status */ - 200: Array<{ - workspaceID: string - status: "connected" | "connecting" | "disconnected" | "error" - }> + 200: Array } export type ExperimentalWorkspaceStatusResponse = @@ -11447,11 +11299,7 @@ export type V2SessionCreateData = { body: { id?: string agent?: string - model?: { - id: string - providerID: string - variant?: string - } + model?: ModelRef location?: LocationRef } path?: never @@ -11559,11 +11407,7 @@ export type V2SessionSwitchAgentResponse = V2SessionSwitchAgentResponses[keyof V export type V2SessionSwitchModelData = { body: { - model: { - id: string - providerID: string - variant?: string - } + model: ModelRef } path: { sessionID: string @@ -11760,13 +11604,7 @@ export type V2SessionRevertStageResponses = { * Success */ 200: { - data: { - messageID: string - partID?: string - snapshot?: string - diff?: string - files?: Array - } + data: RevertState } } @@ -12293,36 +12131,7 @@ export type V2IntegrationAttemptStatusResponses = { */ 200: { location: LocationInfo - data: - | { - status: "pending" - time: { - created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } - } - | { - status: "complete" - time: { - created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } - } - | { - status: "failed" - message: string - time: { - created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } - } - | { - status: "expired" - time: { - created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } - } + data: IntegrationAttemptStatus } } @@ -13180,10 +12989,7 @@ export type V2PtyConnectTokenResponses = { */ 200: { location: LocationInfo - data: { - ticket: string - expires_in: number - } + data: PtyTicketConnectToken } } From ecabab49a7ca393bc5d9bdf9bed5499dc7c40022 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 25 Jun 2026 17:13:12 +0000 Subject: [PATCH 08/26] chore: generate --- packages/core/src/reference.ts | 14 +- packages/sdk/openapi.json | 1879 ++++++++++++-------------------- 2 files changed, 684 insertions(+), 1209 deletions(-) diff --git a/packages/core/src/reference.ts b/packages/core/src/reference.ts index 31c23a2161..36065ff920 100644 --- a/packages/core/src/reference.ts +++ b/packages/core/src/reference.ts @@ -86,13 +86,13 @@ export const layer = Layer.effect( seen.set(target, source.branch) materialized.set( name, - new Info({ - name, - path: AbsolutePath.make(target), - ...(source.description === undefined ? {} : { description: source.description }), - ...(source.hidden === undefined ? {} : { hidden: source.hidden }), - source, - }), + new Info({ + name, + path: AbsolutePath.make(target), + ...(source.description === undefined ? {} : { description: source.description }), + ...(source.hidden === undefined ? {} : { hidden: source.hidden }), + source, + }), ) yield* cache.ensure({ reference: repository, branch: source.branch, refresh: true }).pipe( Effect.catchCause((cause) => diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index c831ba8dfb..be02709256 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -3846,29 +3846,10 @@ "type": "string" }, "icon": { - "type": "object", - "properties": { - "url": { - "type": "string" - }, - "override": { - "type": "string" - }, - "color": { - "type": "string" - } - }, - "additionalProperties": false + "$ref": "#/components/schemas/ProjectIcon" }, "commands": { - "type": "object", - "properties": { - "start": { - "type": "string", - "description": "Startup script to run when creating a new workspace (worktree)" - } - }, - "additionalProperties": false + "$ref": "#/components/schemas/ProjectCommands" } }, "additionalProperties": false @@ -4541,19 +4522,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "ticket": { - "type": "string" - }, - "expires_in": { - "type": "integer", - "exclusiveMinimum": 0 - } - }, - "required": ["ticket", "expires_in"], - "additionalProperties": false, - "description": "WebSocket connect token" + "$ref": "#/components/schemas/PtyTicketConnectToken" } } } @@ -9612,19 +9581,7 @@ "schema": { "type": "array", "items": { - "type": "object", - "properties": { - "workspaceID": { - "type": "string", - "pattern": "^wrk" - }, - "status": { - "type": "string", - "enum": ["connected", "connecting", "disconnected", "error"] - } - }, - "required": ["workspaceID", "status"], - "additionalProperties": false + "$ref": "#/components/schemas/WorkspaceEventConnectionStatus" }, "description": "Workspace status" } @@ -10210,20 +10167,7 @@ "type": "string" }, "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false + "$ref": "#/components/schemas/ModelRef" }, "location": { "$ref": "#/components/schemas/LocationRef" @@ -10477,20 +10421,7 @@ "type": "object", "properties": { "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false + "$ref": "#/components/schemas/ModelRef" } }, "required": ["model"], @@ -10809,30 +10740,7 @@ "type": "object", "properties": { "data": { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "pattern": "^msg_" - }, - "partID": { - "type": "string" - }, - "snapshot": { - "type": "string" - }, - "diff": { - "type": "string" - }, - "files": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FileDiff" - } - } - }, - "required": ["messageID"], - "additionalProperties": false + "$ref": "#/components/schemas/RevertState" } }, "required": ["data"], @@ -11996,267 +11904,7 @@ "$ref": "#/components/schemas/LocationInfo" }, "data": { - "anyOf": [ - { - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": ["pending"] - }, - "time": { - "type": "object", - "properties": { - "created": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - }, - "expires": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - } - }, - "required": ["created", "expires"], - "additionalProperties": false - } - }, - "required": ["status", "time"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": ["complete"] - }, - "time": { - "type": "object", - "properties": { - "created": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - }, - "expires": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - } - }, - "required": ["created", "expires"], - "additionalProperties": false - } - }, - "required": ["status", "time"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": ["failed"] - }, - "message": { - "type": "string" - }, - "time": { - "type": "object", - "properties": { - "created": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - }, - "expires": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - } - }, - "required": ["created", "expires"], - "additionalProperties": false - } - }, - "required": ["status", "message", "time"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": ["expired"] - }, - "time": { - "type": "object", - "properties": { - "created": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - }, - "expires": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - } - }, - "required": ["created", "expires"], - "additionalProperties": false - } - }, - "required": ["status", "time"], - "additionalProperties": false - } - ] + "$ref": "#/components/schemas/IntegrationAttemptStatus" } }, "required": ["location", "data"], @@ -14231,18 +13879,7 @@ "$ref": "#/components/schemas/LocationInfo" }, "data": { - "type": "object", - "properties": { - "ticket": { - "type": "string" - }, - "expires_in": { - "type": "integer", - "exclusiveMinimum": 0 - } - }, - "required": ["ticket", "expires_in"], - "additionalProperties": false + "$ref": "#/components/schemas/PtyTicketConnectToken" } }, "required": ["location", "data"], @@ -17661,20 +17298,7 @@ "pattern": "^msg_" }, "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false + "$ref": "#/components/schemas/ModelRef" } }, "required": ["timestamp", "sessionID", "messageID", "model"], @@ -17974,20 +17598,7 @@ "type": "string" }, "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false + "$ref": "#/components/schemas/ModelRef" }, "snapshot": { "type": "string" @@ -18256,10 +17867,7 @@ "type": "string" }, "providerMetadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "$ref": "#/components/schemas/LLMProviderMetadata" } }, "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID"], @@ -18340,10 +17948,7 @@ "type": "string" }, "providerMetadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "$ref": "#/components/schemas/LLMProviderMetadata" } }, "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID", "text"], @@ -18511,10 +18116,7 @@ "type": "boolean" }, "metadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "$ref": "#/components/schemas/LLMProviderMetadata" } }, "required": ["executed"], @@ -18562,14 +18164,7 @@ "content": { "type": "array", "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/ToolTextContent" - }, - { - "$ref": "#/components/schemas/ToolFileContent" - } - ] + "$ref": "#/components/schemas/LLMToolContent" } } }, @@ -18614,14 +18209,7 @@ "content": { "type": "array", "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/ToolTextContent" - }, - { - "$ref": "#/components/schemas/ToolFileContent" - } - ] + "$ref": "#/components/schemas/LLMToolContent" } }, "outputPaths": { @@ -18638,10 +18226,7 @@ "type": "boolean" }, "metadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "$ref": "#/components/schemas/LLMProviderMetadata" } }, "required": ["executed"], @@ -18702,10 +18287,7 @@ "type": "boolean" }, "metadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "$ref": "#/components/schemas/LLMProviderMetadata" } }, "required": ["executed"], @@ -18892,30 +18474,7 @@ "pattern": "^ses" }, "revert": { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "pattern": "^msg_" - }, - "partID": { - "type": "string" - }, - "snapshot": { - "type": "string" - }, - "diff": { - "type": "string" - }, - "files": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FileDiff" - } - } - }, - "required": ["messageID"], - "additionalProperties": false + "$ref": "#/components/schemas/RevertState" } }, "required": ["timestamp", "sessionID", "revert"], @@ -19975,55 +19534,19 @@ "type": "string" }, "vcs": { - "type": "string", - "enum": ["git"] + "$ref": "#/components/schemas/ProjectVcs" }, "name": { "type": "string" }, "icon": { - "type": "object", - "properties": { - "url": { - "type": "string" - }, - "override": { - "type": "string" - }, - "color": { - "type": "string" - } - }, - "additionalProperties": false + "$ref": "#/components/schemas/ProjectIcon" }, "commands": { - "type": "object", - "properties": { - "start": { - "type": "string", - "description": "Startup script to run when creating a new workspace (worktree)" - } - }, - "additionalProperties": false + "$ref": "#/components/schemas/ProjectCommands" }, "time": { - "type": "object", - "properties": { - "created": { - "type": "integer", - "minimum": 0 - }, - "updated": { - "type": "integer", - "minimum": 0 - }, - "initialized": { - "type": "integer", - "minimum": 0 - } - }, - "required": ["created", "updated"], - "additionalProperties": false + "$ref": "#/components/schemas/ProjectTime" }, "sandboxes": { "type": "array", @@ -22705,55 +22228,19 @@ "type": "string" }, "vcs": { - "type": "string", - "enum": ["git"] + "$ref": "#/components/schemas/ProjectVcs" }, "name": { "type": "string" }, "icon": { - "type": "object", - "properties": { - "url": { - "type": "string" - }, - "override": { - "type": "string" - }, - "color": { - "type": "string" - } - }, - "additionalProperties": false + "$ref": "#/components/schemas/ProjectIcon" }, "commands": { - "type": "object", - "properties": { - "start": { - "type": "string", - "description": "Startup script to run when creating a new workspace (worktree)" - } - }, - "additionalProperties": false + "$ref": "#/components/schemas/ProjectCommands" }, "time": { - "type": "object", - "properties": { - "created": { - "type": "integer", - "minimum": 0 - }, - "updated": { - "type": "integer", - "minimum": 0 - }, - "initialized": { - "type": "integer", - "minimum": 0 - } - }, - "required": ["created", "updated"], - "additionalProperties": false + "$ref": "#/components/schemas/ProjectTime" }, "sandboxes": { "type": "array", @@ -24200,6 +23687,22 @@ "required": ["directory"], "additionalProperties": false }, + "ModelRef": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"], + "additionalProperties": false + }, "LocationRef": { "type": "object", "properties": { @@ -24279,6 +23782,12 @@ "required": ["type", "message"], "additionalProperties": false }, + "LLMProviderMetadata": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, "ToolTextContent": { "type": "object", "properties": { @@ -24313,6 +23822,16 @@ "required": ["type", "uri", "mime"], "additionalProperties": false }, + "LLMToolContent": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolTextContent" + }, + { + "$ref": "#/components/schemas/ToolFileContent" + } + ] + }, "SessionNextRetry_error": { "type": "object", "properties": { @@ -24369,6 +23888,32 @@ "required": ["path", "status", "additions", "deletions", "patch"], "additionalProperties": false }, + "RevertState": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "partID": { + "type": "string" + }, + "snapshot": { + "type": "string" + }, + "diff": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FileDiff" + } + } + }, + "required": ["messageID"], + "additionalProperties": false + }, "PermissionV2Source": { "type": "object", "properties": { @@ -24452,6 +23997,54 @@ "type": "string" } }, + "ProjectVcs": { + "type": "string", + "enum": ["git"] + }, + "ProjectIcon": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "override": { + "type": "string" + }, + "color": { + "type": "string" + } + }, + "additionalProperties": false + }, + "ProjectCommands": { + "type": "object", + "properties": { + "start": { + "type": "string", + "description": "Startup script to run when creating a new workspace (worktree)" + } + }, + "additionalProperties": false + }, + "ProjectTime": { + "type": "object", + "properties": { + "created": { + "type": "integer", + "minimum": 0 + }, + "updated": { + "type": "integer", + "minimum": 0 + }, + "initialized": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["created", "updated"], + "additionalProperties": false + }, "EventServerInstanceDisposed": { "type": "object", "properties": { @@ -24935,20 +24528,7 @@ "pattern": "^msg_" }, "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false + "$ref": "#/components/schemas/ModelRef" } }, "required": ["timestamp", "sessionID", "messageID", "model"], @@ -25416,20 +24996,7 @@ "type": "string" }, "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false + "$ref": "#/components/schemas/ModelRef" }, "snapshot": { "type": "string" @@ -25764,10 +25331,7 @@ "type": "string" }, "providerMetadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "$ref": "#/components/schemas/LLMProviderMetadata" } }, "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID"], @@ -25830,10 +25394,7 @@ "type": "string" }, "providerMetadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "$ref": "#/components/schemas/LLMProviderMetadata" } }, "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID", "text"], @@ -26025,10 +25586,7 @@ "type": "boolean" }, "metadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "$ref": "#/components/schemas/LLMProviderMetadata" } }, "required": ["executed"], @@ -26097,14 +25655,7 @@ "content": { "type": "array", "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/ToolTextContent" - }, - { - "$ref": "#/components/schemas/ToolFileContent" - } - ] + "$ref": "#/components/schemas/LLMToolContent" } } }, @@ -26170,14 +25721,7 @@ "content": { "type": "array", "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/ToolTextContent" - }, - { - "$ref": "#/components/schemas/ToolFileContent" - } - ] + "$ref": "#/components/schemas/LLMToolContent" } }, "outputPaths": { @@ -26194,10 +25738,7 @@ "type": "boolean" }, "metadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "$ref": "#/components/schemas/LLMProviderMetadata" } }, "required": ["executed"], @@ -26279,10 +25820,7 @@ "type": "boolean" }, "metadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "$ref": "#/components/schemas/LLMProviderMetadata" } }, "required": ["executed"], @@ -26517,30 +26055,7 @@ "pattern": "^ses" }, "revert": { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "pattern": "^msg_" - }, - "partID": { - "type": "string" - }, - "snapshot": { - "type": "string" - }, - "diff": { - "type": "string" - }, - "files": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FileDiff" - } - } - }, - "required": ["messageID"], - "additionalProperties": false + "$ref": "#/components/schemas/RevertState" } }, "required": ["timestamp", "sessionID", "revert"], @@ -26730,6 +26245,35 @@ "additionalProperties": false } }, + "PtyTicketConnectToken": { + "type": "object", + "properties": { + "ticket": { + "type": "string" + }, + "expires_in": { + "type": "integer", + "exclusiveMinimum": 0 + } + }, + "required": ["ticket", "expires_in"], + "additionalProperties": false + }, + "WorkspaceEventConnectionStatus": { + "type": "object", + "properties": { + "workspaceID": { + "type": "string", + "pattern": "^wrk" + }, + "status": { + "type": "string", + "enum": ["connected", "connecting", "disconnected", "error"] + } + }, + "required": ["workspaceID", "status"], + "additionalProperties": false + }, "LocationInfo": { "type": "object", "properties": { @@ -26757,6 +26301,34 @@ "required": ["directory", "project"], "additionalProperties": false }, + "ProviderRequest": { + "type": "object", + "properties": { + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "body": { + "type": "object" + } + }, + "required": ["headers", "body"], + "additionalProperties": false + }, + "AgentColor": { + "anyOf": [ + { + "type": "string", + "pattern": "^#[0-9a-fA-F]{6}$" + }, + { + "type": "string", + "enum": ["primary", "secondary", "accent", "success", "warning", "error", "info"] + } + ] + }, "PermissionV2Effect": { "type": "string", "enum": ["allow", "deny", "ask"] @@ -26790,36 +26362,10 @@ "type": "string" }, "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false + "$ref": "#/components/schemas/ModelRef" }, "request": { - "type": "object", - "properties": { - "headers": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "body": { - "type": "object" - } - }, - "required": ["headers", "body"], - "additionalProperties": false + "$ref": "#/components/schemas/ProviderRequest" }, "system": { "type": "string" @@ -26835,16 +26381,7 @@ "type": "boolean" }, "color": { - "anyOf": [ - { - "type": "string", - "pattern": "^#[0-9a-fA-F]{6}$" - }, - { - "type": "string", - "enum": ["primary", "secondary", "accent", "success", "warning", "error", "info"] - } - ] + "$ref": "#/components/schemas/AgentColor" }, "steps": { "type": "integer", @@ -26875,20 +26412,7 @@ "type": "string" }, "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false + "$ref": "#/components/schemas/ModelRef" }, "cost": { "type": "number" @@ -26948,30 +26472,7 @@ "type": "string" }, "revert": { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "pattern": "^msg_" - }, - "partID": { - "type": "string" - }, - "snapshot": { - "type": "string" - }, - "diff": { - "type": "string" - }, - "files": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FileDiff" - } - } - }, - "required": ["messageID"], - "additionalProperties": false + "$ref": "#/components/schemas/RevertState" } }, "required": ["id", "projectID", "cost", "tokens", "time", "title", "location"], @@ -27066,20 +26567,7 @@ "enum": ["model-switched"] }, "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false + "$ref": "#/components/schemas/ModelRef" } }, "required": ["id", "time", "type", "model"], @@ -27265,10 +26753,7 @@ "type": "string" }, "providerMetadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "$ref": "#/components/schemas/LLMProviderMetadata" } }, "required": ["type", "id", "text"], @@ -27304,14 +26789,7 @@ "content": { "type": "array", "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/ToolTextContent" - }, - { - "$ref": "#/components/schemas/ToolFileContent" - } - ] + "$ref": "#/components/schemas/LLMToolContent" } } }, @@ -27337,14 +26815,7 @@ "content": { "type": "array", "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/ToolTextContent" - }, - { - "$ref": "#/components/schemas/ToolFileContent" - } - ] + "$ref": "#/components/schemas/LLMToolContent" } }, "outputPaths": { @@ -27374,14 +26845,7 @@ "content": { "type": "array", "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/ToolTextContent" - }, - { - "$ref": "#/components/schemas/ToolFileContent" - } - ] + "$ref": "#/components/schemas/LLMToolContent" } }, "structured": { @@ -27415,16 +26879,10 @@ "type": "boolean" }, "metadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "$ref": "#/components/schemas/LLMProviderMetadata" }, "resultMetadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "$ref": "#/components/schemas/LLMProviderMetadata" } }, "required": ["executed"], @@ -27500,20 +26958,7 @@ "type": "string" }, "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false + "$ref": "#/components/schemas/ModelRef" }, "content": { "type": "array", @@ -27657,6 +27102,115 @@ } ] }, + "ModelApi": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["aisdk"] + }, + "package": { + "type": "string" + }, + "url": { + "type": "string" + }, + "settings": { + "type": "object" + } + }, + "required": ["id", "type", "package"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["native"] + }, + "url": { + "type": "string" + }, + "settings": { + "type": "object" + } + }, + "required": ["id", "type", "settings"], + "additionalProperties": false + } + ] + }, + "ModelCapabilities": { + "type": "object", + "properties": { + "tools": { + "type": "boolean" + }, + "input": { + "type": "array", + "items": { + "type": "string" + } + }, + "output": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["tools", "input", "output"], + "additionalProperties": false + }, + "ModelCost": { + "type": "object", + "properties": { + "tier": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["context"] + }, + "size": { + "type": "integer" + } + }, + "required": ["type", "size"], + "additionalProperties": false + }, + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "cache"], + "additionalProperties": false + }, "ModelV2Info": { "type": "object", "properties": { @@ -27673,73 +27227,10 @@ "type": "string" }, "api": { - "anyOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["aisdk"] - }, - "package": { - "type": "string" - }, - "url": { - "type": "string" - }, - "settings": { - "type": "object" - } - }, - "required": ["id", "type", "package"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["native"] - }, - "url": { - "type": "string" - }, - "settings": { - "type": "object" - } - }, - "required": ["id", "type", "settings"], - "additionalProperties": false - } - ] + "$ref": "#/components/schemas/ModelApi" }, "capabilities": { - "type": "object", - "properties": { - "tools": { - "type": "boolean" - }, - "input": { - "type": "array", - "items": { - "type": "string" - } - }, - "output": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["tools", "input", "output"], - "additionalProperties": false + "$ref": "#/components/schemas/ModelCapabilities" }, "request": { "type": "object", @@ -27795,44 +27286,7 @@ "cost": { "type": "array", "items": { - "type": "object", - "properties": { - "tier": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["context"] - }, - "size": { - "type": "integer" - } - }, - "required": ["type", "size"], - "additionalProperties": false - }, - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": ["read", "write"], - "additionalProperties": false - } - }, - "required": ["input", "output", "cache"], - "additionalProperties": false + "$ref": "#/components/schemas/ModelCost" } }, "status": { @@ -27875,6 +27329,53 @@ ], "additionalProperties": false }, + "ProviderAISDK": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["aisdk"] + }, + "package": { + "type": "string" + }, + "url": { + "type": "string" + }, + "settings": { + "type": "object" + } + }, + "required": ["type", "package"], + "additionalProperties": false + }, + "ProviderNative": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["native"] + }, + "url": { + "type": "string" + }, + "settings": { + "type": "object" + } + }, + "required": ["type", "settings"], + "additionalProperties": false + }, + "ProviderApi": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProviderAISDK" + }, + { + "$ref": "#/components/schemas/ProviderNative" + } + ] + }, "ProviderV2Info": { "type": "object", "properties": { @@ -27891,61 +27392,10 @@ "type": "boolean" }, "api": { - "anyOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["aisdk"] - }, - "package": { - "type": "string" - }, - "url": { - "type": "string" - }, - "settings": { - "type": "object" - } - }, - "required": ["type", "package"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["native"] - }, - "url": { - "type": "string" - }, - "settings": { - "type": "object" - } - }, - "required": ["type", "settings"], - "additionalProperties": false - } - ] + "$ref": "#/components/schemas/ProviderApi" }, "request": { - "type": "object", - "properties": { - "headers": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "body": { - "type": "object" - } - }, - "required": ["headers", "body"], - "additionalProperties": false + "$ref": "#/components/schemas/ProviderRequest" } }, "required": ["id", "name", "api", "request"], @@ -28230,6 +27680,269 @@ "required": ["attemptID", "url", "instructions", "mode", "time"], "additionalProperties": false }, + "IntegrationAttemptStatus": { + "anyOf": [ + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["pending"] + }, + "time": { + "type": "object", + "properties": { + "created": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "expires": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + } + }, + "required": ["created", "expires"], + "additionalProperties": false + } + }, + "required": ["status", "time"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["complete"] + }, + "time": { + "type": "object", + "properties": { + "created": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "expires": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + } + }, + "required": ["created", "expires"], + "additionalProperties": false + } + }, + "required": ["status", "time"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["failed"] + }, + "message": { + "type": "string" + }, + "time": { + "type": "object", + "properties": { + "created": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "expires": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + } + }, + "required": ["created", "expires"], + "additionalProperties": false + } + }, + "required": ["status", "message", "time"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["expired"] + }, + "time": { + "type": "object", + "properties": { + "created": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "expires": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + } + }, + "required": ["created", "expires"], + "additionalProperties": false + } + }, + "required": ["status", "time"], + "additionalProperties": false + } + ] + }, "PermissionV2Request": { "type": "object", "properties": { @@ -28315,20 +28028,7 @@ "type": "string" }, "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false + "$ref": "#/components/schemas/ModelRef" }, "subtask": { "type": "boolean" @@ -29001,20 +28701,7 @@ "pattern": "^msg_" }, "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false + "$ref": "#/components/schemas/ModelRef" } }, "required": ["timestamp", "sessionID", "messageID", "model"], @@ -29490,20 +29177,7 @@ "type": "string" }, "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false + "$ref": "#/components/schemas/ModelRef" }, "snapshot": { "type": "string" @@ -29904,10 +29578,7 @@ "type": "string" }, "providerMetadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "$ref": "#/components/schemas/LLMProviderMetadata" } }, "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID"], @@ -30032,10 +29703,7 @@ "type": "string" }, "providerMetadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "$ref": "#/components/schemas/LLMProviderMetadata" } }, "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID", "text"], @@ -30291,10 +29959,7 @@ "type": "boolean" }, "metadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "$ref": "#/components/schemas/LLMProviderMetadata" } }, "required": ["executed"], @@ -30364,14 +30029,7 @@ "content": { "type": "array", "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/ToolTextContent" - }, - { - "$ref": "#/components/schemas/ToolFileContent" - } - ] + "$ref": "#/components/schemas/LLMToolContent" } } }, @@ -30438,14 +30096,7 @@ "content": { "type": "array", "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/ToolTextContent" - }, - { - "$ref": "#/components/schemas/ToolFileContent" - } - ] + "$ref": "#/components/schemas/LLMToolContent" } }, "outputPaths": { @@ -30462,10 +30113,7 @@ "type": "boolean" }, "metadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "$ref": "#/components/schemas/LLMProviderMetadata" } }, "required": ["executed"], @@ -30540,10 +30188,7 @@ "type": "boolean" }, "metadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "$ref": "#/components/schemas/LLMProviderMetadata" } }, "required": ["executed"], @@ -30840,30 +30485,7 @@ "pattern": "^ses" }, "revert": { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "pattern": "^msg_" - }, - "partID": { - "type": "string" - }, - "snapshot": { - "type": "string" - }, - "diff": { - "type": "string" - }, - "files": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FileDiff" - } - } - }, - "required": ["messageID"], - "additionalProperties": false + "$ref": "#/components/schemas/RevertState" } }, "required": ["timestamp", "sessionID", "revert"], @@ -32649,55 +32271,19 @@ "type": "string" }, "vcs": { - "type": "string", - "enum": ["git"] + "$ref": "#/components/schemas/ProjectVcs" }, "name": { "type": "string" }, "icon": { - "type": "object", - "properties": { - "url": { - "type": "string" - }, - "override": { - "type": "string" - }, - "color": { - "type": "string" - } - }, - "additionalProperties": false + "$ref": "#/components/schemas/ProjectIcon" }, "commands": { - "type": "object", - "properties": { - "start": { - "type": "string", - "description": "Startup script to run when creating a new workspace (worktree)" - } - }, - "additionalProperties": false + "$ref": "#/components/schemas/ProjectCommands" }, "time": { - "type": "object", - "properties": { - "created": { - "type": "integer", - "minimum": 0 - }, - "updated": { - "type": "integer", - "minimum": 0 - }, - "initialized": { - "type": "integer", - "minimum": 0 - } - }, - "required": ["created", "updated"], - "additionalProperties": false + "$ref": "#/components/schemas/ProjectTime" }, "sandboxes": { "type": "array", @@ -33485,6 +33071,16 @@ "required": ["type", "repository"], "additionalProperties": false }, + "ReferenceSource": { + "anyOf": [ + { + "$ref": "#/components/schemas/ReferenceLocalSource" + }, + { + "$ref": "#/components/schemas/ReferenceGitSource" + } + ] + }, "ReferenceInfo": { "type": "object", "properties": { @@ -33501,14 +33097,7 @@ "type": "boolean" }, "source": { - "anyOf": [ - { - "$ref": "#/components/schemas/ReferenceLocalSource" - }, - { - "$ref": "#/components/schemas/ReferenceGitSource" - } - ] + "$ref": "#/components/schemas/ReferenceSource" } }, "required": ["name", "path", "source"], @@ -33880,20 +33469,7 @@ "pattern": "^msg_" }, "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false + "$ref": "#/components/schemas/ModelRef" } }, "required": ["timestamp", "sessionID", "messageID", "model"], @@ -34193,20 +33769,7 @@ "type": "string" }, "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false + "$ref": "#/components/schemas/ModelRef" }, "snapshot": { "type": "string" @@ -34475,10 +34038,7 @@ "type": "string" }, "providerMetadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "$ref": "#/components/schemas/LLMProviderMetadata" } }, "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID"], @@ -34559,10 +34119,7 @@ "type": "string" }, "providerMetadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "$ref": "#/components/schemas/LLMProviderMetadata" } }, "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID", "text"], @@ -34730,10 +34287,7 @@ "type": "boolean" }, "metadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "$ref": "#/components/schemas/LLMProviderMetadata" } }, "required": ["executed"], @@ -34781,14 +34335,7 @@ "content": { "type": "array", "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/ToolTextContent" - }, - { - "$ref": "#/components/schemas/ToolFileContent" - } - ] + "$ref": "#/components/schemas/LLMToolContent" } } }, @@ -34833,14 +34380,7 @@ "content": { "type": "array", "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/ToolTextContent" - }, - { - "$ref": "#/components/schemas/ToolFileContent" - } - ] + "$ref": "#/components/schemas/LLMToolContent" } }, "outputPaths": { @@ -34857,10 +34397,7 @@ "type": "boolean" }, "metadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "$ref": "#/components/schemas/LLMProviderMetadata" } }, "required": ["executed"], @@ -34913,10 +34450,7 @@ "type": "boolean" }, "metadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "$ref": "#/components/schemas/LLMProviderMetadata" } }, "required": ["executed"], @@ -35103,30 +34637,7 @@ "pattern": "^ses" }, "revert": { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "pattern": "^msg_" - }, - "partID": { - "type": "string" - }, - "snapshot": { - "type": "string" - }, - "diff": { - "type": "string" - }, - "files": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FileDiff" - } - } - }, - "required": ["messageID"], - "additionalProperties": false + "$ref": "#/components/schemas/RevertState" } }, "required": ["timestamp", "sessionID", "revert"], @@ -36048,55 +35559,19 @@ "type": "string" }, "vcs": { - "type": "string", - "enum": ["git"] + "$ref": "#/components/schemas/ProjectVcs" }, "name": { "type": "string" }, "icon": { - "type": "object", - "properties": { - "url": { - "type": "string" - }, - "override": { - "type": "string" - }, - "color": { - "type": "string" - } - }, - "additionalProperties": false + "$ref": "#/components/schemas/ProjectIcon" }, "commands": { - "type": "object", - "properties": { - "start": { - "type": "string", - "description": "Startup script to run when creating a new workspace (worktree)" - } - }, - "additionalProperties": false + "$ref": "#/components/schemas/ProjectCommands" }, "time": { - "type": "object", - "properties": { - "created": { - "type": "integer", - "minimum": 0 - }, - "updated": { - "type": "integer", - "minimum": 0 - }, - "initialized": { - "type": "integer", - "minimum": 0 - } - }, - "required": ["created", "updated"], - "additionalProperties": false + "$ref": "#/components/schemas/ProjectTime" }, "sandboxes": { "type": "array", From a2672220803a3fa098386669a401178c1238e78f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 25 Jun 2026 19:26:47 +0200 Subject: [PATCH 09/26] refactor(schema): isolate v1 contracts (#33769) --- packages/schema/src/legacy-event.ts | 19 +- packages/schema/src/permission-v1.ts | 67 +-- packages/schema/src/question-v1.ts | 67 +-- packages/schema/src/session-v1.ts | 677 +--------------------- packages/schema/src/v1/legacy-event.ts | 18 + packages/schema/src/v1/permission.ts | 66 +++ packages/schema/src/v1/question.ts | 66 +++ packages/schema/src/v1/session.ts | 676 +++++++++++++++++++++ packages/schema/test/v1-isolation.test.ts | 28 + 9 files changed, 858 insertions(+), 826 deletions(-) create mode 100644 packages/schema/src/v1/legacy-event.ts create mode 100644 packages/schema/src/v1/permission.ts create mode 100644 packages/schema/src/v1/question.ts create mode 100644 packages/schema/src/v1/session.ts create mode 100644 packages/schema/test/v1-isolation.test.ts diff --git a/packages/schema/src/legacy-event.ts b/packages/schema/src/legacy-event.ts index 21c46df0e0..c7115992a6 100644 --- a/packages/schema/src/legacy-event.ts +++ b/packages/schema/src/legacy-event.ts @@ -1,18 +1 @@ -export * as LegacyEvent from "./legacy-event" - -import { Schema } from "effect" -import { define, inventory } from "./event" -import { SessionID } from "./session-id" -import { SessionV1 } from "./session-v1" - -export const CommandExecuted = define({ - type: "command.executed", - schema: { - name: Schema.String, - sessionID: SessionID, - arguments: Schema.String, - messageID: SessionV1.MessageID, - }, -}) - -export const Definitions = inventory(CommandExecuted) +export * from "./v1/legacy-event" diff --git a/packages/schema/src/permission-v1.ts b/packages/schema/src/permission-v1.ts index 26cac5950c..558fec83da 100644 --- a/packages/schema/src/permission-v1.ts +++ b/packages/schema/src/permission-v1.ts @@ -1,66 +1 @@ -export * as PermissionV1 from "./permission-v1" - -import { Schema } from "effect" -import { define, inventory } from "./event" -import { ascending } from "./identifier" -import { Project } from "./project" -import { statics } from "./schema" -import { SessionID } from "./session-id" - -export const ID = Schema.String.check(Schema.isStartsWith("per")).pipe( - Schema.brand("PermissionID"), - statics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "per_" + ascending()) })), -) -export type ID = typeof ID.Type - -export const Action = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "PermissionAction" }) -export type Action = typeof Action.Type - -export const Rule = Schema.Struct({ permission: Schema.String, pattern: Schema.String, action: Action }).annotate({ - identifier: "PermissionRule", -}) -export type Rule = typeof Rule.Type - -export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionRuleset" }) -export type Ruleset = typeof Ruleset.Type - -export const Request = Schema.Struct({ - id: ID, - sessionID: SessionID, - permission: Schema.String, - patterns: Schema.Array(Schema.String), - metadata: Schema.Record(Schema.String, Schema.Unknown), - always: Schema.Array(Schema.String), - tool: Schema.optional(Schema.Struct({ messageID: Schema.String, callID: Schema.String })), -}).annotate({ identifier: "PermissionRequest" }) -export type Request = typeof Request.Type - -export const Reply = Schema.Literals(["once", "always", "reject"]) -export type Reply = typeof Reply.Type - -export const ReplyBody = Schema.Struct({ reply: Reply, message: Schema.optional(Schema.String) }).annotate({ - identifier: "PermissionReplyBody", -}) -export type ReplyBody = typeof ReplyBody.Type - -export const Approval = Schema.Struct({ projectID: Project.ID, patterns: Schema.Array(Schema.String) }).annotate({ - identifier: "PermissionApproval", -}) -export type Approval = typeof Approval.Type - -export const AskInput = Schema.Struct({ ...Request.fields, id: Schema.optional(ID), ruleset: Ruleset }).annotate({ - identifier: "PermissionAskInput", -}) -export type AskInput = typeof AskInput.Type - -export const ReplyInput = Schema.Struct({ requestID: ID, ...ReplyBody.fields }).annotate({ - identifier: "PermissionReplyInput", -}) -export type ReplyInput = typeof ReplyInput.Type - -const Asked = define({ type: "permission.asked", schema: Request.fields }) -const Replied = define({ - type: "permission.replied", - schema: { sessionID: SessionID, requestID: ID, reply: Reply }, -}) -export const Event = { Asked, Replied, Definitions: inventory(Asked, Replied) } +export * from "./v1/permission" diff --git a/packages/schema/src/question-v1.ts b/packages/schema/src/question-v1.ts index 837a51a469..4bb237244e 100644 --- a/packages/schema/src/question-v1.ts +++ b/packages/schema/src/question-v1.ts @@ -1,66 +1 @@ -export * as QuestionV1 from "./question-v1" - -import { Schema } from "effect" -import { define, inventory } from "./event" -import { ascending } from "./identifier" -import { statics } from "./schema" -import { SessionID } from "./session-id" -import { SessionV1 } from "./session-v1" - -export const ID = Schema.String.check(Schema.isStartsWith("que")).pipe( - Schema.brand("QuestionID"), - statics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "que_" + ascending()) })), -) - -export const Option = Schema.Struct({ - label: Schema.String.annotate({ description: "Display text (1-5 words, concise)" }), - description: Schema.String.annotate({ description: "Explanation of choice" }), -}).annotate({ identifier: "QuestionOption" }) - -const base = { - question: Schema.String.annotate({ description: "Complete question" }), - header: Schema.String.annotate({ description: "Very short label (max 30 chars)" }), - options: Schema.Array(Option).annotate({ description: "Available choices" }), - multiple: Schema.optional(Schema.Boolean).annotate({ description: "Allow selecting multiple choices" }), -} - -export const Info = Schema.Struct({ - ...base, - custom: Schema.optional(Schema.Boolean).annotate({ description: "Allow typing a custom answer (default: true)" }), -}).annotate({ identifier: "QuestionInfo" }) -export const Prompt = Schema.Struct(base).annotate({ identifier: "QuestionPrompt" }) -export const Tool = Schema.Struct({ messageID: SessionV1.MessageID, callID: Schema.String }).annotate({ - identifier: "QuestionTool", -}) -export const Request = Schema.Struct({ - id: ID, - sessionID: SessionID, - questions: Schema.Array(Info).annotate({ description: "Questions to ask" }), - tool: Schema.optional(Tool), -}).annotate({ identifier: "QuestionRequest" }) -export const Answer = Schema.Array(Schema.String).annotate({ identifier: "QuestionAnswer" }) -export const Reply = Schema.Struct({ - answers: Schema.Array(Answer).annotate({ - description: "User answers in order of questions (each answer is an array of selected labels)", - }), -}).annotate({ identifier: "QuestionReply" }) -export const Replied = Schema.Struct({ - sessionID: SessionID, - requestID: ID, - answers: Schema.Array(Answer), -}).annotate({ - identifier: "QuestionReplied", -}) -export const Rejected = Schema.Struct({ sessionID: SessionID, requestID: ID }).annotate({ - identifier: "QuestionRejected", -}) - -const Asked = define({ type: "question.asked", schema: Request.fields }) -const RepliedEvent = define({ type: "question.replied", schema: Replied.fields }) -const RejectedEvent = define({ type: "question.rejected", schema: Rejected.fields }) -export const Event = { - Asked, - Replied: RepliedEvent, - Rejected: RejectedEvent, - Definitions: inventory(Asked, RepliedEvent, RejectedEvent), -} +export * from "./v1/question" diff --git a/packages/schema/src/session-v1.ts b/packages/schema/src/session-v1.ts index 6766e9296b..22455dd412 100644 --- a/packages/schema/src/session-v1.ts +++ b/packages/schema/src/session-v1.ts @@ -1,676 +1 @@ -export * as SessionV1 from "./session-v1" - -import { Effect, Schema, Types } from "effect" -import { define, inventory } from "./event" -import { FileDiff } from "./file-diff" -import { PermissionV1 } from "./permission-v1" -import { Project } from "./project" -import { Provider } from "./provider" -import { Model } from "./model" -import { NonNegativeInt, optional, statics } from "./schema" -import { ascending } from "./identifier" -import { SessionID } from "./session-id" -import { WorkspaceID } from "./workspace-id" - -const Timestamp = Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0)) - -export const MessageID = Schema.String.check(Schema.isStartsWith("msg")).pipe( - Schema.brand("MessageID"), - statics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "msg_" + ascending()) })), -) -export type MessageID = typeof MessageID.Type - -export const PartID = Schema.String.check(Schema.isStartsWith("prt")).pipe( - Schema.brand("PartID"), - statics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "prt_" + ascending()) })), -) -export type PartID = typeof PartID.Type - -const namedError = (name: Name, fields: Fields) => { - const schema = Schema.Struct({ name: Schema.Literal(name), data: Schema.Struct(fields) }).annotate({ - identifier: name, - }) - return { Schema: schema, EffectSchema: schema } -} - -export const OutputLengthError = namedError("MessageOutputLengthError", {}) - -export const AuthError = namedError("ProviderAuthError", { - providerID: Schema.String, - message: Schema.String, -}) - -export const AbortedError = namedError("MessageAbortedError", { message: Schema.String }) -export const StructuredOutputError = namedError("StructuredOutputError", { - message: Schema.String, - retries: NonNegativeInt, -}) -export const APIError = namedError("APIError", { - message: Schema.String, - statusCode: Schema.optional(NonNegativeInt), - isRetryable: Schema.Boolean, - responseHeaders: Schema.optional(Schema.Record(Schema.String, Schema.String)), - responseBody: Schema.optional(Schema.String), - metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), -}) -export type APIError = Schema.Schema.Type -export const ContextOverflowError = namedError("ContextOverflowError", { - message: Schema.String, - responseBody: Schema.optional(Schema.String), -}) -export const ContentFilterError = namedError("ContentFilterError", { - message: Schema.String, -}) - -export class OutputFormatText extends Schema.Class("OutputFormatText")({ - type: Schema.Literal("text"), -}) {} - -export class OutputFormatJsonSchema extends Schema.Class("OutputFormatJsonSchema")({ - type: Schema.Literal("json_schema"), - schema: Schema.Record(Schema.String, Schema.Any).annotate({ identifier: "JSONSchema" }), - retryCount: NonNegativeInt.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(2))), -}) {} - -export const Format = Schema.Union([OutputFormatText, OutputFormatJsonSchema]).annotate({ - discriminator: "type", - identifier: "OutputFormat", -}) -export type OutputFormat = Schema.Schema.Type - -const partBase = { - id: PartID, - sessionID: SessionID, - messageID: MessageID, -} - -export const SnapshotPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("snapshot"), - snapshot: Schema.String, -}).annotate({ identifier: "SnapshotPart" }) -export type SnapshotPart = Types.DeepMutable> - -export const PatchPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("patch"), - hash: Schema.String, - files: Schema.Array(Schema.String), -}).annotate({ identifier: "PatchPart" }) -export type PatchPart = Types.DeepMutable> - -export const TextPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("text"), - text: Schema.String, - synthetic: Schema.optional(Schema.Boolean), - ignored: Schema.optional(Schema.Boolean), - time: Schema.optional( - Schema.Struct({ - start: NonNegativeInt, - end: Schema.optional(NonNegativeInt), - }), - ), - metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), -}).annotate({ identifier: "TextPart" }) -export type TextPart = Types.DeepMutable> - -export const ReasoningPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("reasoning"), - text: Schema.String, - metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), - time: Schema.Struct({ - start: NonNegativeInt, - end: Schema.optional(NonNegativeInt), - }), -}).annotate({ identifier: "ReasoningPart" }) -export type ReasoningPart = Types.DeepMutable> - -const filePartSourceBase = { - text: Schema.Struct({ - value: Schema.String, - start: Schema.Finite, - end: Schema.Finite, - }).annotate({ identifier: "FilePartSourceText" }), -} - -export const Range = Schema.Struct({ - start: Schema.Struct({ line: NonNegativeInt, character: NonNegativeInt }), - end: Schema.Struct({ line: NonNegativeInt, character: NonNegativeInt }), -}).annotate({ identifier: "Range" }) -export type Range = typeof Range.Type - -export const FileSource = Schema.Struct({ - ...filePartSourceBase, - type: Schema.Literal("file"), - path: Schema.String, -}).annotate({ identifier: "FileSource" }) - -export const SymbolSource = Schema.Struct({ - ...filePartSourceBase, - type: Schema.Literal("symbol"), - path: Schema.String, - range: Range, - name: Schema.String, - kind: NonNegativeInt, -}).annotate({ identifier: "SymbolSource" }) - -export const ResourceSource = Schema.Struct({ - ...filePartSourceBase, - type: Schema.Literal("resource"), - clientName: Schema.String, - uri: Schema.String, -}).annotate({ identifier: "ResourceSource" }) - -export const FilePartSource = Schema.Union([FileSource, SymbolSource, ResourceSource]).annotate({ - discriminator: "type", - identifier: "FilePartSource", -}) - -export const FilePart = Schema.Struct({ - ...partBase, - type: Schema.Literal("file"), - mime: Schema.String, - filename: Schema.optional(Schema.String), - url: Schema.String, - source: Schema.optional(FilePartSource), -}).annotate({ identifier: "FilePart" }) -export type FilePart = Types.DeepMutable> - -export const AgentPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("agent"), - name: Schema.String, - source: Schema.optional( - Schema.Struct({ - value: Schema.String, - start: NonNegativeInt, - end: NonNegativeInt, - }), - ), -}).annotate({ identifier: "AgentPart" }) -export type AgentPart = Types.DeepMutable> - -export const CompactionPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("compaction"), - auto: Schema.Boolean, - overflow: Schema.optional(Schema.Boolean), - tail_start_id: Schema.optional(MessageID), -}).annotate({ identifier: "CompactionPart" }) -export type CompactionPart = Types.DeepMutable> - -export const SubtaskPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("subtask"), - prompt: Schema.String, - description: Schema.String, - agent: Schema.String, - model: Schema.optional( - Schema.Struct({ - providerID: Provider.ID, - modelID: Model.ID, - }), - ), - command: Schema.optional(Schema.String), -}).annotate({ identifier: "SubtaskPart" }) -export type SubtaskPart = Types.DeepMutable> - -export const RetryPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("retry"), - attempt: NonNegativeInt, - error: APIError.EffectSchema, - time: Schema.Struct({ - created: NonNegativeInt, - }), -}).annotate({ identifier: "RetryPart" }) -export type RetryPart = Omit>, "error"> & { - error: APIError -} - -export const StepStartPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("step-start"), - snapshot: Schema.optional(Schema.String), -}).annotate({ identifier: "StepStartPart" }) -export type StepStartPart = Types.DeepMutable> - -export const StepFinishPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("step-finish"), - reason: Schema.String, - snapshot: Schema.optional(Schema.String), - cost: Schema.Finite, - tokens: Schema.Struct({ - total: Schema.optional(Schema.Finite), - input: Schema.Finite, - output: Schema.Finite, - reasoning: Schema.Finite, - cache: Schema.Struct({ - read: Schema.Finite, - write: Schema.Finite, - }), - }), -}).annotate({ identifier: "StepFinishPart" }) -export type StepFinishPart = Types.DeepMutable> - -export const ToolStatePending = Schema.Struct({ - status: Schema.Literal("pending"), - input: Schema.Record(Schema.String, Schema.Any), - raw: Schema.String, -}).annotate({ identifier: "ToolStatePending" }) -export type ToolStatePending = Types.DeepMutable> - -export const ToolStateRunning = Schema.Struct({ - status: Schema.Literal("running"), - input: Schema.Record(Schema.String, Schema.Any), - title: Schema.optional(Schema.String), - metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), - time: Schema.Struct({ - start: NonNegativeInt, - }), -}).annotate({ identifier: "ToolStateRunning" }) -export type ToolStateRunning = Types.DeepMutable> - -export const ToolStateCompleted = Schema.Struct({ - status: Schema.Literal("completed"), - input: Schema.Record(Schema.String, Schema.Any), - output: Schema.String, - title: Schema.String, - metadata: Schema.Record(Schema.String, Schema.Any), - time: Schema.Struct({ - start: NonNegativeInt, - end: NonNegativeInt, - compacted: Schema.optional(NonNegativeInt), - }), - attachments: Schema.optional(Schema.Array(FilePart)), -}).annotate({ identifier: "ToolStateCompleted" }) -export type ToolStateCompleted = Types.DeepMutable> - -export const ToolStateError = Schema.Struct({ - status: Schema.Literal("error"), - input: Schema.Record(Schema.String, Schema.Any), - error: Schema.String, - metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), - time: Schema.Struct({ - start: NonNegativeInt, - end: NonNegativeInt, - }), -}).annotate({ identifier: "ToolStateError" }) -export type ToolStateError = Types.DeepMutable> - -export const ToolState = Schema.Union([ - ToolStatePending, - ToolStateRunning, - ToolStateCompleted, - ToolStateError, -]).annotate({ - discriminator: "status", - identifier: "ToolState", -}) -export type ToolState = ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError - -export const ToolPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("tool"), - callID: Schema.String, - tool: Schema.String, - state: ToolState, - metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), -}).annotate({ identifier: "ToolPart" }) -export type ToolPart = Omit>, "state"> & { - state: ToolState -} - -const messageBase = { - id: MessageID, - sessionID: partBase.sessionID, -} - -export const User = Schema.Struct({ - ...messageBase, - role: Schema.Literal("user"), - time: Schema.Struct({ - created: Timestamp, - }), - format: Schema.optional(Format), - summary: Schema.optional( - Schema.Struct({ - title: Schema.optional(Schema.String), - body: Schema.optional(Schema.String), - diffs: Schema.Array(FileDiff.Info), - }), - ), - agent: Schema.String, - model: Schema.Struct({ - providerID: Provider.ID, - modelID: Model.ID, - variant: Schema.optional(Schema.String), - }), - system: Schema.optional(Schema.String), - tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), -}).annotate({ identifier: "UserMessage" }) -export type User = Types.DeepMutable> - -export const Part = Schema.Union([ - TextPart, - SubtaskPart, - ReasoningPart, - FilePart, - ToolPart, - StepStartPart, - StepFinishPart, - SnapshotPart, - PatchPart, - AgentPart, - RetryPart, - CompactionPart, -]).annotate({ discriminator: "type", identifier: "Part" }) -export type Part = - | TextPart - | SubtaskPart - | ReasoningPart - | FilePart - | ToolPart - | StepStartPart - | StepFinishPart - | SnapshotPart - | PatchPart - | AgentPart - | RetryPart - | CompactionPart - -const AssistantErrorSchema = Schema.Union([ - AuthError.EffectSchema, - namedError("UnknownError", { message: Schema.String, ref: Schema.optional(Schema.String) }).EffectSchema, - OutputLengthError.EffectSchema, - AbortedError.EffectSchema, - StructuredOutputError.EffectSchema, - ContextOverflowError.EffectSchema, - ContentFilterError.EffectSchema, - APIError.EffectSchema, -]).annotate({ discriminator: "name" }) -type AssistantError = Schema.Schema.Type - -export const TextPartInput = Schema.Struct({ - id: Schema.optional(PartID), - type: Schema.Literal("text"), - text: Schema.String, - synthetic: Schema.optional(Schema.Boolean), - ignored: Schema.optional(Schema.Boolean), - time: Schema.optional( - Schema.Struct({ - start: NonNegativeInt, - end: Schema.optional(NonNegativeInt), - }), - ), - metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), -}).annotate({ identifier: "TextPartInput" }) -export type TextPartInput = Types.DeepMutable> - -export const FilePartInput = Schema.Struct({ - id: Schema.optional(PartID), - type: Schema.Literal("file"), - mime: Schema.String, - filename: Schema.optional(Schema.String), - url: Schema.String, - source: Schema.optional(FilePartSource), -}).annotate({ identifier: "FilePartInput" }) -export type FilePartInput = Types.DeepMutable> - -export const AgentPartInput = Schema.Struct({ - id: Schema.optional(PartID), - type: Schema.Literal("agent"), - name: Schema.String, - source: Schema.optional( - Schema.Struct({ - value: Schema.String, - start: NonNegativeInt, - end: NonNegativeInt, - }), - ), -}).annotate({ identifier: "AgentPartInput" }) -export type AgentPartInput = Types.DeepMutable> - -export const SubtaskPartInput = Schema.Struct({ - id: Schema.optional(PartID), - type: Schema.Literal("subtask"), - prompt: Schema.String, - description: Schema.String, - agent: Schema.String, - model: Schema.optional( - Schema.Struct({ - providerID: Provider.ID, - modelID: Model.ID, - }), - ), - command: Schema.optional(Schema.String), -}).annotate({ identifier: "SubtaskPartInput" }) -export type SubtaskPartInput = Types.DeepMutable> - -export const Assistant = Schema.Struct({ - ...messageBase, - role: Schema.Literal("assistant"), - time: Schema.Struct({ - created: NonNegativeInt, - completed: Schema.optional(NonNegativeInt), - }), - error: Schema.optional(AssistantErrorSchema), - parentID: MessageID, - modelID: Model.ID, - providerID: Provider.ID, - mode: Schema.String, - agent: Schema.String, - path: Schema.Struct({ - cwd: Schema.String, - root: Schema.String, - }), - summary: Schema.optional(Schema.Boolean), - cost: Schema.Finite, - tokens: Schema.Struct({ - total: Schema.optional(Schema.Finite), - input: Schema.Finite, - output: Schema.Finite, - reasoning: Schema.Finite, - cache: Schema.Struct({ - read: Schema.Finite, - write: Schema.Finite, - }), - }), - structured: Schema.optional(Schema.Any), - variant: Schema.optional(Schema.String), - finish: Schema.optional(Schema.String), -}).annotate({ identifier: "AssistantMessage" }) -export type Assistant = Omit>, "error"> & { - error?: AssistantError -} - -export const Info = Schema.Union([User, Assistant]).annotate({ discriminator: "role", identifier: "Message" }) -export type Info = User | Assistant - -export const WithParts = Schema.Struct({ - info: Info, - parts: Schema.Array(Part), -}) -export type WithParts = { - info: Info - parts: Part[] -} - -const options = { - durable: { - aggregate: "sessionID", - version: 1, - }, -} as const - -const SessionSummary = Schema.Struct({ - additions: Schema.Finite, - deletions: Schema.Finite, - files: Schema.Finite, - diffs: optional(Schema.Array(FileDiff.Info)), -}) - -const SessionTokens = Schema.Struct({ - input: Schema.Finite, - output: Schema.Finite, - reasoning: Schema.Finite, - cache: Schema.Struct({ - read: Schema.Finite, - write: Schema.Finite, - }), -}) - -const SessionShare = Schema.Struct({ - url: Schema.String, -}) - -const SessionRevert = Schema.Struct({ - messageID: MessageID, - partID: optional(PartID), - snapshot: optional(Schema.String), - diff: optional(Schema.String), -}) - -const SessionModel = Schema.Struct({ - id: Model.ID, - providerID: Provider.ID, - variant: optional(Schema.String), -}) - -export const SessionInfo = Schema.Struct({ - id: SessionID, - slug: Schema.String, - projectID: Project.ID, - workspaceID: optional(WorkspaceID), - directory: Schema.String, - path: optional(Schema.String), - parentID: optional(SessionID), - summary: optional(SessionSummary), - cost: optional(Schema.Finite), - tokens: optional(SessionTokens), - share: optional(SessionShare), - title: Schema.String, - agent: optional(Schema.String), - model: optional(SessionModel), - version: Schema.String, - metadata: optional(Schema.Record(Schema.String, Schema.Any)), - time: Schema.Struct({ - created: NonNegativeInt, - updated: NonNegativeInt, - compacting: optional(NonNegativeInt), - archived: optional(Schema.Finite), - }), - permission: optional(PermissionV1.Ruleset), - revert: optional(SessionRevert), -}).annotate({ identifier: "Session" }) -export type SessionInfo = typeof SessionInfo.Type - -const events = { - Created: define({ - type: "session.created", - ...options, - schema: { - sessionID: SessionID, - info: SessionInfo, - }, - }), - Updated: define({ - type: "session.updated", - ...options, - schema: { - sessionID: SessionID, - info: SessionInfo, - }, - }), - Deleted: define({ - type: "session.deleted", - ...options, - schema: { - sessionID: SessionID, - info: SessionInfo, - }, - }), - MessageUpdated: define({ - type: "message.updated", - ...options, - schema: { - sessionID: SessionID, - info: Info, - }, - }), - MessageRemoved: define({ - type: "message.removed", - ...options, - schema: { - sessionID: SessionID, - messageID: MessageID, - }, - }), - PartUpdated: define({ - type: "message.part.updated", - ...options, - schema: { - sessionID: SessionID, - part: Part, - time: Schema.Finite, - }, - }), - PartRemoved: define({ - type: "message.part.removed", - ...options, - schema: { - sessionID: SessionID, - messageID: MessageID, - partID: PartID, - }, - }), -} - -export const PartDelta = define({ - type: "message.part.delta", - schema: { - sessionID: SessionID, - messageID: MessageID, - partID: PartID, - field: Schema.String, - delta: Schema.String, - }, -}) - -export const Diff = define({ - type: "session.diff", - schema: { - sessionID: SessionID, - diff: Schema.Array(FileDiff.Info), - }, -}) - -export const Error = define({ - type: "session.error", - schema: { - sessionID: Schema.optional(SessionID), - error: Assistant.fields.error, - }, -}) - -export const Event = { - ...events, - PartDelta, - Diff, - Error, - Definitions: inventory( - events.Created, - events.Updated, - events.Deleted, - events.MessageUpdated, - events.MessageRemoved, - events.PartUpdated, - events.PartRemoved, - PartDelta, - Diff, - Error, - ), -} +export * from "./v1/session" diff --git a/packages/schema/src/v1/legacy-event.ts b/packages/schema/src/v1/legacy-event.ts new file mode 100644 index 0000000000..d87902dad5 --- /dev/null +++ b/packages/schema/src/v1/legacy-event.ts @@ -0,0 +1,18 @@ +export * as LegacyEvent from "./legacy-event" + +import { Schema } from "effect" +import { define, inventory } from "../event" +import { SessionID } from "../session-id" +import { SessionV1 } from "./session" + +export const CommandExecuted = define({ + type: "command.executed", + schema: { + name: Schema.String, + sessionID: SessionID, + arguments: Schema.String, + messageID: SessionV1.MessageID, + }, +}) + +export const Definitions = inventory(CommandExecuted) diff --git a/packages/schema/src/v1/permission.ts b/packages/schema/src/v1/permission.ts new file mode 100644 index 0000000000..9096f4f249 --- /dev/null +++ b/packages/schema/src/v1/permission.ts @@ -0,0 +1,66 @@ +export * as PermissionV1 from "./permission" + +import { Schema } from "effect" +import { define, inventory } from "../event" +import { ascending } from "../identifier" +import { Project } from "../project" +import { statics } from "../schema" +import { SessionID } from "../session-id" + +export const ID = Schema.String.check(Schema.isStartsWith("per")).pipe( + Schema.brand("PermissionID"), + statics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "per_" + ascending()) })), +) +export type ID = typeof ID.Type + +export const Action = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "PermissionAction" }) +export type Action = typeof Action.Type + +export const Rule = Schema.Struct({ permission: Schema.String, pattern: Schema.String, action: Action }).annotate({ + identifier: "PermissionRule", +}) +export type Rule = typeof Rule.Type + +export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionRuleset" }) +export type Ruleset = typeof Ruleset.Type + +export const Request = Schema.Struct({ + id: ID, + sessionID: SessionID, + permission: Schema.String, + patterns: Schema.Array(Schema.String), + metadata: Schema.Record(Schema.String, Schema.Unknown), + always: Schema.Array(Schema.String), + tool: Schema.optional(Schema.Struct({ messageID: Schema.String, callID: Schema.String })), +}).annotate({ identifier: "PermissionRequest" }) +export type Request = typeof Request.Type + +export const Reply = Schema.Literals(["once", "always", "reject"]) +export type Reply = typeof Reply.Type + +export const ReplyBody = Schema.Struct({ reply: Reply, message: Schema.optional(Schema.String) }).annotate({ + identifier: "PermissionReplyBody", +}) +export type ReplyBody = typeof ReplyBody.Type + +export const Approval = Schema.Struct({ projectID: Project.ID, patterns: Schema.Array(Schema.String) }).annotate({ + identifier: "PermissionApproval", +}) +export type Approval = typeof Approval.Type + +export const AskInput = Schema.Struct({ ...Request.fields, id: Schema.optional(ID), ruleset: Ruleset }).annotate({ + identifier: "PermissionAskInput", +}) +export type AskInput = typeof AskInput.Type + +export const ReplyInput = Schema.Struct({ requestID: ID, ...ReplyBody.fields }).annotate({ + identifier: "PermissionReplyInput", +}) +export type ReplyInput = typeof ReplyInput.Type + +const Asked = define({ type: "permission.asked", schema: Request.fields }) +const Replied = define({ + type: "permission.replied", + schema: { sessionID: SessionID, requestID: ID, reply: Reply }, +}) +export const Event = { Asked, Replied, Definitions: inventory(Asked, Replied) } diff --git a/packages/schema/src/v1/question.ts b/packages/schema/src/v1/question.ts new file mode 100644 index 0000000000..e566996472 --- /dev/null +++ b/packages/schema/src/v1/question.ts @@ -0,0 +1,66 @@ +export * as QuestionV1 from "./question" + +import { Schema } from "effect" +import { define, inventory } from "../event" +import { ascending } from "../identifier" +import { statics } from "../schema" +import { SessionID } from "../session-id" +import { SessionV1 } from "./session" + +export const ID = Schema.String.check(Schema.isStartsWith("que")).pipe( + Schema.brand("QuestionID"), + statics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "que_" + ascending()) })), +) + +export const Option = Schema.Struct({ + label: Schema.String.annotate({ description: "Display text (1-5 words, concise)" }), + description: Schema.String.annotate({ description: "Explanation of choice" }), +}).annotate({ identifier: "QuestionOption" }) + +const base = { + question: Schema.String.annotate({ description: "Complete question" }), + header: Schema.String.annotate({ description: "Very short label (max 30 chars)" }), + options: Schema.Array(Option).annotate({ description: "Available choices" }), + multiple: Schema.optional(Schema.Boolean).annotate({ description: "Allow selecting multiple choices" }), +} + +export const Info = Schema.Struct({ + ...base, + custom: Schema.optional(Schema.Boolean).annotate({ description: "Allow typing a custom answer (default: true)" }), +}).annotate({ identifier: "QuestionInfo" }) +export const Prompt = Schema.Struct(base).annotate({ identifier: "QuestionPrompt" }) +export const Tool = Schema.Struct({ messageID: SessionV1.MessageID, callID: Schema.String }).annotate({ + identifier: "QuestionTool", +}) +export const Request = Schema.Struct({ + id: ID, + sessionID: SessionID, + questions: Schema.Array(Info).annotate({ description: "Questions to ask" }), + tool: Schema.optional(Tool), +}).annotate({ identifier: "QuestionRequest" }) +export const Answer = Schema.Array(Schema.String).annotate({ identifier: "QuestionAnswer" }) +export const Reply = Schema.Struct({ + answers: Schema.Array(Answer).annotate({ + description: "User answers in order of questions (each answer is an array of selected labels)", + }), +}).annotate({ identifier: "QuestionReply" }) +export const Replied = Schema.Struct({ + sessionID: SessionID, + requestID: ID, + answers: Schema.Array(Answer), +}).annotate({ + identifier: "QuestionReplied", +}) +export const Rejected = Schema.Struct({ sessionID: SessionID, requestID: ID }).annotate({ + identifier: "QuestionRejected", +}) + +const Asked = define({ type: "question.asked", schema: Request.fields }) +const RepliedEvent = define({ type: "question.replied", schema: Replied.fields }) +const RejectedEvent = define({ type: "question.rejected", schema: Rejected.fields }) +export const Event = { + Asked, + Replied: RepliedEvent, + Rejected: RejectedEvent, + Definitions: inventory(Asked, RepliedEvent, RejectedEvent), +} diff --git a/packages/schema/src/v1/session.ts b/packages/schema/src/v1/session.ts new file mode 100644 index 0000000000..75e9282f11 --- /dev/null +++ b/packages/schema/src/v1/session.ts @@ -0,0 +1,676 @@ +export * as SessionV1 from "./session" + +import { Effect, Schema, Types } from "effect" +import { define, inventory } from "../event" +import { FileDiff } from "../file-diff" +import { Project } from "../project" +import { Provider } from "../provider" +import { Model } from "../model" +import { NonNegativeInt, optional, statics } from "../schema" +import { ascending } from "../identifier" +import { SessionID } from "../session-id" +import { WorkspaceID } from "../workspace-id" +import { PermissionV1 } from "./permission" + +const Timestamp = Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0)) + +export const MessageID = Schema.String.check(Schema.isStartsWith("msg")).pipe( + Schema.brand("MessageID"), + statics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "msg_" + ascending()) })), +) +export type MessageID = typeof MessageID.Type + +export const PartID = Schema.String.check(Schema.isStartsWith("prt")).pipe( + Schema.brand("PartID"), + statics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "prt_" + ascending()) })), +) +export type PartID = typeof PartID.Type + +const namedError = (name: Name, fields: Fields) => { + const schema = Schema.Struct({ name: Schema.Literal(name), data: Schema.Struct(fields) }).annotate({ + identifier: name, + }) + return { Schema: schema, EffectSchema: schema } +} + +export const OutputLengthError = namedError("MessageOutputLengthError", {}) + +export const AuthError = namedError("ProviderAuthError", { + providerID: Schema.String, + message: Schema.String, +}) + +export const AbortedError = namedError("MessageAbortedError", { message: Schema.String }) +export const StructuredOutputError = namedError("StructuredOutputError", { + message: Schema.String, + retries: NonNegativeInt, +}) +export const APIError = namedError("APIError", { + message: Schema.String, + statusCode: Schema.optional(NonNegativeInt), + isRetryable: Schema.Boolean, + responseHeaders: Schema.optional(Schema.Record(Schema.String, Schema.String)), + responseBody: Schema.optional(Schema.String), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), +}) +export type APIError = Schema.Schema.Type +export const ContextOverflowError = namedError("ContextOverflowError", { + message: Schema.String, + responseBody: Schema.optional(Schema.String), +}) +export const ContentFilterError = namedError("ContentFilterError", { + message: Schema.String, +}) + +export class OutputFormatText extends Schema.Class("OutputFormatText")({ + type: Schema.Literal("text"), +}) {} + +export class OutputFormatJsonSchema extends Schema.Class("OutputFormatJsonSchema")({ + type: Schema.Literal("json_schema"), + schema: Schema.Record(Schema.String, Schema.Any).annotate({ identifier: "JSONSchema" }), + retryCount: NonNegativeInt.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(2))), +}) {} + +export const Format = Schema.Union([OutputFormatText, OutputFormatJsonSchema]).annotate({ + discriminator: "type", + identifier: "OutputFormat", +}) +export type OutputFormat = Schema.Schema.Type + +const partBase = { + id: PartID, + sessionID: SessionID, + messageID: MessageID, +} + +export const SnapshotPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("snapshot"), + snapshot: Schema.String, +}).annotate({ identifier: "SnapshotPart" }) +export type SnapshotPart = Types.DeepMutable> + +export const PatchPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("patch"), + hash: Schema.String, + files: Schema.Array(Schema.String), +}).annotate({ identifier: "PatchPart" }) +export type PatchPart = Types.DeepMutable> + +export const TextPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("text"), + text: Schema.String, + synthetic: Schema.optional(Schema.Boolean), + ignored: Schema.optional(Schema.Boolean), + time: Schema.optional( + Schema.Struct({ + start: NonNegativeInt, + end: Schema.optional(NonNegativeInt), + }), + ), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), +}).annotate({ identifier: "TextPart" }) +export type TextPart = Types.DeepMutable> + +export const ReasoningPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("reasoning"), + text: Schema.String, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), + time: Schema.Struct({ + start: NonNegativeInt, + end: Schema.optional(NonNegativeInt), + }), +}).annotate({ identifier: "ReasoningPart" }) +export type ReasoningPart = Types.DeepMutable> + +const filePartSourceBase = { + text: Schema.Struct({ + value: Schema.String, + start: Schema.Finite, + end: Schema.Finite, + }).annotate({ identifier: "FilePartSourceText" }), +} + +export const Range = Schema.Struct({ + start: Schema.Struct({ line: NonNegativeInt, character: NonNegativeInt }), + end: Schema.Struct({ line: NonNegativeInt, character: NonNegativeInt }), +}).annotate({ identifier: "Range" }) +export type Range = typeof Range.Type + +export const FileSource = Schema.Struct({ + ...filePartSourceBase, + type: Schema.Literal("file"), + path: Schema.String, +}).annotate({ identifier: "FileSource" }) + +export const SymbolSource = Schema.Struct({ + ...filePartSourceBase, + type: Schema.Literal("symbol"), + path: Schema.String, + range: Range, + name: Schema.String, + kind: NonNegativeInt, +}).annotate({ identifier: "SymbolSource" }) + +export const ResourceSource = Schema.Struct({ + ...filePartSourceBase, + type: Schema.Literal("resource"), + clientName: Schema.String, + uri: Schema.String, +}).annotate({ identifier: "ResourceSource" }) + +export const FilePartSource = Schema.Union([FileSource, SymbolSource, ResourceSource]).annotate({ + discriminator: "type", + identifier: "FilePartSource", +}) + +export const FilePart = Schema.Struct({ + ...partBase, + type: Schema.Literal("file"), + mime: Schema.String, + filename: Schema.optional(Schema.String), + url: Schema.String, + source: Schema.optional(FilePartSource), +}).annotate({ identifier: "FilePart" }) +export type FilePart = Types.DeepMutable> + +export const AgentPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("agent"), + name: Schema.String, + source: Schema.optional( + Schema.Struct({ + value: Schema.String, + start: NonNegativeInt, + end: NonNegativeInt, + }), + ), +}).annotate({ identifier: "AgentPart" }) +export type AgentPart = Types.DeepMutable> + +export const CompactionPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("compaction"), + auto: Schema.Boolean, + overflow: Schema.optional(Schema.Boolean), + tail_start_id: Schema.optional(MessageID), +}).annotate({ identifier: "CompactionPart" }) +export type CompactionPart = Types.DeepMutable> + +export const SubtaskPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("subtask"), + prompt: Schema.String, + description: Schema.String, + agent: Schema.String, + model: Schema.optional( + Schema.Struct({ + providerID: Provider.ID, + modelID: Model.ID, + }), + ), + command: Schema.optional(Schema.String), +}).annotate({ identifier: "SubtaskPart" }) +export type SubtaskPart = Types.DeepMutable> + +export const RetryPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("retry"), + attempt: NonNegativeInt, + error: APIError.EffectSchema, + time: Schema.Struct({ + created: NonNegativeInt, + }), +}).annotate({ identifier: "RetryPart" }) +export type RetryPart = Omit>, "error"> & { + error: APIError +} + +export const StepStartPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("step-start"), + snapshot: Schema.optional(Schema.String), +}).annotate({ identifier: "StepStartPart" }) +export type StepStartPart = Types.DeepMutable> + +export const StepFinishPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("step-finish"), + reason: Schema.String, + snapshot: Schema.optional(Schema.String), + cost: Schema.Finite, + tokens: Schema.Struct({ + total: Schema.optional(Schema.Finite), + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, + cache: Schema.Struct({ + read: Schema.Finite, + write: Schema.Finite, + }), + }), +}).annotate({ identifier: "StepFinishPart" }) +export type StepFinishPart = Types.DeepMutable> + +export const ToolStatePending = Schema.Struct({ + status: Schema.Literal("pending"), + input: Schema.Record(Schema.String, Schema.Any), + raw: Schema.String, +}).annotate({ identifier: "ToolStatePending" }) +export type ToolStatePending = Types.DeepMutable> + +export const ToolStateRunning = Schema.Struct({ + status: Schema.Literal("running"), + input: Schema.Record(Schema.String, Schema.Any), + title: Schema.optional(Schema.String), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), + time: Schema.Struct({ + start: NonNegativeInt, + }), +}).annotate({ identifier: "ToolStateRunning" }) +export type ToolStateRunning = Types.DeepMutable> + +export const ToolStateCompleted = Schema.Struct({ + status: Schema.Literal("completed"), + input: Schema.Record(Schema.String, Schema.Any), + output: Schema.String, + title: Schema.String, + metadata: Schema.Record(Schema.String, Schema.Any), + time: Schema.Struct({ + start: NonNegativeInt, + end: NonNegativeInt, + compacted: Schema.optional(NonNegativeInt), + }), + attachments: Schema.optional(Schema.Array(FilePart)), +}).annotate({ identifier: "ToolStateCompleted" }) +export type ToolStateCompleted = Types.DeepMutable> + +export const ToolStateError = Schema.Struct({ + status: Schema.Literal("error"), + input: Schema.Record(Schema.String, Schema.Any), + error: Schema.String, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), + time: Schema.Struct({ + start: NonNegativeInt, + end: NonNegativeInt, + }), +}).annotate({ identifier: "ToolStateError" }) +export type ToolStateError = Types.DeepMutable> + +export const ToolState = Schema.Union([ + ToolStatePending, + ToolStateRunning, + ToolStateCompleted, + ToolStateError, +]).annotate({ + discriminator: "status", + identifier: "ToolState", +}) +export type ToolState = ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError + +export const ToolPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("tool"), + callID: Schema.String, + tool: Schema.String, + state: ToolState, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), +}).annotate({ identifier: "ToolPart" }) +export type ToolPart = Omit>, "state"> & { + state: ToolState +} + +const messageBase = { + id: MessageID, + sessionID: partBase.sessionID, +} + +export const User = Schema.Struct({ + ...messageBase, + role: Schema.Literal("user"), + time: Schema.Struct({ + created: Timestamp, + }), + format: Schema.optional(Format), + summary: Schema.optional( + Schema.Struct({ + title: Schema.optional(Schema.String), + body: Schema.optional(Schema.String), + diffs: Schema.Array(FileDiff.Info), + }), + ), + agent: Schema.String, + model: Schema.Struct({ + providerID: Provider.ID, + modelID: Model.ID, + variant: Schema.optional(Schema.String), + }), + system: Schema.optional(Schema.String), + tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), +}).annotate({ identifier: "UserMessage" }) +export type User = Types.DeepMutable> + +export const Part = Schema.Union([ + TextPart, + SubtaskPart, + ReasoningPart, + FilePart, + ToolPart, + StepStartPart, + StepFinishPart, + SnapshotPart, + PatchPart, + AgentPart, + RetryPart, + CompactionPart, +]).annotate({ discriminator: "type", identifier: "Part" }) +export type Part = + | TextPart + | SubtaskPart + | ReasoningPart + | FilePart + | ToolPart + | StepStartPart + | StepFinishPart + | SnapshotPart + | PatchPart + | AgentPart + | RetryPart + | CompactionPart + +const AssistantErrorSchema = Schema.Union([ + AuthError.EffectSchema, + namedError("UnknownError", { message: Schema.String, ref: Schema.optional(Schema.String) }).EffectSchema, + OutputLengthError.EffectSchema, + AbortedError.EffectSchema, + StructuredOutputError.EffectSchema, + ContextOverflowError.EffectSchema, + ContentFilterError.EffectSchema, + APIError.EffectSchema, +]).annotate({ discriminator: "name" }) +type AssistantError = Schema.Schema.Type + +export const TextPartInput = Schema.Struct({ + id: Schema.optional(PartID), + type: Schema.Literal("text"), + text: Schema.String, + synthetic: Schema.optional(Schema.Boolean), + ignored: Schema.optional(Schema.Boolean), + time: Schema.optional( + Schema.Struct({ + start: NonNegativeInt, + end: Schema.optional(NonNegativeInt), + }), + ), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), +}).annotate({ identifier: "TextPartInput" }) +export type TextPartInput = Types.DeepMutable> + +export const FilePartInput = Schema.Struct({ + id: Schema.optional(PartID), + type: Schema.Literal("file"), + mime: Schema.String, + filename: Schema.optional(Schema.String), + url: Schema.String, + source: Schema.optional(FilePartSource), +}).annotate({ identifier: "FilePartInput" }) +export type FilePartInput = Types.DeepMutable> + +export const AgentPartInput = Schema.Struct({ + id: Schema.optional(PartID), + type: Schema.Literal("agent"), + name: Schema.String, + source: Schema.optional( + Schema.Struct({ + value: Schema.String, + start: NonNegativeInt, + end: NonNegativeInt, + }), + ), +}).annotate({ identifier: "AgentPartInput" }) +export type AgentPartInput = Types.DeepMutable> + +export const SubtaskPartInput = Schema.Struct({ + id: Schema.optional(PartID), + type: Schema.Literal("subtask"), + prompt: Schema.String, + description: Schema.String, + agent: Schema.String, + model: Schema.optional( + Schema.Struct({ + providerID: Provider.ID, + modelID: Model.ID, + }), + ), + command: Schema.optional(Schema.String), +}).annotate({ identifier: "SubtaskPartInput" }) +export type SubtaskPartInput = Types.DeepMutable> + +export const Assistant = Schema.Struct({ + ...messageBase, + role: Schema.Literal("assistant"), + time: Schema.Struct({ + created: NonNegativeInt, + completed: Schema.optional(NonNegativeInt), + }), + error: Schema.optional(AssistantErrorSchema), + parentID: MessageID, + modelID: Model.ID, + providerID: Provider.ID, + mode: Schema.String, + agent: Schema.String, + path: Schema.Struct({ + cwd: Schema.String, + root: Schema.String, + }), + summary: Schema.optional(Schema.Boolean), + cost: Schema.Finite, + tokens: Schema.Struct({ + total: Schema.optional(Schema.Finite), + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, + cache: Schema.Struct({ + read: Schema.Finite, + write: Schema.Finite, + }), + }), + structured: Schema.optional(Schema.Any), + variant: Schema.optional(Schema.String), + finish: Schema.optional(Schema.String), +}).annotate({ identifier: "AssistantMessage" }) +export type Assistant = Omit>, "error"> & { + error?: AssistantError +} + +export const Info = Schema.Union([User, Assistant]).annotate({ discriminator: "role", identifier: "Message" }) +export type Info = User | Assistant + +export const WithParts = Schema.Struct({ + info: Info, + parts: Schema.Array(Part), +}) +export type WithParts = { + info: Info + parts: Part[] +} + +const options = { + durable: { + aggregate: "sessionID", + version: 1, + }, +} as const + +const SessionSummary = Schema.Struct({ + additions: Schema.Finite, + deletions: Schema.Finite, + files: Schema.Finite, + diffs: optional(Schema.Array(FileDiff.Info)), +}) + +const SessionTokens = Schema.Struct({ + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, + cache: Schema.Struct({ + read: Schema.Finite, + write: Schema.Finite, + }), +}) + +const SessionShare = Schema.Struct({ + url: Schema.String, +}) + +const SessionRevert = Schema.Struct({ + messageID: MessageID, + partID: optional(PartID), + snapshot: optional(Schema.String), + diff: optional(Schema.String), +}) + +const SessionModel = Schema.Struct({ + id: Model.ID, + providerID: Provider.ID, + variant: optional(Schema.String), +}) + +export const SessionInfo = Schema.Struct({ + id: SessionID, + slug: Schema.String, + projectID: Project.ID, + workspaceID: optional(WorkspaceID), + directory: Schema.String, + path: optional(Schema.String), + parentID: optional(SessionID), + summary: optional(SessionSummary), + cost: optional(Schema.Finite), + tokens: optional(SessionTokens), + share: optional(SessionShare), + title: Schema.String, + agent: optional(Schema.String), + model: optional(SessionModel), + version: Schema.String, + metadata: optional(Schema.Record(Schema.String, Schema.Any)), + time: Schema.Struct({ + created: NonNegativeInt, + updated: NonNegativeInt, + compacting: optional(NonNegativeInt), + archived: optional(Schema.Finite), + }), + permission: optional(PermissionV1.Ruleset), + revert: optional(SessionRevert), +}).annotate({ identifier: "Session" }) +export type SessionInfo = typeof SessionInfo.Type + +const events = { + Created: define({ + type: "session.created", + ...options, + schema: { + sessionID: SessionID, + info: SessionInfo, + }, + }), + Updated: define({ + type: "session.updated", + ...options, + schema: { + sessionID: SessionID, + info: SessionInfo, + }, + }), + Deleted: define({ + type: "session.deleted", + ...options, + schema: { + sessionID: SessionID, + info: SessionInfo, + }, + }), + MessageUpdated: define({ + type: "message.updated", + ...options, + schema: { + sessionID: SessionID, + info: Info, + }, + }), + MessageRemoved: define({ + type: "message.removed", + ...options, + schema: { + sessionID: SessionID, + messageID: MessageID, + }, + }), + PartUpdated: define({ + type: "message.part.updated", + ...options, + schema: { + sessionID: SessionID, + part: Part, + time: Schema.Finite, + }, + }), + PartRemoved: define({ + type: "message.part.removed", + ...options, + schema: { + sessionID: SessionID, + messageID: MessageID, + partID: PartID, + }, + }), +} + +export const PartDelta = define({ + type: "message.part.delta", + schema: { + sessionID: SessionID, + messageID: MessageID, + partID: PartID, + field: Schema.String, + delta: Schema.String, + }, +}) + +export const Diff = define({ + type: "session.diff", + schema: { + sessionID: SessionID, + diff: Schema.Array(FileDiff.Info), + }, +}) + +export const Error = define({ + type: "session.error", + schema: { + sessionID: Schema.optional(SessionID), + error: Assistant.fields.error, + }, +}) + +export const Event = { + ...events, + PartDelta, + Diff, + Error, + Definitions: inventory( + events.Created, + events.Updated, + events.Deleted, + events.MessageUpdated, + events.MessageRemoved, + events.PartUpdated, + events.PartRemoved, + PartDelta, + Diff, + Error, + ), +} diff --git a/packages/schema/test/v1-isolation.test.ts b/packages/schema/test/v1-isolation.test.ts new file mode 100644 index 0000000000..2710d85567 --- /dev/null +++ b/packages/schema/test/v1-isolation.test.ts @@ -0,0 +1,28 @@ +import { expect, test } from "bun:test" +import { LegacyEvent } from "../src/legacy-event" +import { PermissionV1 } from "../src/permission-v1" +import { QuestionV1 } from "../src/question-v1" +import { SessionV1 } from "../src/session-v1" +import { LegacyEvent as IsolatedLegacyEvent } from "../src/v1/legacy-event" +import { PermissionV1 as IsolatedPermissionV1 } from "../src/v1/permission" +import { QuestionV1 as IsolatedQuestionV1 } from "../src/v1/question" +import { SessionV1 as IsolatedSessionV1 } from "../src/v1/session" + +test("compatibility entrypoints preserve isolated V1 schema identity", () => { + expect(LegacyEvent).toBe(IsolatedLegacyEvent) + expect(PermissionV1).toBe(IsolatedPermissionV1) + expect(QuestionV1).toBe(IsolatedQuestionV1) + expect(SessionV1).toBe(IsolatedSessionV1) +}) + +test("current source does not import the V1 subtree directly", async () => { + const allowed = new Set(["legacy-event.ts", "permission-v1.ts", "question-v1.ts", "session-v1.ts"]) + const files = [...new Bun.Glob("*.ts").scanSync(new URL("../src", import.meta.url).pathname)].filter( + (file) => !allowed.has(file), + ) + const directImports = await Promise.all( + files.map(async (file) => ({ file, source: await Bun.file(new URL(`../src/${file}`, import.meta.url)).text() })), + ).then((values) => values.filter((value) => value.source.includes('from "./v1/'))) + + expect(directImports).toEqual([]) +}) From 44806777ca74614e4393a7e893b8d7816528743f Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Fri, 26 Jun 2026 01:47:45 +0800 Subject: [PATCH 10/26] feat(app): group prompt projects by server (#33941) --- packages/app/src/components/prompt-input.tsx | 192 ++++++++++++++---- .../composer/session-composer-controls.ts | 51 +++-- 2 files changed, 192 insertions(+), 51 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 20bee2dbb1..811551e3f4 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -30,7 +30,7 @@ import { AgentPart, FileAttachmentPart, } from "@/context/prompt" -import { useLayout } from "@/context/layout" +import { getProjectAvatarVariant, useLayout } from "@/context/layout" import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" import { useComments } from "@/context/comments" @@ -38,6 +38,7 @@ import { Button } from "@opencode-ai/ui/button" import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface" import { Icon, type IconProps } from "@opencode-ai/ui/icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { IconButton } from "@opencode-ai/ui/icon-button" import { Select } from "@opencode-ai/ui/select" @@ -71,7 +72,7 @@ import { createPromptInputTransientState } from "./prompt-input/transient-state" import { showToast } from "@/utils/toast" import { ImagePreview } from "@opencode-ai/ui/image-preview" import { pathKey } from "@/utils/path-key" -import { displayName } from "@/pages/layout/helpers" +import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers" export type PromptInputState = ReturnType @@ -100,10 +101,18 @@ export type PromptInputControls = { loading: boolean } projects: { - available: { name?: string; worktree: string; sandboxes?: string[] }[] + available: { + name?: string + id?: string + worktree: string + sandboxes?: string[] + icon?: { color?: string; url?: string; override?: string } + server?: { key: string; name: string } + }[] directory: string - select: (worktree: string) => void - add: (title: string) => void + server?: string + select: (worktree: string, server?: string) => void + add: (title: string, server?: string) => void } session: { id?: string @@ -1397,7 +1406,9 @@ export const PromptInput: Component = (props) => { if (!directory) return const key = pathKey(directory) return projects().find( - (project) => pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key), + (project) => + (!project.server || project.server.key === props.controls.projects.server) && + (pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key)), ) } const selectedProject = createMemo(() => projectForDirectory(props.controls.projects.directory)) @@ -1407,47 +1418,114 @@ export const PromptInput: Component = (props) => { return projects().filter((project) => displayName(project).toLowerCase().includes(search)) }) const showAgentControl = createMemo(() => props.controls.agents.visible && props.controls.agents.options.length > 0) - const selectProject = (worktree: string) => { + const selectProject = (worktree: string, server?: string) => { setPicker({ projectOpen: false, projectSearch: "", }) - if (pathKey(worktree) === pathKey(selectedProject()?.worktree ?? "")) { + if ( + pathKey(worktree) === pathKey(selectedProject()?.worktree ?? "") && + server === selectedProject()?.server?.key + ) { restoreFocus() return } - props.controls.projects.select(worktree) + props.controls.projects.select(worktree, server) restoreFocus() } - const addProject = () => { - props.controls.projects.add(language.t("command.project.open")) + const addProject = (server?: string) => { + props.controls.projects.add(language.t("command.project.open"), server) } - const projectPickerState = createMemo(() => ({ - open: picker.projectOpen, + const projectItems = createMemo(() => + projectResults().map((project) => ({ + leading: ( + + ), + label: displayName(project), + selected: + selectedProject()?.worktree === project.worktree && + selectedProject()?.server?.key === project.server?.key, + onSelect: () => selectProject(project.worktree, project.server?.key), + })), + ) + const projectServers = createMemo(() => + projects() + .map((project) => project.server) + .filter((server, index, all) => server && all.findIndex((item) => item?.key === server.key) === index), + ) + const addProjectLabel = createMemo(() => language.t("session.new.project.add")) + const projectGroups = createMemo(() => { + if (projectServers().length <= 1) return + return projectServers() + .map((server) => ({ + label: server!.name, + server: server!.key, + items: projectItems().filter((_, index) => projectResults()[index]?.server?.key === server!.key), + action: { + icon: "plus" as const, + label: addProjectLabel(), + onSelect: () => { + setPicker("projectOpen", false) + addProject(server!.key) + }, + }, + })) + .filter((group) => group.items.length > 0) + }) + const projectPickerState: ComposerPickerState = { + get open() { + return picker.projectOpen + }, trigger: { action: "prompt-project", - icon: "folder", - label: selectedProject() ? displayName(selectedProject()!) : language.t("session.new.project.new"), + get icon() { + return selectedProject() ? undefined : "folder" + }, + get leading() { + const project = selectedProject() + if (!project) return + return ( + + ) + }, + get label() { + const project = selectedProject() + return project ? displayName(project) : language.t("session.new.project.new") + }, class: "max-w-[203px]", - style: control(), + get style() { + return control() + }, onPress: () => setPicker("projectOpen", true), }, - search: picker.projectSearch, + get search() { + return picker.projectSearch + }, searchPlaceholder: language.t("session.new.project.search"), clearLabel: language.t("common.clear"), - items: projectResults().map((project) => ({ - icon: "folder", - label: displayName(project), - selected: selectedProject()?.worktree === project.worktree, - onSelect: () => selectProject(project.worktree), - })), + get items() { + return projectItems() + }, + get groups() { + return projectGroups() + }, action: { icon: "plus", - label: language.t("session.new.project.add"), + get label() { + return addProjectLabel() + }, onSelect: () => { setPicker("projectOpen", false) - void addProject() + addProject(projectServers()[0]?.key) }, }, onOpenChange: (open) => { @@ -1457,7 +1535,7 @@ export const PromptInput: Component = (props) => { onSearchInput: (value) => setPicker("projectSearch", value), onSearchClear: () => setPicker("projectSearch", ""), searchRef: (el) => (projectSearchRef = el), - })) + } const agentControlState = createMemo(() => ({ title: language.t("command.agent.cycle"), keybind: command.keybind("agent.cycle"), @@ -1666,7 +1744,7 @@ export const PromptInput: Component = (props) => {
- +
@@ -2011,7 +2089,8 @@ export const PromptInput: Component = (props) => { } type ComposerPickerItemState = { - icon: IconProps["name"] + icon?: IconProps["name"] + leading?: JSX.Element label: string selected?: boolean onSelect: () => void @@ -2020,6 +2099,7 @@ type ComposerPickerItemState = { type ComposerPickerTriggerState = { action: string icon?: IconProps["name"] + leading?: JSX.Element label: string class?: string style: JSX.CSSProperties | undefined @@ -2033,6 +2113,7 @@ type ComposerPickerState = { searchPlaceholder: string clearLabel: string items: ComposerPickerItemState[] + groups?: { label: string; server: string; items: ComposerPickerItemState[]; action: ComposerPickerItemState }[] action: ComposerPickerItemState listClass?: string searchRef: (el: HTMLInputElement) => void @@ -2070,12 +2151,19 @@ function ComposerPickerTrigger(props: ComponentProps<"button"> & { state: Compos {...rest} data-action={local.state.action} type="button" - class={`flex h-7 min-w-0 items-center gap-1.5 rounded px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none ${local.state.class ?? ""}`} + class={`flex h-7 min-w-0 items-center gap-1.5 rounded-sm px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none ${local.state.class ?? ""}`} style={local.state.style} onClick={() => local.state.onPress()} > - - {(icon) => } + + {(icon) => } + + } + > + {(leading) => leading()} {local.state.label} @@ -2087,10 +2175,19 @@ function ComposerPickerMenuItem(props: { state: ComposerPickerItemState }) { return (
- {(item) => } -
-
-
- + {(item) => }} + > + {(groups) => ( + + {(group) => ( +
+
+ {group.label} +
+ {(item) => } + +
+ )} +
+ )} +
+ +
+
+ +
+ diff --git a/packages/app/src/pages/session/composer/session-composer-controls.ts b/packages/app/src/pages/session/composer/session-composer-controls.ts index 44ad636a21..21b753e6ac 100644 --- a/packages/app/src/pages/session/composer/session-composer-controls.ts +++ b/packages/app/src/pages/session/composer/session-composer-controls.ts @@ -8,7 +8,7 @@ import { useGlobal } from "@/context/global" import { useLayout } from "@/context/layout" import { useLocal } from "@/context/local" import type { QueryOptionsApi } from "@/context/server-sync" -import { ServerConnection, useServer } from "@/context/server" +import { serverName, ServerConnection, useServer } from "@/context/server" import { useSDK } from "@/context/sdk" import { useSettings } from "@/context/settings" import { useSync } from "@/context/sync" @@ -49,38 +49,61 @@ export function createSessionComposerControls(input: { const conn = projectServer() if (conn) return global.ensureServerCtx(conn) }) - const projects = createMemo(() => - search.draftId ? (projectServerCtx()?.projects.list() ?? []) : layout.projects.list(), - ) + const projects = createMemo(() => { + if (server.list.length <= 1) { + return search.draftId ? (projectServerCtx()?.projects.list() ?? []) : layout.projects.list() + } + return server.list.flatMap((conn) => { + const server = { key: ServerConnection.key(conn), name: serverName(conn) } + return global + .ensureServerCtx(conn) + .projects.list() + .map((project) => ({ ...project, server })) + }) + }) const agentsQuery = createQuery(() => input.queryOptions.agents(pathKey(sdk().directory))) const globalProvidersQuery = createQuery(() => input.queryOptions.providers(null)) const providersQuery = createQuery(() => input.queryOptions.providers(pathKey(sdk().directory))) - const selectProject = (worktree: string) => { - const conn = projectServer() - const target = projectServerCtx() + const selectProject = (worktree: string, serverKey?: string) => { + const conn = serverKey + ? server.list.find((conn) => ServerConnection.key(conn) === serverKey) + : projectServer() if (search.draftId) { - if (!conn || !target) return + if (!conn) return + const target = global.ensureServerCtx(conn) target.projects.open(worktree) target.projects.touch(worktree) tabs.updateDraft(search.draftId, { server: ServerConnection.key(conn), directory: worktree }) return } - layout.projects.open(worktree) - server.projects.touch(worktree) + if (!serverKey) { + layout.projects.open(worktree) + server.projects.touch(worktree) + navigate(`/${base64Encode(worktree)}/session`) + return + } + + if (!conn) return + const target = global.ensureServerCtx(conn) + target.projects.open(worktree) + target.projects.touch(worktree) + server.setActive(ServerConnection.key(conn)) navigate(`/${base64Encode(worktree)}/session`) } - const addProject = (title: string) => { - const conn = projectServer() + const addProject = (title: string, serverKey?: string) => { + const conn = serverKey + ? server.list.find((conn) => ServerConnection.key(conn) === serverKey) + : projectServer() if (!conn) return pickDirectory({ server: conn, title, onSelect: (result) => { const directory = Array.isArray(result) ? result[0] : result - if (directory) selectProject(directory) + if (directory) selectProject(directory, serverKey) }, }) } @@ -102,6 +125,8 @@ export function createSessionComposerControls(input: { projects: { available: projects(), directory: sdk().directory, + server: + server.list.length > 1 && projectServer() ? ServerConnection.key(projectServer()!) : undefined, select: selectProject, add: addProject, }, From f44423609b03a47baf8a771c821a1de17046309f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 25 Jun 2026 20:23:01 +0200 Subject: [PATCH 11/26] feat(sdk): restore session runtime operations (#33777) --- AGENTS.md | 4 +- CONTEXT.md | 17 +- .../client/src/generated-effect/client.ts | 34 +- packages/client/src/generated/client.ts | 40 ++ packages/client/src/generated/types.ts | 616 ++++++++++++++++++ packages/client/test/effect.test.ts | 48 +- packages/client/test/promise.test.ts | 34 + .../core/src/session/runner/to-llm-message.ts | 23 +- .../core/test/session-runner-message.test.ts | 72 ++ packages/httpapi-codegen/README.md | 4 +- packages/httpapi-codegen/src/index.ts | 47 +- .../httpapi-codegen/test/generate.test.ts | 8 +- .../test/server/httpapi-exercise/index.ts | 25 + packages/protocol/src/groups/session.ts | 51 +- packages/schema/src/event.ts | 35 +- packages/sdk-next/README.md | 4 +- packages/sdk-next/src/opencode.ts | 60 +- packages/sdk-next/test/embedded.test.ts | 38 +- packages/server/src/handlers/session.ts | 29 +- specs/v2/session.md | 4 +- 20 files changed, 1100 insertions(+), 93 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2fe10ba44d..cd2327e888 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,6 @@ -- To regenerate the JavaScript SDK, run `./packages/sdk/js/script/build.ts`. +- To regenerate the legacy JavaScript SDK, run `./packages/sdk/js/script/build.ts`. +- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit `src/generated` or `src/generated-effect` directly. +- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server. - The default branch in this repo is `dev`. - Local `main` ref may not exist; use `dev` or `origin/dev` for diffs. diff --git a/CONTEXT.md b/CONTEXT.md index 97919b63f6..762a1ba7c2 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -57,11 +57,21 @@ The bounded projection of a Core-executed tool result persisted in Session histo **Managed Tool Output File**: A temporary file created under OpenCode's shared tool-output directory to retain complete output that was too large for Session history. +**Model Request Options**: +Provider-semantic model settings selected from the Catalog and active Session variant before the LLM protocol adapter encodes them for a provider request. +_Avoid_: Request body, wire options + +**Generation Controls**: +Provider-neutral sampling and output controls, partitioned from provider semantics and compatibility wire fields when model metadata enters the Catalog. + +**Native Continuation Metadata**: +Opaque protocol-shaped data attached to assistant content and required to continue that content natively with a compatible model, such as a reasoning signature or provider-hosted item identifier. + **PTY Environment**: The host-supplied environment overlay applied by the server when creating a PTY, observed for the request Location and resolved PTY working directory. **OpenCode Client**: -The generated Effect API shared by networked and in-process consumers, executed through an `HttpClient` against the same `HttpApi` router and handlers. +The generated Promise and Effect APIs derived from the public `HttpApi`; **Embedded OpenCode** shares the Effect API through an in-memory `HttpClient` against the same router and handlers. _Avoid_: Remote client **SDK Contract IR**: @@ -122,6 +132,9 @@ _Avoid_: Response envelope - A **Baseline System Context** durably preserves the exact joined text used for the active provider-cache prefix. - Completed compaction starts a new **Context Epoch** on the next provider attempt, folding the current complete **System Context** into a fresh baseline and removing earlier **Mid-Conversation System Messages** from active model history. - A model/provider switch preserves the current **Context Epoch** and chronological conversation history; the new selection applies to the next provider turn. +- **Native Continuation Metadata** remains in durable history. Provider-turn projection includes it only for a successful exact originating provider/model match; failed turns and incompatible models omit opaque metadata, while non-empty visible reasoning lowers to ordinary assistant text after a model switch. This conservative relation may widen only when recorded provider tests establish compatibility. +- **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding. +- **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing. - The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `OPENCODE_TERMINAL`. - Networked and **Embedded OpenCode** use the same **OpenCode Client** and preserve the full HTTP encoding, routing, middleware, and decoding boundary; only the `HttpClient` transport differs. - The Effect-native network constructor obtains `HttpClient.HttpClient` from its environment so callers own transport selection, recording, tracing, retries, and tests. Convenience runtimes may provide a fetch transport separately. @@ -159,7 +172,7 @@ _Avoid_: Response envelope - Session list cursors are opaque branded values carrying continuation query and ordering state. Consumers pass them back unchanged and do not inspect storage anchors or encoded filter fields. - A Session list continuation accepts only its opaque cursor. Scope, filters, ordering, and page size are fixed by the initial query and carried by that cursor. - `sessions.messages(...)` returns a **Page** and uses the same cursor discipline as `sessions.list(...)`: the initial request supplies `sessionID`, ordering, and page size; continuation supplies `sessionID` plus only an opaque branded message cursor carrying ordering, page size, direction, and message anchor. Using a cursor with another Session is invalid. -- `sessions.message({ sessionID, messageID })` is a required resource lookup. An unknown Session fails with `SessionNotFoundError`; a known Session with an absent or differently owned message fails with `SessionMessageNotFoundError` without disclosing cross-Session ownership. Absence is not represented as `undefined` across the public HTTP boundary. +- `sessions.message({ sessionID, messageID })` is a required resource lookup. An unknown Session fails with `SessionNotFoundError`; a known Session with an absent or differently owned message fails with `MessageNotFoundError` without disclosing cross-Session ownership. Absence is not represented as `undefined` across the public HTTP boundary. - `sessions.interrupt({ sessionID })` first verifies that the durable Session exists, failing with `SessionNotFoundError` otherwise. For a known Session, interruption is idempotent: idle, already-settled, or locally unowned execution is a no-op. - `sessions.context({ sessionID })` preserves the existing message-only operation. It returns projected conversational messages selected as Session context; it does not include or represent the complete provider request context, whose baseline system context and other contributions remain separate. - **Open question**: Should a future, separately named operation expose the complete provider request context, including baseline system context, selected source contributions, and context-epoch metadata? diff --git a/packages/client/src/generated-effect/client.ts b/packages/client/src/generated-effect/client.ts index 9088c533fa..5d4a58464b 100644 --- a/packages/client/src/generated-effect/client.ts +++ b/packages/client/src/generated-effect/client.ts @@ -1,5 +1,5 @@ // Generated by @opencode-ai/httpapi-codegen. Do not edit. -import { Effect, Schema } from "effect" +import { Effect, Stream, Schema } from "effect" import { Sse } from "effect/unstable/encoding" import { HttpClientError } from "effect/unstable/http" import { HttpApi, HttpApiClient } from "effect/unstable/httpapi" @@ -143,6 +143,35 @@ const Endpoint0_11 = (raw: RawClient["server.session"]) => (input: Endpoint0_11I Effect.map((value) => value.data), ) +type Endpoint0_12Request = Parameters[0] +type Endpoint0_12Input = { + readonly sessionID: Endpoint0_12Request["params"]["sessionID"] + readonly after?: Endpoint0_12Request["query"]["after"] +} +const Endpoint0_12 = (raw: RawClient["server.session"]) => (input: Endpoint0_12Input) => + Stream.unwrap( + raw["session.events"]({ params: { sessionID: input.sessionID }, query: { after: input.after } }).pipe( + Effect.mapError(mapClientError), + Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError))), + ), + ) + +type Endpoint0_13Request = Parameters[0] +type Endpoint0_13Input = { readonly sessionID: Endpoint0_13Request["params"]["sessionID"] } +const Endpoint0_13 = (raw: RawClient["server.session"]) => (input: Endpoint0_13Input) => + raw["session.interrupt"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint0_14Request = Parameters[0] +type Endpoint0_14Input = { + readonly sessionID: Endpoint0_14Request["params"]["sessionID"] + readonly messageID: Endpoint0_14Request["params"]["messageID"] +} +const Endpoint0_14 = (raw: RawClient["server.session"]) => (input: Endpoint0_14Input) => + raw["session.message"]({ params: { sessionID: input.sessionID, messageID: input.messageID } }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ) + const adaptGroup0 = (raw: RawClient["server.session"]) => ({ list: Endpoint0_0(raw), create: Endpoint0_1(raw), @@ -156,6 +185,9 @@ const adaptGroup0 = (raw: RawClient["server.session"]) => ({ clear: Endpoint0_9(raw), commit: Endpoint0_10(raw), context: Endpoint0_11(raw), + events: Endpoint0_12(raw), + interrupt: Endpoint0_13(raw), + message: Endpoint0_14(raw), }) const adaptClient = (raw: RawClient) => ({ sessions: adaptGroup0(raw["server.session"]) }) diff --git a/packages/client/src/generated/client.ts b/packages/client/src/generated/client.ts index 781ba9730c..b1aa2a2405 100644 --- a/packages/client/src/generated/client.ts +++ b/packages/client/src/generated/client.ts @@ -23,6 +23,12 @@ import type { SessionsCommitOutput, SessionsContextInput, SessionsContextOutput, + SessionsEventsInput, + SessionsEventsOutput, + SessionsInterruptInput, + SessionsInterruptOutput, + SessionsMessageInput, + SessionsMessageOutput, } from "./types" import { ClientError } from "./client-error" @@ -306,6 +312,40 @@ export function make(options: ClientOptions) { }, requestOptions, ).then((value) => value.data), + events: (input: SessionsEventsInput, requestOptions?: RequestOptions): AsyncIterable => + sse( + { + method: "GET", + path: `/api/session/${encodeURIComponent(input.sessionID)}/event`, + query: { after: input.after }, + successStatus: 200, + declaredStatuses: [404, 400, 401], + empty: false, + }, + requestOptions, + ), + interrupt: (input: SessionsInterruptInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/interrupt`, + successStatus: 204, + declaredStatuses: [404, 400, 401], + empty: true, + }, + requestOptions, + ), + message: (input: SessionsMessageInput, requestOptions?: RequestOptions) => + request<{ readonly data: SessionsMessageOutput }>( + { + method: "GET", + path: `/api/session/${encodeURIComponent(input.sessionID)}/message/${encodeURIComponent(input.messageID)}`, + successStatus: 200, + declaredStatuses: [404, 400, 401], + empty: false, + }, + requestOptions, + ).then((value) => value.data), }, } } diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index f51e742798..858756e2a4 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -592,3 +592,619 @@ export type SessionsContextOutput = { } > }["data"] + +export type SessionsEventsInput = { + readonly sessionID: { readonly sessionID: string }["sessionID"] + readonly after?: { readonly after?: string | undefined }["after"] +} + +export type SessionsEventsOutput = + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.agent.switched" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly agent: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.model.switched" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.moved" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly location: { readonly directory: string; readonly workspaceID?: string } + readonly subdirectory?: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.prompted" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly prompt: { + readonly text: string + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + } + readonly delivery: "steer" | "queue" + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.prompt.admitted" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly prompt: { + readonly text: string + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + } + readonly delivery: "steer" | "queue" + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.context.updated" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly text: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.synthetic" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly text: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.shell.started" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly callID: string + readonly command: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.shell.ended" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly callID: string + readonly output: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.step.started" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly agent: string + readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } + readonly snapshot?: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.step.ended" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly finish: string + readonly cost: number + readonly tokens: { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } + readonly snapshot?: string + readonly files?: ReadonlyArray + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.step.failed" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly error: { readonly type: "unknown"; readonly message: string } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.text.started" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly textID: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.text.ended" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly textID: string + readonly text: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.tool.input.started" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly name: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.tool.input.ended" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly text: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.tool.called" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly tool: string + readonly input: { readonly [x: string]: unknown } + readonly provider: { + readonly executed: boolean + readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } } + } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.tool.progress" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly structured: { readonly [x: string]: unknown } + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly text: string } + | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } + > + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.tool.success" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly structured: { readonly [x: string]: unknown } + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly text: string } + | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } + > + readonly outputPaths?: ReadonlyArray + readonly result?: unknown + readonly provider: { + readonly executed: boolean + readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } } + } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.tool.failed" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly error: { readonly type: "unknown"; readonly message: string } + readonly result?: unknown + readonly provider: { + readonly executed: boolean + readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } } + } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.reasoning.started" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly reasoningID: string + readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.reasoning.ended" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly reasoningID: string + readonly text: string + readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.retried" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly attempt: number + readonly error: { + readonly message: string + readonly statusCode?: number + readonly isRetryable: boolean + readonly responseHeaders?: { readonly [x: string]: string } + readonly responseBody?: string + readonly metadata?: { readonly [x: string]: string } + } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.compaction.started" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly reason: "auto" | "manual" + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.compaction.ended" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly reason: "auto" | "manual" + readonly text: string + readonly recent: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.revert.staged" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly revert: { + readonly messageID: string + readonly partID?: string + readonly snapshot?: string + readonly diff?: string + readonly files?: ReadonlyArray<{ + readonly path: string + readonly status: "added" | "modified" | "deleted" + readonly additions: number + readonly deletions: number + readonly patch: string + }> + } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.revert.cleared" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { readonly timestamp: number; readonly sessionID: string } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.revert.committed" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { readonly timestamp: number; readonly sessionID: string; readonly messageID: string } + } + +export type SessionsInterruptInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } + +export type SessionsInterruptOutput = void + +export type SessionsMessageInput = { + readonly sessionID: { readonly sessionID: string; readonly messageID: string }["sessionID"] + readonly messageID: { readonly sessionID: string; readonly messageID: string }["messageID"] +} + +export type SessionsMessageOutput = { + readonly data: + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly type: "agent-switched" + readonly agent: string + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly type: "model-switched" + readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly text: string + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly type: "user" + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly sessionID: string + readonly text: string + readonly type: "synthetic" + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly type: "system" + readonly text: string + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number; readonly completed?: number } + readonly type: "shell" + readonly callID: string + readonly command: string + readonly output: string + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number; readonly completed?: number } + readonly type: "assistant" + readonly agent: string + readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly id: string; readonly text: string } + | { + readonly type: "reasoning" + readonly id: string + readonly text: string + readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } + } + | { + readonly type: "tool" + readonly id: string + readonly name: string + readonly provider?: { + readonly executed: boolean + readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } + readonly resultMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } + } + readonly state: + | { readonly status: "pending"; readonly input: string } + | { + readonly status: "running" + readonly input: { readonly [x: string]: JsonValue } + readonly structured: { readonly [x: string]: JsonValue } + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly text: string } + | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } + > + } + | { + readonly status: "completed" + readonly input: { readonly [x: string]: JsonValue } + readonly attachments?: ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly text: string } + | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } + > + readonly outputPaths?: ReadonlyArray + readonly structured: { readonly [x: string]: JsonValue } + readonly result?: JsonValue + } + | { + readonly status: "error" + readonly input: { readonly [x: string]: JsonValue } + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly text: string } + | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } + > + readonly structured: { readonly [x: string]: JsonValue } + readonly error: { readonly type: "unknown"; readonly message: string } + readonly result?: JsonValue + } + readonly time: { + readonly created: number + readonly ran?: number + readonly completed?: number + readonly pruned?: number + } + } + > + readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray } + readonly finish?: string + readonly cost?: number + readonly tokens?: { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } + readonly error?: { readonly type: "unknown"; readonly message: string } + } + | { + readonly type: "compaction" + readonly reason: "auto" | "manual" + readonly summary: string + readonly recent: string + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + } +}["data"] diff --git a/packages/client/test/effect.test.ts b/packages/client/test/effect.test.ts index 5613c3ce77..f9fbf1b96f 100644 --- a/packages/client/test/effect.test.ts +++ b/packages/client/test/effect.test.ts @@ -1,7 +1,7 @@ import { expect, test } from "bun:test" -import { DateTime, Effect } from "effect" +import { DateTime, Effect, Stream } from "effect" import { HttpClient, HttpClientResponse } from "effect/unstable/http" -import { AbsolutePath, Agent, Location, Model, OpenCode, Prompt, Session } from "../src/effect" +import { AbsolutePath, Agent, Location, Model, OpenCode, Prompt, Session, SessionMessage } from "../src/effect" test("sessions.get returns the decoded Effect projection", async () => { const httpClient = HttpClient.make((request) => @@ -18,12 +18,25 @@ test("sessions.get returns the decoded Effect projection", async () => { test("session methods retain decoded Effect inputs and outputs", async () => { const httpClient = HttpClient.make((request) => { const url = request.url + if (url.includes("/event")) { + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(`data: ${JSON.stringify(modelSwitchedEvent)}\n\n`, { + headers: { "content-type": "text/event-stream" }, + }), + ), + ) + } if (url.includes("/prompt")) { return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(admission))) } if (url.includes("/context")) { return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ data: [] }))) } + if (url.includes("/message/")) { + return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ data: modelSwitchedMessage }))) + } if (request.method === "POST" && url.endsWith("/api/session")) { return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session))) } @@ -53,7 +66,15 @@ test("session methods retain decoded Effect inputs and outputs", async () => { yield* client.sessions.compact({ sessionID: Session.ID.make("ses_test") }) yield* client.sessions.wait({ sessionID: Session.ID.make("ses_test") }) const context = yield* client.sessions.context({ sessionID: Session.ID.make("ses_test") }) - return { page, created, admitted, context } + const events = yield* client.sessions + .events({ sessionID: Session.ID.make("ses_test"), after: 0 }) + .pipe(Stream.runCollect) + yield* client.sessions.interrupt({ sessionID: Session.ID.make("ses_test") }) + const message = yield* client.sessions.message({ + sessionID: Session.ID.make("ses_test"), + messageID: SessionMessage.ID.make("msg_model"), + }) + return { page, created, admitted, context, events, message } }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise) expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000) @@ -64,6 +85,8 @@ test("session methods retain decoded Effect inputs and outputs", async () => { expect(Object.getPrototypeOf(result.admitted.prompt)).toBe(Object.prototype) expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000) expect(result.context).toEqual([]) + expect(DateTime.toEpochMillis(result.events[0].data.timestamp)).toBe(1_717_171_717_000) + expect(result.message).toEqual(expect.objectContaining({ id: "msg_model", type: "model-switched" })) }) const session = { @@ -96,3 +119,22 @@ const admission = { timeCreated: 1_717_171_717_000, }, } + +const modelSwitchedMessage = { + id: "msg_model", + type: "model-switched", + time: { created: 1_717_171_717_000 }, + model: { id: "claude", providerID: "anthropic" }, +} + +const modelSwitchedEvent = { + id: "evt_model", + type: "session.next.model.switched", + durable: { aggregateID: "ses_test", seq: 1, version: 1 }, + data: { + timestamp: 1_717_171_717_000, + sessionID: "ses_test", + messageID: "msg_model", + model: { id: "claude", providerID: "anthropic" }, + }, +} diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index a7fac9780f..16a475b443 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -24,8 +24,14 @@ test("session methods use the public HTTP contract", async () => { fetch: async (input, init) => { const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url requests.push({ url, init }) + if (url.includes("/event")) { + return new Response(`data: ${JSON.stringify(modelSwitchedEvent)}\n\n`, { + headers: { "content-type": "text/event-stream" }, + }) + } if (url.includes("/prompt")) return Response.json(admission) if (url.includes("/context")) return Response.json({ data: [] }) + if (url.includes("/message/")) return Response.json({ data: modelSwitchedMessage }) if (init?.method === "POST" && url.endsWith("/api/session")) return Response.json(session) if (init?.method === "POST") return new Response(null, { status: 204 }) return Response.json({ data: [session.data], cursor: { next: "next" } }) @@ -47,11 +53,17 @@ test("session methods use the public HTTP contract", async () => { await client.sessions.compact({ sessionID: "ses_test" }) await client.sessions.wait({ sessionID: "ses_test" }) const context = await client.sessions.context({ sessionID: "ses_test" }) + const events = [] + for await (const event of client.sessions.events({ sessionID: "ses_test", after: "0" })) events.push(event) + await client.sessions.interrupt({ sessionID: "ses_test" }) + const message = await client.sessions.message({ sessionID: "ses_test", messageID: "msg_model" }) expect(page.cursor.next).toBe("next") expect(created.id).toBe("ses_test") expect(admitted.id).toBe("msg_test") expect(context).toEqual([]) + expect(events).toEqual([modelSwitchedEvent]) + expect(message).toEqual(modelSwitchedMessage) expect(requests.map((request) => [request.init?.method, request.url])).toEqual([ ["GET", "http://localhost:3000/api/session?limit=10&order=desc"], ["POST", "http://localhost:3000/api/session"], @@ -61,6 +73,9 @@ test("session methods use the public HTTP contract", async () => { ["POST", "http://localhost:3000/api/session/ses_test/compact"], ["POST", "http://localhost:3000/api/session/ses_test/wait"], ["GET", "http://localhost:3000/api/session/ses_test/context"], + ["GET", "http://localhost:3000/api/session/ses_test/event?after=0"], + ["POST", "http://localhost:3000/api/session/ses_test/interrupt"], + ["GET", "http://localhost:3000/api/session/ses_test/message/msg_model"], ]) const body = requests[4]?.init?.body if (typeof body !== "string") throw new Error("Expected JSON request body") @@ -115,3 +130,22 @@ const admission = { timeCreated: 1_717_171_717_000, }, } + +const modelSwitchedMessage = { + id: "msg_model", + type: "model-switched", + time: { created: 1_717_171_717_000 }, + model: { id: "claude", providerID: "anthropic" }, +} + +const modelSwitchedEvent = { + id: "evt_model", + type: "session.next.model.switched", + durable: { aggregateID: "ses_test", seq: 1, version: 1 }, + data: { + timestamp: 1_717_171_717_000, + sessionID: "ses_test", + messageID: "msg_model", + model: { id: "claude", providerID: "anthropic" }, + }, +} diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts index f0ce7eef7f..b2b1af5d30 100644 --- a/packages/core/src/session/runner/to-llm-message.ts +++ b/packages/core/src/session/runner/to-llm-message.ts @@ -70,17 +70,28 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid const assistant = (message: SessionMessage.Assistant, model: Model) => { const sameModel = String(message.model.providerID) === String(model.provider) && String(message.model.id) === String(model.id) + const reuseProviderMetadata = sameModel && message.error === undefined const content = message.content.flatMap((item): ContentPart[] => { if (item.type === "text") return [{ type: "text", text: item.text }] if (item.type === "reasoning") return sameModel - ? [{ type: "reasoning", text: item.text, providerMetadata: item.providerMetadata }] + ? [ + { + type: "reasoning", + text: item.text, + providerMetadata: reuseProviderMetadata ? item.providerMetadata : undefined, + }, + ] : item.text.length > 0 ? [{ type: "text", text: item.text }] : [] - const call = toolCall(item, sameModel ? item.provider?.metadata : undefined) - const result = toolResult(item, sameModel ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined) - return item.provider?.executed === true && result ? [call, result] : [call] + const call = toolCall(item, reuseProviderMetadata ? item.provider?.metadata : undefined) + if (item.provider?.executed !== true) return [call] + const result = toolResult( + item, + reuseProviderMetadata ? (item.provider.resultMetadata ?? item.provider.metadata) : undefined, + ) + return result ? [call, result] : [call] }) const meaningful = content.filter((part) => { if (part.type === "text") return part.text !== "" @@ -89,7 +100,9 @@ const assistant = (message: SessionMessage.Assistant, model: Model) => { }) const results = message.content .filter((item): item is SessionMessage.AssistantTool => item.type === "tool" && item.provider?.executed !== true) - .map((item) => toolResult(item, sameModel ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined)) + .map((item) => + toolResult(item, reuseProviderMetadata ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined), + ) .filter((message) => message !== undefined) .map(Message.tool) if (meaningful.length === 0) return results diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index 653a192a62..5798b665a8 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -327,6 +327,78 @@ Recent work ]) }) + test("drops provider-native continuation metadata from failed assistant turns", () => { + const messages = toLLMMessages( + [ + SessionMessage.Assistant.make({ + id: id("assistant-failed"), + type: "assistant", + agent: "build", + model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, + content: [ + SessionMessage.AssistantReasoning.make({ + type: "reasoning", + id: "reasoning-failed", + text: "Partial thought", + providerMetadata: { openai: { itemId: "rs_failed", reasoningEncryptedContent: null } }, + }), + SessionMessage.AssistantTool.make({ + type: "tool", + id: "hosted-failed", + name: "web_search", + provider: { + executed: true, + metadata: { openai: { itemId: "call_failed" } }, + resultMetadata: { openai: { itemId: "result_failed" } }, + }, + state: SessionMessage.ToolStateError.make({ + status: "error", + input: { query: "Effect" }, + error: { type: "unknown", message: "Provider turn interrupted" }, + content: [], + structured: {}, + }), + time: { created, completed: created }, + }), + ], + finish: "error", + error: { type: "unknown", message: "Provider turn interrupted" }, + time: { created, completed: created }, + }), + ], + model, + ) + + expect(messages[0]?.content).toEqual([ + { type: "reasoning", text: "Partial thought", providerMetadata: undefined }, + { + type: "tool-call", + id: "hosted-failed", + name: "web_search", + input: { query: "Effect" }, + providerExecuted: true, + providerMetadata: undefined, + }, + { + type: "tool-result", + id: "hosted-failed", + name: "web_search", + result: { + type: "error", + value: { + error: { type: "unknown", message: "Provider turn interrupted" }, + content: [], + structured: {}, + }, + }, + providerExecuted: true, + cache: undefined, + metadata: undefined, + providerMetadata: undefined, + }, + ]) + }) + test("drops provider-native continuation metadata after a model switch", () => { const messages = toLLMMessages( [ diff --git a/packages/httpapi-codegen/README.md b/packages/httpapi-codegen/README.md index 14a4dd3932..0d31b129b7 100644 --- a/packages/httpapi-codegen/README.md +++ b/packages/httpapi-codegen/README.md @@ -37,6 +37,6 @@ The existing public `generate(Api, { directory })` operation writes the rich Eff Generation formats TypeScript with Prettier before writing. Output paths are flat, unique, and checked against traversal, reserved manifest names, and existing symbolic links. -Generated source starts with one self-contained module per `HttpApiGroup`, plus root client and index modules. Schema dependencies may be duplicated across group modules. Cross-group schema partitioning is deferred until measured output or bundle cost requires it. +Portable Effect output uses one self-contained module per `HttpApiGroup`, plus root client and index modules. Promise output uses shared type and client modules, while imported Effect output keeps adapters in the root client module. Schema dependencies may be duplicated across portable Effect group modules. Cross-group schema partitioning is deferred until measured output or bundle cost requires it. -Codegen preserves group and endpoint identifiers exactly. The composed remote `HttpApi` owns public names such as `session` and `get`; the generator performs no prefix stripping, casing conversion, or public-name annotation mapping. +Codegen preserves transport identifiers internally. `compile` may explicitly map consumer-facing group names, and endpoint operation IDs are projected to their final dot-delimited segment. The generator performs no other implicit product-specific naming or public-name annotation mapping. diff --git a/packages/httpapi-codegen/src/index.ts b/packages/httpapi-codegen/src/index.ts index d576b24cd1..289884d961 100644 --- a/packages/httpapi-codegen/src/index.ts +++ b/packages/httpapi-codegen/src/index.ts @@ -69,6 +69,7 @@ type Slot = { const resolveHttpApiStatus = SchemaAST.resolveAt("httpApiStatus") const resolveHttpApiEncoding = SchemaAST.resolveAt("~httpApiEncoding") +const resolveContentSchema = SchemaAST.resolveAt("contentSchema") const Manifest = Schema.fromJsonString(Schema.Array(Schema.String)) const manifestName = ".httpapi-codegen.json" @@ -125,9 +126,10 @@ export function compile( ...responseSchemas(success.schema, `${name}.success`), ...errorSchemas.map((item) => [`${name}.error`, item.schema] as const), ] - const effectPortable = [params, query, headers, ...payloads, success, ...errorSchemas].every( - (item) => item?.effectPortable !== false, - ) + const effectPortable = + [params, query, headers, ...payloads, success, ...errorSchemas].every( + (item) => item?.effectPortable !== false, + ) && streamEffectPortable(success.schema) if (effectPortable) { for (const [path, schema] of schemaPaths) assertPortable(schema, path, portable) } @@ -454,7 +456,7 @@ function renderPromiseTypes(groups: ReadonlyArray) { const success = typeOf( isStreamSchema(successSchema) && successSchema._tag === "StreamSse" ? successSchema.sseMode === "data" - ? streamDataSchema(successSchema) + ? streamEncodedDataSchema(successSchema) : successSchema.events : successSchema, ) @@ -782,17 +784,6 @@ function responseSchemas(schema: Schema.Top, path: string): Array) { - const ast = Schema.toType(schema.events).ast + return Schema.make(streamDataAst(Schema.toType(schema.events).ast)) +} + +function streamEncodedDataSchema(schema: Extract) { + const data = streamDataAst(schema.events.ast) + const encodedAst = data.encoding?.at(-1)?.to + if (encodedAst === undefined) throw new GenerationError({ reason: "Invalid SSE data schema" }) + const encoded = resolveContentSchema(encodedAst) + if (!SchemaAST.isAST(encoded)) throw new GenerationError({ reason: "Invalid SSE data schema" }) + return Schema.make(encoded) +} + +function streamDataAst(ast: SchemaAST.AST) { if (!SchemaAST.isObjects(ast)) throw new GenerationError({ reason: "Invalid SSE data schema" }) const data = ast.propertySignatures.find((field) => field.name === "data")?.type if (data === undefined) throw new GenerationError({ reason: "Invalid SSE data schema" }) - return Schema.make(data) + return data +} + +function streamEffectPortable(schema: Schema.Top) { + if (!isStreamSchema(schema) || schema._tag === "StreamUint8Array" || schema.sseMode === "events") return true + const rebuilt = HttpApiSchema.StreamSse({ + data: streamDataSchema(schema), + error: schema.error, + contentType: schema.contentType, + }) + return sameEncoding(schema.events.ast, rebuilt.events.ast) } function renderGroup(group: Group, groupIndex: number) { diff --git a/packages/httpapi-codegen/test/generate.test.ts b/packages/httpapi-codegen/test/generate.test.ts index 8254652ec5..543b2a13f3 100644 --- a/packages/httpapi-codegen/test/generate.test.ts +++ b/packages/httpapi-codegen/test/generate.test.ts @@ -395,7 +395,9 @@ describe("HttpApiCodegen.generate", () => { api( HttpApiEndpoint.get("subscribe", "/event", { query: { after: Schema.optional(Schema.Number) }, - success: HttpApiSchema.StreamSse({ data: Schema.Struct({ type: Schema.String }) }), + success: HttpApiSchema.StreamSse({ + data: Schema.Struct({ type: Schema.String, count: Schema.NumberFromString }), + }), }), ), ), @@ -416,7 +418,7 @@ describe("HttpApiCodegen.generate", () => { return new Response( new ReadableStream({ start(controller) { - controller.enqueue(encoder.encode('data: {"type":"ready"}\r')) + controller.enqueue(encoder.encode('data: {"type":"ready","count":"1"}\r')) controller.enqueue(encoder.encode("\n\r\n")) controller.close() }, @@ -430,7 +432,7 @@ describe("HttpApiCodegen.generate", () => { expect(requests).toBe(0) const received = [] for await (const event of events) received.push(event) - expect(received).toEqual([{ type: "ready" }]) + expect(received).toEqual([{ type: "ready", count: "1" }]) expect(requests).toBe(1) expect(url).toBe("https://example.com/event?after=2") } finally { diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index d0ffe7f8f1..692a63defd 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -1066,6 +1066,31 @@ const scenarios: Scenario[] = [ headers: ctx.headers(), })) .status(400, undefined, "none"), + http.protected + .get("/api/session/{sessionID}/event", "v2.session.events.missing") + .at((ctx) => ({ + path: `${route("/api/session/{sessionID}/event", { sessionID: "ses_httpapi_missing" })}?after=0`, + headers: ctx.headers(), + })) + .status(404, undefined, "status"), + http.protected + .post("/api/session/{sessionID}/interrupt", "v2.session.interrupt") + .seeded((ctx) => ctx.session({ title: "Interrupt session" })) + .at((ctx) => ({ + path: route("/api/session/{sessionID}/interrupt", { sessionID: ctx.state.id }), + headers: ctx.headers(), + })) + .status(204, undefined, "none"), + http.protected + .get("/api/session/{sessionID}/message/{messageID}", "v2.session.message.missing") + .at((ctx) => ({ + path: route("/api/session/{sessionID}/message/{messageID}", { + sessionID: "ses_httpapi_missing", + messageID: "msg_httpapi_missing", + }), + headers: ctx.headers(), + })) + .json(404, object, "status"), http.protected .post("/api/session/{sessionID}/prompt", "v2.session.prompt.invalid") .seeded((ctx) => ctx.session({ title: "Invalid prompt owner" })) diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index 431deb860e..5e799091a9 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -3,7 +3,7 @@ import { SessionInput } from "@opencode-ai/schema/session-input" import { Prompt } from "@opencode-ai/schema/prompt" import { Session } from "@opencode-ai/schema/session" import { Project } from "@opencode-ai/schema/project" -import { AbsolutePath, PositiveInt, RelativePath, statics } from "@opencode-ai/schema/schema" +import { AbsolutePath, NonNegativeInt, PositiveInt, RelativePath, statics } from "@opencode-ai/schema/schema" import { Workspace } from "@opencode-ai/schema/workspace" import { Context, Encoding, Result, Schema, Struct } from "effect" import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" @@ -20,6 +20,7 @@ import { Agent } from "@opencode-ai/schema/agent" import { Model } from "@opencode-ai/schema/model" import { Location } from "@opencode-ai/schema/location" import { Revert } from "@opencode-ai/schema/revert" +import { SessionEvent } from "@opencode-ai/schema/session-event" const SessionsQueryFields = { workspace: Workspace.ID.pipe(Schema.optional), @@ -272,6 +273,54 @@ export const makeSessionGroup = (sessionLo }), ), ) + .add( + HttpApiEndpoint.get("session.events", "/api/session/:sessionID/event", { + params: { sessionID: Session.ID }, + query: { + after: Schema.NumberFromString.pipe(Schema.decodeTo(NonNegativeInt), Schema.optional), + }, + success: HttpApiSchema.StreamSse({ data: SessionEvent.Durable }), + error: SessionNotFoundError, + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.events", + summary: "Subscribe to session events", + description: "Replay durable events after an aggregate sequence, then continue with new durable events.", + }), + ), + ) + .add( + HttpApiEndpoint.post("session.interrupt", "/api/session/:sessionID/interrupt", { + params: { sessionID: Session.ID }, + success: HttpApiSchema.NoContent, + error: SessionNotFoundError, + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.interrupt", + summary: "Interrupt session execution", + description: "Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op.", + }), + ), + ) + .add( + HttpApiEndpoint.get("session.message", "/api/session/:sessionID/message/:messageID", { + params: { sessionID: Session.ID, messageID: SessionMessage.ID }, + success: Schema.Struct({ data: SessionMessage.Message }), + error: [SessionNotFoundError, MessageNotFoundError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.message", + summary: "Get session message", + description: "Retrieve one projected message owned by the Session.", + }), + ), + ) .annotateMerge( OpenApi.annotations({ title: "sessions", diff --git a/packages/schema/src/event.ts b/packages/schema/src/event.ts index da1cadfa7b..3bd0da9894 100644 --- a/packages/schema/src/event.ts +++ b/packages/schema/src/event.ts @@ -41,7 +41,7 @@ export type Payload = { export function define< const Type extends string, - Fields extends Readonly>>, + const Fields extends Readonly>>, >(input: { readonly type: Type readonly durable?: { @@ -49,23 +49,24 @@ export function define< readonly aggregate: string } readonly schema: Fields -}): Schema.Schema>>> & Definition> { +}) { const data = Schema.Struct(input.schema) - return Object.assign( - Schema.Struct({ - id: ID, - metadata: optional(Schema.Record(Schema.String, Schema.Unknown)), - type: Schema.Literal(input.type), - durable: optional(Schema.Struct({ aggregateID: Schema.String, seq: Schema.Number, version: Schema.Number })), - location: optional(Location.Ref), - data, - }).annotate({ identifier: input.type }), - { - type: input.type, - ...(input.durable === undefined ? {} : { durable: input.durable }), - data, - }, - ) as Schema.Schema>>> & Definition> + return Schema.Struct({ + id: ID, + metadata: optional(Schema.Record(Schema.String, Schema.Unknown)), + type: Schema.Literal(input.type), + durable: optional(Schema.Struct({ aggregateID: Schema.String, seq: Schema.Number, version: Schema.Number })), + location: optional(Location.Ref), + data, + }) + .annotate({ identifier: input.type }) + .pipe( + statics(() => ({ + type: input.type, + ...(input.durable === undefined ? {} : { durable: input.durable }), + data, + })), + ) satisfies Definition } export function inventory>(...definitions: Definitions) { diff --git a/packages/sdk-next/README.md b/packages/sdk-next/README.md index 449bd8f6da..6a9e3bef3d 100644 --- a/packages/sdk-next/README.md +++ b/packages/sdk-next/README.md @@ -11,7 +11,9 @@ const opencode = yield * OpenCode.create() const session = yield * opencode.sessions.get({ sessionID }) ``` -It also exposes local-only `tools.register(...)`. Closing the owning Effect Scope releases router resources, location services, fibers, and scoped tool registrations. +It also exports `Tool` and exposes local-only `tools.register(...)`, replacing the former `@opencode-ai/core/public` facade. Registration uses Core's host-level `ApplicationTools` service shared by the host's Locations; each Location retains its own `ToolRegistry` for overlay, lookup, and settlement. Closing the owning Effect Scope releases router resources, location services, fibers, and scoped tool registrations. + +`sessions.events({ sessionID, after })` replays durable events after the optional aggregate sequence, then emits newly committed durable events. `sessions.interrupt(...)` targets execution owned by this host, and `sessions.message(...)` retrieves one projected Session message. The same constructor is available as a service Layer: diff --git a/packages/sdk-next/src/opencode.ts b/packages/sdk-next/src/opencode.ts index ad89ae49aa..5256548efc 100644 --- a/packages/sdk-next/src/opencode.ts +++ b/packages/sdk-next/src/opencode.ts @@ -2,43 +2,37 @@ import { OpenCode } from "@opencode-ai/client/effect" import { PermissionSaved } from "@opencode-ai/core/permission/saved" import { ApplicationTools } from "@opencode-ai/core/tool/application-tools" import { createEmbeddedRoutes } from "@opencode-ai/server/routes" -import { Cause, Context, Effect, Layer } from "effect" -import { - HttpClient, - HttpRouter, - HttpServer, - HttpServerError, - HttpServerRequest, - HttpServerResponse, -} from "effect/unstable/http" +import { Context, Effect, Layer, Scope } from "effect" +import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http" export const create = Effect.fn("OpenCode.create")(function* () { - const applicationTools = ApplicationTools.layer - const { handler, permissions, tools } = yield* Effect.all({ - // Reusing this Layer value lets registration and every Location share one memoized host-level registry. - handler: HttpRouter.toHttpEffect( - createEmbeddedRoutes().pipe(Layer.provide(applicationTools), Layer.provide(HttpServer.layerServices)), - ), - permissions: PermissionSaved.Service, - tools: ApplicationTools.Service, - }).pipe(Effect.provide(Layer.merge(applicationTools, PermissionSaved.defaultLayer))) - const httpClient = HttpClient.make( - Effect.fnUntraced(function* (request) { - const response = yield* handler.pipe( - Effect.provideService(HttpServerRequest.HttpServerRequest, HttpServerRequest.fromClientRequest(request)), - Effect.provideService(ApplicationTools.Service, tools), - Effect.provideService(PermissionSaved.Service, permissions), - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.interrupt - : HttpServerError.causeResponse(cause).pipe(Effect.map(([response]) => response)), - ), - ) - return HttpServerResponse.toClientResponse(response, { request }) - }, Effect.scoped), + const scope = yield* Scope.Scope + const memoMap = yield* Layer.makeMemoMap + const context = yield* Layer.buildWithMemoMap( + Layer.merge(ApplicationTools.layer, PermissionSaved.defaultLayer), + memoMap, + scope, ) + const tools = Context.get(context, ApplicationTools.Service) + const permissions = Context.get(context, PermissionSaved.Service) + const web = yield* Effect.acquireRelease( + Effect.sync(() => + HttpRouter.toWebHandler( + createEmbeddedRoutes().pipe( + HttpRouter.provideRequest(Layer.succeed(PermissionSaved.Service, permissions)), + Layer.provide(HttpServer.layerServices), + ), + { disableLogger: true, memoMap }, + ), + ), + (web) => Effect.promise(web.dispose), + ) + const fetch = Object.assign((input: RequestInfo | URL, init?: RequestInit) => web.handler(new Request(input, init)), { + preconnect: () => undefined, + }) satisfies typeof globalThis.fetch const client = yield* OpenCode.make({ baseUrl: "http://opencode.local" }).pipe( - Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provide(FetchHttpClient.layer), + Effect.provideService(FetchHttpClient.Fetch, fetch), ) return { ...client, diff --git a/packages/sdk-next/test/embedded.test.ts b/packages/sdk-next/test/embedded.test.ts index e163d12c37..df1b72dbfc 100644 --- a/packages/sdk-next/test/embedded.test.ts +++ b/packages/sdk-next/test/embedded.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" import { Flag } from "@opencode-ai/core/flag/flag" -import { Effect, Schema } from "effect" +import { Effect, Option, Schema, Stream } from "effect" test("embedded client uses the real router and handlers", async () => { const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-")) @@ -39,8 +39,31 @@ test("embedded client uses the real router and handlers", async () => { resume: false, }) const context = yield* opencode.sessions.context({ sessionID }) - const missing = yield* Effect.flip( - opencode.sessions.get({ sessionID: Session.ID.make(`ses_missing_${crypto.randomUUID()}`) }), + const event = yield* opencode.sessions + .events({ sessionID }) + .pipe(Stream.take(1), Stream.runHead, Effect.map(Option.getOrUndefined)) + const modelMessage = Option.fromNullishOr(context.find((message) => message.type === "model-switched")).pipe( + Option.getOrThrow, + ) + const message = yield* opencode.sessions.message({ sessionID, messageID: modelMessage.id }) + yield* opencode.sessions.interrupt({ sessionID }) + const other = yield* opencode.sessions.create({ + location: Location.Ref.make({ directory: AbsolutePath.make(directory) }), + }) + const missingSessionID = Session.ID.make(`ses_missing_${crypto.randomUUID()}`) + const missing = yield* Effect.all( + [ + opencode.sessions.events({ sessionID: missingSessionID }).pipe(Stream.runHead, Effect.flip), + opencode.sessions.interrupt({ sessionID: missingSessionID }).pipe(Effect.flip), + opencode.sessions.message({ sessionID: missingSessionID, messageID: modelMessage.id }).pipe(Effect.flip), + ], + { concurrency: "unbounded" }, + ) + const missingMessage = yield* Effect.flip( + opencode.sessions.message({ + sessionID: other.id, + messageID: modelMessage.id, + }), ) expect(created.id).toBe(sessionID) @@ -49,7 +72,14 @@ test("embedded client uses the real router and handlers", async () => { expect(page.data.some((session) => session.id === sessionID)).toBe(true) expect(admitted.sessionID).toBe(sessionID) expect(context.some((message) => message.type === "model-switched")).toBe(true) - expect(missing._tag).toBe("SessionNotFoundError") + expect(event).toMatchObject({ type: "session.next.model.switched", durable: { seq: 1 } }) + expect(message).toEqual(modelMessage) + expect(missing.map((error) => error._tag)).toEqual([ + "SessionNotFoundError", + "SessionNotFoundError", + "SessionNotFoundError", + ]) + expect(missingMessage._tag).toBe("MessageNotFoundError") }) await Effect.runPromise(Effect.scoped(program)) } finally { diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index 2ac6b17742..e3b1270b94 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -1,5 +1,5 @@ import { SessionV2 } from "@opencode-ai/core/session" -import { DateTime, Effect } from "effect" +import { DateTime, Effect, Stream } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { Api } from "../api" import { SessionsCursor } from "@opencode-ai/protocol/groups/session" @@ -318,5 +318,32 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl } }), ) + .handle( + "session.events", + Effect.fn((ctx) => + Effect.succeed( + session.events({ sessionID: ctx.params.sessionID, after: ctx.query.after }).pipe(Stream.orDie), + ), + ), + ) + .handle( + "session.interrupt", + Effect.fn(function* (ctx) { + yield* session.interrupt(ctx.params.sessionID) + return HttpApiSchema.NoContent.make() + }), + ) + .handle( + "session.message", + Effect.fn(function* (ctx) { + const message = yield* session.message(ctx.params) + if (message) return { data: message } + return yield* new MessageNotFoundError({ + sessionID: ctx.params.sessionID, + messageID: ctx.params.messageID, + message: `Message not found: ${ctx.params.messageID}`, + }) + }), + ) }), ) diff --git a/specs/v2/session.md b/specs/v2/session.md index 6529ff2232..6b16dddee1 100644 --- a/specs/v2/session.md +++ b/specs/v2/session.md @@ -167,7 +167,7 @@ Eager local-tool execution is intentionally unbounded in the current local slice The synchronized `session.next.*` event family and projected Session-message model predate this branch. This slice refines their replay contract: projected Session messages retain their source aggregate sequence so canonical context ordering and `sessions.messages(...)` pagination follow durable event order even when caller-supplied IDs or timestamps do not. Consumers can use `sessions.events({ sessionID, after? })` to replay durable `session.next.*` events after an aggregate sequence cursor, then tail durable events without a race. Live-only text, reasoning, and tool-input fragments remain available through EventV2 subscriptions for connected renderers; they are intentionally absent from the replayable Session stream. -The first `sessions.events(...)` contract is durable-only during both replay and live tailing. This keeps one cursor equal to one persisted aggregate sequence and is sufficient for reconnect-safe consumers such as Discord publication. A later UI-facing API may optionally interleave live-only deltas while connected, but those fragments must remain explicitly ephemeral: they cannot advance the durable cursor, replay after reconnect, or be mistaken for publication boundaries. Until that contract is designed, connected renderers can combine `sessions.events(...)` with direct EventV2 delta subscriptions. +The first `sessions.events(...)` contract is durable-only during both replay and live tailing. This keeps one cursor equal to one persisted aggregate sequence and is sufficient for reconnect-safe consumers. A later UI-facing API may optionally interleave live-only deltas while connected, but those fragments must remain explicitly ephemeral: they cannot advance the durable cursor, replay after reconnect, or be mistaken for publication boundaries. Durable event tail wakeups are advisory and edge-triggered. Each active tail owns one sliding-capacity-1 dirty signal for its aggregate and re-queries SQLite after a wake. Repeated commits coalesce while the tail is busy because durable rows, not in-memory notifications, preserve every event and sequence. Subscribe and register the dirty signal before historical replay, then remove it when the tail closes, so replay handoff cannot miss a commit and inactive aggregates retain no wake state. @@ -175,7 +175,7 @@ Event replay owner claims are separate from clustered Session execution ownershi ## Current Tool Registry Slice -`ToolRegistry` is Location-scoped. Contributions are scoped replayable transforms: closing a contribution scope removes its definition and rebuilds the advertised catalog. Execution decodes input, optionally authorizes the call, invokes the retained handler, validates output, and settles failures as typed tool-result errors. +`ApplicationTools` stores process-scoped application registrations shared by all Locations. Each Location-scoped `ToolRegistry` overlays Location registrations, materializes definitions, and owns lookup and settlement. Closing a contribution scope removes its definition and rebuilds the advertised catalog. Trusted tool executors capture and perform authorization; the registry applies catalog visibility filtering, decodes input, invokes the retained handler, validates output, and settles failures as typed tool-result errors. When a Session omits `agent`, both execution and permission evaluation use the default `build` agent. A caller must not observe `build` model behavior while permission checks silently evaluate an empty no-agent policy. From 21b78c49fce4980447c0848c6006a55431b2d25e Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 25 Jun 2026 18:25:16 +0000 Subject: [PATCH 12/26] chore: generate --- packages/app/src/components/prompt-input.tsx | 8 +- .../composer/session-composer-controls.ts | 11 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 85 + packages/sdk/js/src/v2/gen/types.gen.ts | 723 ++++ packages/sdk/openapi.json | 3069 +++++++++++++++++ 5 files changed, 3882 insertions(+), 14 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 811551e3f4..a760d34029 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1423,10 +1423,7 @@ export const PromptInput: Component = (props) => { projectOpen: false, projectSearch: "", }) - if ( - pathKey(worktree) === pathKey(selectedProject()?.worktree ?? "") && - server === selectedProject()?.server?.key - ) { + if (pathKey(worktree) === pathKey(selectedProject()?.worktree ?? "") && server === selectedProject()?.server?.key) { restoreFocus() return } @@ -1448,8 +1445,7 @@ export const PromptInput: Component = (props) => { ), label: displayName(project), selected: - selectedProject()?.worktree === project.worktree && - selectedProject()?.server?.key === project.server?.key, + selectedProject()?.worktree === project.worktree && selectedProject()?.server?.key === project.server?.key, onSelect: () => selectProject(project.worktree, project.server?.key), })), ) diff --git a/packages/app/src/pages/session/composer/session-composer-controls.ts b/packages/app/src/pages/session/composer/session-composer-controls.ts index 21b753e6ac..7ae8fa0c85 100644 --- a/packages/app/src/pages/session/composer/session-composer-controls.ts +++ b/packages/app/src/pages/session/composer/session-composer-controls.ts @@ -66,9 +66,7 @@ export function createSessionComposerControls(input: { const providersQuery = createQuery(() => input.queryOptions.providers(pathKey(sdk().directory))) const selectProject = (worktree: string, serverKey?: string) => { - const conn = serverKey - ? server.list.find((conn) => ServerConnection.key(conn) === serverKey) - : projectServer() + const conn = serverKey ? server.list.find((conn) => ServerConnection.key(conn) === serverKey) : projectServer() if (search.draftId) { if (!conn) return const target = global.ensureServerCtx(conn) @@ -94,9 +92,7 @@ export function createSessionComposerControls(input: { } const addProject = (title: string, serverKey?: string) => { - const conn = serverKey - ? server.list.find((conn) => ServerConnection.key(conn) === serverKey) - : projectServer() + const conn = serverKey ? server.list.find((conn) => ServerConnection.key(conn) === serverKey) : projectServer() if (!conn) return pickDirectory({ server: conn, @@ -125,8 +121,7 @@ export function createSessionComposerControls(input: { projects: { available: projects(), directory: sdk().directory, - server: - server.list.length > 1 && projectServer() ? ServerConnection.key(projectServer()!) : undefined, + server: server.list.length > 1 && projectServer() ? ServerConnection.key(projectServer()!) : undefined, select: selectProject, add: addProject, }, diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 08e93f02a2..b2989b68df 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -339,10 +339,16 @@ import type { V2SessionContextResponses, V2SessionCreateErrors, V2SessionCreateResponses, + V2SessionEventsErrors, + V2SessionEventsResponses, V2SessionGetErrors, V2SessionGetResponses, + V2SessionInterruptErrors, + V2SessionInterruptResponses, V2SessionListErrors, V2SessionListResponses, + V2SessionMessageErrors, + V2SessionMessageResponses, V2SessionMessagesErrors, V2SessionMessagesResponses, V2SessionPermissionCreateErrors, @@ -5690,6 +5696,85 @@ export class Session3 extends HeyApiClient { }) } + /** + * Subscribe to session events + * + * Replay durable events after an aggregate sequence, then continue with new durable events. + */ + public events( + parameters: { + sessionID: string + after?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "after" }, + ], + }, + ], + ) + return (options?.client ?? this.client).sse.get({ + url: "/api/session/{sessionID}/event", + ...options, + ...params, + }) + } + + /** + * Interrupt session execution + * + * Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. + */ + public interrupt( + parameters: { + sessionID: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "sessionID" }] }]) + return (options?.client ?? this.client).post({ + url: "/api/session/{sessionID}/interrupt", + ...options, + ...params, + }) + } + + /** + * Get session message + * + * Retrieve one projected message owned by the Session. + */ + public message( + parameters: { + sessionID: string + messageID: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "path", key: "messageID" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/api/session/{sessionID}/message/{messageID}", + ...options, + ...params, + }) + } + /** * Get session messages * diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index cab35b9c27..9fde0f4e5e 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -4032,6 +4032,615 @@ export type SessionMessage = | SessionMessageAssistant | SessionMessageCompaction +export type SessionNextAgentSwitched = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.agent.switched" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" + version: number | "NaN" | "Infinity" | "-Infinity" + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + agent: string + } +} + +export type SessionNextModelSwitched = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.model.switched" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" + version: number | "NaN" | "Infinity" | "-Infinity" + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + model: ModelRef + } +} + +export type SessionNextMoved = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.moved" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" + version: number | "NaN" | "Infinity" | "-Infinity" + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + location: LocationRef + subdirectory?: string + } +} + +export type SessionNextPrompted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.prompted" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" + version: number | "NaN" | "Infinity" | "-Infinity" + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + prompt: Prompt + delivery: "steer" | "queue" + } +} + +export type SessionNextPromptAdmitted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.prompt.admitted" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" + version: number | "NaN" | "Infinity" | "-Infinity" + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + prompt: Prompt + delivery: "steer" | "queue" + } +} + +export type SessionNextContextUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.context.updated" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" + version: number | "NaN" | "Infinity" | "-Infinity" + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + text: string + } +} + +export type SessionNextSynthetic = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.synthetic" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" + version: number | "NaN" | "Infinity" | "-Infinity" + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + text: string + } +} + +export type SessionNextShellStarted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.shell.started" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" + version: number | "NaN" | "Infinity" | "-Infinity" + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + callID: string + command: string + } +} + +export type SessionNextShellEnded = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.shell.ended" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" + version: number | "NaN" | "Infinity" | "-Infinity" + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + callID: string + output: string + } +} + +export type SessionNextStepStarted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.step.started" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" + version: number | "NaN" | "Infinity" | "-Infinity" + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + agent: string + model: ModelRef + snapshot?: string + } +} + +export type SessionNextStepEnded = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.step.ended" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" + version: number | "NaN" | "Infinity" | "-Infinity" + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + finish: string + cost: number + tokens: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + snapshot?: string + files?: Array + } +} + +export type SessionNextStepFailed = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.step.failed" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" + version: number | "NaN" | "Infinity" | "-Infinity" + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + error: SessionErrorUnknown + } +} + +export type SessionNextTextStarted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.text.started" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" + version: number | "NaN" | "Infinity" | "-Infinity" + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + textID: string + } +} + +export type SessionNextTextEnded = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.text.ended" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" + version: number | "NaN" | "Infinity" | "-Infinity" + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + textID: string + text: string + } +} + +export type SessionNextToolInputStarted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.tool.input.started" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" + version: number | "NaN" | "Infinity" | "-Infinity" + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + name: string + } +} + +export type SessionNextToolInputEnded = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.tool.input.ended" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" + version: number | "NaN" | "Infinity" | "-Infinity" + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + text: string + } +} + +export type SessionNextToolCalled = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.tool.called" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" + version: number | "NaN" | "Infinity" | "-Infinity" + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + tool: string + input: { + [key: string]: unknown + } + provider: { + executed: boolean + metadata?: LlmProviderMetadata + } + } +} + +export type SessionNextToolProgress = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.tool.progress" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" + version: number | "NaN" | "Infinity" | "-Infinity" + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + structured: { + [key: string]: unknown + } + content: Array + } +} + +export type SessionNextToolSuccess = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.tool.success" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" + version: number | "NaN" | "Infinity" | "-Infinity" + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + structured: { + [key: string]: unknown + } + content: Array + outputPaths?: Array + result?: unknown + provider: { + executed: boolean + metadata?: LlmProviderMetadata + } + } +} + +export type SessionNextToolFailed = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.tool.failed" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" + version: number | "NaN" | "Infinity" | "-Infinity" + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + error: SessionErrorUnknown + result?: unknown + provider: { + executed: boolean + metadata?: LlmProviderMetadata + } + } +} + +export type SessionNextReasoningStarted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.reasoning.started" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" + version: number | "NaN" | "Infinity" | "-Infinity" + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + reasoningID: string + providerMetadata?: LlmProviderMetadata + } +} + +export type SessionNextReasoningEnded = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.reasoning.ended" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" + version: number | "NaN" | "Infinity" | "-Infinity" + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + reasoningID: string + text: string + providerMetadata?: LlmProviderMetadata + } +} + +export type SessionNextRetried = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.retried" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" + version: number | "NaN" | "Infinity" | "-Infinity" + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + attempt: number + error: SessionNextRetryError + } +} + +export type SessionNextCompactionStarted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.compaction.started" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" + version: number | "NaN" | "Infinity" | "-Infinity" + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + reason: "auto" | "manual" + } +} + +export type SessionNextCompactionEnded = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.compaction.ended" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" + version: number | "NaN" | "Infinity" | "-Infinity" + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + reason: "auto" | "manual" + text: string + recent: string + } +} + +export type SessionNextRevertStaged = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.revert.staged" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" + version: number | "NaN" | "Infinity" | "-Infinity" + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + revert: RevertState + } +} + +export type SessionNextRevertCleared = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.revert.cleared" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" + version: number | "NaN" | "Infinity" | "-Infinity" + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + } +} + +export type SessionNextRevertCommitted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.revert.committed" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" + version: number | "NaN" | "Infinity" | "-Infinity" + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + } +} + export type ModelApi = | { id: string @@ -11725,6 +12334,120 @@ export type V2SessionContextResponses = { export type V2SessionContextResponse = V2SessionContextResponses[keyof V2SessionContextResponses] +export type V2SessionEventsData = { + body?: never + path: { + sessionID: string + } + query?: { + after?: string + } + url: "/api/session/{sessionID}/event" +} + +export type V2SessionEventsErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError +} + +export type V2SessionEventsError = V2SessionEventsErrors[keyof V2SessionEventsErrors] + +export type V2SessionEventsResponses = { + /** + * Success + */ + 200: { + id: string + event: string + data: string + } +} + +export type V2SessionEventsResponse = V2SessionEventsResponses[keyof V2SessionEventsResponses] + +export type V2SessionInterruptData = { + body?: never + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/interrupt" +} + +export type V2SessionInterruptErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError +} + +export type V2SessionInterruptError = V2SessionInterruptErrors[keyof V2SessionInterruptErrors] + +export type V2SessionInterruptResponses = { + /** + * + */ + 204: void +} + +export type V2SessionInterruptResponse = V2SessionInterruptResponses[keyof V2SessionInterruptResponses] + +export type V2SessionMessageData = { + body?: never + path: { + sessionID: string + messageID: string + } + query?: never + url: "/api/session/{sessionID}/message/{messageID}" +} + +export type V2SessionMessageErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError | MessageNotFoundError + */ + 404: MessageNotFoundError | SessionNotFoundError +} + +export type V2SessionMessageError = V2SessionMessageErrors[keyof V2SessionMessageErrors] + +export type V2SessionMessageResponses = { + /** + * Success + */ + 200: { + data: SessionMessage + } +} + +export type V2SessionMessageResponse = V2SessionMessageResponses[keyof V2SessionMessageResponses] + export type V2SessionMessagesData = { body?: never path: { diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index be02709256..7377e63f9a 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -11070,6 +11070,414 @@ ] } }, + "/api/session/{sessionID}/event": { + "get": { + "tags": ["sessions"], + "operationId": "v2.session.events", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses" + }, + "required": true + }, + { + "name": "after", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "text/event-stream": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "event": { + "type": "string" + }, + "data": { + "type": "string", + "contentSchema": { + "oneOf": [ + { + "$ref": "#/components/schemas/SessionNextAgentSwitched" + }, + { + "$ref": "#/components/schemas/SessionNextModelSwitched" + }, + { + "$ref": "#/components/schemas/SessionNextMoved" + }, + { + "$ref": "#/components/schemas/SessionNextPrompted" + }, + { + "$ref": "#/components/schemas/SessionNextPromptAdmitted" + }, + { + "$ref": "#/components/schemas/SessionNextContextUpdated" + }, + { + "$ref": "#/components/schemas/SessionNextSynthetic" + }, + { + "$ref": "#/components/schemas/SessionNextShellStarted" + }, + { + "$ref": "#/components/schemas/SessionNextShellEnded" + }, + { + "$ref": "#/components/schemas/SessionNextStepStarted" + }, + { + "$ref": "#/components/schemas/SessionNextStepEnded" + }, + { + "$ref": "#/components/schemas/SessionNextStepFailed" + }, + { + "$ref": "#/components/schemas/SessionNextTextStarted" + }, + { + "$ref": "#/components/schemas/SessionNextTextEnded" + }, + { + "$ref": "#/components/schemas/SessionNextToolInputStarted" + }, + { + "$ref": "#/components/schemas/SessionNextToolInputEnded" + }, + { + "$ref": "#/components/schemas/SessionNextToolCalled" + }, + { + "$ref": "#/components/schemas/SessionNextToolProgress" + }, + { + "$ref": "#/components/schemas/SessionNextToolSuccess" + }, + { + "$ref": "#/components/schemas/SessionNextToolFailed" + }, + { + "$ref": "#/components/schemas/SessionNextReasoningStarted" + }, + { + "$ref": "#/components/schemas/SessionNextReasoningEnded" + }, + { + "$ref": "#/components/schemas/SessionNextRetried" + }, + { + "$ref": "#/components/schemas/SessionNextCompactionStarted" + }, + { + "$ref": "#/components/schemas/SessionNextCompactionEnded" + }, + { + "$ref": "#/components/schemas/SessionNextRevertStaged" + }, + { + "$ref": "#/components/schemas/SessionNextRevertCleared" + }, + { + "$ref": "#/components/schemas/SessionNextRevertCommitted" + } + ] + }, + "contentMediaType": "application/json" + } + }, + "required": ["id", "event", "data"], + "additionalProperties": false + }, + "x-effect-stream": { + "encoding": "sse", + "causeSchema": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["Fail"] + }, + "error": { + "not": {} + } + }, + "required": ["_tag", "error"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["Die"] + }, + "defect": {} + }, + "required": ["_tag", "defect"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["Interrupt"] + }, + "fiberId": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "required": ["_tag", "fiberId"], + "additionalProperties": false + } + ] + } + }, + "errorSchema": { + "not": {} + }, + "failureEvent": "effect/httpapi/stream/failure" + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Replay durable events after an aggregate sequence, then continue with new durable events.", + "summary": "Subscribe to session events", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.session.events({\n ...\n})" + } + ] + } + }, + "/api/session/{sessionID}/interrupt": { + "post": { + "tags": ["sessions"], + "operationId": "v2.session.interrupt", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses" + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op.", + "summary": "Interrupt session execution", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.session.interrupt({\n ...\n})" + } + ] + } + }, + "/api/session/{sessionID}/message/{messageID}": { + "get": { + "tags": ["sessions"], + "operationId": "v2.session.message", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses" + }, + "required": true + }, + { + "name": "messageID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^msg_" + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/SessionMessage" + } + }, + "required": ["data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError | MessageNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/MessageNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Retrieve one projected message owned by the Session.", + "summary": "Get session message", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.session.message({\n ...\n})" + } + ] + } + }, "/api/session/{sessionID}/message": { "get": { "tags": ["messages"], @@ -27102,6 +27510,2667 @@ } ] }, + "SessionNextAgentSwitched": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.agent.switched"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + "version": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "agent": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "messageID", "agent"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextModelSwitched": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.model.switched"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + "version": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "model": { + "$ref": "#/components/schemas/ModelRef" + } + }, + "required": ["timestamp", "sessionID", "messageID", "model"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextMoved": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.moved"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + "version": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "subdirectory": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "location"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextPrompted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.prompted"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + "version": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "prompt": { + "$ref": "#/components/schemas/Prompt" + }, + "delivery": { + "type": "string", + "enum": ["steer", "queue"] + } + }, + "required": ["timestamp", "sessionID", "messageID", "prompt", "delivery"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextPromptAdmitted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.prompt.admitted"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + "version": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "prompt": { + "$ref": "#/components/schemas/Prompt" + }, + "delivery": { + "type": "string", + "enum": ["steer", "queue"] + } + }, + "required": ["timestamp", "sessionID", "messageID", "prompt", "delivery"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextContextUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.context.updated"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + "version": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "messageID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextSynthetic": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.synthetic"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + "version": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "messageID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextShellStarted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.shell.started"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + "version": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "callID": { + "type": "string" + }, + "command": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "messageID", "callID", "command"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextShellEnded": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.shell.ended"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + "version": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "callID": { + "type": "string" + }, + "output": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "output"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextStepStarted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.step.started"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + "version": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "agent": { + "type": "string" + }, + "model": { + "$ref": "#/components/schemas/ModelRef" + }, + "snapshot": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "agent", "model"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextStepEnded": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.step.ended"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + "version": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "finish": { + "type": "string" + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "reasoning", "cache"], + "additionalProperties": false + }, + "snapshot": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "finish", "cost", "tokens"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextStepFailed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.step.failed"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + "version": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "error": { + "$ref": "#/components/schemas/SessionErrorUnknown" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "error"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextTextStarted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.text.started"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + "version": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "textID": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "textID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextTextEnded": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.text.ended"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + "version": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "textID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "textID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextToolInputStarted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.input.started"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + "version": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "callID": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "callID", "name"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextToolInputEnded": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.input.ended"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + "version": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "callID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "callID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextToolCalled": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.called"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + "version": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "callID": { + "type": "string" + }, + "tool": { + "type": "string" + }, + "input": { + "type": "object" + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "$ref": "#/components/schemas/LLMProviderMetadata" + } + }, + "required": ["executed"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "callID", "tool", "input", "provider"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextToolProgress": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.progress"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + "version": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "callID": { + "type": "string" + }, + "structured": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LLMToolContent" + } + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "callID", "structured", "content"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextToolSuccess": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.success"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + "version": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "callID": { + "type": "string" + }, + "structured": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LLMToolContent" + } + }, + "outputPaths": { + "type": "array", + "items": { + "type": "string" + } + }, + "result": {}, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "$ref": "#/components/schemas/LLMProviderMetadata" + } + }, + "required": ["executed"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "callID", "structured", "content", "provider"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextToolFailed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.failed"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + "version": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "callID": { + "type": "string" + }, + "error": { + "$ref": "#/components/schemas/SessionErrorUnknown" + }, + "result": {}, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "$ref": "#/components/schemas/LLMProviderMetadata" + } + }, + "required": ["executed"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "callID", "error", "provider"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextReasoningStarted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.reasoning.started"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + "version": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "reasoningID": { + "type": "string" + }, + "providerMetadata": { + "$ref": "#/components/schemas/LLMProviderMetadata" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextReasoningEnded": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.reasoning.ended"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + "version": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "reasoningID": { + "type": "string" + }, + "text": { + "type": "string" + }, + "providerMetadata": { + "$ref": "#/components/schemas/LLMProviderMetadata" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextRetried": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.retried"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + "version": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "attempt": { + "type": "number" + }, + "error": { + "$ref": "#/components/schemas/SessionNextRetry_error" + } + }, + "required": ["timestamp", "sessionID", "attempt", "error"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextCompactionStarted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.compaction.started"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + "version": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "reason": { + "type": "string", + "enum": ["auto", "manual"] + } + }, + "required": ["timestamp", "sessionID", "messageID", "reason"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextCompactionEnded": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.compaction.ended"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + "version": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "reason": { + "type": "string", + "enum": ["auto", "manual"] + }, + "text": { + "type": "string" + }, + "recent": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "messageID", "reason", "text", "recent"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextRevertStaged": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.revert.staged"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + "version": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "revert": { + "$ref": "#/components/schemas/RevertState" + } + }, + "required": ["timestamp", "sessionID", "revert"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextRevertCleared": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.revert.cleared"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + "version": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + } + }, + "required": ["timestamp", "sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextRevertCommitted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.revert.committed"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + "version": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + } + }, + "required": ["timestamp", "sessionID", "messageID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, "ModelApi": { "anyOf": [ { From 0e731c2a1a61fa55f1546885369d402846985aff Mon Sep 17 00:00:00 2001 From: James Long Date: Thu, 25 Jun 2026 14:34:17 -0400 Subject: [PATCH 13/26] refactor(core): support tiered layer nodes (#33937) --- packages/core/src/cross-spawn-spawner.ts | 2 +- packages/core/src/database/database.ts | 2 +- .../core/src/effect/layer-node-platform.ts | 16 +- packages/core/src/effect/layer-node.ts | 242 +++++++++++++---- packages/core/src/effect/scoped-node.ts | 11 + packages/core/src/event.ts | 2 +- packages/core/src/fs-util.ts | 2 +- packages/core/src/git.ts | 2 +- packages/core/src/global.ts | 2 +- packages/core/src/models-dev.ts | 2 +- packages/core/src/npm.ts | 6 +- packages/core/src/process.ts | 2 +- packages/core/src/project.ts | 6 +- packages/core/src/project/copy.ts | 6 +- packages/core/src/project/directories.ts | 2 +- packages/core/src/pty/ticket.ts | 2 +- packages/core/src/ripgrep.ts | 2 +- packages/core/src/ripgrep/binary.ts | 6 +- packages/core/src/session/projector.ts | 2 +- packages/core/src/util/effect-flock.ts | 2 +- packages/opencode/src/account/account.ts | 2 +- packages/opencode/src/account/repo.ts | 2 +- packages/opencode/src/agent/agent.ts | 15 +- packages/opencode/src/auth/index.ts | 2 +- packages/opencode/src/background/job.ts | 2 +- packages/opencode/src/command/index.ts | 2 +- packages/opencode/src/config/config.ts | 6 +- .../opencode/src/control-plane/workspace.ts | 26 +- packages/opencode/src/effect/runtime-flags.ts | 2 +- packages/opencode/src/env/index.ts | 2 +- packages/opencode/src/event-v2-bridge.ts | 2 +- packages/opencode/src/format/index.ts | 6 +- packages/opencode/src/git/index.ts | 2 +- packages/opencode/src/image/image.ts | 2 +- packages/opencode/src/installation/index.ts | 2 +- packages/opencode/src/lsp/lsp.ts | 6 +- packages/opencode/src/mcp/auth.ts | 2 +- packages/opencode/src/mcp/index.ts | 6 +- packages/opencode/src/permission/index.ts | 2 +- packages/opencode/src/plugin/index.ts | 6 +- packages/opencode/src/project/bootstrap.ts | 15 +- .../opencode/src/project/instance-store.ts | 6 +- packages/opencode/src/project/project.ts | 24 +- packages/opencode/src/project/vcs.ts | 2 +- packages/opencode/src/provider/auth.ts | 2 +- packages/opencode/src/provider/provider.ts | 14 +- packages/opencode/src/question/index.ts | 2 +- packages/opencode/src/session/compaction.ts | 24 +- packages/opencode/src/session/instruction.ts | 6 +- packages/opencode/src/session/llm.ts | 24 +- packages/opencode/src/session/processor.ts | 34 +-- packages/opencode/src/session/prompt.ts | 60 +++-- packages/opencode/src/session/revert.ts | 13 +- packages/opencode/src/session/run-state.ts | 2 +- packages/opencode/src/session/session.ts | 6 +- packages/opencode/src/session/status.ts | 2 +- packages/opencode/src/session/summary.ts | 6 +- packages/opencode/src/session/system.ts | 8 +- packages/opencode/src/session/todo.ts | 2 +- packages/opencode/src/share/session.ts | 6 +- packages/opencode/src/share/share-next.ts | 14 +- packages/opencode/src/skill/discovery.ts | 2 +- packages/opencode/src/skill/index.ts | 13 +- packages/opencode/src/snapshot/index.ts | 6 +- packages/opencode/src/storage/storage.ts | 2 +- packages/opencode/src/tool/registry.ts | 46 ++-- packages/opencode/src/tool/truncate.ts | 2 +- packages/opencode/src/worktree/index.ts | 14 +- .../test/effect/app-graph-types.test.ts | 108 -------- .../opencode/test/effect/app-graph.test.ts | 204 -------------- .../effect/layer-node-tiers-types.test.ts | 19 ++ .../test/effect/layer-node-tiers.test.ts | 169 ++++++++++++ .../test/effect/layer-node-types.test.ts | 66 +++++ .../opencode/test/effect/layer-node.test.ts | 82 ++++++ .../test/effect/scoped-node-types.test.ts | 23 ++ .../test/session/processor-effect.test.ts | 13 +- .../test/session/snapshot-tool-race.test.ts | 8 +- .../opencode/test/share/share-next.test.ts | 8 +- packages/opencode/test/tool/registry.test.ts | 6 +- specs/layer-node-tiers.md | 254 ++++++++++++++++++ 80 files changed, 1132 insertions(+), 601 deletions(-) create mode 100644 packages/core/src/effect/scoped-node.ts delete mode 100644 packages/opencode/test/effect/app-graph-types.test.ts delete mode 100644 packages/opencode/test/effect/app-graph.test.ts create mode 100644 packages/opencode/test/effect/layer-node-tiers-types.test.ts create mode 100644 packages/opencode/test/effect/layer-node-tiers.test.ts create mode 100644 packages/opencode/test/effect/layer-node-types.test.ts create mode 100644 packages/opencode/test/effect/layer-node.test.ts create mode 100644 packages/opencode/test/effect/scoped-node-types.test.ts create mode 100644 specs/layer-node-tiers.md diff --git a/packages/core/src/cross-spawn-spawner.ts b/packages/core/src/cross-spawn-spawner.ts index d6e0f9f95d..370c07a648 100644 --- a/packages/core/src/cross-spawn-spawner.ts +++ b/packages/core/src/cross-spawn-spawner.ts @@ -503,6 +503,6 @@ export const layer: Layer.Layer -type AnyNode = Node -type NodeList = readonly [] | readonly [AnyNode, ...AnyNode[]] -type Output = [Item] extends [never] ? never : Item extends Node ? A : never -type Error = [Item] extends [never] ? never : Item extends Node ? E : never +type AnyNode = Node +type NodeList = readonly [] | readonly [Item, ...Item[]] +type Output = [Item] extends [never] ? never : Item extends Node ? A : never +type Error = [Item] extends [never] ? never : Item extends Node ? E : never type Missing = Exclude> type CheckDependencies = [ Missing, Dependencies>, @@ -14,89 +14,235 @@ type CheckDependencies = { +export type Tier = Name & Brand.Brand<"LayerNode.Tier"> + +const makeTier = Brand.nominal() + +export type Node = { readonly kind: "layer" | "group" + readonly name: string + readonly service?: Context.Service.Any readonly implementation?: Layer.Any readonly dependencies: readonly AnyNode[] + readonly tier?: T readonly [$OutputType]?: () => A readonly [$ErrorType]?: () => E } -export function make( - implementation: Implementation, - dependencies: Items & CheckDependencies>, -): Node, Layer.Error | Error> { - return { kind: "layer", implementation: implementation as Layer.Any, dependencies } +type NodeIdentity = + | { readonly service: Context.Service.Any; readonly name?: never } + | { readonly name: string; readonly service?: never } +type DistributiveOmit = A extends unknown ? Omit : never + +type NodeInput< + Implementation extends Layer.Any, + Items extends NodeList, + T extends Tier | undefined = undefined, +> = NodeIdentity & { + readonly layer: Implementation + readonly deps: Items & CheckDependencies> + readonly tier?: T +} + +export function make< + const Implementation extends Layer.Any, + const Items extends NodeList, + const T extends Tier | undefined = undefined, +>( + input: NodeInput, +): Node, Layer.Error | Error, T> { + return { + kind: "layer", + name: input.service !== undefined ? input.service.key : input.name, + service: input.service, + implementation: input.layer, + dependencies: input.deps, + tier: input.tier, + } } export function group( dependencies: Items, ): Node, Error> { - return { kind: "group", dependencies } + return { kind: "group", name: "group", dependencies } } -export type Replacement = { - readonly source: Node - readonly replacement: Node +type AllowedTierNames = Names extends readonly [ + infer Head extends string, + ...infer Tail extends readonly string[], +] + ? Head extends Name + ? Head | Tail[number] + : AllowedTierNames + : never + +type NodeInTiers = Node> + +export interface Tiers { + readonly names: Names + readonly values: { readonly [K in Names[number]]: Tier } + readonly make: ( + name: Name, + ) => < + const Implementation extends Layer.Any, + const Items extends NodeList>>, + >( + input: DistributiveOmit>, "tier">, + ) => Node, Layer.Error | Error, Tier> +} + +export function tiers(names: Names): Tiers { + const values = Object.fromEntries(names.map((name) => [name, makeTier(name)])) as Tiers["values"] + return { + names, + values, + make: ((name: Names[number]) => (input: DistributiveOmit, "tier">) => + make({ ...input, tier: values[name] })) as Tiers["make"], + } +} + +const defaultTiers = tiers(["untiered"]) +const untiered = defaultTiers.values.untiered + +export type Replacement = { + readonly source: Layer.Any + readonly replacement: Layer.Any } type CheckReplacementErrors = [Exclude] extends [never] ? unknown : { readonly "New replacement errors": Exclude } -export function replaceWithNode( - source: Node, - replacement: Node, E2> & CheckReplacementErrors>, -): Replacement { +export function replace( + source: Layer.Layer, + replacement: Layer.Layer, E2, never> & CheckReplacementErrors>, +): Replacement { return { source, replacement } } -export function replace( - source: Node, - replacement: Layer.Layer, E2, never> & CheckReplacementErrors>, -): Replacement { - return { source, replacement: make(replacement as Layer.Layer, []) } +export function buildLayer< + A, + E, + const Names extends readonly [string, ...string[]] = readonly ["untiered"], + const Built extends Layer.Any = Layer.Layer, +>( + node: Node, + options?: { + readonly tiers?: Tiers + readonly buildTier?: (tier: Names[number], layers: readonly Layer.Any[]) => Built + readonly replacements?: readonly Replacement[] + }, +): Layer.Layer, E | Layer.Error, never> { + const tiers = options?.tiers ?? (defaultTiers as unknown as Tiers) + const replacementMap = new Map(options?.replacements?.map((item) => [item.source, item.replacement])) + const plans = plan(node, tiers, replacementMap) + const layers: RuntimeLayer[] = tiers.names.map((name) => { + const tier = tiers.values[name as Names[number]] + const layers = plans.get(tier) ?? [] + return (options?.buildTier?.(name, layers) ?? combine(layers)) as RuntimeLayer + }) + if (layers.length === 0) return Layer.empty as never + return layers.slice(1).reduce((result, layer) => result.pipe(Layer.provideMerge(layer)), layers[0]) as never } -export function buildLayer(node: Node, options?: { readonly replacements?: readonly Replacement[] }) { - const replacements = new Map(options?.replacements?.map((item) => [item.source, item.replacement])) - const cache = new Map() +export function combine(layers: readonly Layer.Any[]): RuntimeLayer { + return layers.reduce( + (result, layer) => (layer as RuntimeLayer).pipe(Layer.provideMerge(result)), + Layer.empty as RuntimeLayer, + ) +} + +function plan( + root: AnyNode, + tiers: Tiers, + replacements: ReadonlyMap, +) { + const indexes = new Map(tiers.names.map((name, index) => [tiers.values[name], index])) + const plans = new Map() + const activeImplementations = new Map>() + const serviceTiers = new Map() const visiting = new Set() const stack: AnyNode[] = [] - const ids = new Map() + const boundaryVisited = new Map>() + const boundaryServices = new Map>() + + const validateBoundary = (node: AnyNode, origin: Tier) => { + const checked = boundaryVisited.get(node) ?? new Set() + boundaryVisited.set(node, checked) + if (checked.has(origin)) return false + checked.add(origin) + const services = boundaryServices.get(origin) ?? new Map() + boundaryServices.set(origin, services) + const key = node.name + const existing = services.get(key) + if (existing && existing !== node) { + throw new Error(`Tier ${origin} has conflicting implementations for ${key}`) + } + services.set(key, node) + return true + } + + const visit = (node: AnyNode, currentTier?: Tier, origins: readonly Tier[] = []) => { + if (node.kind === "group") { + node.dependencies.forEach((dependency) => visit(dependency, currentTier, origins)) + return + } + + const tier = node.tier ?? untiered + if (!indexes.has(tier)) throw new Error(`Node ${node.name} is not in the tier configuration`) + const key = node.name + const serviceTier = serviceTiers.get(key) + if (serviceTier && serviceTier !== tier) { + throw new Error(`Service ${key} belongs to both tier ${serviceTier} and tier ${tier}`) + } + serviceTiers.set(key, tier) + const nextOrigins = [...origins] + if (currentTier) { + const current = indexes.get(currentTier)! + const required = indexes.get(tier)! + if (required < current) { + throw new Error(`Tier ${currentTier} cannot depend on lower tier ${tier}`) + } + if (required > current) nextOrigins.push(currentTier) + } + const unseenOrigins = nextOrigins.filter((origin) => validateBoundary(node, origin)) + + // A node may need to be emitted more than once because the final output is a + // flat list of layers applied with Layer.provideMerge. If another node for + // the same service was emitted afterward, this node is no longer the active + // implementation for subsequent consumers. Re-emitting restores the intended + // implementation ordering while Effect memoization avoids reacquiring the layer. + const implementations = activeImplementations.get(tier) ?? new Map() + activeImplementations.set(tier, implementations) + if (implementations.get(key) === node && unseenOrigins.length === 0) return - const visit = (input: AnyNode): RuntimeLayer => { - const node = replacements.get(input) ?? input - const cached = cache.get(node) - if (cached) return cached if (visiting.has(node)) { const start = stack.indexOf(node) - const cycle = [...stack.slice(start), node].map((item) => `${item.kind}#${ids.get(item)}`).join(" -> ") - throw new Error(`Cycle detected in app graph: ${cycle}`) + throw new Error( + `Cycle detected in layer graph: ${[...stack.slice(start), node].map((item) => item.name).join(" -> ")}`, + ) } - if (!ids.has(node)) ids.set(node, ids.size + 1) + visiting.add(node) stack.push(node) try { - const dependencies = node.dependencies.map(visit) - const nonEmpty = dependencies as [RuntimeLayer, ...RuntimeLayer[]] - const result = - node.kind === "group" - ? dependencies.length === 0 - ? Layer.empty - : Layer.mergeAll(...nonEmpty) - : dependencies.length === 0 - ? (node.implementation as RuntimeLayer) - : Layer.provide(node.implementation as RuntimeLayer, nonEmpty) - cache.set(node, result) - return result + node.dependencies.forEach((dependency) => visit(dependency, tier, unseenOrigins)) + const layers = plans.get(tier) ?? [] + plans.set(tier, layers) + layers.push(replacements.get(node.implementation!) ?? node.implementation!) + implementations.set(key, node) } finally { stack.pop() visiting.delete(node) } } - return visit(node) as unknown as Layer.Layer + visit(root) + return plans +} + +function requireTier(node: AnyNode, indexes: ReadonlyMap) { + if (!node.tier || !indexes.has(node.tier)) throw new Error(`Node ${node.name} is not in the tier configuration`) } export * as LayerNode from "./layer-node" diff --git a/packages/core/src/effect/scoped-node.ts b/packages/core/src/effect/scoped-node.ts new file mode 100644 index 0000000000..6583d2bb8a --- /dev/null +++ b/packages/core/src/effect/scoped-node.ts @@ -0,0 +1,11 @@ +import { LayerNode } from "./layer-node" + +export const tiers = LayerNode.tiers(["location", "global"]) + +export type GlobalNode = LayerNode.Node +export type LocationNode = LayerNode.Node + +export const makeGlobalNode = tiers.make("global") +export const makeLocationNode = tiers.make("location") + +export * as ScopedNode from "./scoped-node" diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index 132a88b111..5324c319b0 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -569,6 +569,6 @@ export const layerWith = (options?: LayerOptions) => ) export const layer = layerWith() -export const node = LayerNode.make(layer, [Database.node]) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [Database.node] }) export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer)) diff --git a/packages/core/src/fs-util.ts b/packages/core/src/fs-util.ts index 3363cc8c8d..da3d615b59 100644 --- a/packages/core/src/fs-util.ts +++ b/packages/core/src/fs-util.ts @@ -201,7 +201,7 @@ export namespace FSUtil { ) export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer)) - export const node = LayerNode.make(layer, [filesystem]) + export const node = LayerNode.make({ service: Service, layer: layer, deps: [filesystem] }) // Pure helpers that don't need Effect (path manipulation, sync operations) export function mimeType(p: string): string { diff --git a/packages/core/src/git.ts b/packages/core/src/git.ts index 86708d5f9a..d6401fcdfe 100644 --- a/packages/core/src/git.ts +++ b/packages/core/src/git.ts @@ -944,7 +944,7 @@ export const layer = Layer.effect( ) export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(AppProcess.defaultLayer)) -export const node = LayerNode.make(layer, [FSUtil.node, AppProcess.node]) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [FSUtil.node, AppProcess.node] }) interface Result { readonly exitCode: number diff --git a/packages/core/src/global.ts b/packages/core/src/global.ts index 2a0ac95d1a..eed28b94cf 100644 --- a/packages/core/src/global.ts +++ b/packages/core/src/global.ts @@ -77,7 +77,7 @@ export const layer = Layer.effect( ) export const defaultLayer = layer -export const node = LayerNode.make(layer, []) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [] }) export const layerWith = (input: Partial) => Layer.effect( diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index 313c78d815..6d4dc09a79 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -244,6 +244,6 @@ export const defaultLayer = layer.pipe( Layer.provide(FSUtil.defaultLayer), Layer.provide(EventV2.defaultLayer), ) -export const node = LayerNode.make(layer, [FSUtil.node, EventV2.node, httpClient]) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [FSUtil.node, EventV2.node, httpClient] }) export * as ModelsDev from "./models-dev" diff --git a/packages/core/src/npm.ts b/packages/core/src/npm.ts index 3ad8beb0a7..0a13e0f7c3 100644 --- a/packages/core/src/npm.ts +++ b/packages/core/src/npm.ts @@ -253,7 +253,11 @@ export const defaultLayer = layer.pipe( Layer.provide(Global.layer), Layer.provide(NodeFileSystem.layer), ) -export const node = LayerNode.make(layer, [FSUtil.node, Global.node, filesystem, EffectFlock.node]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [FSUtil.node, Global.node, filesystem, EffectFlock.node], +}) const { runPromise } = makeRuntime(Service, defaultLayer) diff --git a/packages/core/src/process.ts b/packages/core/src/process.ts index ed8881be91..f8d4ef0331 100644 --- a/packages/core/src/process.ts +++ b/packages/core/src/process.ts @@ -238,6 +238,6 @@ export const layer = Layer.effect( ) export const defaultLayer = layer.pipe(Layer.provide(CrossSpawnSpawner.defaultLayer)) -export const node = LayerNode.make(layer, [CrossSpawnSpawner.node]) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [CrossSpawnSpawner.node] }) export * as AppProcess from "./process" diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts index e94178122c..c504c309be 100644 --- a/packages/core/src/project.ts +++ b/packages/core/src/project.ts @@ -134,4 +134,8 @@ export const defaultLayer = layer.pipe( Layer.provide(Git.defaultLayer), Layer.provideMerge(ProjectDirectories.defaultLayer), ) -export const node = LayerNode.make(layer, [FSUtil.node, Git.node, ProjectDirectories.node]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [FSUtil.node, Git.node, ProjectDirectories.node], +}) diff --git a/packages/core/src/project/copy.ts b/packages/core/src/project/copy.ts index dbaf77f4a5..d144b15ece 100644 --- a/packages/core/src/project/copy.ts +++ b/packages/core/src/project/copy.ts @@ -279,4 +279,8 @@ export const layer = Layer.effect( ) export const locationLayer = layer -export const node = LayerNode.make(layer, [FSUtil.node, Git.node, ProjectDirectories.node, EventV2.node, Database.node]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [FSUtil.node, Git.node, ProjectDirectories.node, EventV2.node, Database.node], +}) diff --git a/packages/core/src/project/directories.ts b/packages/core/src/project/directories.ts index 46408bc5c7..54c31628bf 100644 --- a/packages/core/src/project/directories.ts +++ b/packages/core/src/project/directories.ts @@ -156,4 +156,4 @@ export const layer = Layer.effect( ) export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer)) -export const node = LayerNode.make(layer, [Database.node]) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [Database.node] }) diff --git a/packages/core/src/pty/ticket.ts b/packages/core/src/pty/ticket.ts index 70b853799b..aee1debc78 100644 --- a/packages/core/src/pty/ticket.ts +++ b/packages/core/src/pty/ticket.ts @@ -54,4 +54,4 @@ export const make = (ttl: Duration.Input = DEFAULT_TTL) => export const layer = Layer.effect(Service, make()) export const defaultLayer = layer -export const node = LayerNode.make(layer, []) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [] }) diff --git a/packages/core/src/ripgrep.ts b/packages/core/src/ripgrep.ts index 7f0c61e1f5..c240c6763f 100644 --- a/packages/core/src/ripgrep.ts +++ b/packages/core/src/ripgrep.ts @@ -279,4 +279,4 @@ export const layer = Layer.effect( ) export const defaultLayer = layer.pipe(Layer.provide(Layer.merge(RipgrepBinary.defaultLayer, AppProcess.defaultLayer))) -export const node = LayerNode.make(layer, [RipgrepBinary.node, AppProcess.node]) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [RipgrepBinary.node, AppProcess.node] }) diff --git a/packages/core/src/ripgrep/binary.ts b/packages/core/src/ripgrep/binary.ts index 99fa8a2fd0..18cd6a62c2 100644 --- a/packages/core/src/ripgrep/binary.ts +++ b/packages/core/src/ripgrep/binary.ts @@ -130,5 +130,9 @@ export namespace RipgrepBinary { Layer.provide(CrossSpawnSpawner.defaultLayer), ) - export const node = LayerNode.make(layer, [FSUtil.node, httpClient, CrossSpawnSpawner.node]) + export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [FSUtil.node, httpClient, CrossSpawnSpawner.node], + }) } diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 453608eafb..4d8b9a05b4 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -456,4 +456,4 @@ export const layer = Layer.effectDiscard( ) export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer)) -export const node = LayerNode.make(layer, [EventV2.node, Database.node]) +export const node = LayerNode.make({ name: "session-projector", layer, deps: [EventV2.node, Database.node] }) diff --git a/packages/core/src/util/effect-flock.ts b/packages/core/src/util/effect-flock.ts index fa864e9252..5d25553893 100644 --- a/packages/core/src/util/effect-flock.ts +++ b/packages/core/src/util/effect-flock.ts @@ -281,5 +281,5 @@ export namespace EffectFlock { ) export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(Global.layer)) - export const node = LayerNode.make(layer, [Global.node, FSUtil.node]) + export const node = LayerNode.make({ service: Service, layer: layer, deps: [Global.node, FSUtil.node] }) } diff --git a/packages/opencode/src/account/account.ts b/packages/opencode/src/account/account.ts index 948eb3c063..d2ada94f96 100644 --- a/packages/opencode/src/account/account.ts +++ b/packages/opencode/src/account/account.ts @@ -458,6 +458,6 @@ export const layer: Layer.Layer = {}) => export const defaultLayer = Service.defaultLayer.pipe(Layer.orDie) -export const node = LayerNode.make(defaultLayer, []) +export const node = LayerNode.make({ service: Service, layer: defaultLayer, deps: [] }) export * as RuntimeFlags from "./runtime-flags" import { LayerNode } from "@opencode-ai/core/effect/layer-node" diff --git a/packages/opencode/src/env/index.ts b/packages/opencode/src/env/index.ts index 5f85dc6f2e..98dd0b3004 100644 --- a/packages/opencode/src/env/index.ts +++ b/packages/opencode/src/env/index.ts @@ -38,6 +38,6 @@ export const layer = Layer.effect( export const defaultLayer = layer -export const node = LayerNode.make(layer, []) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [] }) export * as Env from "." diff --git a/packages/opencode/src/event-v2-bridge.ts b/packages/opencode/src/event-v2-bridge.ts index 836f408e0f..0231207334 100644 --- a/packages/opencode/src/event-v2-bridge.ts +++ b/packages/opencode/src/event-v2-bridge.ts @@ -68,6 +68,6 @@ export const layer = Layer.effect( export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer)) -export const node = LayerNode.make(layer, [EventV2.node]) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [EventV2.node] }) export * as EventV2Bridge from "./event-v2-bridge" diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index e323fcc243..413c147d64 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -200,6 +200,10 @@ export const defaultLayer = layer.pipe( Layer.provide(RuntimeFlags.defaultLayer), ) -export const node = LayerNode.make(layer, [Config.node, AppProcess.node, RuntimeFlags.node]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [Config.node, AppProcess.node, RuntimeFlags.node], +}) export * as Format from "." diff --git a/packages/opencode/src/git/index.ts b/packages/opencode/src/git/index.ts index 7a37b1cb82..81358ef960 100644 --- a/packages/opencode/src/git/index.ts +++ b/packages/opencode/src/git/index.ts @@ -345,6 +345,6 @@ export const layer = Layer.effect( export const defaultLayer = layer.pipe(Layer.provide(AppProcess.defaultLayer)) -export const node = LayerNode.make(layer, [AppProcess.node]) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [AppProcess.node] }) export * as Git from "." diff --git a/packages/opencode/src/image/image.ts b/packages/opencode/src/image/image.ts index 91c8955e15..a2edf352e4 100644 --- a/packages/opencode/src/image/image.ts +++ b/packages/opencode/src/image/image.ts @@ -169,6 +169,6 @@ export const layer = Layer.effect( export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer)) -export const node = LayerNode.make(layer, [Config.node]) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [Config.node] }) export * as Image from "./image" diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index b4b888ed78..b5d9be5fcf 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -332,6 +332,6 @@ export const latest = (...args: Parameters) => runPromise(( export const method = () => runPromise((s) => s.method()) export const upgrade = (...args: Parameters) => runPromise((s) => s.upgrade(...args)) -export const node = LayerNode.make(layer, [httpClient, AppProcess.node]) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [httpClient, AppProcess.node] }) export * as Installation from "." diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts index bf19dda7c8..16fb10f7bf 100644 --- a/packages/opencode/src/lsp/lsp.ts +++ b/packages/opencode/src/lsp/lsp.ts @@ -504,6 +504,10 @@ export const defaultLayer = layer.pipe( export * as Diagnostic from "./diagnostic" -export const node = LayerNode.make(layer, [Config.node, RuntimeFlags.node, FSUtil.node, EventV2Bridge.node]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [Config.node, RuntimeFlags.node, FSUtil.node, EventV2Bridge.node], +}) export * as LSP from "./lsp" diff --git a/packages/opencode/src/mcp/auth.ts b/packages/opencode/src/mcp/auth.ts index be03760c52..3866d1564a 100644 --- a/packages/opencode/src/mcp/auth.ts +++ b/packages/opencode/src/mcp/auth.ts @@ -169,6 +169,6 @@ export const layer = Layer.effect( export const defaultLayer = layer.pipe(Layer.provide(EffectFlock.defaultLayer), Layer.provide(FSUtil.defaultLayer)) -export const node = LayerNode.make(layer, [FSUtil.node, EffectFlock.node]) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [FSUtil.node, EffectFlock.node] }) export * as McpAuth from "./auth" diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 2355a89ad7..59f99aae38 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -1005,6 +1005,10 @@ export const defaultLayer = layer.pipe( Layer.provide(FSUtil.defaultLayer), ) -export const node = LayerNode.make(layer, [CrossSpawnSpawner.node, McpAuth.node, EventV2Bridge.node, Config.node]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [CrossSpawnSpawner.node, McpAuth.node, EventV2Bridge.node, Config.node], +}) export * as MCP from "." diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index d8e92b2a34..e1eb13ccc8 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -215,6 +215,6 @@ export function disabled(tools: string[], ruleset: PermissionV1.Ruleset): Set = layer.pipe( ]), ) -export const node = LayerNode.make(layer, [ - Config.node, - Format.node, - LSP.node, - Plugin.node, - Project.node, - ShareNext.node, - Snapshot.node, - Vcs.node, -]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [Config.node, Format.node, LSP.node, Plugin.node, Project.node, ShareNext.node, Snapshot.node, Vcs.node], +}) export * as InstanceBootstrap from "./bootstrap" diff --git a/packages/opencode/src/project/instance-store.ts b/packages/opencode/src/project/instance-store.ts index aab8f60c85..6877fe5681 100644 --- a/packages/opencode/src/project/instance-store.ts +++ b/packages/opencode/src/project/instance-store.ts @@ -204,6 +204,10 @@ export const layer: Layer.Layer layer.pipe(Layer.provide(Auth.defaultLayer), Layer.provide(Plugin.defaultLayer)), ) -export const node = LayerNode.make(layer, [Auth.node, Plugin.node]) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [Auth.node, Plugin.node] }) export * as ProviderAuth from "./auth" diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 0b68cd4318..46c78dc0e6 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1979,14 +1979,10 @@ export function parseModel(model: string) { } } -export const node = LayerNode.make(layer, [ - FSUtil.node, - Config.node, - Auth.node, - Env.node, - Plugin.node, - ModelsDev.node, - RuntimeFlags.node, -]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [FSUtil.node, Config.node, Auth.node, Env.node, Plugin.node, ModelsDev.node, RuntimeFlags.node], +}) export * as Provider from "./provider" diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts index 0517ec7ebe..1534222466 100644 --- a/packages/opencode/src/question/index.ts +++ b/packages/opencode/src/question/index.ts @@ -158,6 +158,6 @@ export const layer = Layer.effect( export const defaultLayer = layer.pipe(Layer.provide(EventV2Bridge.defaultLayer)) -export const node = LayerNode.make(layer, [EventV2Bridge.node]) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [EventV2Bridge.node] }) export * as Question from "." diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index fe4df34619..3176c320ea 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -599,15 +599,19 @@ export const defaultLayer = Layer.suspend(() => ), ) -export const node = LayerNode.make(layer, [ - Config.node, - Session.node, - Agent.node, - Plugin.node, - SessionProcessor.node, - Provider.node, - EventV2Bridge.node, - RuntimeFlags.node, -]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [ + Config.node, + Session.node, + Agent.node, + Plugin.node, + SessionProcessor.node, + Provider.node, + EventV2Bridge.node, + RuntimeFlags.node, + ], +}) export * as SessionCompaction from "./compaction" diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index 38ac55bbb6..6c0e2edfc6 100644 --- a/packages/opencode/src/session/instruction.ts +++ b/packages/opencode/src/session/instruction.ts @@ -236,6 +236,10 @@ export function loaded(messages: SessionV1.WithParts[]) { return extract(messages) } -export const node = LayerNode.make(layer, [Config.node, FSUtil.node, Global.node, RuntimeFlags.node, httpClient]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [Config.node, FSUtil.node, Global.node, RuntimeFlags.node, httpClient], +}) export * as Instruction from "./instruction" diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index adacfc4315..e9e83808e0 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -401,15 +401,19 @@ export const defaultLayer = Layer.suspend(() => export const hasToolCalls = LLMRequestPrep.hasToolCalls -export const node = LayerNode.make(layer, [ - Auth.node, - Config.node, - Provider.node, - Plugin.node, - Permission.node, - EventV2Bridge.node, - llmClient, - RuntimeFlags.node, -]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [ + Auth.node, + Config.node, + Provider.node, + Plugin.node, + Permission.node, + EventV2Bridge.node, + llmClient, + RuntimeFlags.node, + ], +}) export * as LLM from "./llm" diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 2554315908..17cd125d40 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -1065,20 +1065,24 @@ export const defaultLayer = Layer.suspend(() => ), ) -export const node = LayerNode.make(layer, [ - Session.node, - Config.node, - Snapshot.node, - Agent.node, - LLM.node, - Permission.node, - Plugin.node, - SessionSummary.node, - SessionStatus.node, - Image.node, - EventV2Bridge.node, - RuntimeFlags.node, - Database.node, -]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [ + Session.node, + Config.node, + Snapshot.node, + Agent.node, + LLM.node, + Permission.node, + Plugin.node, + SessionSummary.node, + SessionStatus.node, + Image.node, + EventV2Bridge.node, + RuntimeFlags.node, + Database.node, + ], +}) export * as SessionProcessor from "./processor" diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index e24bfd3df2..1986ce2118 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1731,33 +1731,37 @@ const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi const placeholderRegex = /\$(\d+)/g const quoteTrimRegex = /^["']|["']$/g -export const node = LayerNode.make(layer, [ - SessionStatus.node, - Session.node, - Agent.node, - Provider.node, - SessionProcessor.node, - SessionCompaction.node, - Plugin.node, - Command.node, - Config.node, - Permission.node, - FSUtil.node, - MCP.node, - LSP.node, - ToolRegistry.node, - Truncate.node, - Image.node, - CrossSpawnSpawner.node, - Instruction.node, - SessionRunState.node, - SessionRevert.node, - SessionSummary.node, - SystemPrompt.node, - LLM.node, - EventV2Bridge.node, - RuntimeFlags.node, - Database.node, -]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [ + SessionStatus.node, + Session.node, + Agent.node, + Provider.node, + SessionProcessor.node, + SessionCompaction.node, + Plugin.node, + Command.node, + Config.node, + Permission.node, + FSUtil.node, + MCP.node, + LSP.node, + ToolRegistry.node, + Truncate.node, + Image.node, + CrossSpawnSpawner.node, + Instruction.node, + SessionRunState.node, + SessionRevert.node, + SessionSummary.node, + SystemPrompt.node, + LLM.node, + EventV2Bridge.node, + RuntimeFlags.node, + Database.node, + ], +}) export * as SessionPrompt from "./prompt" diff --git a/packages/opencode/src/session/revert.ts b/packages/opencode/src/session/revert.ts index 04631e4ec0..4ddf38e67d 100644 --- a/packages/opencode/src/session/revert.ts +++ b/packages/opencode/src/session/revert.ts @@ -148,13 +148,10 @@ export const defaultLayer = Layer.suspend(() => ), ) -export const node = LayerNode.make(layer, [ - Session.node, - Snapshot.node, - Storage.node, - EventV2Bridge.node, - SessionSummary.node, - SessionRunState.node, -]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [Session.node, Snapshot.node, Storage.node, EventV2Bridge.node, SessionSummary.node, SessionRunState.node], +}) export * as SessionRevert from "./revert" diff --git a/packages/opencode/src/session/run-state.ts b/packages/opencode/src/session/run-state.ts index 9c85191610..17b0efaeaa 100644 --- a/packages/opencode/src/session/run-state.ts +++ b/packages/opencode/src/session/run-state.ts @@ -151,6 +151,6 @@ function busyError(sessionID: SessionID) { return new Session.BusyError({ sessionID }) } -export const node = LayerNode.make(layer, [BackgroundJob.node, SessionStatus.node]) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [BackgroundJob.node, SessionStatus.node] }) export * as SessionRunState from "./run-state" diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 4f36a8ea0a..13527164be 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -1070,6 +1070,10 @@ export function* listGlobal(input?: { } } -export const node = LayerNode.make(layer, [BackgroundJob.node, RuntimeFlags.node, Database.node, EventV2Bridge.node]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [BackgroundJob.node, RuntimeFlags.node, Database.node, EventV2Bridge.node], +}) export * as Session from "./session" diff --git a/packages/opencode/src/session/status.ts b/packages/opencode/src/session/status.ts index a5714c55d5..2a7830ba71 100644 --- a/packages/opencode/src/session/status.ts +++ b/packages/opencode/src/session/status.ts @@ -53,6 +53,6 @@ export const layer = Layer.effect( export const defaultLayer = layer.pipe(Layer.provide(EventV2Bridge.defaultLayer)) -export const node = LayerNode.make(layer, [EventV2Bridge.node]) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [EventV2Bridge.node] }) export * as SessionStatus from "./status" diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index 370870935a..e36fe6116f 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -160,6 +160,10 @@ export const DiffInput = Schema.Struct({ }) export type DiffInput = Schema.Schema.Type -export const node = LayerNode.make(layer, [Session.node, Snapshot.node, EventV2Bridge.node, Config.node]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [Session.node, Snapshot.node, EventV2Bridge.node, Config.node], +}) export * as SessionSummary from "./summary" diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index 0158690df3..f88795dc77 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -134,8 +134,12 @@ export const defaultLayer = layer.pipe( Layer.provide(LocationServiceMap.layer), ) -const locationServiceMapNode = LayerNode.make(LocationServiceMap.layer, []) +const locationServiceMapNode = LayerNode.make({ service: Service, layer: LocationServiceMap.layer, deps: [] }) -export const node = LayerNode.make(layer, [Skill.node, MCP.node, locationServiceMapNode]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [Skill.node, MCP.node, locationServiceMapNode], +}) export * as SystemPrompt from "./system" diff --git a/packages/opencode/src/session/todo.ts b/packages/opencode/src/session/todo.ts index d8cc825dfc..bdcbf0a6e3 100644 --- a/packages/opencode/src/session/todo.ts +++ b/packages/opencode/src/session/todo.ts @@ -71,6 +71,6 @@ export const layer = Layer.effect( export const defaultLayer = layer.pipe(Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(Database.defaultLayer)) -export const node = LayerNode.make(layer, [EventV2Bridge.node, Database.node]) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [EventV2Bridge.node, Database.node] }) export * as Todo from "./todo" diff --git a/packages/opencode/src/share/session.ts b/packages/opencode/src/share/session.ts index 776e8aa1c8..91f4b1feef 100644 --- a/packages/opencode/src/share/session.ts +++ b/packages/opencode/src/share/session.ts @@ -56,6 +56,10 @@ export const defaultLayer = layer.pipe( Layer.provide(RuntimeFlags.defaultLayer), ) -export const node = LayerNode.make(layer, [Config.node, Session.node, ShareNext.node, RuntimeFlags.node]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [Config.node, Session.node, ShareNext.node, RuntimeFlags.node], +}) export * as SessionShare from "./session" diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index 90c2eafac8..a3c610523a 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -372,14 +372,10 @@ export const defaultLayer = layer.pipe( Layer.provide(Session.defaultLayer), ) -export const node = LayerNode.make(layer, [ - Account.node, - EventV2Bridge.node, - Config.node, - Database.node, - httpClient, - Provider.node, - Session.node, -]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [Account.node, EventV2Bridge.node, Config.node, Database.node, httpClient, Provider.node, Session.node], +}) export * as ShareNext from "./share-next" diff --git a/packages/opencode/src/skill/discovery.ts b/packages/opencode/src/skill/discovery.ts index 0495bc637d..cbb997a020 100644 --- a/packages/opencode/src/skill/discovery.ts +++ b/packages/opencode/src/skill/discovery.ts @@ -104,6 +104,6 @@ export const defaultLayer: Layer.Layer = layer.pipe( Layer.provide(NodePath.layer), ) -export const node = LayerNode.make(layer, [FSUtil.node, path, httpClient]) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [FSUtil.node, path, httpClient] }) export * as Discovery from "./discovery" diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index b8bd6bef6e..08a88a2154 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -354,13 +354,10 @@ export function fmt(list: Info[], opts: { verbose: boolean }) { ].join("\n") } -export const node = LayerNode.make(layer, [ - Discovery.node, - Config.node, - EventV2Bridge.node, - FSUtil.node, - Global.node, - RuntimeFlags.node, -]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [Discovery.node, Config.node, EventV2Bridge.node, FSUtil.node, Global.node, RuntimeFlags.node], +}) export * as Skill from "." diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index c425d08dba..0ab48604a5 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -804,6 +804,10 @@ export const defaultLayer = layer.pipe( Layer.provide(Config.defaultLayer), ) -export const node = LayerNode.make(layer, [FSUtil.node, AppProcess.node, Config.node]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [FSUtil.node, AppProcess.node, Config.node], +}) export * as Snapshot from "." diff --git a/packages/opencode/src/storage/storage.ts b/packages/opencode/src/storage/storage.ts index 5449865fb9..ad95ace034 100644 --- a/packages/opencode/src/storage/storage.ts +++ b/packages/opencode/src/storage/storage.ts @@ -324,6 +324,6 @@ export const layer = Layer.effect( export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(Git.defaultLayer)) -export const node = LayerNode.make(layer, [FSUtil.node, Git.node]) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [FSUtil.node, Git.node] }) export * as Storage from "./storage" diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 541d1f4bbb..c098de329c 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -415,26 +415,30 @@ function isJsonSchemaObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value) } -export const node = LayerNode.make(layer.pipe(Layer.provide(Ripgrep.defaultLayer)), [ - Config.node, - Plugin.node, - Question.node, - Todo.node, - Agent.node, - Skill.node, - Session.node, - BackgroundJob.node, - Provider.node, - LSP.node, - Instruction.node, - FSUtil.node, - EventV2Bridge.node, - httpClient, - CrossSpawnSpawner.node, - Format.node, - Truncate.node, - RuntimeFlags.node, - Database.node, -]) +export const node = LayerNode.make({ + service: Service, + layer: layer.pipe(Layer.provide(Ripgrep.defaultLayer)), + deps: [ + Config.node, + Plugin.node, + Question.node, + Todo.node, + Agent.node, + Skill.node, + Session.node, + BackgroundJob.node, + Provider.node, + LSP.node, + Instruction.node, + FSUtil.node, + EventV2Bridge.node, + httpClient, + CrossSpawnSpawner.node, + Format.node, + Truncate.node, + RuntimeFlags.node, + Database.node, + ], +}) export * as ToolRegistry from "./registry" diff --git a/packages/opencode/src/tool/truncate.ts b/packages/opencode/src/tool/truncate.ts index 1815643d03..7871e37bd3 100644 --- a/packages/opencode/src/tool/truncate.ts +++ b/packages/opencode/src/tool/truncate.ts @@ -153,6 +153,6 @@ export const layer = Layer.effect( export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(NodePath.layer)) -export const node = LayerNode.make(layer, [FSUtil.node]) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [FSUtil.node] }) export * as Truncate from "./truncate" diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index f21a42c6ac..34fc3ef9ab 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -627,14 +627,10 @@ export const appLayer = layer.pipe( export const defaultLayer = appLayer.pipe(Layer.provide(InstanceLayer.layer)) -export const node = LayerNode.make(layer, [ - FSUtil.node, - path, - AppProcess.node, - Git.node, - Project.node, - InstanceStore.node, - Database.node, -]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [FSUtil.node, path, AppProcess.node, Git.node, Project.node, InstanceStore.node, Database.node], +}) export * as Worktree from "." diff --git a/packages/opencode/test/effect/app-graph-types.test.ts b/packages/opencode/test/effect/app-graph-types.test.ts deleted file mode 100644 index 527c4daf54..0000000000 --- a/packages/opencode/test/effect/app-graph-types.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { test } from "bun:test" -import { Context, Effect, Layer } from "effect" -import { LayerNode } from "@opencode-ai/core/effect/layer-node" - -class A extends Context.Service()("test/A") {} -class B extends Context.Service()("test/B") {} -class C extends Context.Service()("test/C") {} -class LayerError { - readonly _tag = "LayerError" -} -class NotFoundError { - readonly _tag = "NotFoundError" -} -class DiskError { - readonly _tag = "DiskError" -} -class NetworkError { - readonly _tag = "NetworkError" -} - -const aImplementation = Layer.succeed(A, A.of({ value: "a" })) -const bImplementation = Layer.effect( - B, - Effect.gen(function* () { - yield* A - return B.of({ value: "b" }) - }), -) -const cImplementation = Layer.effect( - C, - Effect.gen(function* () { - yield* A - yield* B - return C.of({ value: "c" }) - }), -) -const failingAImplementation = Layer.effect(A, Effect.fail(new LayerError())) -const notFoundAImplementation = Layer.effect(A, Effect.fail(new NotFoundError())) -const diskAImplementation = Layer.effect(A, Effect.fail(new DiskError())) -const networkAImplementation = Layer.effect(A, Effect.fail(new NetworkError())) -const notFoundOrDiskAImplementation = Layer.effect(A, Effect.fail(new NotFoundError() as NotFoundError | DiskError)) - -type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false -type Assert = T - -type AProvides = Assert, A>> -type ARequires = Assert, never>> -type BProvides = Assert, B>> -type BRequires = Assert, A>> -type CRequires = Assert, A | B>> -void (0 as unknown as AProvides) -void (0 as unknown as ARequires) -void (0 as unknown as BProvides) -void (0 as unknown as BRequires) -void (0 as unknown as CRequires) - -const a = LayerNode.make(aImplementation, []) -const b = LayerNode.make(bImplementation, [a]) -const c = LayerNode.make(cImplementation, [a, b]) -const failingA = LayerNode.make(failingAImplementation, []) -const bWithFailingA = LayerNode.make(bImplementation, [failingA]) -const notFoundA = LayerNode.make(notFoundAImplementation, []) -const diskA = LayerNode.make(diskAImplementation, []) -const networkA = LayerNode.make(networkAImplementation, []) -const notFoundOrDiskA = LayerNode.make(notFoundOrDiskAImplementation, []) - -// @ts-expect-error B requires A -LayerNode.make(bImplementation, []) - -// @ts-expect-error C requires both A and B -LayerNode.make(cImplementation, [a]) - -type ANodeProvides = Assert>> -type BNodeProvides = Assert>> -type CNodeProvides = Assert>> -type FailingANodeError = Assert>> -type DependentNodeError = Assert>> -void (0 as unknown as ANodeProvides) -void (0 as unknown as BNodeProvides) -void (0 as unknown as CNodeProvides) -void (0 as unknown as FailingANodeError) -void (0 as unknown as DependentNodeError) - -const closed = LayerNode.buildLayer(c) -const closedWithError = LayerNode.buildLayer(bWithFailingA) -type ClosedProvides = Assert, C>> -type ClosedRequires = Assert, never>> -type ClosedError = Assert, LayerError>> -void (0 as unknown as ClosedProvides) -void (0 as unknown as ClosedRequires) -void (0 as unknown as ClosedError) - -const replacement = LayerNode.make(Layer.succeed(A, A.of({ value: "a" })), []) -LayerNode.replace(a, Layer.succeed(A, A.of({ value: "a" }))) -LayerNode.replace(notFoundOrDiskA, notFoundAImplementation) -LayerNode.replace(notFoundOrDiskA, diskAImplementation) -LayerNode.replaceWithNode(a, replacement) - -// @ts-expect-error An override for A must still provide A -LayerNode.replaceWithNode(a, b) - -// @ts-expect-error A replacement cannot introduce NetworkError -LayerNode.replace(notFoundOrDiskA, networkAImplementation) - -// @ts-expect-error A replacement layer must not have unresolved dependencies -LayerNode.replace(b, bImplementation) - -test("type exploration compiles", () => {}) diff --git a/packages/opencode/test/effect/app-graph.test.ts b/packages/opencode/test/effect/app-graph.test.ts deleted file mode 100644 index 7ae7a982ba..0000000000 --- a/packages/opencode/test/effect/app-graph.test.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { Cause, Context, Effect, Exit, Layer } from "effect" -import { LayerNode } from "@opencode-ai/core/effect/layer-node" - -const { buildLayer: build, group, replace, replaceWithNode } = LayerNode -const node = LayerNode.make - -class Value extends Context.Service()("test/Value") {} -class Greeting extends Context.Service()("test/Greeting") {} - -const value = LayerNode.make(Layer.succeed(Value, Value.of({ value: "production" })), []) -const greetingImplementation = Layer.effect( - Greeting, - Effect.gen(function* () { - return Greeting.of({ text: `hello ${(yield* Value).value}` }) - }), -) -const greeting = LayerNode.make(greetingImplementation, [value]) - -// @ts-expect-error Greeting requires Value -LayerNode.make(greetingImplementation, []) - -describe("app graph", () => { - test("creates any selected dependency layer", async () => { - const result = Effect.gen(function* () { - return (yield* Greeting).text - }).pipe(Effect.provide(build(greeting))) - - expect(await Effect.runPromise(result)).toBe("hello production") - }) - - test("applies overrides before dependency materialization", async () => { - const replacement = Layer.succeed(Value, Value.of({ value: "simulation" })) - const graph = build(greeting, { replacements: [replace(value, replacement)] }) - const result = Effect.gen(function* () { - return (yield* Greeting).text - }).pipe(Effect.provide(graph)) - - expect(await Effect.runPromise(result)).toBe("hello simulation") - }) - - test("acquires a shared dependency once", async () => { - class Shared extends Context.Service()("test/Shared") {} - class Left extends Context.Service()("test/Left") {} - class Right extends Context.Service()("test/Right") {} - let acquisitions = 0 - const shared = node( - Layer.effect( - Shared, - Effect.sync(() => { - acquisitions++ - return Shared.of({ value: "shared" }) - }), - ), - [], - ) - const left = node( - Layer.effect( - Left, - Effect.gen(function* () { - return Left.of({ value: `${(yield* Shared).value}-left` }) - }), - ), - [shared], - ) - const right = node( - Layer.effect( - Right, - Effect.gen(function* () { - return Right.of({ value: `${(yield* Shared).value}-right` }) - }), - ), - [shared], - ) - - const result = Effect.gen(function* () { - return [(yield* Left).value, (yield* Right).value] - }).pipe(Effect.provide(build(group([left, right])))) - - expect(await Effect.runPromise(result)).toEqual(["shared-left", "shared-right"]) - expect(acquisitions).toBe(1) - }) - - test("applies a replacement to every transitive consumer", async () => { - class Left extends Context.Service()("test/ReplacementLeft") {} - class Right extends Context.Service()("test/ReplacementRight") {} - const left = node( - Layer.effect( - Left, - Effect.gen(function* () { - return Left.of({ value: (yield* Value).value }) - }), - ), - [value], - ) - const right = node( - Layer.effect( - Right, - Effect.gen(function* () { - return Right.of({ value: (yield* Value).value }) - }), - ), - [value], - ) - const replacement = Layer.succeed(Value, Value.of({ value: "simulation" })) - const graph = build(group([left, right]), { replacements: [replace(value, replacement)] }) - - const result = Effect.gen(function* () { - return [(yield* Left).value, (yield* Right).value] - }).pipe(Effect.provide(graph)) - - expect(await Effect.runPromise(result)).toEqual(["simulation", "simulation"]) - }) - - test("propagates layer acquisition errors", async () => { - class AcquisitionError { - readonly _tag = "AcquisitionError" - } - const failing = node(Layer.effect(Value, Effect.fail(new AcquisitionError())), []) - const exit = await Effect.runPromiseExit(Effect.provide(Value, build(failing))) - - expect(Exit.isFailure(exit)).toBe(true) - if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(AcquisitionError) - }) - - test("groups expose every selected service", async () => { - class Count extends Context.Service()("test/Count") {} - const count = node(Layer.succeed(Count, Count.of({ value: 3 })), []) - const result = Effect.gen(function* () { - return { text: (yield* Value).value, count: (yield* Count).value } - }).pipe(Effect.provide(build(group([value, count])))) - - expect(await Effect.runPromise(result)).toEqual({ text: "production", count: 3 }) - }) - - test("builds an empty group", async () => { - expect(await Effect.runPromise(Effect.succeed("ok").pipe(Effect.provide(build(group([])))))).toBe("ok") - }) - - test("builds replacements with their own dependencies", async () => { - class ReplacementConfig extends Context.Service()( - "test/ReplacementConfig", - ) {} - const replacementConfig = node(Layer.succeed(ReplacementConfig, ReplacementConfig.of({ value: "replacement" })), []) - const replacement = node( - Layer.effect( - Value, - Effect.gen(function* () { - return Value.of({ value: (yield* ReplacementConfig).value }) - }), - ), - [replacementConfig], - ) - const result = Effect.gen(function* () { - return (yield* Greeting).text - }).pipe(Effect.provide(build(greeting, { replacements: [replaceWithNode(value, replacement)] }))) - - expect(await Effect.runPromise(result)).toBe("hello replacement") - }) - - test("does not acquire unreachable replacements", async () => { - let acquisitions = 0 - const unreachable = node(Layer.succeed(Value, Value.of({ value: "unreachable" })), []) - const replacement = Layer.effect( - Value, - Effect.sync(() => { - acquisitions++ - return Value.of({ value: "replacement" }) - }), - ) - - await Effect.runPromise( - Effect.provide(Greeting, build(greeting, { replacements: [replace(unreachable, replacement)] })), - ) - - expect(acquisitions).toBe(0) - }) - - test("rejects a direct cycle", () => { - const cyclic = node(Layer.succeed(Value, Value.of({ value: "cyclic" })), []) - ;(cyclic.dependencies as LayerNode.Node[]).push(cyclic) - - expect(() => build(cyclic)).toThrow("Cycle detected in app graph: layer#1 -> layer#1") - }) - - test("rejects an indirect cycle", () => { - const first = node(Layer.succeed(Value, Value.of({ value: "first" })), []) - const second = node(Layer.succeed(Value, Value.of({ value: "second" })), [first]) - const third = node(Layer.succeed(Value, Value.of({ value: "third" })), [second]) - ;(first.dependencies as LayerNode.Node[]).push(third) - - expect(() => build(first)).toThrow("Cycle detected in app graph: layer#1 -> layer#2 -> layer#3 -> layer#1") - }) - - test("rejects a cycle introduced by a replacement", () => { - const replacement = node(Layer.succeed(Value, Value.of({ value: "replacement" })), []) - const consumer = node(greetingImplementation, [value]) - ;(replacement.dependencies as LayerNode.Node[]).push(consumer) - - expect(() => build(consumer, { replacements: [replaceWithNode(value, replacement)] })).toThrow( - "Cycle detected in app graph: layer#1 -> layer#2 -> layer#1", - ) - }) -}) diff --git a/packages/opencode/test/effect/layer-node-tiers-types.test.ts b/packages/opencode/test/effect/layer-node-tiers-types.test.ts new file mode 100644 index 0000000000..c41b0ccb3c --- /dev/null +++ b/packages/opencode/test/effect/layer-node-tiers-types.test.ts @@ -0,0 +1,19 @@ +import { test } from "bun:test" +import { Context, Effect, Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" + +class A extends Context.Service()("test/TierA") {} +class B extends Context.Service()("test/TierB") {} + +const tiers = LayerNode.tiers(["request", "global"]) +const request = tiers.make("request") +const global = tiers.make("global") +const globalA = global({ service: A, layer: Layer.succeed(A, A.of({})), deps: [] }) +const bLayer = Layer.effect(B, Effect.as(A, B.of({}))) + +request({ service: B, layer: bLayer, deps: [globalA] }) + +// @ts-expect-error Global cannot depend on request +global({ service: B, layer: bLayer, deps: [request({ service: A, layer: Layer.succeed(A, A.of({})), deps: [] })] }) + +test("type exploration compiles", () => {}) diff --git a/packages/opencode/test/effect/layer-node-tiers.test.ts b/packages/opencode/test/effect/layer-node-tiers.test.ts new file mode 100644 index 0000000000..ec460214a5 --- /dev/null +++ b/packages/opencode/test/effect/layer-node-tiers.test.ts @@ -0,0 +1,169 @@ +import { expect, test } from "bun:test" +import { Context, Effect, Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" + +class Value extends Context.Service()("test/TierValue") {} +class Result extends Context.Service()("test/TierResult") {} +class Left extends Context.Service()("test/TierLeft") {} +class Right extends Context.Service()("test/TierRight") {} +class Last extends Context.Service()("test/TierLast") {} + +test("builds tiers with a custom builder", async () => { + let locationBuilds = 0 + const tiers = LayerNode.tiers(["location", "global"]) + const global = tiers.make("global") + const location = tiers.make("location") + const value = global({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "value" })), deps: [] }) + const result = location({ + service: Result, + layer: Layer.effect( + Result, + Effect.gen(function* () { + return Result.of({ value: (yield* Value).value }) + }), + ), + deps: [value], + }) + const layer = LayerNode.buildLayer(LayerNode.group([result]), { + tiers, + buildTier: (tier, layers) => { + if (tier !== "location") return LayerNode.combine(layers) + locationBuilds++ + return LayerNode.combine(layers).pipe(Layer.fresh) + }, + }) + const program = Effect.gen(function* () { + return (yield* Result).value + }).pipe(Effect.provide(layer)) + + expect(await Effect.runPromise(program)).toBe("value") + expect(locationBuilds).toBe(1) +}) + +test("rejects conflicting higher-tier service implementations", () => { + const tiers = LayerNode.tiers(["location", "global"]) + const global = tiers.make("global") + const location = tiers.make("location") + const first = global({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "first" })), deps: [] }) + const second = global({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "second" })), deps: [] }) + const left = location({ + service: Left, + layer: Layer.effect(Left, Effect.as(Value, Left.of({ value: "left" }))), + deps: [first], + }) + const right = location({ + service: Right, + layer: Layer.effect(Right, Effect.as(Value, Right.of({ value: "right" }))), + deps: [second], + }) + + expect(() => LayerNode.buildLayer(LayerNode.group([left, right]), { tiers })).toThrow( + "conflicting implementations for test/TierValue", + ) +}) + +test("validates tier dependencies through groups", () => { + const tiers = LayerNode.tiers(["location", "global"]) + const global = tiers.make("global") + const location = tiers.make("location") + const local = location({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "local" })), deps: [] }) + const invalid = global({ + service: Result, + layer: Layer.effect( + Result, + Effect.map(Value, (value) => Result.of({ value: value.value })), + ), + deps: [LayerNode.group([local])], + }) + + expect(() => LayerNode.buildLayer(invalid, { tiers })).toThrow("Tier global cannot depend on lower tier location") +}) + +test("validates shared groups in each consumer tier", () => { + const tiers = LayerNode.tiers(["location", "global"]) + const global = tiers.make("global") + const location = tiers.make("location") + const local = location({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "local" })), deps: [] }) + const shared = LayerNode.group([local]) + const valid = location({ + service: Left, + layer: Layer.effect( + Left, + Effect.map(Value, (value) => Left.of({ value: value.value })), + ), + deps: [shared], + }) + const invalid = global({ + service: Result, + layer: Layer.effect( + Result, + Effect.map(Value, (value) => Result.of({ value: value.value })), + ), + deps: [shared], + }) + + expect(() => LayerNode.buildLayer(LayerNode.group([valid, invalid]), { tiers })).toThrow( + "Tier global cannot depend on lower tier location", + ) +}) + +test("rejects a service assigned to multiple tiers", () => { + const tiers = LayerNode.tiers(["location", "global"]) + const global = tiers.make("global") + const location = tiers.make("location") + const local = location({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "local" })), deps: [] }) + const shared = global({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "global" })), deps: [] }) + + expect(() => LayerNode.buildLayer(LayerNode.group([local, shared]), { tiers })).toThrow( + "Service test/TierValue belongs to both tier location and tier global", + ) +}) + +test("rebinds same-tier providers without reacquiring them", async () => { + let firstAcquisitions = 0 + const tiers = LayerNode.tiers(["global"]) + const global = tiers.make("global") + const first = global({ + service: Value, + layer: Layer.effect( + Value, + Effect.sync(() => { + firstAcquisitions++ + return Value.of({ value: "first" }) + }), + ), + deps: [], + }) + const second = global({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "second" })), deps: [] }) + const left = global({ + service: Left, + layer: Layer.effect( + Left, + Effect.map(Value, (value) => Left.of({ value: value.value })), + ), + deps: [first], + }) + const right = global({ + service: Right, + layer: Layer.effect( + Right, + Effect.map(Value, (value) => Right.of({ value: value.value })), + ), + deps: [second], + }) + const last = global({ + service: Last, + layer: Layer.effect( + Last, + Effect.map(Value, (value) => Last.of({ value: value.value })), + ), + deps: [first], + }) + const layer = LayerNode.buildLayer(LayerNode.group([left, right, last]), { tiers }) + const values = Effect.gen(function* () { + return [(yield* Left).value, (yield* Right).value, (yield* Last).value] + }).pipe(Effect.provide(layer)) + + expect(await Effect.runPromise(values)).toEqual(["first", "second", "first"]) + expect(firstAcquisitions).toBe(1) +}) diff --git a/packages/opencode/test/effect/layer-node-types.test.ts b/packages/opencode/test/effect/layer-node-types.test.ts new file mode 100644 index 0000000000..4ff88cf80f --- /dev/null +++ b/packages/opencode/test/effect/layer-node-types.test.ts @@ -0,0 +1,66 @@ +import { test } from "bun:test" +import { Context, Effect, Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" + +class A extends Context.Service()("test/LayerNodeA") {} +class B extends Context.Service()("test/LayerNodeB") {} +class C extends Context.Service()("test/LayerNodeC") {} +class LayerError { + readonly _tag = "LayerError" +} +class OtherError { + readonly _tag = "OtherError" +} + +const tiers = LayerNode.tiers(["app"]) +const make = tiers.make("app") +const aLayer = Layer.succeed(A, A.of({})) +const bLayer = Layer.effect(B, Effect.as(A, B.of({}))) +const cLayer = Layer.effect( + C, + Effect.gen(function* () { + yield* A + yield* B + return C.of({}) + }), +) +const failingA = Layer.effect(A, Effect.fail(new LayerError())) +const a = make({ service: A, layer: aLayer, deps: [] }) +const b = make({ service: B, layer: bLayer, deps: [a] }) +const c = make({ service: C, layer: cLayer, deps: [a, b] }) +const failing = make({ service: A, layer: failingA, deps: [] }) +const dependent = make({ service: B, layer: bLayer, deps: [failing] }) + +make({ name: "manual-a", layer: aLayer, deps: [] }) + +// @ts-expect-error A node must have a service or name +make({ layer: aLayer, deps: [] }) + +// @ts-expect-error Service and name are mutually exclusive +make({ service: A, name: "a", layer: aLayer, deps: [] }) + +// @ts-expect-error B requires A +make({ service: B, layer: bLayer, deps: [] }) + +// @ts-expect-error C requires A and B +make({ service: C, layer: cLayer, deps: [a] }) + +const closed = LayerNode.buildLayer(c, { tiers }) +const closedWithError = LayerNode.buildLayer(dependent, { tiers }) +const checkClosed: Layer.Layer = closed +const checkError: Layer.Layer = closedWithError +void checkClosed +void checkError + +LayerNode.replace(aLayer, Layer.succeed(A, A.of({}))) + +// @ts-expect-error Replacement must provide A +LayerNode.replace(aLayer, Layer.succeed(B, B.of({}))) + +// @ts-expect-error Replacement cannot introduce a new error +LayerNode.replace(aLayer, Layer.effect(A, Effect.fail(new OtherError()))) + +// @ts-expect-error Replacement must be closed +LayerNode.replace(bLayer, bLayer) + +test("type exploration compiles", () => {}) diff --git a/packages/opencode/test/effect/layer-node.test.ts b/packages/opencode/test/effect/layer-node.test.ts new file mode 100644 index 0000000000..353cb7d230 --- /dev/null +++ b/packages/opencode/test/effect/layer-node.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from "bun:test" +import { Context, Effect, Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" + +class Value extends Context.Service()("test/LayerNodeValue") {} +class Greeting extends Context.Service()("test/LayerNodeGreeting") {} +class Left extends Context.Service()("test/LayerNodeLeft") {} +class Right extends Context.Service()("test/LayerNodeRight") {} + +const tiers = LayerNode.tiers(["app"]) +const make = tiers.make("app") +const valueLayer = Layer.succeed(Value, Value.of({ value: "production" })) +const greetingLayer = Layer.effect( + Greeting, + Effect.map(Value, (value) => Greeting.of({ value: `hello ${value.value}` })), +) +const value = make({ service: Value, layer: valueLayer, deps: [] }) +const greeting = make({ service: Greeting, layer: greetingLayer, deps: [value] }) + +describe("layer node", () => { + test("builds an untiered graph", async () => { + const value = LayerNode.make({ service: Value, layer: valueLayer, deps: [] }) + const greeting = LayerNode.make({ service: Greeting, layer: greetingLayer, deps: [value] }) + const program = Effect.map(Greeting, (item) => item.value).pipe(Effect.provide(LayerNode.buildLayer(greeting))) + expect(await Effect.runPromise(program)).toBe("hello production") + }) + + test("builds a dependency graph", async () => { + const program = Effect.map(Greeting, (item) => item.value).pipe( + Effect.provide(LayerNode.buildLayer(greeting, { tiers })), + ) + expect(await Effect.runPromise(program)).toBe("hello production") + }) + + test("replaces a layer by identity", async () => { + const replacement = Layer.succeed(Value, Value.of({ value: "simulation" })) + const program = Effect.map(Greeting, (item) => item.value).pipe( + Effect.provide(LayerNode.buildLayer(greeting, { tiers, replacements: [LayerNode.replace(valueLayer, replacement)] })), + ) + expect(await Effect.runPromise(program)).toBe("hello simulation") + }) + + test("replaces every use of the same layer", async () => { + const leftLayer = Layer.effect( + Left, + Effect.map(Value, (item) => Left.of({ value: item.value })), + ) + const rightLayer = Layer.effect( + Right, + Effect.map(Value, (item) => Right.of({ value: item.value })), + ) + const left = make({ service: Left, layer: leftLayer, deps: [value] }) + const right = make({ service: Right, layer: rightLayer, deps: [value] }) + const replacement = Layer.succeed(Value, Value.of({ value: "replaced" })) + const layer = LayerNode.buildLayer(LayerNode.group([left, right]), { + tiers, + replacements: [LayerNode.replace(valueLayer, replacement)], + }) + const program = Effect.gen(function* () { + return [(yield* Left).value, (yield* Right).value] + }).pipe(Effect.provide(layer)) + expect(await Effect.runPromise(program)).toEqual(["replaced", "replaced"]) + }) + + test("does not acquire an unused replacement", async () => { + let acquisitions = 0 + const other = Layer.succeed(Value, Value.of({ value: "other" })) + const replacement = Layer.effect( + Value, + Effect.sync(() => { + acquisitions++ + return Value.of({ value: "replacement" }) + }), + ) + await Effect.runPromise( + Effect.map(Greeting, (item) => item.value).pipe( + Effect.provide(LayerNode.buildLayer(greeting, { tiers, replacements: [LayerNode.replace(other, replacement)] })), + ), + ) + expect(acquisitions).toBe(0) + }) +}) diff --git a/packages/opencode/test/effect/scoped-node-types.test.ts b/packages/opencode/test/effect/scoped-node-types.test.ts new file mode 100644 index 0000000000..0521e5c6a4 --- /dev/null +++ b/packages/opencode/test/effect/scoped-node-types.test.ts @@ -0,0 +1,23 @@ +import { test } from "bun:test" +import { Context, Effect, Layer } from "effect" +import { makeGlobalNode, makeLocationNode } from "@opencode-ai/core/effect/scoped-node" + +class A extends Context.Service()("test/ScopedA") {} +class B extends Context.Service()("test/ScopedB") {} + +const a = Layer.succeed(A, A.of({})) +const b = Layer.effect(B, Effect.as(A, B.of({}))) +const globalA = makeGlobalNode({ service: A, layer: a, deps: [] }) +const locationA = makeLocationNode({ service: A, layer: a, deps: [] }) + +makeGlobalNode({ service: B, layer: b, deps: [globalA] }) +makeLocationNode({ service: B, layer: b, deps: [globalA] }) +makeLocationNode({ service: B, layer: b, deps: [locationA] }) + +// @ts-expect-error Global nodes cannot depend on location nodes +makeGlobalNode({ service: B, layer: b, deps: [locationA] }) + +// @ts-expect-error B requires A +makeLocationNode({ service: B, layer: b, deps: [] }) + +test("type exploration compiles", () => {}) diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index c8f40d0de1..2e1864034e 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -177,10 +177,13 @@ const root = LayerNode.group([ CrossSpawnSpawner.node, ]) const replacements = [ - LayerNode.replace(SessionSummary.node, summary), - LayerNode.replace(RuntimeFlags.node, RuntimeFlags.layer({ experimentalEventSystem: true })), + LayerNode.replace(SessionSummary.layer, summary), + LayerNode.replace(RuntimeFlags.defaultLayer, RuntimeFlags.layer({ experimentalEventSystem: true })), ] -const env = LayerNode.buildLayer(LayerNode.group([root, LayerNode.make(TestLLMServer.layer, [])]), { replacements }) +const env = LayerNode.buildLayer( + LayerNode.group([root, LayerNode.make({ service: TestLLMServer, layer: TestLLMServer.layer, deps: [] })]), + { replacements }, +) const it = testEffect(env) @@ -205,7 +208,7 @@ const providerErrorLLM = Layer.succeed( }), ) const providerErrorEnv = LayerNode.buildLayer(root, { - replacements: [...replacements, LayerNode.replace(LLM.node, providerErrorLLM)], + replacements: [...replacements, LayerNode.replace(LLM.layer, providerErrorLLM)], }) const itProviderError = testEffect(providerErrorEnv) @@ -224,7 +227,7 @@ const fragmentFailureLLM = Layer.succeed( }), ) const fragmentFailureEnv = LayerNode.buildLayer(root, { - replacements: [...replacements, LayerNode.replace(LLM.node, fragmentFailureLLM)], + replacements: [...replacements, LayerNode.replace(LLM.layer, fragmentFailureLLM)], }) const itFragmentFailure = testEffect(fragmentFailureEnv) diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 0a5065837d..313e5cf9c7 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -84,14 +84,14 @@ const root = LayerNode.group([ SessionSummary.node, Database.node, CrossSpawnSpawner.node, - LayerNode.make(TestLLMServer.layer, []), + LayerNode.make({ service: TestLLMServer, layer: TestLLMServer.layer, deps: [] }), ]) const it = testEffect( LayerNode.buildLayer(root, { replacements: [ - LayerNode.replace(MCP.node, mcp), - LayerNode.replace(LSP.node, lsp), - LayerNode.replace(RuntimeFlags.node, RuntimeFlags.layer({ experimentalEventSystem: true })), + LayerNode.replace(MCP.layer, mcp), + LayerNode.replace(LSP.layer, lsp), + LayerNode.replace(RuntimeFlags.defaultLayer, RuntimeFlags.layer({ experimentalEventSystem: true })), ], }), ) diff --git a/packages/opencode/test/share/share-next.test.ts b/packages/opencode/test/share/share-next.test.ts index 7bc76ed905..c541af5281 100644 --- a/packages/opencode/test/share/share-next.test.ts +++ b/packages/opencode/test/share/share-next.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect } from "bun:test" import { Effect, Exit, Layer, Option } from "effect" -import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { httpClient } from "@opencode-ai/core/effect/layer-node-platform" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" @@ -35,7 +35,7 @@ const none = HttpClient.make(() => Effect.die("unexpected http call")) function requestLayer(client: HttpClient.HttpClient) { return LayerNode.buildLayer(LayerNode.group([ShareNext.node, AccountRepo.node]), { - replacements: [LayerNode.replace(httpClient, Layer.succeed(HttpClient.HttpClient, client))], + replacements: [LayerNode.replace(FetchHttpClient.layer, Layer.succeed(HttpClient.HttpClient, client))], }) } @@ -49,9 +49,7 @@ function integrationLayer(client: HttpClient.HttpClient) { AccountRepo.node, Database.node, ]), - { - replacements: [LayerNode.replace(httpClient, Layer.succeed(HttpClient.HttpClient, client))], - }, + { replacements: [LayerNode.replace(FetchHttpClient.layer, Layer.succeed(HttpClient.HttpClient, client))] }, ) } diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 27447616fb..9c616f1a06 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -50,14 +50,14 @@ const brokenPluginLayer = Layer.succeed( const root = LayerNode.group([ToolRegistry.node, Agent.node]) const replacements = [ - LayerNode.replace(Config.node, configLayer), - LayerNode.replace(RuntimeFlags.node, RuntimeFlags.layer()), + LayerNode.replace(Config.layer, configLayer), + LayerNode.replace(RuntimeFlags.defaultLayer, RuntimeFlags.layer()), ] const it = testEffect(LayerNode.buildLayer(root, { replacements })) const withBrokenPlugin = testEffect( LayerNode.buildLayer(root, { - replacements: [...replacements, LayerNode.replace(Plugin.node, brokenPluginLayer)], + replacements: [...replacements, LayerNode.replace(Plugin.layer, brokenPluginLayer)], }), ) diff --git a/specs/layer-node-tiers.md b/specs/layer-node-tiers.md new file mode 100644 index 0000000000..888e949efb --- /dev/null +++ b/specs/layer-node-tiers.md @@ -0,0 +1,254 @@ +# Layer Node Tiers + +## Goal + +`LayerNode` describes the complete dependency graph while allowing groups of nodes to be constructed with different lifecycle boundaries. The abstraction must not hard-code concepts such as global or location services. + +## Node Definition + +Nodes have an Effect service tag, a layer, dependencies, and a tier. The service tag's runtime `key` identifies the node in diagnostics: + +```ts +export const node = LayerNode.make({ + service: Watcher.Service, + layer, + deps: [], + tier: location, +}) +``` + +Tier-specific makers supply the tier automatically: + +```ts +export const node = makeLocationNode({ + service: Watcher.Service, + layer, + deps: [Config.node, Git.node], +}) +``` + +## Tier Declaration + +Tiers are declared bottom-up, from the most specific lifecycle to the most foundational: + +```ts +const tiers = LayerNode.tiers([ + "location", + "global", +]) +``` + +An earlier tier may depend on its own tier or any later tier. A later tier cannot depend on an earlier tier. + +For the example above: + +- `location` may depend on `location` or `global`. +- `global` may depend only on `global`. + +### Cross-Tier Dependencies + +Dependencies on a later, more foundational tier are hoisted outside the current tier's construction boundary. For example, global dependencies of location nodes must be built outside a location `Layer.fresh` boundary. + +From the perspective of a lower tier, each service crossing into a higher tier must resolve to one unique node identity: + +- Multiple consumers may depend on the same higher-tier node. +- Two different higher-tier nodes for the same service are a conflict, even if both satisfy the same dependency type. +- This constraint applies to transitive higher-tier dependencies as well as direct dependencies. + +This validation happens while traversing dependency edges for the single complete-graph topological sort. It is not reconstructed from the flattened sorted list and is not a separate validation pass. + +The traversal must retain the lower-tier perspective when following a dependency into a higher tier. For each lower tier, it tracks the higher-tier node selected for every service key. Reaching the same service through the same node identity is valid; reaching it through a different node identity is a conflict. + +Topological visitation and boundary validation are distinct traversal state: + +- A node is emitted into the topological order once. +- A higher-tier node may need boundary validation once for each lower tier from which it is reachable. + +This distinction is required for transitive dependencies. A higher-tier node may already be topologically visited when another lower-tier branch reaches it, but that later branch must still participate in service uniqueness validation. + +The tier configuration generates correctly constrained makers: + +```ts +export const makeLocationNode = tiers.make("location") +export const makeGlobalNode = tiers.make("global") +``` + +This must reject invalid dependencies at compile time: + +```ts +makeGlobalNode({ + service: Database.Service, + layer, + deps: [locationNode], // type error +}) +``` + +## Building + +`buildLayer` remains a top-level `LayerNode` function and receives the tier configuration: + +```ts +const appLayer = LayerNode.buildLayer(root, tiers) +``` + +It performs these steps: + +1. Traverse and topologically sort the complete reachable graph once, dependency-first. +2. While traversing dependency edges, detect cycles, validate tier direction, and validate unique cross-tier service implementations from each lower tier's perspective. +3. Partition the one sorted list by tier while preserving its relative order. +4. Process tiers in their declared bottom-up order. +5. Build one layer for each tier. +6. Connect tier layers with `Layer.provideMerge` according to tier dependencies. +7. Return one final closed Effect layer. + +### One Topological Sort + +There is one topological sort for the entire graph, not one sort per tier. Every reachable node is emitted into the sorted result once. Boundary-validation state may separately process a higher-tier node once per originating lower tier; this does not create another topological sort. + +For example, one dependency-first result may be: + +```text +[globalDatabase, globalGit, locationConfig, locationWatcher] +``` + +Stable partitioning then produces: + +```text +global: [globalDatabase, globalGit] +location: [locationConfig, locationWatcher] +``` + +Because partitioning preserves relative order, dependencies within each tier remain before their consumers. Cross-tier dependencies were already validated while their dependency edges were available during traversal; validation is not attempted from the partitioned lists. + +### Provider Rebinding Within A Tier + +Topological node deduplication is not sufficient when a tier contains different nodes that provide the same service. The final linear layer plan must preserve which implementation each consumer depends on. + +For example: + +```text +ConsumerX -> X provides Service +ConsumerY -> Y provides Service +ConsumerX2 -> X provides Service +``` + +The resulting dependency-first plan must be able to represent: + +```text +ConsumerX, X, ConsumerY, Y, ConsumerX2, X +``` + +After `Y` becomes the active implementation, the later dependency on `X` must emit `X` again. A global visited set must not incorrectly remove that second placement. + +While constructing a tier's linear plan, track the active provider node for each service key: + +- If the required provider is already active, its repeated placement may be omitted. +- If a different provider for the same service is active, emit the required provider again and make it active. +- If no provider is active, emit the provider and make it active. + +Repeated placement of the same node does not imply repeated resource acquisition. Effect layer memoization may still reuse the same layer instance. The repeated placement restores the intended provider binding for subsequent consumers. + +This differs from cross-tier uniqueness. Multiple implementations may be rebound within one tier, but different implementations of the same service cannot both be hoisted across a tier boundary. + +Without a custom build function, a tier's sorted layers are combined with the default `Layer.provideMerge` behavior. + +## Custom Tier Build Function + +The optional third argument customizes how each tier's sorted layers are constructed: + +```ts +const appLayer = LayerNode.buildLayer( + root, + tiers, + (tier, layers) => { + const combined = LayerNode.combine(layers) + + if (tier !== "location") return combined + + return Layer.effect( + LocationServiceMap, + LayerMap.make( + (ref: Location.Ref) => + combined.pipe( + Layer.provide(Location.layer(ref)), + Layer.fresh, + ), + { idleTimeToLive: "60 minutes" }, + ), + ) + }, +) +``` + +The callback receives: + +- The tier name. +- The tier's layers in the dependency-first order preserved from the single complete-graph topological sort. + +It returns the final layer representing that tier. This permits a tier to introduce a lifecycle boundary, wrap its layers in a `LayerMap`, or otherwise transform how the tier is built. + +## Replacements + +Tests and alternate runtimes may replace a specific layer implementation by exact object identity: + +```ts +const layer = LayerNode.buildLayer( + root, + tiers, + buildTier, + [LayerNode.replace(Config.layer, testConfigLayer)], +) +``` + +The replacement applies to every placement of that exact source layer in the generated plans. Unused replacements are not acquired. A replacement must provide the same service output, must not introduce new errors, and must not have unresolved dependencies. + +## Freshness + +Global implementations must remain outside the location freshness boundary. Conceptually: + +```ts +locationTier + .pipe(Layer.fresh) + .pipe(Layer.provideMerge(globalTier)) +``` + +The location tier contains only location implementations. Global dependencies are connected after the location build function creates its fresh or `LayerMap` boundary, so global services remain shared. + +## Responsibilities + +`LayerNode` owns: + +- Service tags and dependency edges. +- Tier declarations and type-safe tier makers. +- Cycle detection and diagnostics using service keys such as `Watcher.Service.key`. +- One dependency-first topological sort of the complete graph. +- Cross-tier service uniqueness validation during dependency traversal, tracked per originating lower tier. +- Stable partitioning of the sorted nodes by tier. +- Provider-aware linearization within each tier, including rebinding when different nodes provide the same service. +- Invoking the optional tier build function. +- Wiring the resulting tier layers into one final layer. + +The caller owns: + +- The meaning of each tier. +- Tier-specific lifecycle behavior. +- Specialized wrappers such as `LocationServiceMap`. + +The abstraction must not contain built-in knowledge of global, location, request, workspace, or other application-specific tiers. + +## Deferred: packages/opencode Compatibility + +The first implementation will not migrate or redesign the existing `packages/opencode` integration with core's `LocationServiceMap`. + +`packages/opencode` currently uses its own `InstanceState` lifecycle while bridging to core location services through `LocationServiceMap`. Production consumers include: + +- `packages/opencode/src/session/system.ts` +- `packages/opencode/src/agent/agent.ts` +- `packages/opencode/src/cli/cmd/debug/file.ts` +- `packages/opencode/src/cli/cmd/debug/v2.ts` +- `packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts` +- `packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts` + +Some consumers wrap `LocationServiceMap.layer` as an opaque `LayerNode`; others provide the layer directly. We need to determine how these bridges consume the tier-built core graph and how unresolved global dependencies are exposed after the new core location builder is implemented. + +This compatibility work must happen after the first tier implementation. The first implementation should preserve existing `packages/opencode` behavior and avoid changing these bridges. From 0870a838a8d32e5c7afb732d160cd61ff4815fec Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 25 Jun 2026 18:36:16 +0000 Subject: [PATCH 14/26] chore: generate --- .../opencode/test/effect/layer-node.test.ts | 8 +++- specs/layer-node-tiers.md | 45 ++++++------------- 2 files changed, 19 insertions(+), 34 deletions(-) diff --git a/packages/opencode/test/effect/layer-node.test.ts b/packages/opencode/test/effect/layer-node.test.ts index 353cb7d230..036d4a501b 100644 --- a/packages/opencode/test/effect/layer-node.test.ts +++ b/packages/opencode/test/effect/layer-node.test.ts @@ -35,7 +35,9 @@ describe("layer node", () => { test("replaces a layer by identity", async () => { const replacement = Layer.succeed(Value, Value.of({ value: "simulation" })) const program = Effect.map(Greeting, (item) => item.value).pipe( - Effect.provide(LayerNode.buildLayer(greeting, { tiers, replacements: [LayerNode.replace(valueLayer, replacement)] })), + Effect.provide( + LayerNode.buildLayer(greeting, { tiers, replacements: [LayerNode.replace(valueLayer, replacement)] }), + ), ) expect(await Effect.runPromise(program)).toBe("hello simulation") }) @@ -74,7 +76,9 @@ describe("layer node", () => { ) await Effect.runPromise( Effect.map(Greeting, (item) => item.value).pipe( - Effect.provide(LayerNode.buildLayer(greeting, { tiers, replacements: [LayerNode.replace(other, replacement)] })), + Effect.provide( + LayerNode.buildLayer(greeting, { tiers, replacements: [LayerNode.replace(other, replacement)] }), + ), ), ) expect(acquisitions).toBe(0) diff --git a/specs/layer-node-tiers.md b/specs/layer-node-tiers.md index 888e949efb..fc2212ece8 100644 --- a/specs/layer-node-tiers.md +++ b/specs/layer-node-tiers.md @@ -32,10 +32,7 @@ export const node = makeLocationNode({ Tiers are declared bottom-up, from the most specific lifecycle to the most foundational: ```ts -const tiers = LayerNode.tiers([ - "location", - "global", -]) +const tiers = LayerNode.tiers(["location", "global"]) ``` An earlier tier may depend on its own tier or any later tier. A later tier cannot depend on an earlier tier. @@ -157,27 +154,18 @@ Without a custom build function, a tier's sorted layers are combined with the de The optional third argument customizes how each tier's sorted layers are constructed: ```ts -const appLayer = LayerNode.buildLayer( - root, - tiers, - (tier, layers) => { - const combined = LayerNode.combine(layers) +const appLayer = LayerNode.buildLayer(root, tiers, (tier, layers) => { + const combined = LayerNode.combine(layers) - if (tier !== "location") return combined + if (tier !== "location") return combined - return Layer.effect( - LocationServiceMap, - LayerMap.make( - (ref: Location.Ref) => - combined.pipe( - Layer.provide(Location.layer(ref)), - Layer.fresh, - ), - { idleTimeToLive: "60 minutes" }, - ), - ) - }, -) + return Layer.effect( + LocationServiceMap, + LayerMap.make((ref: Location.Ref) => combined.pipe(Layer.provide(Location.layer(ref)), Layer.fresh), { + idleTimeToLive: "60 minutes", + }), + ) +}) ``` The callback receives: @@ -192,12 +180,7 @@ It returns the final layer representing that tier. This permits a tier to introd Tests and alternate runtimes may replace a specific layer implementation by exact object identity: ```ts -const layer = LayerNode.buildLayer( - root, - tiers, - buildTier, - [LayerNode.replace(Config.layer, testConfigLayer)], -) +const layer = LayerNode.buildLayer(root, tiers, buildTier, [LayerNode.replace(Config.layer, testConfigLayer)]) ``` The replacement applies to every placement of that exact source layer in the generated plans. Unused replacements are not acquired. A replacement must provide the same service output, must not introduce new errors, and must not have unresolved dependencies. @@ -207,9 +190,7 @@ The replacement applies to every placement of that exact source layer in the gen Global implementations must remain outside the location freshness boundary. Conceptually: ```ts -locationTier - .pipe(Layer.fresh) - .pipe(Layer.provideMerge(globalTier)) +locationTier.pipe(Layer.fresh).pipe(Layer.provideMerge(globalTier)) ``` The location tier contains only location implementations. Global dependencies are connected after the location build function creates its fresh or `LayerMap` boundary, so global services remain shared. From b60c0a5e8ccc71ef7cce327602401418d419472e Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Fri, 26 Jun 2026 03:09:22 +0800 Subject: [PATCH 15/26] fix(app): use tab-scoped servers in sessions (#33946) --- packages/app/src/app.tsx | 69 +++++++++++-------- .../app/src/components/dialog-select-file.tsx | 6 +- .../app/src/components/status-popover.tsx | 7 +- packages/app/src/components/terminal.tsx | 10 +-- packages/app/src/components/titlebar.tsx | 6 ++ packages/app/src/context/layout.tsx | 6 ++ packages/app/src/context/prompt.tsx | 6 +- packages/app/src/context/server-sdk.tsx | 1 + .../composer/session-composer-controls.ts | 24 ++----- 9 files changed, 77 insertions(+), 58 deletions(-) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index b618df3980..569a6099cf 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -32,7 +32,7 @@ import { CommentsProvider } from "@/context/comments" import { FileProvider } from "@/context/file" import { ServerSDKProvider, useServerSDK } from "@/context/server-sdk" import { ServerSyncProvider, useServerSync } from "@/context/server-sync" -import { GlobalProvider } from "@/context/global" +import { GlobalProvider, useGlobal } from "@/context/global" import { HighlightsProvider } from "@/context/highlights" import { LanguageProvider, type Locale, useLanguage } from "@/context/language" import { LayoutProvider } from "@/context/layout" @@ -103,10 +103,10 @@ const SessionRoute = () => { const TargetSessionRoute = () => { const params = useParams<{ serverKey: string; id: string }>() - const server = useServer() + const global = useGlobal() const conn = createMemo(() => { const key = requireServerKey(params.serverKey) - return server.list.find((item) => ServerConnection.key(item) === key) + return global.servers.list().find((item) => ServerConnection.key(item) === key) }) return ( @@ -221,25 +221,27 @@ function DraftRoute() { } function ResolvedDraftRoute(props: { draft: DraftTab }) { - const server = useServer() - const conn = createMemo(() => server.list.find((item) => ServerConnection.key(item) === props.draft.server)) + const global = useGlobal() + const conn = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === props.draft.server)) const directory = () => props.draft.directory const serverKey = () => props.draft.server return ( - - - - - - - - - - - - - + + + + + + + + + + + + + + + ) } @@ -588,18 +590,31 @@ function Routes() { - { - const server = useServer() - const { id } = useParams() - - return - }} - /> + ) } + +function LegacyTargetSessionRoute() { + const server = useServer() + const tabs = useTabs() + const params = useParams<{ id: string }>() + + return ( + + item.type === "session"), + params.id, + server.key, + ), + params.id, + )} + /> + + ) +} diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index 444cb37f8a..49a0d5961a 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -15,7 +15,6 @@ import { useLayout } from "@/context/layout" import { useFile } from "@/context/file" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" -import { useServer } from "@/context/server" import { useSettings } from "@/context/settings" import { useSessionLayout } from "@/pages/session/session-layout" import { createSessionTabs } from "@/pages/session/helpers" @@ -272,7 +271,6 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil const command = useCommand() const language = useLanguage() const platform = usePlatform() - const server = useServer() const settings = useSettings() const layout = useLayout() const file = useFile() @@ -393,10 +391,10 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil state.cleanup?.() }) - if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns() && server.current) { + if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns()) { return ( global.servers.health[server.key]?.healthy + const serverHealth = () => global.servers.health[ServerConnection.key(server().server)]?.healthy const ready = createMemo(() => serverHealth() === false || sync().data.mcp_ready) const mcpIssue = createMemo(() => { const mcp = Object.values(sync().data.mcp ?? {}) diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index f69cace84a..8998fc06c8 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -10,7 +10,7 @@ import { matchKeybind, parseKeybind } from "@/context/command" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { useSDK } from "@/context/sdk" -import { useServer } from "@/context/server" +import { useServerSDK } from "@/context/server-sdk" import { terminalFontFamily, useSettings } from "@/context/settings" import type { LocalPTY } from "@/context/terminal" import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters" @@ -160,13 +160,15 @@ export const Terminal = (props: TerminalProps) => { const settings = useSettings() const theme = useTheme() const language = useLanguage() - const server = useServer() + // Terminal captures its connection for the PTY lifetime, so callers must key it per server/session. + const connection = useServerSDK()().server const directory = sdk().directory const client = sdk().client const url = sdk().url - const auth = server.current?.http + const auth = connection.http const username = auth?.username ?? "opencode" const password = auth?.password ?? "" + const authToken = connection.type === "http" ? connection.authToken : false const sameOrigin = new URL(url, location.href).origin === location.origin let container!: HTMLDivElement const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"]) @@ -540,7 +542,7 @@ export const Terminal = (props: TerminalProps) => { sameOrigin, username, password, - authToken: server.current?.type === "http" ? server.current.authToken : false, + authToken, }), ) socket.binaryType = "arraybuffer" diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 56427ae0eb..3e06787cea 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -319,6 +319,12 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { return } + const activeTab = currentTab() + if (activeTab?.type === "draft") { + tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "") + return + } + const current = layout.projects.list()[0] if (current) { tabs.newDraft({ server: server.key, directory: current.worktree }, "") diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 44d2787051..eb97b400c8 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -17,6 +17,7 @@ import type { ProjectAvatarVariant } from "@opencode-ai/ui/v2/project-avatar-v2" import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/utils/server-scope" import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers" import { requireServerKey } from "@/utils/session-route" +import { type DraftTab, useTabs } from "./tabs" export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } @@ -159,12 +160,17 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( const serverSdk = useServerSDK() const serverSync = useServerSync() const server = useServer() + const tabs = useTabs() const platform = usePlatform() const location = useLocation() const route = createMemo(() => { const value = currentRoute(location.pathname, location.search) if (value.type === "home") return value if (value.server) return value + if (value.type === "draft") { + const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === value.draftID) + if (draft) return { ...value, server: draft.server } + } return { ...value, server: server.key } }) diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index 104d4f839a..014352506a 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -9,7 +9,7 @@ import { useServerSDK } from "./server-sdk" import type { ServerScope } from "@/utils/server-scope" import { useSDK } from "./sdk" import { useTabs, type Tab } from "./tabs" -import { ServerConnection, useServer } from "./server" +import { ServerConnection } from "./server" import { requireServerKey } from "@/utils/session-route" import { useSettings } from "./settings" @@ -287,7 +287,6 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( const sdk = useSDK() const [search] = useSearchParams<{ draftId?: string }>() const serverSDK = useServerSDK() - const server = useServer() const tabs = useTabs() const settings = useSettings() const cache = new Map() @@ -312,7 +311,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( } const owner = getOwner() - const serverKey = () => (params.serverKey ? requireServerKey(params.serverKey) : server.key) + const serverKey = () => + params.serverKey ? requireServerKey(params.serverKey) : ServerConnection.key(serverSDK().server) const scope = () => search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id } const load = (scope: Scope) => { diff --git a/packages/app/src/context/server-sdk.tsx b/packages/app/src/context/server-sdk.tsx index 2f63d3482a..903c0697a1 100644 --- a/packages/app/src/context/server-sdk.tsx +++ b/packages/app/src/context/server-sdk.tsx @@ -269,6 +269,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS }) return { + server, scope, url: server.http.url, client: sdk, diff --git a/packages/app/src/pages/session/composer/session-composer-controls.ts b/packages/app/src/pages/session/composer/session-composer-controls.ts index 7ae8fa0c85..7aa12f4676 100644 --- a/packages/app/src/pages/session/composer/session-composer-controls.ts +++ b/packages/app/src/pages/session/composer/session-composer-controls.ts @@ -8,11 +8,12 @@ import { useGlobal } from "@/context/global" import { useLayout } from "@/context/layout" import { useLocal } from "@/context/local" import type { QueryOptionsApi } from "@/context/server-sync" +import { useServerSDK } from "@/context/server-sdk" import { serverName, ServerConnection, useServer } from "@/context/server" import { useSDK } from "@/context/sdk" import { useSettings } from "@/context/settings" import { useSync } from "@/context/sync" -import { type DraftTab, useTabs } from "@/context/tabs" +import { useTabs } from "@/context/tabs" import { useProviders } from "@/hooks/use-providers" import { pathKey } from "@/utils/path-key" @@ -27,6 +28,7 @@ export function createSessionComposerControls(input: { const providers = useProviders() const settings = useSettings() const server = useServer() + const serverSDK = useServerSDK() const sync = useSync() const sdk = useSDK() const tabs = useTabs() @@ -35,23 +37,11 @@ export function createSessionComposerControls(input: { const [search] = useSearchParams<{ draftId?: string }>() const view = layout.view(input.sessionKey) - const draft = createMemo(() => { - if (!search.draftId) return - return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId) - }) - const projectServer = createMemo(() => { - if (!search.draftId) return server.current - const target = draft()?.server - if (!target) return - return server.list.find((conn) => ServerConnection.key(conn) === target) - }) - const projectServerCtx = createMemo(() => { - const conn = projectServer() - if (conn) return global.ensureServerCtx(conn) - }) + const projectServer = () => serverSDK().server + const projectServerCtx = createMemo(() => global.ensureServerCtx(projectServer())) const projects = createMemo(() => { if (server.list.length <= 1) { - return search.draftId ? (projectServerCtx()?.projects.list() ?? []) : layout.projects.list() + return search.draftId ? projectServerCtx().projects.list() : layout.projects.list() } return server.list.flatMap((conn) => { const server = { key: ServerConnection.key(conn), name: serverName(conn) } @@ -121,7 +111,7 @@ export function createSessionComposerControls(input: { projects: { available: projects(), directory: sdk().directory, - server: server.list.length > 1 && projectServer() ? ServerConnection.key(projectServer()!) : undefined, + server: server.list.length > 1 ? ServerConnection.key(projectServer()) : undefined, select: selectProject, add: addProject, }, From fae100fe72af02d0dc44fcadd28327f8f7a25c59 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Fri, 26 Jun 2026 04:05:36 +0800 Subject: [PATCH 16/26] fix(app): enable composer shortcuts on drafts (#33956) --- packages/app/src/pages/new-session.tsx | 3 + packages/app/src/pages/session.tsx | 2 + .../pages/session/use-composer-commands.tsx | 69 +++++++++++++++++++ .../pages/session/use-session-commands.tsx | 49 ------------- 4 files changed, 74 insertions(+), 49 deletions(-) create mode 100644 packages/app/src/pages/session/use-composer-commands.tsx diff --git a/packages/app/src/pages/new-session.tsx b/packages/app/src/pages/new-session.tsx index 68d87570e0..21dc254a13 100644 --- a/packages/app/src/pages/new-session.tsx +++ b/packages/app/src/pages/new-session.tsx @@ -13,6 +13,7 @@ import { SessionComposerRegion, } from "@/pages/session/composer" import { useSessionKey } from "@/pages/session/session-layout" +import { useComposerCommands } from "@/pages/session/use-composer-commands" /** * The `/new-session` draft page. Unlike `session.tsx`, this only renders the prompt @@ -28,6 +29,8 @@ export default function NewSessionPage() { const route = useSessionKey() const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>() + useComposerCommands() + let inputRef: HTMLDivElement | undefined const composer = createSessionComposerState() diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 898977e390..d920d8efa4 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -63,6 +63,7 @@ import { useSessionLayout } from "@/pages/session/session-layout" import { syncSessionModel } from "@/pages/session/session-model-helpers" import { SessionSidePanel } from "@/pages/session/session-side-panel" import { TerminalPanel } from "@/pages/session/terminal-panel" +import { useComposerCommands } from "@/pages/session/use-composer-commands" import { useSessionCommands } from "@/pages/session/use-session-commands" import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll" import { Identifier } from "@/utils/id" @@ -783,6 +784,7 @@ export default function Page() { inputRef?.focus() } + useComposerCommands() useSessionCommands({ navigateMessageByOffset, setActiveMessage, diff --git a/packages/app/src/pages/session/use-composer-commands.tsx b/packages/app/src/pages/session/use-composer-commands.tsx new file mode 100644 index 0000000000..9079a41300 --- /dev/null +++ b/packages/app/src/pages/session/use-composer-commands.tsx @@ -0,0 +1,69 @@ +import { useCommand, type CommandOption } from "@/context/command" +import { useLanguage } from "@/context/language" +import { useLocal } from "@/context/local" +import { useSettings } from "@/context/settings" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useSessionLayout } from "./session-layout" +import { createSessionOwnership } from "./session-ownership" + +const withCategory = (category: string) => { + return (option: Omit): CommandOption => ({ + ...option, + category, + }) +} + +export const useComposerCommands = () => { + const command = useCommand() + const dialog = useDialog() + const language = useLanguage() + const local = useLocal() + const settings = useSettings() + const { sessionKey } = useSessionLayout() + const sessionOwnership = createSessionOwnership(sessionKey) + const modelCommand = withCategory(language.t("command.category.model")) + const agentCommand = withCategory(language.t("command.category.agent")) + + const chooseModel = async () => { + const owner = sessionOwnership.capture() + const { DialogSelectModel } = await import("@/components/dialog-select-model") + owner.run(() => { + void dialog.show(() => ) + }) + } + + command.register("composer", () => [ + modelCommand({ + id: "model.choose", + title: language.t("command.model.choose"), + description: language.t("command.model.choose.description"), + keybind: "mod+'", + slash: "model", + onSelect: chooseModel, + }), + modelCommand({ + id: "model.variant.cycle", + title: language.t("command.model.variant.cycle"), + description: language.t("command.model.variant.cycle.description"), + keybind: "shift+mod+d", + onSelect: () => local.model.variant.cycle(), + }), + agentCommand({ + id: "agent.cycle", + title: language.t("command.agent.cycle"), + description: language.t("command.agent.cycle.description"), + keybind: "mod+.", + slash: "agent", + disabled: !settings.visibility.customAgents(), + onSelect: () => local.agent.move(1), + }), + agentCommand({ + id: "agent.cycle.reverse", + title: language.t("command.agent.cycle.reverse"), + description: language.t("command.agent.cycle.reverse.description"), + keybind: "shift+mod+.", + disabled: !settings.visibility.customAgents(), + onSelect: () => local.agent.move(-1), + }), + ]) +} diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index f302643284..fd2e29d31c 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -136,9 +136,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const contextCommand = withCategory(language.t("command.category.context")) const viewCommand = withCategory(language.t("command.category.view")) const terminalCommand = withCategory(language.t("command.category.terminal")) - const modelCommand = withCategory(language.t("command.category.model")) const mcpCommand = withCategory(language.t("command.category.mcp")) - const agentCommand = withCategory(language.t("command.category.agent")) const permissionsCommand = withCategory(language.t("command.category.permissions")) const isAutoAcceptActive = () => { @@ -271,13 +269,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => { view().terminal.open() } - const chooseModel = () => { - void openDialog( - () => import("@/components/dialog-select-model"), - (x) => dialog.show(() => ), - ) - } - const chooseMcp = () => { void openDialog( () => import("@/components/dialog-select-mcp"), @@ -555,24 +546,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => { }), ] - const modelCmds = () => [ - modelCommand({ - id: "model.choose", - title: language.t("command.model.choose"), - description: language.t("command.model.choose.description"), - keybind: "mod+'", - slash: "model", - onSelect: chooseModel, - }), - modelCommand({ - id: "model.variant.cycle", - title: language.t("command.model.variant.cycle"), - description: language.t("command.model.variant.cycle.description"), - keybind: "shift+mod+d", - onSelect: () => local.model.variant.cycle(), - }), - ] - const mcpCmds = () => [ mcpCommand({ id: "mcp.toggle", @@ -584,26 +557,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => { }), ] - const agentCmds = () => [ - agentCommand({ - id: "agent.cycle", - title: language.t("command.agent.cycle"), - description: language.t("command.agent.cycle.description"), - keybind: "mod+.", - slash: "agent", - disabled: !settings.visibility.customAgents(), - onSelect: () => local.agent.move(1), - }), - agentCommand({ - id: "agent.cycle.reverse", - title: language.t("command.agent.cycle.reverse"), - description: language.t("command.agent.cycle.reverse.description"), - keybind: "shift+mod+.", - disabled: !settings.visibility.customAgents(), - onSelect: () => local.agent.move(-1), - }), - ] - const permissionsCmds = () => [ permissionsCommand({ id: "permissions.autoaccept", @@ -624,9 +577,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { ...viewCmds(), ...terminalCmds(), ...messageCmds(), - ...modelCmds(), ...mcpCmds(), - ...agentCmds(), ...permissionsCmds(), ]) } From a2b847e29eec6c0eb42bc9930c27b2ef28fecab4 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Fri, 26 Jun 2026 04:23:58 +0800 Subject: [PATCH 17/26] refactor(app): split session composer (#33954) --- .../src/components/prompt-input.stories.tsx | 52 +-- packages/app/src/components/prompt-input.tsx | 333 +----------------- .../components/prompt-project-selector.tsx | 278 +++++++++++++++ packages/app/src/pages/new-session.tsx | 86 +++-- packages/app/src/pages/session.tsx | 130 +++---- .../app/src/pages/session/composer/index.ts | 5 +- .../composer/session-composer-controls.ts | 81 +++-- .../session-composer-region-controller.ts | 145 ++++++++ .../composer/session-composer-region.tsx | 228 +++--------- .../composer/session-composer-state.ts | 4 +- .../composer/todo-panel-motion.stories.tsx | 69 ++-- 11 files changed, 710 insertions(+), 701 deletions(-) create mode 100644 packages/app/src/components/prompt-project-selector.tsx create mode 100644 packages/app/src/pages/session/composer/session-composer-region-controller.ts diff --git a/packages/app/src/components/prompt-input.stories.tsx b/packages/app/src/components/prompt-input.stories.tsx index fcdd544ec0..4d71ed6dc7 100644 --- a/packages/app/src/components/prompt-input.stories.tsx +++ b/packages/app/src/components/prompt-input.stories.tsx @@ -2,7 +2,7 @@ import { createStore } from "solid-js/store" import type { Todo } from "@opencode-ai/sdk/v2" import { createPromptState } from "@/context/prompt" -import { SessionComposerRegion } from "@/pages/session/composer" +import { SessionComposerRegion, createSessionComposerRegionController } from "@/pages/session/composer" import { createPromptInputHistory, PromptInput } from "./prompt-input" function createPromptInputStoryRuntime() { @@ -54,12 +54,6 @@ function PromptInputExample() { paid: true, loading: false, }, - projects: { - available: [{ name: "Story project", worktree: "/tmp/story", sandboxes: [] }], - directory: "/tmp/story", - select() {}, - add() {}, - }, session: { id: "story-session", tabs: { @@ -142,7 +136,6 @@ function PromptInputWithOpenDock() { paid: true, loading: false, }, - projects: { available: [], directory: "/tmp/story", select: () => {}, add: () => {} }, session: { id: "story-session", tabs: { @@ -166,22 +159,37 @@ function PromptInputWithOpenDock() { closing: () => false, opening: () => false, } - return ( {}, newSessionWorktree: "", onNewSessionWorktreeReset: () => {} }} - todo={{ - collapsed: controls.todoCollapsed, - onToggle: () => setControls("todoCollapsed", (collapsed) => !collapsed), - }} - ready - centered={false} - onResponseSubmit={() => {}} - setPromptDockRef={() => {}} + controller={ + createSessionComposerRegionController({ + state, + sessionKey: () => "story-session", + sessionID: () => "story-session", + prompt: input.state, + ready: () => true, + centered: () => false, + todo: { + collapsed: () => controls.todoCollapsed, + onToggle: () => setControls("todoCollapsed", (collapsed) => !collapsed), + }, + followup: () => undefined, + revert: () => undefined, + onResponseSubmit: () => {}, + openParent: () => {}, + setPromptRef: () => {}, + setDockRef: () => {}, + }) + } + promptInput={ + {}} + newSessionWorktree="" + onNewSessionWorktreeReset={() => {}} + /> + } /> ) } diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index a760d34029..f9a99aebb6 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -4,8 +4,6 @@ import { createEffect, on, Component, - splitProps, - For, Show, onCleanup, createMemo, @@ -13,10 +11,8 @@ import { createResource, Switch, Match, - type ComponentProps, type JSX, } from "solid-js" -import { Popover as KobaltePopover } from "@kobalte/core/popover" import { createStore, type SetStoreFunction, type Store } from "solid-js/store" import type { useLocal } from "@/context/local" import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file" @@ -30,15 +26,14 @@ import { AgentPart, FileAttachmentPart, } from "@/context/prompt" -import { getProjectAvatarVariant, useLayout } from "@/context/layout" +import { useLayout } from "@/context/layout" import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" import { useComments } from "@/context/comments" import { Button } from "@opencode-ai/ui/button" import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface" -import { Icon, type IconProps } from "@opencode-ai/ui/icon" +import { Icon } from "@opencode-ai/ui/icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" -import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { IconButton } from "@opencode-ai/ui/icon-button" import { Select } from "@opencode-ai/ui/select" @@ -71,8 +66,6 @@ import { promptPlaceholder } from "./prompt-input/placeholder" import { createPromptInputTransientState } from "./prompt-input/transient-state" import { showToast } from "@/utils/toast" import { ImagePreview } from "@opencode-ai/ui/image-preview" -import { pathKey } from "@/utils/path-key" -import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers" export type PromptInputState = ReturnType @@ -100,20 +93,6 @@ export type PromptInputControls = { paid: boolean loading: boolean } - projects: { - available: { - name?: string - id?: string - worktree: string - sandboxes?: string[] - icon?: { color?: string; url?: string; override?: string } - server?: { key: string; name: string } - }[] - directory: string - server?: string - select: (worktree: string, server?: string) => void - add: (title: string, server?: string) => void - } session: { id?: string tabs: { @@ -184,6 +163,7 @@ export interface PromptInputProps { onQueue?: (draft: FollowupDraft) => void onAbort?: () => void onSubmit?: () => void + toolbar?: JSX.Element } const EXAMPLES = [ @@ -232,7 +212,6 @@ export const PromptInput: Component = (props) => { let fileInputRef: HTMLInputElement | undefined let scrollRef!: HTMLDivElement let slashPopoverRef!: HTMLDivElement - let projectSearchRef: HTMLInputElement | undefined const mirror = { input: false } const inset = 56 @@ -360,11 +339,6 @@ export const PromptInput: Component = (props) => { () => prompt.capture(), Math.floor(Math.random() * EXAMPLES.length), ) - const [picker, setPicker] = createStore({ - projectOpen: false, - projectSearch: "", - }) - const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 }) const motion = (value: number) => ({ opacity: value, @@ -1401,137 +1375,7 @@ export const PromptInput: Component = (props) => { })) const newSession = () => props.variant === "new-session" - const projects = createMemo(() => props.controls.projects.available) - const projectForDirectory = (directory: string | undefined) => { - if (!directory) return - const key = pathKey(directory) - return projects().find( - (project) => - (!project.server || project.server.key === props.controls.projects.server) && - (pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key)), - ) - } - const selectedProject = createMemo(() => projectForDirectory(props.controls.projects.directory)) - const projectResults = createMemo(() => { - const search = picker.projectSearch.trim().toLowerCase() - if (!search) return projects() - return projects().filter((project) => displayName(project).toLowerCase().includes(search)) - }) const showAgentControl = createMemo(() => props.controls.agents.visible && props.controls.agents.options.length > 0) - const selectProject = (worktree: string, server?: string) => { - setPicker({ - projectOpen: false, - projectSearch: "", - }) - if (pathKey(worktree) === pathKey(selectedProject()?.worktree ?? "") && server === selectedProject()?.server?.key) { - restoreFocus() - return - } - props.controls.projects.select(worktree, server) - restoreFocus() - } - const addProject = (server?: string) => { - props.controls.projects.add(language.t("command.project.open"), server) - } - - const projectItems = createMemo(() => - projectResults().map((project) => ({ - leading: ( - - ), - label: displayName(project), - selected: - selectedProject()?.worktree === project.worktree && selectedProject()?.server?.key === project.server?.key, - onSelect: () => selectProject(project.worktree, project.server?.key), - })), - ) - const projectServers = createMemo(() => - projects() - .map((project) => project.server) - .filter((server, index, all) => server && all.findIndex((item) => item?.key === server.key) === index), - ) - const addProjectLabel = createMemo(() => language.t("session.new.project.add")) - const projectGroups = createMemo(() => { - if (projectServers().length <= 1) return - return projectServers() - .map((server) => ({ - label: server!.name, - server: server!.key, - items: projectItems().filter((_, index) => projectResults()[index]?.server?.key === server!.key), - action: { - icon: "plus" as const, - label: addProjectLabel(), - onSelect: () => { - setPicker("projectOpen", false) - addProject(server!.key) - }, - }, - })) - .filter((group) => group.items.length > 0) - }) - const projectPickerState: ComposerPickerState = { - get open() { - return picker.projectOpen - }, - trigger: { - action: "prompt-project", - get icon() { - return selectedProject() ? undefined : "folder" - }, - get leading() { - const project = selectedProject() - if (!project) return - return ( - - ) - }, - get label() { - const project = selectedProject() - return project ? displayName(project) : language.t("session.new.project.new") - }, - class: "max-w-[203px]", - get style() { - return control() - }, - onPress: () => setPicker("projectOpen", true), - }, - get search() { - return picker.projectSearch - }, - searchPlaceholder: language.t("session.new.project.search"), - clearLabel: language.t("common.clear"), - get items() { - return projectItems() - }, - get groups() { - return projectGroups() - }, - action: { - icon: "plus", - get label() { - return addProjectLabel() - }, - onSelect: () => { - setPicker("projectOpen", false) - addProject(projectServers()[0]?.key) - }, - }, - onOpenChange: (open) => { - setPicker("projectOpen", open) - if (open) requestAnimationFrame(() => projectSearchRef?.focus()) - }, - onSearchInput: (value) => setPicker("projectSearch", value), - onSearchClear: () => setPicker("projectSearch", ""), - searchRef: (el) => (projectSearchRef = el), - } const agentControlState = createMemo(() => ({ title: language.t("command.agent.cycle"), keybind: command.keybind("agent.cycle"), @@ -1543,15 +1387,6 @@ export const PromptInput: Component = (props) => { restoreFocus() }, })) - const newProjectTriggerState = createMemo(() => ({ - action: "prompt-project", - icon: "folder-add-left", - label: language.t("session.new.project.new"), - class: "max-w-[160px]", - style: control(), - onPress: () => void addProject(), - })) - return (
{(promptReady(), null)} @@ -1682,9 +1517,7 @@ export const PromptInput: Component = (props) => { - - - + {props.toolbar}
= (props) => {
- -
- -
-
@@ -2084,40 +1912,6 @@ export const PromptInput: Component = (props) => { ) } -type ComposerPickerItemState = { - icon?: IconProps["name"] - leading?: JSX.Element - label: string - selected?: boolean - onSelect: () => void -} - -type ComposerPickerTriggerState = { - action: string - icon?: IconProps["name"] - leading?: JSX.Element - label: string - class?: string - style: JSX.CSSProperties | undefined - onPress: () => void -} - -type ComposerPickerState = { - open: boolean - trigger: ComposerPickerTriggerState - search: string - searchPlaceholder: string - clearLabel: string - items: ComposerPickerItemState[] - groups?: { label: string; server: string; items: ComposerPickerItemState[]; action: ComposerPickerItemState }[] - action: ComposerPickerItemState - listClass?: string - searchRef: (el: HTMLInputElement) => void - onOpenChange: (open: boolean) => void - onSearchInput: (value: string) => void - onSearchClear: () => void -} - type ComposerAgentControlState = { title: string keybind: string @@ -2140,125 +1934,6 @@ type ComposerModelControlState = { onUnpaidClick: () => void } -function ComposerPickerTrigger(props: ComponentProps<"button"> & { state: ComposerPickerTriggerState }) { - const [local, rest] = splitProps(props, ["state", "class", "style", "onClick"]) - return ( - - ) -} - -function ComposerPickerMenuItem(props: { state: ComposerPickerItemState }) { - return ( - - ) -} - -function ComposerPicker(props: { state: ComposerPickerState }) { - return ( - - - - event.preventDefault()} - > -
-
- - props.state.onSearchInput(event.currentTarget.value)} - /> - - - -
- {(item) => }} - > - {(groups) => ( - - {(group) => ( -
-
- {group.label} -
- {(item) => } - -
- )} -
- )} -
-
- -
-
- -
- - - - - ) -} - function ComposerAgentControl(props: { state: ComposerAgentControlState }) { return (
diff --git a/packages/app/src/components/prompt-project-selector.tsx b/packages/app/src/components/prompt-project-selector.tsx new file mode 100644 index 0000000000..ed1923d3c1 --- /dev/null +++ b/packages/app/src/components/prompt-project-selector.tsx @@ -0,0 +1,278 @@ +import { Popover } from "@kobalte/core/popover" +import { For, Show, splitProps, type Accessor, type ComponentProps } from "solid-js" +import { createStore } from "solid-js/store" +import { Icon } from "@opencode-ai/ui/icon" +import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2" +import { getProjectAvatarVariant } from "@/context/layout" +import { useLanguage } from "@/context/language" +import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers" +import { pathKey } from "@/utils/path-key" + +export type PromptProject = { + name?: string + id?: string + worktree: string + sandboxes?: string[] + icon?: { color?: string; url?: string; override?: string } + server?: { key: string; name: string } +} + +export type PromptProjectControls = { + available: PromptProject[] + directory: string + server?: string + select: (worktree: string, server?: string) => void + add: (title: string, server?: string) => void +} + +export function createPromptProjectController(input: { + controls: Accessor + onDone: () => void +}) { + const language = useLanguage() + const [store, setStore] = createStore({ open: false, search: "" }) + let searchRef: HTMLInputElement | undefined + + const selected = () => { + const key = pathKey(input.controls().directory) + return input + .controls() + .available.find( + (project) => + (!project.server || project.server.key === input.controls().server) && + (pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key)), + ) + } + const projects = () => { + const search = store.search.trim().toLowerCase() + if (!search) return input.controls().available + return input.controls().available.filter((project) => displayName(project).toLowerCase().includes(search)) + } + const servers = () => + input + .controls() + .available.map((project) => project.server) + .filter((server, index, all) => server && all.findIndex((item) => item?.key === server.key) === index) + const close = () => { + setStore({ open: false, search: "" }) + input.onDone() + } + const select = (project: PromptProject) => { + if ( + pathKey(project.worktree) !== pathKey(selected()?.worktree ?? "") || + project.server?.key !== selected()?.server?.key + ) { + input.controls().select(project.worktree, project.server?.key) + } + close() + } + const add = (server?: string) => { + setStore("open", false) + input.controls().add(language.t("command.project.open"), server) + } + + return { + selected, + projects, + servers, + open: () => store.open, + search: () => store.search, + labels: { + add: () => language.t("session.new.project.add"), + clear: () => language.t("common.clear"), + new: () => language.t("session.new.project.new"), + search: () => language.t("session.new.project.search"), + }, + add, + select, + setOpen(open: boolean) { + setStore("open", open) + if (open) requestAnimationFrame(() => searchRef?.focus()) + }, + setSearch(value: string) { + setStore("search", value) + }, + setSearchRef(el: HTMLInputElement) { + searchRef = el + }, + } +} + +export type PromptProjectController = ReturnType + +export function PromptProjectSelector(props: { controller: PromptProjectController }) { + return ( + props.controller.setOpen(open)} + > + + + event.preventDefault()} + > +
+
+ + props.controller.setSearchRef(el)} + value={props.controller.search()} + placeholder={props.controller.labels.search()} + class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint" + onInput={(event) => props.controller.setSearch(event.currentTarget.value)} + /> + + + +
+ 1} + fallback={ + + {(project) => ( + + )} + + } + > + + {(server) => ( +
+
+ {server!.name} +
+ project.server?.key === server!.key)}> + {(project) => ( + + )} + + props.controller.add(server!.key)} + /> +
+ )} +
+
+
+ +
+
+ props.controller.add(props.controller.servers()[0]?.key)} + /> +
+ + + + + ) +} + +export function PromptProjectAddButton(props: { controller: PromptProjectController }) { + return ( + + ) +} + +function ProjectTrigger(props: ComponentProps<"button"> & { controller: PromptProjectController }) { + const [local, rest] = splitProps(props, ["controller", "class", "onClick"]) + const project = () => local.controller.selected() + return ( + + ) +} + +function ProjectItem(props: { + project: PromptProject + selected?: PromptProject + onSelect: (project: PromptProject) => void +}) { + return ( + + ) +} + +function ProjectAction(props: { label: string; onSelect: () => void }) { + return ( + + ) +} diff --git a/packages/app/src/pages/new-session.tsx b/packages/app/src/pages/new-session.tsx index 21dc254a13..fb07221b69 100644 --- a/packages/app/src/pages/new-session.tsx +++ b/packages/app/src/pages/new-session.tsx @@ -1,19 +1,23 @@ -import { createEffect, createMemo, onMount, untrack } from "solid-js" +import { Show, createEffect, createMemo, createResource, untrack } from "solid-js" import { createStore } from "solid-js/store" import { useSearchParams } from "@solidjs/router" import { NewSessionDesignView } from "@/components/session" +import { PromptInput } from "@/components/prompt-input" +import { + PromptProjectAddButton, + PromptProjectSelector, + createPromptProjectController, +} from "@/components/prompt-project-selector" import { useComments } from "@/context/comments" import { usePrompt } from "@/context/prompt" import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" import { useServerSync } from "@/context/server-sync" -import { - createSessionComposerControls, - createSessionComposerState, - SessionComposerRegion, -} from "@/pages/session/composer" +import { useLanguage } from "@/context/language" +import { createPromptInputController, createPromptProjectControls } from "@/pages/session/composer" import { useSessionKey } from "@/pages/session/session-layout" import { useComposerCommands } from "@/pages/session/use-composer-commands" +import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout" /** * The `/new-session` draft page. Unlike `session.tsx`, this only renders the prompt @@ -26,6 +30,7 @@ export default function NewSessionPage() { const sync = useSync() const serverSync = useServerSync() const comments = useComments() + const language = useLanguage() const route = useSessionKey() const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>() @@ -33,12 +38,16 @@ export default function NewSessionPage() { let inputRef: HTMLDivElement | undefined - const composer = createSessionComposerState() - const composerControls = createSessionComposerControls({ + const inputController = createPromptInputController({ sessionKey: route.sessionKey, sessionID: () => route.params.id, queryOptions: serverSync().queryOptions, }) + const projectControls = createPromptProjectControls() + const projectController = createPromptProjectController({ + controls: projectControls, + onDone: () => inputRef?.focus(), + }) const [store, setStore] = createStore({ worktree: "main", @@ -61,9 +70,15 @@ export default function NewSessionPage() { }) }) - onMount(() => { + createEffect(() => { + if (!prompt.ready()) return requestAnimationFrame(() => inputRef?.focus()) }) + const ready = Promise.resolve() + const [promptReady] = createResource( + () => prompt.ready.promise ?? ready, + (promise) => promise.then(() => true), + ) return (
@@ -71,26 +86,39 @@ export default function NewSessionPage() {
- { - inputRef = el - }, - newSessionWorktree: newSessionWorktree(), - onNewSessionWorktreeReset: () => setStore("worktree", "main"), - onSubmit: () => comments.clear(), - }} - todo={{ collapsed: false, onToggle: () => {} }} - ready - centered={false} - placement="inline" - onResponseSubmit={() => {}} - setPromptDockRef={() => {}} - /> +
+ + {language.t("prompt.loading")} +
+ } + > +
+ { + inputRef = el + }} + newSessionWorktree={newSessionWorktree()} + onNewSessionWorktreeReset={() => setStore("worktree", "main")} + onSubmit={() => comments.clear()} + toolbar={ + + + + } + /> + +
+ +
+
+
+ +
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index d920d8efa4..b1688f4b26 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -42,10 +42,12 @@ import { useServerSDK } from "@/context/server-sdk" import { useSettings } from "@/context/settings" import { useSync } from "@/context/sync" import { useTerminal } from "@/context/terminal" +import { PromptInput } from "@/components/prompt-input" import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit" import { - createSessionComposerControls, - createSessionComposerState, + createPromptInputController, + createSessionComposerController, + createSessionComposerRegionController, SessionComposerRegion, } from "@/pages/session/composer" import { @@ -156,8 +158,8 @@ export default function Page() { }, }) - const composer = createSessionComposerState() - const composerControls = createSessionComposerControls({ + const composer = createSessionComposerController() + const inputController = createPromptInputController({ sessionKey, sessionID: () => params.id, queryOptions: serverSync().queryOptions, @@ -1569,62 +1571,28 @@ export default function Page() { useUsageExceededDialogs() - const composerRegion = (placement: "dock" | "inline") => ( - { - inputRef = el - }, - newSessionWorktree: newSessionWorktree(), - onNewSessionWorktreeReset: () => setStore("newSessionWorktree", "main"), - onSubmit: () => { - comments.clear() - resumeScroll() - }, - }} - todo={{ - collapsed: view().todoCollapsed.get(), + const composerRegion = () => { + const controller = createSessionComposerRegionController({ + state: composer, + sessionKey, + sessionID: () => params.id, + prompt, + ready: () => !store.deferRender && messagesReady(), + centered, + todo: { + collapsed: () => view().todoCollapsed.get(), onToggle: () => view().todoCollapsed.set(!view().todoCollapsed.get()), - }} - ready={!store.deferRender && messagesReady()} - centered={placement === "dock" && centered()} - placement={placement} - openParent={() => { - const id = info()?.parentID - if (!id) return - navigate( - params.serverKey - ? sessionHref(requireServerKey(params.serverKey), id) - : legacySessionHref(sdk().directory, id), - ) - }} - onResponseSubmit={resumeScroll} - followup={ + }, + followup: () => params.id && !isChildSession() ? { - queue: queueEnabled, items: followupDock(), sending: sendingFollowup(), - edit: editingFollowup(), - onQueue: queueFollowup, - onAbort: () => { - const id = params.id - if (!id) return - setFollowup("paused", id, true) - }, - onSend: (id) => { - void sendFollowup(params.id!, id, { manual: true }) - }, + onSend: (id) => void sendFollowup(params.id!, id, { manual: true }), onEdit: editFollowup, - onEditLoaded: clearFollowupEdit, } - : undefined - } - revert={ + : undefined, + revert: () => rolled().length > 0 ? { items: rolled(), @@ -1632,13 +1600,53 @@ export default function Page() { disabled: reverting(), onRestore: restore, } - : undefined - } - setPromptDockRef={(el) => { + : undefined, + onResponseSubmit: resumeScroll, + openParent: () => { + const id = info()?.parentID + if (!id) return + navigate( + params.serverKey + ? sessionHref(requireServerKey(params.serverKey), id) + : legacySessionHref(sdk().directory, id), + ) + }, + setPromptRef: (el) => { + inputRef = el + }, + setDockRef: (el) => { promptDock = el - }} - /> - ) + }, + }) + return ( + { + inputRef = el + }} + newSessionWorktree={newSessionWorktree()} + onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")} + onSubmit={() => { + comments.clear() + resumeScroll() + }} + edit={editingFollowup()} + onEditLoaded={clearFollowupEdit} + shouldQueue={queueEnabled} + onQueue={queueFollowup} + onAbort={() => { + const id = params.id + if (!id) return + setFollowup("paused", id, true) + }} + /> + } + /> + ) + } const mobileTabs = (compact = false, bottom = false) => ( @@ -1775,7 +1783,9 @@ export default function Page() {
- {composerRegion("dock")} + + {(_) => composerRegion()} + {mobileTabs(true, true)}
diff --git a/packages/app/src/pages/session/composer/index.ts b/packages/app/src/pages/session/composer/index.ts index e6867461de..2c0b2271f5 100644 --- a/packages/app/src/pages/session/composer/index.ts +++ b/packages/app/src/pages/session/composer/index.ts @@ -1,3 +1,4 @@ export { SessionComposerRegion } from "./session-composer-region" -export { createSessionComposerControls } from "./session-composer-controls" -export { createSessionComposerState } from "./session-composer-state" +export { createPromptInputController, createPromptProjectControls } from "./session-composer-controls" +export { createSessionComposerController } from "./session-composer-state" +export { createSessionComposerRegionController } from "./session-composer-region-controller" diff --git a/packages/app/src/pages/session/composer/session-composer-controls.ts b/packages/app/src/pages/session/composer/session-composer-controls.ts index 7aa12f4676..c7f2304531 100644 --- a/packages/app/src/pages/session/composer/session-composer-controls.ts +++ b/packages/app/src/pages/session/composer/session-composer-controls.ts @@ -3,6 +3,7 @@ import { createQuery } from "@tanstack/solid-query" import { useNavigate, useSearchParams } from "@solidjs/router" import { type Accessor, createMemo } from "solid-js" import type { PromptInputControls } from "@/components/prompt-input" +import type { PromptProjectControls } from "@/components/prompt-project-selector" import { useDirectoryPicker } from "@/components/directory-picker" import { useGlobal } from "@/context/global" import { useLayout } from "@/context/layout" @@ -17,26 +18,55 @@ import { useTabs } from "@/context/tabs" import { useProviders } from "@/hooks/use-providers" import { pathKey } from "@/utils/path-key" -export function createSessionComposerControls(input: { +export function createPromptInputController(input: { sessionKey: Accessor sessionID: Accessor queryOptions: Pick }) { - const navigate = useNavigate() const layout = useLayout() const local = useLocal() const providers = useProviders() const settings = useSettings() + const sync = useSync() + const sdk = useSDK() + const view = layout.view(input.sessionKey) + const agentsQuery = createQuery(() => input.queryOptions.agents(pathKey(sdk().directory))) + const globalProvidersQuery = createQuery(() => input.queryOptions.providers(null)) + const providersQuery = createQuery(() => input.queryOptions.providers(pathKey(sdk().directory))) + + return createMemo(() => ({ + agents: { + available: sync().data.agent, + options: local.agent.list().map((agent) => agent.name), + current: local.agent.current()?.name ?? "", + loading: agentsQuery.isLoading, + visible: settings.visibility.customAgents(), + select: local.agent.set, + }, + model: { + selection: local.model, + paid: providers.paid().length > 0, + loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading, + }, + session: { + id: input.sessionID(), + tabs: layout.tabs(input.sessionKey), + reviewPanel: view.reviewPanel, + }, + newLayoutDesigns: settings.general.newLayoutDesigns(), + })) +} + +export function createPromptProjectControls() { + const navigate = useNavigate() + const layout = useLayout() const server = useServer() const serverSDK = useServerSDK() - const sync = useSync() const sdk = useSDK() const tabs = useTabs() const global = useGlobal() const pickDirectory = useDirectoryPicker() const [search] = useSearchParams<{ draftId?: string }>() - const view = layout.view(input.sessionKey) - const projectServer = () => serverSDK().server const projectServerCtx = createMemo(() => global.ensureServerCtx(projectServer())) const projects = createMemo(() => { @@ -44,17 +74,13 @@ export function createSessionComposerControls(input: { return search.draftId ? projectServerCtx().projects.list() : layout.projects.list() } return server.list.flatMap((conn) => { - const server = { key: ServerConnection.key(conn), name: serverName(conn) } + const item = { key: ServerConnection.key(conn), name: serverName(conn) } return global .ensureServerCtx(conn) .projects.list() - .map((project) => ({ ...project, server })) + .map((project) => ({ ...project, server: item })) }) }) - const agentsQuery = createQuery(() => input.queryOptions.agents(pathKey(sdk().directory))) - const globalProvidersQuery = createQuery(() => input.queryOptions.providers(null)) - const providersQuery = createQuery(() => input.queryOptions.providers(pathKey(sdk().directory))) - const selectProject = (worktree: string, serverKey?: string) => { const conn = serverKey ? server.list.find((conn) => ServerConnection.key(conn) === serverKey) : projectServer() if (search.draftId) { @@ -94,32 +120,11 @@ export function createSessionComposerControls(input: { }) } - return createMemo(() => ({ - agents: { - available: sync().data.agent, - options: local.agent.list().map((agent) => agent.name), - current: local.agent.current()?.name ?? "", - loading: agentsQuery.isLoading, - visible: settings.visibility.customAgents(), - select: local.agent.set, - }, - model: { - selection: local.model, - paid: providers.paid().length > 0, - loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading, - }, - projects: { - available: projects(), - directory: sdk().directory, - server: server.list.length > 1 ? ServerConnection.key(projectServer()) : undefined, - select: selectProject, - add: addProject, - }, - session: { - id: input.sessionID(), - tabs: layout.tabs(input.sessionKey), - reviewPanel: view.reviewPanel, - }, - newLayoutDesigns: settings.general.newLayoutDesigns(), + return createMemo(() => ({ + available: projects(), + directory: sdk().directory, + server: server.list.length > 1 ? ServerConnection.key(projectServer()) : undefined, + select: selectProject, + add: addProject, })) } diff --git a/packages/app/src/pages/session/composer/session-composer-region-controller.ts b/packages/app/src/pages/session/composer/session-composer-region-controller.ts new file mode 100644 index 0000000000..4073f1c191 --- /dev/null +++ b/packages/app/src/pages/session/composer/session-composer-region-controller.ts @@ -0,0 +1,145 @@ +import { createResizeObserver } from "@solid-primitives/resize-observer" +import { useSpring } from "@opencode-ai/ui/motion-spring" +import { type Accessor, createEffect, createMemo, createResource, onCleanup } from "solid-js" +import { createStore } from "solid-js/store" +import type { PromptInputState } from "@/components/prompt-input" +import { useSync } from "@/context/sync" +import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff" +import type { SessionComposerController } from "./session-composer-state" + +export type SessionComposerFollowupDock = { + items: { id: string; text: string }[] + sending?: string + onSend: (id: string) => void + onEdit: (id: string) => void +} + +export type SessionComposerRevertDock = { + items: { id: string; text: string }[] + restoring?: string + disabled?: boolean + onRestore: (id: string) => void +} + +export function createSessionComposerRegionController(input: { + state: SessionComposerController + sessionKey: Accessor + sessionID: Accessor + prompt: PromptInputState + ready: Accessor + centered: Accessor + todo: { + collapsed: Accessor + onToggle: () => void + } + followup: Accessor + revert: Accessor + onResponseSubmit: () => void + openParent: () => void + setPromptRef: (el: HTMLDivElement) => void + setDockRef: (el: HTMLDivElement) => void +}) { + const sync = useSync() + const [store, setStore] = createStore({ + ready: input.ready() || input.state.dock(), + height: 320, + body: undefined as HTMLDivElement | undefined, + }) + let timer: number | undefined + let frame: number | undefined + + const clear = () => { + if (timer !== undefined) window.clearTimeout(timer) + if (frame !== undefined) cancelAnimationFrame(frame) + timer = undefined + frame = undefined + } + + createEffect(() => { + input.sessionKey() + const ready = input.ready() + const dock = input.state.dock() + + clear() + if (store.ready || (!ready && !dock)) return + if (dock) { + setStore("ready", true) + return + } + + frame = requestAnimationFrame(() => { + frame = undefined + timer = window.setTimeout(() => { + setStore("ready", true) + timer = undefined + }, 140) + }) + }) + + createEffect(() => { + if (!input.prompt.ready()) return + setSessionHandoff(input.sessionKey(), { + prompt: input.prompt + .current() + .map((part) => { + if (part.type === "file") return `[file:${part.path}]` + if (part.type === "agent") return `@${part.name}` + if (part.type === "image") return `[image:${part.filename}]` + return part.content + }) + .join("") + .trim(), + }) + }) + + createEffect(() => { + const el = store.body + if (!el) return + const update = () => setStore("height", el.getBoundingClientRect().height) + createResizeObserver(el, update) + update() + }) + + onCleanup(clear) + + const parentID = createMemo(() => { + const id = input.sessionID() + return id ? sync().session.get(id)?.parentID : undefined + }) + const open = createMemo(() => store.ready && input.state.dock() && !input.state.closing()) + const progress = useSpring( + () => (open() ? 1 : 0), + { visualDuration: 0.3, bounce: 0 }, + () => `${input.sessionKey()}\0${store.ready}`, + ) + const value = createMemo(() => Math.max(0, Math.min(1, progress()))) + const ready = Promise.resolve() + const [promptReady] = createResource( + () => input.prompt.ready.promise ?? ready, + (promise) => promise.then(() => true), + ) + + return { + state: input.state, + centered: input.centered, + todo: input.todo, + followup: input.followup, + revert: input.revert, + onResponseSubmit: input.onResponseSubmit, + openParent: input.openParent, + setPromptRef: input.setPromptRef, + setDockRef: input.setDockRef, + parentID, + child: () => !!parentID(), + showComposer: () => !input.state.blocked() || !!parentID(), + handoffPrompt: () => getSessionHandoff(input.sessionKey())?.prompt, + promptReady: () => input.prompt.ready() || promptReady(), + dock: () => (store.ready && input.state.dock()) || value() > 0.001, + dockProgress: value, + dockHeight: () => Math.max(78, store.height), + lift: () => (input.revert()?.items.length ? 18 : 36 * value()), + setDockBodyRef: (el: HTMLDivElement) => setStore("body", el), + } +} + +export type SessionComposerRegionController = ReturnType diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index 4c5ff8ae2b..184e0c6d59 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -1,216 +1,83 @@ -import { Show, createEffect, createMemo, createResource, onCleanup } from "solid-js" -import { createStore } from "solid-js/store" -import { useSpring } from "@opencode-ai/ui/motion-spring" -import { PromptInput, type PromptInputControls, type PromptInputProps } from "@/components/prompt-input" +import { Show, type JSX } from "solid-js" import { useLanguage } from "@/context/language" -import { usePrompt } from "@/context/prompt" -import { useSync } from "@/context/sync" -import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff" import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock" import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock" import { SessionFollowupDock } from "@/pages/session/composer/session-followup-dock" import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock" -import type { SessionComposerState } from "@/pages/session/composer/session-composer-state" import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock" -import type { FollowupDraft } from "@/components/prompt-input/submit" -import { createResizeObserver } from "@solid-primitives/resize-observer" -import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout" +import type { SessionComposerRegionController } from "./session-composer-region-controller" export function SessionComposerRegion(props: { - state: SessionComposerState - sessionKey: string - sessionID?: string - controls: PromptInputControls - promptInput: Omit - todo: { - collapsed: boolean - onToggle: () => void - } - ready: boolean - centered: boolean - placement?: "dock" | "inline" - openParent?: () => void - onResponseSubmit: () => void - followup?: { - queue: () => boolean - items: { id: string; text: string }[] - sending?: string - edit?: { id: string; prompt: FollowupDraft["prompt"]; context: FollowupDraft["context"] } - onQueue: (draft: FollowupDraft) => void - onAbort: () => void - onSend: (id: string) => void - onEdit: (id: string) => void - onEditLoaded: () => void - } - revert?: { - items: { id: string; text: string }[] - restoring?: string - disabled?: boolean - onRestore: (id: string) => void - } - setPromptDockRef: (el: HTMLDivElement) => void + controller: SessionComposerRegionController + promptInput: JSX.Element }) { - const prompt = props.promptInput.state ?? usePrompt() const language = useLanguage() - const sync = useSync() - - const handoffPrompt = createMemo(() => getSessionHandoff(props.sessionKey)?.prompt) - const info = createMemo(() => (props.sessionID ? sync().session.get(props.sessionID) : undefined)) - const parentID = createMemo(() => info()?.parentID) - const child = createMemo(() => !!parentID()) - const showComposer = createMemo(() => !props.state.blocked() || child()) - - const previewPrompt = () => - prompt - .current() - .map((part) => { - if (part.type === "file") return `[file:${part.path}]` - if (part.type === "agent") return `@${part.name}` - if (part.type === "image") return `[image:${part.filename}]` - return part.content - }) - .join("") - .trim() - - createEffect(() => { - if (!prompt.ready()) return - setSessionHandoff(props.sessionKey, { prompt: previewPrompt() }) - }) - - const [store, setStore] = createStore({ - ready: props.ready || props.state.dock(), - height: 320, - body: undefined as HTMLDivElement | undefined, - }) - let timer: number | undefined - let frame: number | undefined - - const clear = () => { - if (timer !== undefined) { - window.clearTimeout(timer) - timer = undefined - } - if (frame !== undefined) { - cancelAnimationFrame(frame) - frame = undefined - } + const controller = props.controller + const rolled = () => { + const revert = controller.revert() + return revert?.items.length ? revert : undefined } - createEffect(() => { - props.sessionKey - const ready = props.ready - const dock = props.state.dock() - const delay = 140 - - clear() - if (store.ready || (!ready && !dock)) return - if (dock) { - setStore("ready", true) - return - } - - frame = requestAnimationFrame(() => { - frame = undefined - timer = window.setTimeout(() => { - setStore("ready", true) - timer = undefined - }, delay) - }) - }) - - onCleanup(clear) - - const open = createMemo(() => store.ready && props.state.dock() && !props.state.closing()) - const progress = useSpring( - () => (open() ? 1 : 0), - { visualDuration: 0.3, bounce: 0 }, - () => `${props.sessionKey}\0${store.ready}`, - ) - const value = createMemo(() => Math.max(0, Math.min(1, progress()))) - const dock = createMemo(() => (store.ready && props.state.dock()) || value() > 0.001) - const rolled = createMemo(() => (props.revert?.items.length ? props.revert : undefined)) - const lift = createMemo(() => (rolled() ? 18 : 36 * value())) - const full = createMemo(() => Math.max(78, store.height)) - - createEffect(() => { - const el = store.body - if (!el) return - const update = () => setStore("height", el.getBoundingClientRect().height) - createResizeObserver(store.body, update) - update() - }) - - const ready = Promise.resolve() - const [promptReadyResource] = createResource( - () => prompt.ready.promise ?? ready, - (promise) => promise.then(() => true), - ) - return (
- + {(request) => (
- +
)}
- + {(request) => (
{ - props.onResponseSubmit() - props.state.decide(response) + controller.onResponseSubmit() + controller.state.decide(response) }} />
)}
- - + +
-
setStore("body", el)}> +
@@ -227,9 +94,9 @@ export function SessionComposerRegion(props: {
- {handoffPrompt() || language.t("prompt.loading")} + {controller.handoffPrompt() || language.t("prompt.loading")}
} @@ -238,7 +105,7 @@ export function SessionComposerRegion(props: { {(revert) => (
- + - + + {props.promptInput} } >
{language.t("session.child.promptDisabled")} - + diff --git a/packages/app/src/pages/session/composer/session-composer-state.ts b/packages/app/src/pages/session/composer/session-composer-state.ts index 67e5c82b3f..45f5e4cb26 100644 --- a/packages/app/src/pages/session/composer/session-composer-state.ts +++ b/packages/app/src/pages/session/composer/session-composer-state.ts @@ -25,7 +25,7 @@ export const todoDockAtBoundary = (state: ReturnType) => state const idle = { type: "idle" as const } -export function createSessionComposerState(options?: { closeMs?: number | (() => number) }) { +export function createSessionComposerController(options?: { closeMs?: number | (() => number) }) { const params = useParams() const sdk = useSDK() const sync = useSync() @@ -201,4 +201,4 @@ export function createSessionComposerState(options?: { closeMs?: number | (() => } } -export type SessionComposerState = ReturnType +export type SessionComposerController = ReturnType diff --git a/packages/app/src/pages/session/composer/todo-panel-motion.stories.tsx b/packages/app/src/pages/session/composer/todo-panel-motion.stories.tsx index 7ee47faa87..482c50fc8d 100644 --- a/packages/app/src/pages/session/composer/todo-panel-motion.stories.tsx +++ b/packages/app/src/pages/session/composer/todo-panel-motion.stories.tsx @@ -3,7 +3,13 @@ import { createEffect, createMemo, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import type { Todo } from "@opencode-ai/sdk/v2" import { useServerSync } from "@/context/global-sync" -import { SessionComposerRegion, createSessionComposerState } from "@/pages/session/composer" +import { PromptInput } from "@/components/prompt-input" +import { usePrompt } from "@/context/prompt" +import { + SessionComposerRegion, + createSessionComposerController, + createSessionComposerRegionController, +} from "@/pages/session/composer" export default { title: "UI/Todo Panel Motion", @@ -60,7 +66,6 @@ const controls = { paid: true, loading: false, }, - projects: { available: [], directory: "/tmp/story", select: () => {}, add: () => {} }, session: { id: "story-session", tabs: { active: () => undefined, all: () => [], open: () => {}, setActive: () => {} }, @@ -149,6 +154,7 @@ const css = ` export const Playground = { render: () => { const global = useServerSync() + const prompt = usePrompt() const [cfg, setCfg] = createStore({ open: true, collapsed: false, @@ -188,7 +194,7 @@ export const Playground = { const countMask = () => cfg.countMask const countMaskHeight = () => cfg.countMaskHeight const countWidthDuration = () => cfg.countWidthDuration - const state = createSessionComposerState({ closeMs: () => Math.round(dockCloseDuration() * 1000) }) + const state = createSessionComposerController({ closeMs: () => Math.round(dockCloseDuration() * 1000) }) let frame let scrollRef @@ -217,7 +223,6 @@ export const Playground = { const collapsed = () => cfg.collapsed const setCollapsed = (value: boolean) => setCfg("collapsed", value) - const openDock = () => { clear() setCfg("open", true) @@ -281,36 +286,32 @@ export const Playground = {
{}, handleSubmit: (event) => event.preventDefault() }, - ref: () => {}, - newSessionWorktree: "", - onNewSessionWorktreeReset: () => {}, - }} - todo={{ collapsed: collapsed(), onToggle: () => setCollapsed(!collapsed()) }} - ready - centered={false} - onResponseSubmit={pin} - setPromptDockRef={() => {}} - dockOpenVisualDuration={dockOpenDuration()} - dockOpenBounce={dockOpenBounce()} - dockCloseVisualDuration={dockCloseDuration()} - dockCloseBounce={dockCloseBounce()} - drawerExpandVisualDuration={drawerExpandDuration()} - drawerExpandBounce={drawerExpandBounce()} - drawerCollapseVisualDuration={drawerCollapseDuration()} - drawerCollapseBounce={drawerCollapseBounce()} - subtitleDuration={subtitleDuration()} - subtitleTravel={subtitleAuto() ? undefined : subtitleTravel()} - subtitleEdge={subtitleAuto() ? undefined : subtitleEdge()} - countDuration={countDuration()} - countMask={countMask()} - countMaskHeight={countMaskHeight()} - countWidthDuration={countWidthDuration()} + controller={ + createSessionComposerRegionController({ + state, + sessionKey: () => "story-session", + sessionID: () => "story-session", + prompt, + ready: () => true, + centered: () => false, + todo: { collapsed, onToggle: () => setCollapsed(!collapsed()) }, + followup: () => undefined, + revert: () => undefined, + onResponseSubmit: pin, + openParent: () => {}, + setPromptRef: () => {}, + setDockRef: () => {}, + }) + } + promptInput={ + {}, handleSubmit: (event) => event.preventDefault() }} + ref={() => {}} + newSessionWorktree="" + onNewSessionWorktreeReset={() => {}} + /> + } />
From 21c0e03a7072a7bc722aebc144076b41fcf199c2 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 25 Jun 2026 20:26:11 +0000 Subject: [PATCH 18/26] chore: generate --- .../src/components/prompt-input.stories.tsx | 38 +++++++++---------- packages/app/src/pages/session.tsx | 4 +- .../composer/session-composer-region.tsx | 6 +-- .../composer/todo-panel-motion.stories.tsx | 32 ++++++++-------- 4 files changed, 35 insertions(+), 45 deletions(-) diff --git a/packages/app/src/components/prompt-input.stories.tsx b/packages/app/src/components/prompt-input.stories.tsx index 4d71ed6dc7..362a741401 100644 --- a/packages/app/src/components/prompt-input.stories.tsx +++ b/packages/app/src/components/prompt-input.stories.tsx @@ -161,26 +161,24 @@ function PromptInputWithOpenDock() { } return ( "story-session", - sessionID: () => "story-session", - prompt: input.state, - ready: () => true, - centered: () => false, - todo: { - collapsed: () => controls.todoCollapsed, - onToggle: () => setControls("todoCollapsed", (collapsed) => !collapsed), - }, - followup: () => undefined, - revert: () => undefined, - onResponseSubmit: () => {}, - openParent: () => {}, - setPromptRef: () => {}, - setDockRef: () => {}, - }) - } + controller={createSessionComposerRegionController({ + state, + sessionKey: () => "story-session", + sessionID: () => "story-session", + prompt: input.state, + ready: () => true, + centered: () => false, + todo: { + collapsed: () => controls.todoCollapsed, + onToggle: () => setControls("todoCollapsed", (collapsed) => !collapsed), + }, + followup: () => undefined, + revert: () => undefined, + onResponseSubmit: () => {}, + openParent: () => {}, + setPromptRef: () => {}, + setDockRef: () => {}, + })} promptInput={
- - {(_) => composerRegion()} - + {(_) => composerRegion()} {mobileTabs(true, true)}
diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index 184e0c6d59..362dbdbcb7 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -135,11 +135,7 @@ export function SessionComposerRegion(props: {
- {props.promptInput} - - } + fallback={{props.promptInput}} >
"story-session", - sessionID: () => "story-session", - prompt, - ready: () => true, - centered: () => false, - todo: { collapsed, onToggle: () => setCollapsed(!collapsed()) }, - followup: () => undefined, - revert: () => undefined, - onResponseSubmit: pin, - openParent: () => {}, - setPromptRef: () => {}, - setDockRef: () => {}, - }) - } + controller={createSessionComposerRegionController({ + state, + sessionKey: () => "story-session", + sessionID: () => "story-session", + prompt, + ready: () => true, + centered: () => false, + todo: { collapsed, onToggle: () => setCollapsed(!collapsed()) }, + followup: () => undefined, + revert: () => undefined, + onResponseSubmit: pin, + openParent: () => {}, + setPromptRef: () => {}, + setDockRef: () => {}, + })} promptInput={ Date: Thu, 25 Jun 2026 16:26:00 -0500 Subject: [PATCH 19/26] fix(mcp): scope auth status to server URL (#33924) --- packages/opencode/src/cli/cmd/mcp.ts | 18 +++++------ packages/opencode/src/mcp/auth.ts | 9 ------ packages/opencode/src/mcp/index.ts | 11 +++++-- .../test/mcp/oauth-auto-connect.test.ts | 32 +++++++++++++++++++ 4 files changed, 49 insertions(+), 21 deletions(-) diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index b42651dc17..b24cae9aec 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -669,14 +669,20 @@ export const McpDebugCommand = effectCmd({ const config = yield* Config.Service.use((cfg) => cfg.get()) const mcp = yield* MCP.Service const auth = yield* McpAuth.Service + const serverConfig = config.mcp?.[args.name] + const authInfo = + serverConfig && isMcpRemote(serverConfig) && serverConfig.oauth !== false + ? yield* Effect.all({ + authStatus: mcp.getAuthStatus(args.name), + entry: auth.get(args.name), + }) + : undefined yield* Effect.promise(async () => { UI.empty() prompts.intro("MCP OAuth Debug") - const mcpServers = config.mcp ?? {} const serverName = args.name - const serverConfig = mcpServers[serverName] if (!serverConfig) { prompts.log.error(`MCP server not found: ${serverName}`) prompts.outro("Done") @@ -698,13 +704,7 @@ export const McpDebugCommand = effectCmd({ prompts.log.info(`Server: ${serverName}`) prompts.log.info(`URL: ${serverConfig.url}`) - // Check stored auth status — services already in hand, run inline. - const { authStatus, entry } = await Effect.runPromise( - Effect.all({ - authStatus: mcp.getAuthStatus(serverName), - entry: auth.get(serverName), - }), - ) + const { authStatus, entry } = authInfo! prompts.log.info(`Auth status: ${getAuthStatusIcon(authStatus)} ${getAuthStatusText(authStatus)}`) if (entry?.tokens) { diff --git a/packages/opencode/src/mcp/auth.ts b/packages/opencode/src/mcp/auth.ts index 3866d1564a..f877824e02 100644 --- a/packages/opencode/src/mcp/auth.ts +++ b/packages/opencode/src/mcp/auth.ts @@ -50,7 +50,6 @@ export interface Interface { readonly updateOAuthState: (mcpName: string, oauthState: string) => Effect.Effect readonly getOAuthState: (mcpName: string) => Effect.Effect readonly clearOAuthState: (mcpName: string) => Effect.Effect - readonly isTokenExpired: (mcpName: string) => Effect.Effect } export class Service extends Context.Service()("@opencode/McpAuth") {} @@ -142,13 +141,6 @@ export const layer = Layer.effect( return entry?.oauthState }) - const isTokenExpired = Effect.fn("McpAuth.isTokenExpired")(function* (mcpName: string) { - const entry = yield* get(mcpName) - if (!entry?.tokens) return null - if (!entry.tokens.expiresAt) return false - return entry.tokens.expiresAt < Date.now() / 1000 - }) - return Service.of({ all, get, @@ -162,7 +154,6 @@ export const layer = Layer.effect( updateOAuthState, getOAuthState, clearOAuthState, - isTokenExpired, }) }), ) diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 59f99aae38..023eab520e 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -963,10 +963,15 @@ export const layer = Layer.effect( }) const getAuthStatus = Effect.fn("MCP.getAuthStatus")(function* (mcpName: string) { - const entry = yield* auth.get(mcpName) + const runtimeConfig = (yield* InstanceState.has(state)) + ? (yield* InstanceState.get(state)).config[mcpName] + : undefined + const mcpConfig = runtimeConfig ?? (yield* cfgSvc.get()).mcp?.[mcpName] + if (!mcpConfig || !isMcpConfigured(mcpConfig) || mcpConfig.type !== "remote") return "not_authenticated" + const entry = yield* auth.getForUrl(mcpName, mcpConfig.url) if (!entry?.tokens) return "not_authenticated" - const expired = yield* auth.isTokenExpired(mcpName) - return expired ? "expired" : "authenticated" + if (entry.tokens.expiresAt && entry.tokens.expiresAt < Date.now() / 1000) return "expired" + return "authenticated" }) return Service.of({ diff --git a/packages/opencode/test/mcp/oauth-auto-connect.test.ts b/packages/opencode/test/mcp/oauth-auto-connect.test.ts index 480792b1cb..d04e6ae1c8 100644 --- a/packages/opencode/test/mcp/oauth-auto-connect.test.ts +++ b/packages/opencode/test/mcp/oauth-auto-connect.test.ts @@ -227,6 +227,38 @@ mcpTest.instance("state() returns existing state when one is saved", () => }), ) +mcpTest.instance( + "auth status only reports credentials stored for the configured server URL", + () => + Effect.gen(function* () { + const mcp = yield* MCP.Service + expect(transportCalls).toHaveLength(0) + yield* McpAuth.use.updateTokens( + "test-status-url", + { accessToken: "old-token" }, + "https://old.example.com/mcp", + ) + + expect(yield* mcp.getAuthStatus("test-status-url")).toBe("not_authenticated") + + yield* McpAuth.use.updateTokens( + "test-status-url", + { accessToken: "current-token" }, + "https://example.com/mcp", + ) + expect(yield* mcp.getAuthStatus("test-status-url")).toBe("authenticated") + + yield* McpAuth.use.updateTokens( + "test-status-url", + { accessToken: "expired-token", expiresAt: 1 }, + "https://example.com/mcp", + ) + expect(yield* mcp.getAuthStatus("test-status-url")).toBe("expired") + expect(transportCalls).toHaveLength(0) + }), + { config: config("test-status-url") }, +) + mcpTest.instance( "authenticate() stores a connected client when auth completes without redirect", () => From 0568855cc6fdae48b78e6ca195ae7e4345dc2882 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 25 Jun 2026 21:28:07 +0000 Subject: [PATCH 20/26] chore: generate --- .../opencode/test/mcp/oauth-auto-connect.test.ts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/packages/opencode/test/mcp/oauth-auto-connect.test.ts b/packages/opencode/test/mcp/oauth-auto-connect.test.ts index d04e6ae1c8..7a87b9a258 100644 --- a/packages/opencode/test/mcp/oauth-auto-connect.test.ts +++ b/packages/opencode/test/mcp/oauth-auto-connect.test.ts @@ -233,19 +233,11 @@ mcpTest.instance( Effect.gen(function* () { const mcp = yield* MCP.Service expect(transportCalls).toHaveLength(0) - yield* McpAuth.use.updateTokens( - "test-status-url", - { accessToken: "old-token" }, - "https://old.example.com/mcp", - ) + yield* McpAuth.use.updateTokens("test-status-url", { accessToken: "old-token" }, "https://old.example.com/mcp") expect(yield* mcp.getAuthStatus("test-status-url")).toBe("not_authenticated") - yield* McpAuth.use.updateTokens( - "test-status-url", - { accessToken: "current-token" }, - "https://example.com/mcp", - ) + yield* McpAuth.use.updateTokens("test-status-url", { accessToken: "current-token" }, "https://example.com/mcp") expect(yield* mcp.getAuthStatus("test-status-url")).toBe("authenticated") yield* McpAuth.use.updateTokens( From 5f61d21487fad090a7bf4da95ab8211032b7768d Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 25 Jun 2026 16:28:39 -0500 Subject: [PATCH 21/26] feat(llm): pass strict through tool definitions for Codex parity (#33392) --- packages/llm/src/protocols/openai-responses.ts | 2 ++ .../openai-responses/gpt-5-5-drives-a-tool-loop.json | 4 ++-- .../openai-responses/gpt-5-5-streams-tool-call.json | 2 +- .../openai-responses-gpt-5-5-image-tool-result.json | 2 +- packages/llm/test/provider/openai-responses.test.ts | 1 + packages/opencode/src/session/llm/request.ts | 10 ++++++++++ .../session/native-openai-oauth-tool-loop.json | 4 ++-- .../recordings/session/native-zen-tool-loop.json | 4 ++-- packages/opencode/test/provider/transform.test.ts | 9 ++++++++- packages/opencode/test/session/llm.test.ts | 2 ++ 10 files changed, 31 insertions(+), 9 deletions(-) diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index b8a955640e..be395b053f 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -258,6 +258,8 @@ const lowerTool = (tool: ToolDefinition): OpenAIResponsesTool => ({ name: tool.name, description: tool.description, parameters: ProviderShared.openAiToolInputSchema(tool.inputSchema), + // TODO: Read this from OpenAI-specific tool options so direct LLM callers can opt into strict schemas. + strict: false, }) const lowerToolChoice = (toolChoice: NonNullable) => diff --git a/packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-drives-a-tool-loop.json b/packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-drives-a-tool-loop.json index a3f2e014df..9d441205fd 100644 --- a/packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-drives-a-tool-loop.json +++ b/packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-drives-a-tool-loop.json @@ -22,7 +22,7 @@ "headers": { "content-type": "application/json" }, - "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"stream\":true,\"max_output_tokens\":80}" + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"stream\":true,\"max_output_tokens\":80}" }, "response": { "status": 200, @@ -40,7 +40,7 @@ "headers": { "content-type": "application/json" }, - "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"function_call\",\"call_id\":\"call_JCuVTkQxVB3cCmFWx52adJKZ\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_JCuVTkQxVB3cCmFWx52adJKZ\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"stream\":true,\"max_output_tokens\":80}" + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"function_call\",\"call_id\":\"call_JCuVTkQxVB3cCmFWx52adJKZ\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_JCuVTkQxVB3cCmFWx52adJKZ\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"stream\":true,\"max_output_tokens\":80}" }, "response": { "status": 200, diff --git a/packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-streams-tool-call.json b/packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-streams-tool-call.json index 172b8407e6..8aa5dedaaf 100644 --- a/packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-streams-tool-call.json +++ b/packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-streams-tool-call.json @@ -14,7 +14,7 @@ "headers": { "content-type": "application/json" }, - "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Call tools exactly as requested.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Call get_weather with city exactly Paris.\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"tool_choice\":{\"type\":\"function\",\"name\":\"get_weather\"},\"stream\":true,\"max_output_tokens\":80}" + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Call tools exactly as requested.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Call get_weather with city exactly Paris.\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"tool_choice\":{\"type\":\"function\",\"name\":\"get_weather\"},\"stream\":true,\"max_output_tokens\":80}" }, "response": { "status": 200, diff --git a/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-image-tool-result.json b/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-image-tool-result.json index 21fb8944f4..4f65f43b57 100644 --- a/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-image-tool-result.json +++ b/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-image-tool-result.json @@ -28,7 +28,7 @@ "headers": { "content-type": "application/json" }, - "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Read images carefully. Reply only with the visible text, lowercase, no punctuation.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Use the read_screenshot tool, then reply with the words shown.\"}]},{\"type\":\"function_call\",\"call_id\":\"call_screenshot_1\",\"name\":\"read_screenshot\",\"arguments\":\"{}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_screenshot_1\",\"output\":[{\"type\":\"input_text\",\"text\":\"Image read successfully\"},{\"type\":\"input_image\",\"image_url\":\"data:image/png;base64,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\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"read_screenshot\",\"description\":\"Capture a screenshot of the current screen.\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false}}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":40,\"stream\":true}" + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Read images carefully. Reply only with the visible text, lowercase, no punctuation.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Use the read_screenshot tool, then reply with the words shown.\"}]},{\"type\":\"function_call\",\"call_id\":\"call_screenshot_1\",\"name\":\"read_screenshot\",\"arguments\":\"{}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_screenshot_1\",\"output\":[{\"type\":\"input_text\",\"text\":\"Image read successfully\"},{\"type\":\"input_image\",\"image_url\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAnYAAACKCAYAAAAnmweyAAACKWlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNi4wLjAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgeG1sbnM6ZXhpZj0iaHR0cDovL25zLmFkb2JlLmNvbS9leGlmLzEuMC8iCiAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyIKICAgZXhpZjpQaXhlbFhEaW1lbnNpb249IjYzMCIKICAgZXhpZjpVc2VyQ29tbWVudD0iU2NyZWVuc2hvdCIKICAgZXhpZjpQaXhlbFlEaW1lbnNpb249IjEzOCIKICAgdGlmZjpZUmVzb2x1dGlvbj0iMTQ0LzEiCiAgIHRpZmY6WFJlc29sdXRpb249IjE0NC8xIgogICB0aWZmOlJlc29sdXRpb25Vbml0PSIyIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+at0SpgAACrhpQ0NQSUNDIFByb2ZpbGUAAEiJlZcHUFNZF8fvey+dhJYQASmh994CSAmhBVCQDjZCEiAQQkxBwa4sruBaUBHBsqKrIgo2qg0RxbYo9r4gi4iyLhZsqHwPGMLufvN933xn5s75zXnn/u+5d959cx4AFFOuRCKC1QHIFsul0SEBjMSkZAb+JcACTUACnoDK5ckkrKioCIDahP+7fbgLoFF/y25U69+f/1fT4AtkPACgKJRT+TJeNsonAIABTyKVA4CgDEwWyCWjfB9lmhQtEOWBUU4fY8yoDi11nGljObHRbJQtASCQuVxpOgBkVzTOyOWlozrkWJQdxXyhGOUClH2zs3P4KLehbInmSFAe1Wem/kUn/W+aqUpNLjddyeN7GTNCoFAmEXHz/s/j+N+WLVJMrGGBDnKGNDQa9Xrouf2elROuZHHqjMgJFvLH8sc4QxEaN8E8GTt5gmWiGM4E87mB4Uod0YyICU4TBitzhHJO7AQLZEExEyzNiVaumyZlsyaYK52sQZEVp4xnCDhK/fyM2IQJzhXGz1DWlhUTPpnDVsalimjlXgTikIDJdYOV55At+8vehRzlXHlGbKjyHLiT9QvErElNWaKyNr4gMGgyJ06ZL5EHKNeSiKKU+QJRiDIuy41RzpWjL+fk3CjlGWZyw6ImGMQAOVAAPhCCHMAAgaiXAQkQAS7IkwsWykc3xM6R5EmF6RlyBgu9dQIGR8yzt2U4Ozq7AzB6h8dfkXf0sbsJ0a9MxlZVAeDTNDIycnIyFnYDgKMpAJDqJmOWcwBQ7wPg0imeQpo7Hhu7a1j0y6AGaEAHGAATYAnsgDNwB97AHwSBMBAJYkESmAt4IANkAylYABaDFaAQFIMNYAsoB7vAHnAAHAbHQAM4Bc6Bi+AquAHugEegC/SCV2AQfADDEAThIQpEhXQgQ8gMsoGcISbkCwVBEVA0lASlQOmQGFJAi6FVUDFUApVDu6Eq6CjUBJ2DLkOd0AOoG+qH3kJfYAQmwzRYHzaHHWAmzILD4Vh4DpwOz4fz4QJ4HVwGV8KH4Hr4HHwVvgN3wa/gIQQgKggdMULsECbCRiKRZCQNkSJLkSKkFKlEapBmpB25hXQhA8hnDA5DxTAwdhhvTCgmDsPDzMcsxazFlGMOYOoxbZhbmG7MIOY7loLVw9pgvbAcbCI2HbsAW4gtxe7D1mEvYO9ge7EfcDgcHWeB88CF4pJwmbhFuLW4HbhaXAuuE9eDG8Lj8Tp4G7wPPhLPxcvxhfht+EP4s/ib+F78J4IKwZDgTAgmJBPEhJWEUsJBwhnCTUIfYZioTjQjehEjiXxiHnE9cS+xmXid2EscJmmQLEg+pFhSJmkFqYxUQ7pAekx6p6KiYqziqTJTRaiyXKVM5YjKJZVulc9kTbI1mU2eTVaQ15H3k1vID8jvKBSKOcWfkkyRU9ZRqijnKU8pn1SpqvaqHFW+6jLVCtV61Zuqr9WIamZqLLW5avlqpWrH1a6rDagT1c3V2epc9aXqFepN6vfUhzSoGk4akRrZGms1Dmpc1nihidc01wzS5GsWaO7RPK/ZQ0WoJlQ2lUddRd1LvUDtpeFoFjQOLZNWTDtM66ANamlquWrFay3UqtA6rdVFR+jmdA5dRF9PP0a/S/8yRX8Ka4pgypopNVNuTvmoPVXbX1ugXaRdq31H+4sOQydIJ0tno06DzhNdjK617kzdBbo7dS/oDkylTfWeyptaNPXY1Id6sJ61XrTeIr09etf0hvQN9EP0Jfrb9M/rDxjQDfwNMg02G5wx6DekGvoaCg03G541fMnQYrAYIkYZo40xaKRnFGqkMNpt1GE0bGxhHGe80rjW+IkJyYRpkmay2aTVZNDU0HS66WLTatOHZkQzplmG2VazdrOP5hbmCearzRvMX1hoW3As8i2qLR5bUiz9LOdbVlretsJZMa2yrHZY3bCGrd2sM6wrrK/bwDbuNkKbHTadtlhbT1uxbaXtPTuyHcsu167artuebh9hv9K+wf61g6lDssNGh3aH745ujiLHvY6PnDSdwpxWOjU7vXW2duY5VzjfdqG4BLssc2l0eeNq4ypw3el6343qNt1ttVur2zd3D3epe417v4epR4rHdo97TBozirmWeckT6xnguczzlOdnL3cvudcxrz+97byzvA96v5hmMU0wbe+0Hh9jH67Pbp8uX4Zviu/Pvl1+Rn5cv0q/Z/4m/nz/ff59LCtWJusQ63WAY4A0oC7gI9uLvYTdEogEhgQWBXYEaQbFBZUHPQ02Dk4Prg4eDHELWRTSEooNDQ/dGHqPo8/hcao4g2EeYUvC2sLJ4THh5eHPIqwjpBHN0+HpYdM3TX88w2yGeEZDJIjkRG6KfBJlETU/6uRM3MyomRUzn0c7RS+Obo+hxsyLORjzITYgdn3sozjLOEVca7xa/Oz4qviPCYEJJQldiQ6JSxKvJukmCZMak/HJ8cn7kodmBc3aMqt3ttvswtl351jMWTjn8lzduaK5p+epzePOO56CTUlIOZjylRvJreQOpXJSt6cO8ti8rbxXfH/+Zn6/wEdQIuhL80krSXuR7pO+Kb0/wy+jNGNAyBaWC99khmbuyvyYFZm1P2tElCCqzSZkp2Q3iTXFWeK2HIOchTmdEhtJoaRrvtf8LfMHpeHSfTJINkfWKKehzdI1haXiB0V3rm9uRe6nBfELji/UWCheeC3POm9NXl9+cP4vizCLeItaFxstXrG4ewlrye6l0NLUpa3LTJYVLOtdHrL8wArSiqwVv650XFmy8v2qhFXNBfoFywt6fgj5obpQtVBaeG+19+pdP2J+FP7YscZlzbY134v4RVeKHYtLi7+u5a298pPTT2U/jaxLW9ex3n39zg24DeINdzf6bTxQolGSX9Kzafqm+s2MzUWb32+Zt+VyqWvprq2krYqtXWURZY3bTLdt2Pa1PKP8TkVARe12ve1rtn/cwd9xc6f/zppd+ruKd335Wfjz/d0hu+srzStL9+D25O55vjd+b/svzF+q9unuK973bb94f9eB6ANtVR5VVQf1Dq6vhqsV1f2HZh+6cTjwcGONXc3uWnpt8RFwRHHk5dGUo3ePhR9rPc48XnPC7MT2OmpdUT1Un1c/2JDR0NWY1NjZFNbU2uzdXHfS/uT+U0anKk5rnV5/hnSm4MzI2fyzQy2SloFz6ed6Wue1PjqfeP5228y2jgvhFy5dDL54vp3VfvaSz6VTl70uN11hXmm46n61/prbtbpf3X6t63DvqL/ucb3xhueN5s5pnWdu+t08dyvw1sXbnNtX78y403k37u79e7Pvdd3n33/xQPTgzcPch8OPlj/GPi56ov6k9Kne08rfrH6r7XLvOt0d2H3tWcyzRz28nle/y37/2lvwnPK8tM+wr+qF84tT/cH9N17Oetn7SvJqeKDwD40/tr+2fH3iT/8/rw0mDva+kb4Zebv2nc67/e9d37cORQ09/ZD9Yfhj0SedTwc+Mz+3f0n40je84Cv+a9k3q2/N38O/Px7JHhmRcKXcsVYAQQeclgbA2/0AUJIAoKI9BGnWeI89ZtD4f8EYgf/E4334mKGdSw3qRtsjdgsAR9BhvhwANX8ARlujWH8Au7gox0Q/PNa7jxoO/Yup8UK0Vjk9ta0C/7Txvv4vdf/TA6Xq3/y/AOOhDyne6KAWAAAAimVYSWZNTQAqAAAACAAEARoABQAAAAEAAAA+ARsABQAAAAEAAABGASgAAwAAAAEAAgAAh2kABAAAAAEAAABOAAAAAAAAAJAAAAABAAAAkAAAAAEAA5KGAAcAAAASAAAAeKACAAQAAAABAAACdqADAAQAAAABAAAAigAAAABBU0NJSQAAAFNjAAAAAAAAAADxh4F4AAAAHGlET1QAAAACAAAAAAAAAEUAAAAoAAAARQAAAEUAAAbT33OL9AAABp9JREFUeAHs3F9olWUcB/DnLHCT/rgKQxbhtLwpkIqsLrxZQfQXKggEA/tjZuCFCRHR1Wg3XiyhoKgVeKFd1k1CFNGNRAhhkFAQFBlSkLhjbqtNbW3jeOB0dt6dHc905/d8dnXe5332nvf3+b7jfGXMUt+NG6aTLwIECBAgQIAAgY4XKCl2HZ+hAQgQIECAAAECcwKKnQeBAAECBAgQIBBEQLELEqQxCBAgQIAAAQKKnWeAAAECBAgQIBBEQLELEqQxCBAgQIAAAQKKnWeAAAECBAgQIBBEQLELEqQxCBAgQIAAAQKKnWeAAAECBAgQIBBEQLELEqQxCBAgQIAAAQKKnWeAAAECBAgQIBBEQLELEqQxCBAgQIAAAQKKnWeAAAECBAgQIBBEoPT+4NHp+WY58edPaeST1+c7ZY0AAQIECBAgQGAZCpQ+H/ln3mJXPnMy7R4eWIa37JYIECBAgAABAgTmE1Ds5lOxRoAAAQIECBDoQIFFF7uelb0dOKZbJkCAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATgYbFburcZNoxdFcdQ8/K3ro1CwQIECBAgAABApdfoGGx+3d6Oj03uLHuDhW7OhILBAgQIECAAIFlIaDYLYsY3AQBAgQIEMhLYOvWp5dk4IMHDyzJdTvloopdpyTlPgkQIECAQCABxW5pwlTslsbVVQkQIECAAIECAcWuAOciTil2F4HnWwkQIECAAIHWBBS71twW+i7FbiEh5wkQIECAAIG2Cyh2bSedu2DDYnf2/Nn0wht31r2rv4qtI7FAgAABAgQILFJAsVskWJPbGxa78pmTaffwQN1lFLs6EgsECBAgQIDAIgUUu0WCNbldsatA3XrbxnTfA4/VsH343r7098REzVrRQXd3d3p+58upq+uK6rYD+99N5dFT1WMvFhboX3dzevTxLdWN4+Nn0v6Rt9P0zP+t6IsAAQKdIuAzoTgpxa7Yp9Wzil1FbuD+B9OLu16pcdzxzJPpr9Ona9aKDtb2r0t7931Qs2Xv0Gvp6LdHatYcFAs89MgTadv2XTWbtm15OE1OTtasOSBAgMByFvCZUJyOYlfs0+pZxa4ip9g1foRW9fam/nW3NN4wc+b8uXPp2PffFe5p9qRi16yUfQQILGcBxa44HcWu2KfVs4pdRU6xa/wI3X3v5rTn1cHGGypn3hoeSl8f/mrBfQttUOwWEnKeAIFOEFDsilNS7Ip9Wj2r2FXk/l/spqYm085nn0oTE+NN20b9IW622I2882b68otDTXs12qjYNZKxXiSwevUNqdTVlaZmfmVfLo8WbXWOwCURiPqZ0C48xa5dkrXXUewqHrO/buzru6mqMzrzBw9//H6ietzMi+6enrR+/Yaarb8d/yWNjY3VrLXjYLb8XHnV1dVLfXbo44t6n7X969OmezZXr3f815/TkW8Ozx3ffsemtP2lPdVzF15cs2pVWrGi+8Jhalexu/a669OaNX3V686++PGHYx3xxxNFjjUDOWirQKlUSvs/+jTN/gyWy6fm/lHW1jdwMQItCFzKz4QWbu+yf4titzQR/AcAAP//YCg3bwAAJAVJREFU7V0HvNXE0x1p0osivYMgIB2xIXb5oyKIoCAovUuvgvTee5WOFBEQULBgAxERQUBAlN5BQOkKCvrNCW7e3tzklpd738v73gy/R5LdzWZzNjc5Ozsze9unb1/7l2zkwuVz1H7U4345KVNl9EuThLhFIGu27DRuyjvmRU8cP0LdOzanv//+20wLd6dVu+5U6bGnzdMmjxtK679aax7b7aRPn4Gmz11uZr09eTR9vna1eZwYd2KDY2LEKdL3nCVrNho/dYFR7fXr16l+7WcjfQmpTxAQBCKMQN269SJc463qFiyI+T5G5QIer/Q2IXYe7yGb5tWoVY9efrWhkXPz5k3q1a01HTywz6ZkaEkpUtzOBG0ZpUyZyjhh86YNNHpYn6AnC7HzhSi2OPrWErdHadOmpatXr9K//9qO7+K2MS6uVrb8A9S15yCjhvggdv9fcHTRBYnm1HTp0tHly5cTzf1G80aF2EUH3URD7PIXKES33XZbyCheu/YnnTxxPGD53HnyUvLkKQKWiS3hypkzN2VhzVzmzFkp0x13Egjc2TOn6fSpE9SmQw+6K2t247pLFs2h5UvmB2wDMlOlTk158uSnzHdlpTvvykKpUqWmSxfO06+nT1KuPPno1debGXVc5LQu7RrRpUuXgtYZCWKXNGkyypsvf8Br/fnnH3Tq5ImAZVRmnrz5KVmyZHSDtZdHjx5WycY2W/achOcAcsedd/G9n6Bf9uwK6yUdDRyNBrn4D23KzvdmJ+fOnaFLFy+aWbfffjs9VbkqFSlawsDirizZ6N0Fs+j9pbe0XWZBm50778zMfVWQUqZKRRkyZKKzZ0/T0SOH+Ln8NWximJKfv/wFClKWLPyM8zMJuXDhd7rIf6f4d3fixDGbFtgn4Z5q1m5AVau/bBbo0bmFuW/dOcbPhZ12Oy5wTJ48OeXKnZfy5i9EWfg3fP63c4zhAeNZ/fOPP6xNDek4Y6Y7KD/XlztfAUqaJClBg3/k8AE68+vpgP2C90omPhdy7OgRxuQvSp8hA5Up9wBdv3aNU/+lrd9vMtJRpmChIlSocFG6fOkC3bhxg3Zs3/JfOeRGT25PmZLwPoTg/fQbYwbB+6fYvaX4PZmDf/PJ6ejh/XRg/146//tvRn6g//AsZ8iYybbI8WNH6a+/rpt5KPt0lWpUoGBhfmbvprTp0lO/nu3pZ353WCWa3wT0c15+v+Hdf8cdmbmNf9H587/RxfO/0+FD+/n3c97anKDHbp/HLFmyGnjgQngX4LlQgmep0N1FCe3+959/jPbu2/uT8VyqMkLsFBKR3SYaYjd38RrCByBU2fvzLur9ZruAxcdOnkvZsucKWKZOjacCvlytJ5e770GqVqMOFb6nuDXL7/jo4YPUvVNz+od/NE6SkV9ez75Qi57mjzk+XMFkxOC3+GX+bbBiRn4kiB0+LlNmLgl4vT27f6R+b3UIWEZlTpy+iIlCFgIxb1DneSM5W/YcVLteE7r/wUp+5P7K5Us0Y+pY2rRxnarCdhtNHG0vGEZimbIVqFuvIbZnrHr/XVo4b7qR91DFx6hu/eZ0Z+YsPmU/WbOCZr89wSdNP6jwQEVq1LwdZcx4iwToedjH4GD8qIH8Uf3FmuV3DK1m5Wer8zNe2/wg+BXihLO/nqIftn5Hy96dx4OMGGKqyqJNjzz2DA9W8hkf9nAGbWs+WEbzZk1WVZnbaOKId0/9xm/Qo09UpqRJk5rXVDvQmH752Uc0d+ZEgsYxFCnOpKZl224mMbae8/tvZ2kSm1Ts3rndmmUcv1ynAdV4+TVjf1DfLnQb/2vTsSelY8KkZOeOrTRicC9qzP2Ptuty6MBeGtyva1gDI/38UPeL3FOM+g259Xyu//JTmjpxBL1StxH977katu/03Tu30dgR/QK2CwPZF158xbYJwwa8Sdt+2GwM2jFYqPbSq37XGTO8L3337dd+50fjmwBi9NIrr9MTT1XhZyeZ3zWRgOcH/bGF392hDPQj9Ty24uev0uPPGG3q2aWlQaxTp07DmNWhKs/XIPzedYGCYuXyRfQeKyTQZiF2OjqR2xdi54BlfBC71xu1omervuTQIv/kQwf30ZudnDUT2XPkpL6DxjmOTP1rJK6vOR06uN8uyy/Ny8QOjW1WvwZrRbJRj74jCC8bJ8HLplv7JnT8+FHbItHG0faiYSQGIiRbNm+kqROGUafuA6ho8ZK2tToRO2g+6zVowR/QF23P0xNv3rxB82dPpY9Xv68n++yDfGG6tEy5+33SAx307NLKljCCaDz9vxcCneqY9/W6z2jSWH8iHC0cc+XOQ+079zE0446N+i/jFGsqRw3t7fgsohhwfIkJGT72wQgtPp4rli00tLLWa+vE7tOPVtEjjz5lO/g7d/ZXR/IY6oyB9drhHOvEDu+848eOGG0NVMcZHhgMHdDdcdYlELED6f/5px+pQ9e+BI22ncQVscM7dvDIqcZg1a4d1rRQzBAi+TzqxG7siP70065t1Kv/KMqdt4C1aT7H0yePoi/WrhFi54NK5A4SDbHr1X8kTyMUC4icrtELhdgNH/M2ZbVMgel14GK1X3wy4DVVJrQpbTv1UofGFtNFZ8+cIkxFpk6VxpiatY7YnBwW8MIfOX4m5cyV16fOC6y6P8+q+yScn4nV+ekz+DrDYJTfummdkLSMkSB2GI3qjiCqsTqOe3bvYI1dR5UVcKs0dig0c9o4qvNaE5PUXblymV/Yu3ha+waVKlPetClE2Y0bvmKt0wDs+khc4OhzwVgclCpdjjoycVOiYwfN1xmewi9eoozKNraYdjvGUycXL16gbzd8aeso06RFB562vaX1VCfjmfydp3czZrqTMEWmCzTHnds2dPyYPlPlBWrUzFcLjg/ROW4fpr4wxYXnQTdvcCJ29Ru3pieefs68vH7PSAyk9dr49Rc0bdJI81y1Ew0ccS/jpsznqf/M6jLGFu374+oVw8zCJ4MPftmzk/r0aG9NNo9BtBs0ecM8VjuYgkydJq2fdgn5g/p0pp0/blNFja1O7FQGNN3QsiRJkkQlmdur/PvBVJs+hYlp305tGpllorGjEzu9fpBWmKZAMBth1ShD2ziob1f9FHMfNsrP8UyGEv352bZ1s2EmgGdcF7w/TvLgD1OeK5ctMLRTej72I/lNQH3tu/SmBx56FLumoB2/8W+QX9L8/s5k9Ifqr2DELtLPo07soIkry4M2Rerwnv1p1w7WnF6iIjwDpc8UwOyiRcNaQuzMXo3sTqIhdqHA1rFbP8IUDyQUYmdXZ3VW29eu19jMCnUqts/A0axRKWWet2LpQlr1/mL644+rZlr69OnZlqgh4QOpZNPG9ca0gzpWW0zT9BowWh2yXcMpg7js3xczXQbSUoy1OCCU+ssaNnawuQkmkSB2Ttfo0WcYlSxd3siOzVSstd5PPlpJSxbMNBwFkAfP4mFMzJXDCDQlHd5oYD2N4gJHv4u6THj4kSeMKTW7ajDlvO7zj2k3v3B1OyJrWdgjjpow25w2xIt4+sSRtH3b98bUP6YT87JNFzQf95Ysa57uRJBRoPeAUWwTVdosu3D+2/TpmpXGtLlKxDNZuEhRKn9/ReODBs1IKHaqtes2puo1XzWqCfZxU9cKto0Ejs+9UJNea9jSvBRIw9LFcwybKGiKYYdUslQ5atGmm2Ebqgo6mUSAgMD7V/1e8fGcxv2yY/v3bH92wegv2JnWqtOQ4FCi5CBPk/fs2tpnwGYldqdOHqeBvTsZpK43vzuUHS/qwHsGnvJoc9tOb7FZwyNG1ZHCWrXTbmtH7E6eOEojh/TyGUQ88FAlgle6Pv0X6gxE05Yd6clnYgYKqh0YyHy8ejlt/nY94d0JMhmuxPabgPuYMf99837wLZg8bgj9sOU7H/MblCtZuiyVr1CRSpQqawzMndoY6edRJ3b6NX/atZ2mc5QERbzxnsXvvwDbaSpp0bAmPfecP+Yq381WvGIl3In5/MQnsZuz6EOTZOAl3IOnoOwEH77xU98xpwhg39Su5S07Gb3889VqGdNoKm34wB6GzZI61rewnWnZJmZki2kqTFcFk4RC7EAgVi1f7Hc7TVvxy/w/rQ9e4K+/UsXvxR0XOPo1zGWCHSH5lTUbs6aPYwKwNaTa23XuTQ8+fEtTAGzat6xnGq3rFcD2cMS4maZdFj58bZrVoXPnzurFjP05Cz9gx4vUxj5e/P17dfIrE9uEuCJ24eCIe53Av1Vls3bu7BkOS9SUrly54nebsEeCFlLJ3p93s41vW3VobmF/Cy20kvmzp9DqVUvVoblNkyYNDR093XxPIGMwa69+ZC2WEiux07WjLd7oQo89+T9VlJq8Vs1s90MVHzfIncqELSs0fdESO2LXsvHLtk4S1ncZbPImjx8WtGl2xG7Xjz+w1n9syI5bTheJLbGDo9eQUdPMapcunktL2eY0thKN59GO2H3/3Tc0bmR/H0cKtLlipSfojQ49zeb3ebMNlS8XMyg0MyKwI8ROiJ35GMUXsQNZW7hsrWkvE0xbOGbSXMqe45bTBj407Vq9bt6D2qnJ9jc1a9dXhzwl0YV27vjBPNZ3rERg4pjBtGH953oR2/2EQOzg7QmvTzuB8bTyBkZ+3ZqVjWlavWxc4KhfLxL71v4ESZ8+aZTp3RjsGpjWmvXOKvN5XPfFJzRlwnDH0+AM0bBpGzPf6VmbtWCVOS0O78ZWTV4xNEDmiS524oLYhYtj+QoPUuc3B5p3NXxQT9a2bDKPrTuDR0w2NRrQzjSq+4K1CA0dNZXysWcmxE4Lp5+gh4BBupUEWomdbjYC8ggSqUTPK1GyDPXsFzOVDVtWOwcXda7brZXYbdn8LWvr3rKtFhrNabOXmgMIOJh17dDUtqyeaCV2E8cM4nfgF3qRWO/Hltjly1/QIOfqwnDWgAY7thKN59FK7DBgw/Q3NLtWKXR3ERo4PMZpCQONEvcWtRaLyLEQOyF25oMUX8QODRjJWg+EHYFA6zFj6hjDuFRX/cO+7rlqNenV12JeVNvYc3AYa+OsgmmJ9l36mMnwmMLUhQoVoDIwJdm911CTKCJdeTepMk7bhEDsAk2FW22V7IhdXODohG9s063Erm2Luj4hBoLVa9UUDBvIXoI8hegk1vJOdp86KUFdsIGaN2tSSNP+TtdW6XFB7MLFUdfC4XfckInaNbaXdRI4qkBDrETXkqm0We+sNOzocPzBindpwdxbHs8qX99aCTocW+bMmGgW0YkdbDHbtKhn5j3Kno7wuIWgzQ1erWrm3VP0Xuo7eJx5HNfEDs4N8Gx2ki49BhKiC0BgF9j4tepORc10ndj9zuFUMOiIlMSW2MHhC4MhJXiG1n78AU/lzw4pHJU6T22j8TxaiV3T16s7eiPDRGD42BmqOYYGWYidCUdEd8TGToMzPomdnaE6HB2OHD7ExtApjLhb+ThWlZrWUc2GBx1U31ZB7KUJHPpDGdUiHy+GA/t+ZsPya6wmZ/settnD6B8aQyWIf9Wtw62YdirNaet1Yvcl25JN49AIThIKsYsLHJ3aF9t0t8TOSmZhi3fk0H7H5qRNl8FnYAD70MVsz2gVK94qH/aNMOyHRx1CVcQm+KsXiV19dnCo8p9HMRwbMH0YSODlC29fJdYBFoIgz5i/UmWTE4E2C/DO9DnLTAcp6yBQJ3bo3268eo0SLxO7YNq0Zq06sWPNs+pWjLBHwaaKdWKHKfM32JwgUhJbYofr698k1R44KUFbu4t/M7v5NwOHMDhDBZNIP4+4nk7sgpFoIXbBeihy+ULsNCz1H1Gw6VDtNJ/d2P6I8dIeMHQiZf8vEKdPpQ4HiHsFt3Fdq6cXtfNC1POt+/hh9u/VkcnkQWuW7bHXid1HHy7nuGCTbNuORCvRsNPYoVy0ccQ1Iiluid0LHGNO1wqH27bPPvnQ0Dhbz4PDRa06DYwpPn0woZfDRwteoXAcCqQl1M/BvheJna45cnLO0e8DS/rB+F/JkP7daMe2LerQCCit21whduBG9mgOJLDHRSBkyH4ODvtWt5gp84RK7Ky4WO8fzmt4Dytpz6YqyohfpVm3XiV2GHzDsUZ3hLG2HcGkEXdvycKZPs4k1nKRfh5Rv07sgikFhNhZeyR6x0LsNGzjk9ihGXdxYN1GzTtwnK8KWqv8d+Hh+sGKJfTZJx84kjp1FpwD4CQQSPAxhTfj+0vmhRXxP7EQO2AXTRwD9U1s8twSO+uUYLhtgAfy7OnjHU+DpzFisN1TrKSPRtl6wtqPV/FU7ZSQtBFeJHb9Bo81VvjAfZ0+dZzat4qxebXeK46thv9dObYiovkrsdq2BdNc4byJ0xeaMeisSwUmVGIH+zrY2TmJ/iygjJOjhX6+V4mdaiPCDj31zPPGiiVOgyJ4KL/DzjRr+btgJ5F+HnENIXZ2SMd/mhA7rQ/im9ihKbrHGZYY2rrlW8NOBPGjECj0NIckwFI+IGOhij5q//zT1XSDQySk4PhaqA9LTu1hg9czvCxUuGIldsFsX8KpPxLhTiKlsVPtjhaOqv5Ibd0SO2tIBESy3/fLTyE3D0vfOQV71ivBmpvFS5Tlv9JGaJusvDSUVUINgKt/zCMVgsMtjnoMMjiLNOfwDoEEwckRpFyJ1dsUgWVHjp+tsmkmr5ji9BFXhXSbvA9XvkfvzJmqsiihEjvEIMRshZPoJA1G/PVqVQ46ANbP8dJUrPUe4YVeileaKVXmPirBYYaspjkoj+XO9vy003qqT0y8SDyPuIAQOz+YPZEgxE7rBi8Qu85vDuB4RA8ZrRrA06KIN+ZGdM+qcAL9hnJNrEwwf8nHpo2e9cMRSh1OZbxG7KKJoxMGsU13S0hgeI5pGyWhekmr8rHdYs3g1xq28omLF2oAXJ3YYRCEj7lbcYsjprMxra0kkGE5yui2YZd5GbWm7G2qC+KVzV282vy9WZ0h9LLYh33opBnvmsnQokKbqiShEjuE/EDoDyeBJzI8QCGhkrSEQuz0e4bmDlO0r3OcRD1QPjTdCM5ulUg/j6hfiJ0VZW8cC7HT+iG+iR28oKaxsTMWZoY0a8BhBLQF3LWmhryrhy1w+sGHXJlNwcn84VBR9SNJHL1G7KKNow20sU5yS0hy5WLNEAcnVuIUU03lR3ILz++ps5aYmohQtW/WsDQIfhqbRdH1e3GLI6bP4BSlZArHU1vHcdXsBKt4jGJtHNY5huzfu4ft4fxXl5g6+z1zhQXY7XXh6Vp94XW9bmsYmqH9uxsBplWZhErsjh89TJ3bxQSBV/eDLXA0wp1wQFxIqM9uQiR2xg3yfxgQDRsT420K21R4slslGs+jEDsryt44FmKn9UN8EzvdEw3NQtwieLDi7y+2n7jEyz9hibFjRw/xeolHg04voI4J0xb4BCmFsTU+ltc5oOi1P/80lqaB/Q/WYLQLnIo6Akn/IeOo8D33mkXe6trKiNBuJvy3A+eQv1mTAkPfUMRrxC7aOIaCSahl3BISaGInz3yXvaZjlptDwNFvv1nn2IQCBe82FmVfveo9R+cbxLFCQO1AXq/w4h41fpbpRATP8BaNAnuTolHWe0ZYj0Dr1jreiJZhrTPccCe6lhfVIjZd947NbEPPNG/dmR7nRd6VIG4g4gdapVP3/nTf/Q+byU5auxw5c9GQkdPMZd8Q77Jjm4Y+8cUSKrHDzTvFSqxcpRo1bBYT2Bne2fDSDiZeJHYgqblz5zV+TwgS7iRYhm/qrPfMbMRKRMxEq0TjeRRiZ0XZG8dC7LR+CIfYQbuWiX9QVqlWsy7Bu01JZ36Z6l6rsG/79fQple2zta5y4JNpOcAHb/Omb+iD9xfRWXbPtxNoP6bNWUpp06azy/ZJU96IWJgZwYn1NvsUtBxgzcUatWLiX8GzFtHaf2Q7QNSBtWof5Qj2lR57xlj6bOv3vkbPeCmlYSyt0qPvcHNtQRBa2I3ocpU/khd4zVur6GvFRsrGLi5wtN5HOMdwutGXUXq40pNUgxeJV4LR+xmHZ+4k22za9bWdswjsudZ9/pHhYIO1hhEkOwdr9x59vLK5Fu0AXpJq987t6tI+W9h74WP14/YfaNM3X7JjwEEeWJw1gtsivWDBwlT1xTo+zkNbNm804i/6VGRzYNVaIPYaAlNv5OtA6w17vrwcLqjCg5VoB3sQol6rRANH/Z2C6x3je/549Qra+8suXgLsPBW6uyiVYHspFRYFZbBcVue2jW3taLGMG1aU0A3oYQKxi2MCHtj/s6E9L1zkXsPjOyeTAiV2jhYJmdghBA+mG7d8t8FYJhD2vlg7+JW6jUxsMIBt3eRlvwErsMueIyfdxv+UwMEMzjxKOtksL4g8hBVxskeO9DcBfY1lDzEg+H7TBg5rtYG/HSf4fX/WiC2IwXIJXo4OnuY5cuZRTWfv2Nm0/L13zGN9J9LPoxA7HV3v7Aux0/pC1xIhntaA3p21XN/dQSMmUcFC9/gmhnBkDQSqn4IXDpbygXdcqIIXzdgR/clKmNT5CB7bq/8oM6ipSg+0xb0PHfCmETsvUDnkYekirF2ZJgTyaF3/Ekvc4GOvx9oLdj2V/xXHqJtqE6MuGsQO14w2juq+wt1aDerDPb9+7WcNDa71PDyLQ0ZOMVc5sOY7HQcjdlik3iogljpR0fOd4jTqZdT+m72HGkbl6thpixUksGyeLtHCEdPawzn4eDjP+OhhfXjQtkFvns9+m449DQ2lT2KAA8So696phR+BT8jETr9dzAJgYGAVJ+9sa7xA63mBjkGee3aJWfpNLxvpb4Iidvo11L7TbwaEF8oEJ/IZ6edRiJ3qEW9thdhp/aFPtwULbjts9DRDA6CdHtJuIGKHCvCBK1m6HAclTmloYVLwEjlp06WnrFlzGAvXI0gxjnXBi63jG/X9VpVQZbJlz2FozlKkSGHUiZdg5sxZKQt7IWLkmidvAb8P60ccpX6uFqVe1WW3xRRys9adeAHyZHbZZpqV2Fkjq5sFQ9iJa2KHJkUbxxBu26+IVVPlVyBIghOxw2nQYLXgNYSLlygTpJZb2dCsYhH5o2wDZSe6h6ZdvjUNWgdoH0IVtHcQk1F9CtnuXDtiF00cKzxQkTDVGmzwc4W13VMnDAsYygP3A01Ns9ZdCPUGE5hzTJ80wtBqWcsmVGIHu7nC9xS33o7P8aaN6w3ybhe4F9pRBOuNjQQidpH+JgQidnZthwfw2BH9bAPW6+Uj+TwKsdOR9c6+ELv/+gIhF6DZUrJo/gxauXyROvTbWm3L/Ao4JFiDhDoUc0yG/VP5Cg/zGqdNzcCjKAwNBD5YsREQlipVaxLsU5TAFqpdy5jpPJXutIVGCx8vTHdZtS8Iq7Jh3ee05sOlPs4gIJgz5q0wnUWc6rZLR9+gj6wyasIsg8QiPZyp2CuXLxleiHbTktZrOB1HAkenup3Scc0xk+b5Ye5UXk/HtHmzBi/52F3p+dhHXyJ+FkJxwPPOqnmC4f6BfXt4qaNVhI+pkyE/6sIg4v4HH6X7ebm7/LziiRJMmWGNTwjwx+LrCLFiF7JBneO0Bel5+dXGxsoD+K1Y5TcO74MYkFb7u2jjmDnzXWz71Y6KFithaM+hWVHT5zCr2PvLHpo3cyKHHzprbbLjMTRP6Jds3C/oJ1Un+uA42+F+zmYV6Bcn0ddKRiBkBP5VUpqnh7uzBhQSaEkxDCrh5IU+jJZY14rtxU4lOTiQe3U2e4E5AOzP0NfAAP0LB5X3Fs3x01Cq9sGWEe+q2IiT/RrqivQ3AfdTuEgxw3zgfjYhUI41uJbqa+yjv9fzPa9YtsDWfhNlrBKp5xErpeA5hIQboLhbhyZU8eEYe1FrG90cy1qxslYsL7mTgXr3H22u1Qp7s45sYxEsWrmbB8/tuU9XrkqNW8TYncGeCAvex1bwEpnC3ogZM96yG8Tor27NZ8KuDmQNBr9JkiTlF+2tcAO/83JKbghT2I2IxxMihWM83oLjpfHxxAcV2mR4biNQNtYejk3fpmSvxYyZMlGGDJnYhuiK8dzBVhQ2d3ZaFsdGOWSgH6DBAxkFaYSd3fnfzznaozpUE5VktCtlqlSseUtPJ44fNWwM3VwI95crdz7+2F/j31wSrvMYk/Ubbqr01Ll2xG4few2jjxH7MEOGjAbROc82t3Z2t566GReNwaAF7+cMbJd8mbXjMGvAoBnv13DimlqbEOnn0Vp/oOO6desFyo51nhC7RE7sSpQqS42atjW98PAkrf9qLU0ed2u0GusnK4onYtqodbselIeNa5UEi8auytltoTnAyF83PD7MXrKwyxEJHQHBMXSspKQgECoCTsQu1POlnHcREGIXnb5JVFOxWKsSxv7wICpavBSVu+8BKlS4mA+yWKwbITugiYhPAUmAJ19a/kvPWg1MOeTkUXmx4iUpd578Pk3DVE4nNpi9evWqT7p+gNEt7h0LtqdjGz2o9eGxmidfQSpVuryf8fHC+W/TquWL9SpknxEQHOUxEATiFgEhdnGLd1xeTYhddNBONMRuxLgZfoTICikCfg7u1zXOp2uwjFgjtlVIniw5JeXpLhBQEIhQBK7wMFg/eGCfT3F4NIIM3qovmZ9tlE9hywE0lgimGpspNktVCf5QcEzwXSg3kMAREGKXwDswQPOF2AUAx0VWoiF2cxevMQ20rXjBpgfG1CvYRi2Q1st6XqSOq1Z/herWbxZWdTBW/mTNCtaqLfSL04SKAt2v04Xg8bWEbfV2bN/qVCTRpQuOia7L5YY9hoAQO491SASbI8QugmBqVSVaYgdPql/27KQd276njV9/Ea9Tr6EQO0y3nmSN4pEjh4xgpFhDFt5qThKMkIDMwjkEhtZ7f95FO3ds4RUtjjhVl2jTBcdE2/Vy4x5BwLrEHUI7nTxx3COtk2a4QUCInRv0nM91JHZ/3bhOzQaW9zszZaqYZYb8Mj2cgKC/WEYLi2tfunTBcAuPpot+OFDAGaIgR6C/ceNvunnjprFFGIHLHILj8uWLdJE9oHAcjjzGqz1AbrIrPOoFkb169TIhrMclYMBegjLVGhxRwTE4RlJCEIg2ArA3hnkK3lmBlqSLdjuk/sgiIMQusniq2hyJ3T/8A2rUL2aJFXVCQiV2qv2yFQQEAUFAEBAEBIH4R0CIXXT6QIhddHCVWgUBQUAQEAQEAUFAEIhzBITYxTnkckFBQBAQBAQBQUAQEASig4AQu+jgKrUKAoKAICAICAKCgCAQ5wgIsYtzyOWCgoAgIAgIAoKAICAIRAcBR2L3982/qemAsn5XFecJP0gkQRAQBAQBQUAQEAQEAU8g4EjsLlw+R+1HPe7XSCF2fpBIgiAgCAgCgoAgIAgIAp5AQIidJ7pBGiEICAKCgCAgCAgCgoB7BITYucdQahAEBAFBQBAQBAQBQcATCAix80Q3SCMEAUFAEBAEBAFBQBBwj4AQO/cYSg2CgCAgCAgCgoAgIAh4AgEhdp7oBmmEICAICAKCgCAgCAgC7hEQYuceQ6lBEBAEBAFBQBAQBAQBTyAgxM4T3SCNEAQEAUFAEBAEBAFBwD0CQuzcYyg1CAKCgCAgCAgCgoAg4AkEhNh5ohukEYKAICAICAKCgCAgCLhHQIidewylBkFAEBAEBAFBQBAQBDyBgBA7T3SDNEIQEAQEAUFAEBAEBAH3CAixc4+h1CAICAKCgCAgCAgCgoAnEBBi54lukEYIAoKAICAICAKCgCDgHgEhdu4xlBoEAUFAEBAEBAFBQBDwBAJC7DzRDdIIQUAQEAQEAUFAEBAE3CMgxM49hlKDICAICAKCgCAgCAgCnkBAiJ0nukEaIQgIAoKAICAICAKCgHsEhNi5x1BqEAQEAUFAEBAEBAFBwBMICLHzRDdIIwQBQUAQEAQEAUFAEHCPgBA79xhKDYKAICAICAKCgCAgCHgCASF2nugGaYQgIAgIAoKAICAICALuERBi5x5DqUEQEAQEAUFAEBAEBAFPICDEzhPdII0QBAQBQUAQEAQEAUHAPQJC7NxjKDUIAoKAICAICAKCgCDgCQSE2HmiG6QRgoAgIAgIAoKAICAIuEdAiJ17DKUGQUAQEAQEAUFAEBAEPIGAEDtPdIM0QhAQBAQBQUAQEAQEAfcICLFzj6HUIAgIAoKAICAICAKCgCcQEGLniW6QRggCgoAgIAgIAoKAIOAeASF27jGUGgQBQUAQEAQEAUFAEPAEAkLsPNEN0ghBQBAQBAQBQUAQEATcIyDEzj2GUoMgIAgIAoKAICAICAKeQECInSe6QRohCAgCgoAgIAgIAoKAewQcid1fN65Ts4Hl/a6QMlVGvzRJEAQEAUFAEBAEBAFBQBCIfwT+D/zF7ZhlIKO3AAAAAElFTkSuQmCC\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"read_screenshot\",\"description\":\"Capture a screenshot of the current screen.\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":40,\"stream\":true}" }, "response": { "status": 200, diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index b854537fe2..06434e1c02 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -115,6 +115,7 @@ describe("OpenAI Responses route", () => { type: "function", name: "read", description: "Read a path or resource.", + strict: false, parameters: { type: "object", properties: { diff --git a/packages/opencode/src/session/llm/request.ts b/packages/opencode/src/session/llm/request.ts index 2785d98526..4f93411107 100644 --- a/packages/opencode/src/session/llm/request.ts +++ b/packages/opencode/src/session/llm/request.ts @@ -146,6 +146,16 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre ) const tools = resolveTools(input) + // Codex parity: OpenAI Responses-family providers hardcode `strict: false` + // on every function tool so MCP-sourced and dynamic schemas that don't + // satisfy OpenAI's structured-outputs constraints still register. + if ( + input.model.api.npm === "@ai-sdk/openai" || + input.model.api.npm === "@ai-sdk/azure" || + input.model.api.npm === "@ai-sdk/amazon-bedrock/mantle" + ) { + for (const key of Object.keys(tools)) tools[key] = { ...tools[key], strict: false } + } if ( input.model.providerID.includes("github-copilot") && Object.keys(tools).length === 0 && diff --git a/packages/opencode/test/fixtures/recordings/session/native-openai-oauth-tool-loop.json b/packages/opencode/test/fixtures/recordings/session/native-openai-oauth-tool-loop.json index 625140f991..380a572b0b 100644 --- a/packages/opencode/test/fixtures/recordings/session/native-openai-oauth-tool-loop.json +++ b/packages/opencode/test/fixtures/recordings/session/native-openai-oauth-tool-loop.json @@ -17,7 +17,7 @@ "headers": { "content-type": "application/json" }, - "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"instructions\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"store\":false,\"prompt_cache_key\":\"session-recorded-openai-oauth-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"stream\":true}" + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"instructions\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"prompt_cache_key\":\"session-recorded-openai-oauth-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"stream\":true}" }, "response": { "status": 200, @@ -33,7 +33,7 @@ "headers": { "content-type": "application/json" }, - "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"reasoning\",\"id\":\"rs_0812d6cbe7a2b19b016a1214d32f6881998bcd9ff2e739d7f2\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEhTUCQT4XELlBu6r5VHqqtu5Il5WdX4m1upE8li0mPmIwgIykAmUTZWiE0213kmviuAgIrmhhiL4B8DXbWQD2vOEkQMhpZq_UCqc22SOg-4DpQLrebMWkzgAPL618VPu9mXNUIH9BW1sRhPdDSbbtK5_bitzsn-FMJGcO3UN7Ga2RW1Rdvt1M3m7J4MRlTutH8cwY8SthzgvOFEBS-_IrAhiwKVz4Se9Jlu3pVNMqhPF7kdrQOfDYui0v-AT8VrHBVomqekJl_dWESww0eWo6bS1PxZB4cLQHWp9JJi5pEECvU9Ntcz3GxuGJEtTKq5mFcRvCanXHOwZGmbBcWMNdVyikk3fxgIE2g9t8rCKJmhNXznMERtrfG2tey19qWbsVbo2YmBbg_5N02AA4NmEVvdfgHJx58nOfEEc2OZYk0YQ1fHBOkpBnwY61hxtrWFdj48QnTEKuvjAyNpX-KKFmMzL4531yLbEEzpaERlr11fDeoMpKofUoMsg3Jz8aTaZ1CpzI3O7iFzGDEV6gKh8vQYGrKOaOXnfBVDXDo8iJhZywpcQY6xB4NNf4pyyjFkR-vjgvBYV2hejlq2V1j8vQHgy8CsZJ6lW5oaTNMfP76MAHlwUwyMYj-cFmuX0epJdDWv8GDznUpOS-v2X5eNsvyx9qvvcTEMLsKJ--3_odisilj4vPhw16P9fB8eLmvESZmJRYmWM4mO7hPTVXOooOa-zxRHGhRQH9ouUea9UHSuH1A0o54qTEPr-JqYlQggugW449IuYW4HSMNMyeGdUNJfodWRu5cL0VPgk6zwTU3ArBq28FDgG7NZMk3njfCId351GZ8VRlTMA6U522_6FFaZ8-5gxsidOm0WULOwyTTo54tJsJFv2pgYUKs0VFWSwi3rvNMVMOgwOVIdSgZt1hFTxBImZh8HUIXUPvdOVKZzQmWT5M6uOTUsm5xsufhj8m79RuYZh2J0bkVOBzZ1As8zH-4v_r9d7e8464EuWXCln_6LAJdrTYgE2gVfHK0zeUaAMbIKhirOf0AVQZyfVsGvJ_CPqrPE_QSECeSA2D4TSa5Tc_IRY-Fb2_HKNCMEP2uvy\"},{\"type\":\"function_call\",\"call_id\":\"call_Ix5Urx04RtKsUJ75K0vTTgFF\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":{}}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_Ix5Urx04RtKsUJ75K0vTTgFF\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"instructions\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"store\":false,\"prompt_cache_key\":\"session-recorded-openai-oauth-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"stream\":true}" + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"reasoning\",\"id\":\"rs_0812d6cbe7a2b19b016a1214d32f6881998bcd9ff2e739d7f2\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEhTUCQT4XELlBu6r5VHqqtu5Il5WdX4m1upE8li0mPmIwgIykAmUTZWiE0213kmviuAgIrmhhiL4B8DXbWQD2vOEkQMhpZq_UCqc22SOg-4DpQLrebMWkzgAPL618VPu9mXNUIH9BW1sRhPdDSbbtK5_bitzsn-FMJGcO3UN7Ga2RW1Rdvt1M3m7J4MRlTutH8cwY8SthzgvOFEBS-_IrAhiwKVz4Se9Jlu3pVNMqhPF7kdrQOfDYui0v-AT8VrHBVomqekJl_dWESww0eWo6bS1PxZB4cLQHWp9JJi5pEECvU9Ntcz3GxuGJEtTKq5mFcRvCanXHOwZGmbBcWMNdVyikk3fxgIE2g9t8rCKJmhNXznMERtrfG2tey19qWbsVbo2YmBbg_5N02AA4NmEVvdfgHJx58nOfEEc2OZYk0YQ1fHBOkpBnwY61hxtrWFdj48QnTEKuvjAyNpX-KKFmMzL4531yLbEEzpaERlr11fDeoMpKofUoMsg3Jz8aTaZ1CpzI3O7iFzGDEV6gKh8vQYGrKOaOXnfBVDXDo8iJhZywpcQY6xB4NNf4pyyjFkR-vjgvBYV2hejlq2V1j8vQHgy8CsZJ6lW5oaTNMfP76MAHlwUwyMYj-cFmuX0epJdDWv8GDznUpOS-v2X5eNsvyx9qvvcTEMLsKJ--3_odisilj4vPhw16P9fB8eLmvESZmJRYmWM4mO7hPTVXOooOa-zxRHGhRQH9ouUea9UHSuH1A0o54qTEPr-JqYlQggugW449IuYW4HSMNMyeGdUNJfodWRu5cL0VPgk6zwTU3ArBq28FDgG7NZMk3njfCId351GZ8VRlTMA6U522_6FFaZ8-5gxsidOm0WULOwyTTo54tJsJFv2pgYUKs0VFWSwi3rvNMVMOgwOVIdSgZt1hFTxBImZh8HUIXUPvdOVKZzQmWT5M6uOTUsm5xsufhj8m79RuYZh2J0bkVOBzZ1As8zH-4v_r9d7e8464EuWXCln_6LAJdrTYgE2gVfHK0zeUaAMbIKhirOf0AVQZyfVsGvJ_CPqrPE_QSECeSA2D4TSa5Tc_IRY-Fb2_HKNCMEP2uvy\"},{\"type\":\"function_call\",\"call_id\":\"call_Ix5Urx04RtKsUJ75K0vTTgFF\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":{}}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_Ix5Urx04RtKsUJ75K0vTTgFF\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"instructions\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"prompt_cache_key\":\"session-recorded-openai-oauth-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"stream\":true}" }, "response": { "status": 200, diff --git a/packages/opencode/test/fixtures/recordings/session/native-zen-tool-loop.json b/packages/opencode/test/fixtures/recordings/session/native-zen-tool-loop.json index afcfd9edc1..e25bc9fae6 100644 --- a/packages/opencode/test/fixtures/recordings/session/native-zen-tool-loop.json +++ b/packages/opencode/test/fixtures/recordings/session/native-zen-tool-loop.json @@ -17,7 +17,7 @@ "headers": { "content-type": "application/json" }, - "body": "{\"model\":\"gpt-5.2-codex\",\"input\":[{\"role\":\"system\",\"content\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"store\":false,\"prompt_cache_key\":\"session-recorded-opencode-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":32000,\"stream\":true}" + "body": "{\"model\":\"gpt-5.2-codex\",\"input\":[{\"role\":\"system\",\"content\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"prompt_cache_key\":\"session-recorded-opencode-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":32000,\"stream\":true}" }, "response": { "status": 200, @@ -35,7 +35,7 @@ "headers": { "content-type": "application/json" }, - "body": "{\"model\":\"gpt-5.2-codex\",\"input\":[{\"role\":\"system\",\"content\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"reasoning\",\"id\":\"rs_0fdce240b46054ad016a1214d326848196b269feebe1844759\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEhTTGeallj_mC3ciDydiTVJLA6bjJfitoj4ftFfWwlxekFNaf_cDNWP3pE6qsvK9gKJNRfbAbpaEVf1qjAhQx53witrmt6H3KaaNJm3wXHG5sEi9gp3nLWK4T76tcVYHG1x6mbbTjEjCvhIuEkn_7Q7lJ1BErkEURYBBMPmkKya2-YuL8XP14Yrko9BA1t56BkwK5U3TFse4nwHI1qi82hdkX_aYAtz6YgbTpf-dvOCBGfeApxWLFotkt355Qy2b6MmPaH6cQwrvLJXOqEzGkwxFcs3mLEKLV103gd8Z5e_OapjJHTv_LarN-WN9C7nCQ0BBHClk4ND3SDdGb-XV665r23RB40GJ3Q9brJALGaJhij4uceXZNYbakZVOxgqLuDnX6EgABwEzrZb7vhVAKCewVYkLDu0LiS1rIvcFT8HpovxaBU2F2kVG7TRvzYewCW9zXWnAR048p5pUvi6zfMzapk8bnl4uM_uD45gp1sMzeSHryai1U0AUO2cLeQV1pA7KJoJBwWlHxo0YNPbDidI2KfByIoI0A7oiKoZ32vJkiwx3BEGePnzb-JQnv1eDXwlimICVKEVPk1BxpUZ2XBoWdUGYR77u5NGmZ2sKh4OM-qIaB0VaChGsCsJLyQ5_MCkeOm9EMjg1cXbIHDzs9jpF2BXlowY1Vw_L-Ve6nzwK7ZcyHM3ij27wEXYO2On6zbN_AqOvX_CFAjI7ktCYF2guftXuVpFCuiqRyDZ6i2RHXMhR77CoPT97sAvXDejN8feNtidqq4OH5uLa3BHYvW0UKfNlBCOL6A6927l4iTKURZznq_mVjLgTHWv9k-ByxP0hC5sIQHyB5hJaD8_svMr4Aqz_vH9Z8HShgjK47NsMQKxGGgaXdnq3xEdwydM-hTG4Pi35o6Kt0bbJ5KTRQ2ObjmnVTG7J__QTKMTrK2S6Ro4VIMrYzaai7BTLa8MGNotj\"},{\"type\":\"function_call\",\"call_id\":\"call_hwPdXfzZmrdySXU2ZmrL51Ln\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":{}}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_hwPdXfzZmrdySXU2ZmrL51Ln\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"store\":false,\"prompt_cache_key\":\"session-recorded-opencode-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":32000,\"stream\":true}" + "body": "{\"model\":\"gpt-5.2-codex\",\"input\":[{\"role\":\"system\",\"content\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"reasoning\",\"id\":\"rs_0fdce240b46054ad016a1214d326848196b269feebe1844759\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEhTTGeallj_mC3ciDydiTVJLA6bjJfitoj4ftFfWwlxekFNaf_cDNWP3pE6qsvK9gKJNRfbAbpaEVf1qjAhQx53witrmt6H3KaaNJm3wXHG5sEi9gp3nLWK4T76tcVYHG1x6mbbTjEjCvhIuEkn_7Q7lJ1BErkEURYBBMPmkKya2-YuL8XP14Yrko9BA1t56BkwK5U3TFse4nwHI1qi82hdkX_aYAtz6YgbTpf-dvOCBGfeApxWLFotkt355Qy2b6MmPaH6cQwrvLJXOqEzGkwxFcs3mLEKLV103gd8Z5e_OapjJHTv_LarN-WN9C7nCQ0BBHClk4ND3SDdGb-XV665r23RB40GJ3Q9brJALGaJhij4uceXZNYbakZVOxgqLuDnX6EgABwEzrZb7vhVAKCewVYkLDu0LiS1rIvcFT8HpovxaBU2F2kVG7TRvzYewCW9zXWnAR048p5pUvi6zfMzapk8bnl4uM_uD45gp1sMzeSHryai1U0AUO2cLeQV1pA7KJoJBwWlHxo0YNPbDidI2KfByIoI0A7oiKoZ32vJkiwx3BEGePnzb-JQnv1eDXwlimICVKEVPk1BxpUZ2XBoWdUGYR77u5NGmZ2sKh4OM-qIaB0VaChGsCsJLyQ5_MCkeOm9EMjg1cXbIHDzs9jpF2BXlowY1Vw_L-Ve6nzwK7ZcyHM3ij27wEXYO2On6zbN_AqOvX_CFAjI7ktCYF2guftXuVpFCuiqRyDZ6i2RHXMhR77CoPT97sAvXDejN8feNtidqq4OH5uLa3BHYvW0UKfNlBCOL6A6927l4iTKURZznq_mVjLgTHWv9k-ByxP0hC5sIQHyB5hJaD8_svMr4Aqz_vH9Z8HShgjK47NsMQKxGGgaXdnq3xEdwydM-hTG4Pi35o6Kt0bbJ5KTRQ2ObjmnVTG7J__QTKMTrK2S6Ro4VIMrYzaai7BTLa8MGNotj\"},{\"type\":\"function_call\",\"call_id\":\"call_hwPdXfzZmrdySXU2ZmrL51Ln\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":{}}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_hwPdXfzZmrdySXU2ZmrL51Ln\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"prompt_cache_key\":\"session-recorded-opencode-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":32000,\"stream\":true}" }, "response": { "status": 200, diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 5fa530155d..aa1b8eee1b 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -4,6 +4,7 @@ import { ProviderTransform } from "@/provider/transform" import { LLMRequestPrep } from "@/session/llm/request" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" +import { jsonSchema } from "ai" describe("ProviderTransform.options - setCacheKey", () => { const sessionID = "test-session-123" @@ -384,7 +385,12 @@ describe("ProviderTransform.options - gpt-5 textVerbosity", () => { } as any, system: [], messages: [{ role: "user", content: "Hello" }], - tools: {}, + tools: { + lookup: { + description: "Look up a value", + inputSchema: jsonSchema({ type: "object", properties: {} }), + }, + }, provider: { id: "azure", options: { useCompletionUrls: true } } as any, auth: undefined, plugin: { @@ -399,6 +405,7 @@ describe("ProviderTransform.options - gpt-5 textVerbosity", () => { expect(result.params.options.reasoningEffort).toBe("high") expect(result.params.options.reasoningSummary).toBeUndefined() expect(result.params.options.include).toBeUndefined() + expect(result.tools.lookup.strict).toBe(false) }) test("gpt-5.1 should have textVerbosity set to low", () => { diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 0c5dadaf17..868477a910 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -1314,6 +1314,7 @@ describe("session.llm.stream", () => { type: "function", name: "lookup", description: "Lookup data", + strict: false, parameters: { type: "object", properties: { query: { type: "string" } }, @@ -1402,6 +1403,7 @@ describe("session.llm.stream", () => { type: "function", name: "lookup", description: "Lookup data", + strict: false, parameters: { type: "object", properties: { query: { type: "string" } }, From 17166b271fb9d7bf7128f0e63732dde0c10dd963 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 09:22:36 +1000 Subject: [PATCH 22/26] fix(app): simplify question prompt (#33968) Co-authored-by: Test --- .../composer/session-question-dock.tsx | 32 ++++++++++--------- .../src/components/message-part.css | 2 ++ 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/packages/app/src/pages/session/composer/session-question-dock.tsx b/packages/app/src/pages/session/composer/session-question-dock.tsx index 32d2bc4fe5..93ab8818c6 100644 --- a/packages/app/src/pages/session/composer/session-question-dock.tsx +++ b/packages/app/src/pages/session/composer/session-question-dock.tsx @@ -432,21 +432,23 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit header={ <>
{summary()}
-
- - {(_, i) => ( -
+ 1}> +
+ + {(_, i) => ( +
+
} footer={ diff --git a/packages/session-ui/src/components/message-part.css b/packages/session-ui/src/components/message-part.css index 78d574d8a1..1b50a70e2f 100644 --- a/packages/session-ui/src/components/message-part.css +++ b/packages/session-ui/src/components/message-part.css @@ -918,6 +918,8 @@ line-height: var(--line-height-large); color: var(--text-strong); padding: 0 10px; + -webkit-user-select: text; + user-select: text; } [data-slot="question-hint"] { From f428755851e12ad066c36f328f55414ddc3c7a54 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Thu, 25 Jun 2026 20:31:00 -0500 Subject: [PATCH 23/26] feat(core): split MCP timeout configuration (#33977) Co-authored-by: Test --- packages/core/src/config/mcp.ts | 15 ++++++++++++--- packages/core/src/v1/config/migrate.ts | 6 +++--- packages/core/test/config/config.test.ts | 23 ++++++++++++++++------- specs/v2/config.md | 17 ++++++++++------- 4 files changed, 41 insertions(+), 20 deletions(-) diff --git a/packages/core/src/config/mcp.ts b/packages/core/src/config/mcp.ts index 54998e1850..f3a5ac9b25 100644 --- a/packages/core/src/config/mcp.ts +++ b/packages/core/src/config/mcp.ts @@ -3,6 +3,15 @@ export * as ConfigMCP from "./mcp" import { Schema } from "effect" import { PositiveInt } from "../schema" +export class Timeout extends Schema.Class("ConfigV2.MCP.Timeout")({ + startup: PositiveInt.pipe(Schema.optional).annotate({ + description: "Maximum time in milliseconds to establish and initialize the MCP server.", + }), + request: PositiveInt.pipe(Schema.optional).annotate({ + description: "Maximum time in milliseconds to wait for each MCP request after initialization.", + }), +}) {} + export class Local extends Schema.Class("ConfigV2.MCP.Local")({ type: Schema.Literal("local"), command: Schema.String.pipe(Schema.Array), @@ -11,7 +20,7 @@ export class Local extends Schema.Class("ConfigV2.MCP.Local")({ }), environment: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), disabled: Schema.Boolean.pipe(Schema.optional), - timeout: PositiveInt.pipe(Schema.optional), + timeout: Timeout.pipe(Schema.optional), }) {} export class OAuth extends Schema.Class("ConfigV2.MCP.OAuth")({ @@ -28,12 +37,12 @@ export class Remote extends Schema.Class("ConfigV2.MCP.Remote")({ headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), oauth: Schema.Union([OAuth, Schema.Literal(false)]).pipe(Schema.optional), disabled: Schema.Boolean.pipe(Schema.optional), - timeout: PositiveInt.pipe(Schema.optional), + timeout: Timeout.pipe(Schema.optional), }) {} export const Server = Schema.Union([Local, Remote]).pipe(Schema.toTaggedUnion("type")) export class Info extends Schema.Class("ConfigV2.MCP")({ - timeout: PositiveInt.pipe(Schema.optional), + timeout: Timeout.pipe(Schema.optional), servers: Schema.Record(Schema.String, Server).pipe(Schema.optional), }) {} diff --git a/packages/core/src/v1/config/migrate.ts b/packages/core/src/v1/config/migrate.ts index d0cc465e57..ebe1b905ff 100644 --- a/packages/core/src/v1/config/migrate.ts +++ b/packages/core/src/v1/config/migrate.ts @@ -132,7 +132,7 @@ function mcp(info: typeof ConfigV1.Info.Type) { ) const timeout = info.experimental?.mcp_timeout if (!timeout && !Object.keys(servers).length) return undefined - return { timeout, servers } + return { timeout: timeout === undefined ? undefined : { request: timeout }, servers } } function migrateMcp(info: ConfigMCPV1.Info) { @@ -144,7 +144,7 @@ function migrateMcp(info: ConfigMCPV1.Info) { cwd: info.cwd, environment: info.environment, disabled, - timeout: info.timeout, + timeout: info.timeout === undefined ? undefined : { request: info.timeout }, } return { type: info.type, @@ -158,7 +158,7 @@ function migrateMcp(info: ConfigMCPV1.Info) { redirect_uri: info.oauth.redirectUri, }, disabled, - timeout: info.timeout, + timeout: info.timeout === undefined ? undefined : { request: info.timeout }, } } diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 4b31835726..3e23e20c70 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -298,14 +298,14 @@ describe("Config", () => { }, tool_output: { max_lines: 1000, max_bytes: 32768 }, mcp: { - timeout: 5000, + timeout: { startup: 5000, request: 60000 }, servers: { local: { type: "local", command: ["node", "./mcp/server.js"], environment: { API_KEY: "secret" }, disabled: false, - timeout: 10000, + timeout: { request: 10000 }, }, remote: { type: "remote", @@ -313,6 +313,7 @@ describe("Config", () => { headers: { Authorization: "Bearer token" }, oauth: { client_id: "client", scope: "read write", callback_port: 19876 }, disabled: true, + timeout: { startup: 15000 }, }, }, }, @@ -383,14 +384,14 @@ describe("Config", () => { }) expect(documents[0]?.info.tool_output).toEqual({ max_lines: 1000, max_bytes: 32768 }) expect(documents[0]?.info.mcp).toEqual({ - timeout: 5000, + timeout: { startup: 5000, request: 60000 }, servers: { local: { type: "local", command: ["node", "./mcp/server.js"], environment: { API_KEY: "secret" }, disabled: false, - timeout: 10000, + timeout: { request: 10000 }, }, remote: { type: "remote", @@ -398,6 +399,7 @@ describe("Config", () => { headers: { Authorization: "Bearer token" }, oauth: { client_id: "client", scope: "read write", callback_port: 19876 }, disabled: true, + timeout: { startup: 15000 }, }, }, }) @@ -541,11 +543,12 @@ describe("Config", () => { compaction: { auto: true, tail_turns: 3, preserve_recent_tokens: 2000, reserved: 10000 }, experimental: { mcp_timeout: 5000 }, mcp: { - local: { type: "local", command: ["node", "server.js"], enabled: false }, + local: { type: "local", command: ["node", "server.js"], enabled: false, timeout: 10000 }, remote: { type: "remote", url: "https://mcp.example.com", oauth: { clientId: "client", callbackPort: 19876 }, + timeout: 20000, }, }, }), @@ -623,13 +626,19 @@ describe("Config", () => { buffer: 10000, }) expect(documents[0]?.info.mcp).toMatchObject({ - timeout: 5000, + timeout: { request: 5000 }, servers: { - local: { type: "local", command: ["node", "server.js"], disabled: true }, + local: { + type: "local", + command: ["node", "server.js"], + disabled: true, + timeout: { request: 10000 }, + }, remote: { type: "remote", url: "https://mcp.example.com", oauth: { client_id: "client", callback_port: 19876 }, + timeout: { request: 20000 }, }, }, }) diff --git a/specs/v2/config.md b/specs/v2/config.md index 9804b14be6..9254af2094 100644 --- a/specs/v2/config.md +++ b/specs/v2/config.md @@ -304,23 +304,25 @@ Rename legacy `permission` to `permissions` and expose the normalized ordered ru External protocol and server integration configuration. -| Field | Current Purpose | Status | Notes | -| ----- | ------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| `mcp` | MCP server definitions and enablement | redesign | Keep opencode's explicit local/remote server entry format, nested under `mcp.servers`; use `disabled` for inactive entries and move timeout here. | +| Field | Current Purpose | Status | Notes | +| ----- | ------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `mcp` | MCP server definitions and enablement | redesign | Keep opencode's explicit local/remote server entry format, nested under `mcp.servers`; use `disabled` for inactive entries and move timeout defaults here. | -Keep the opencode MCP server entry format instead of adopting the common `mcpServers` copy/paste shape. Local servers remain explicit `type: "local"` entries with command arrays and `environment`; remote servers remain explicit `type: "remote"` entries with `url`, `headers`, and optional `oauth`. Nest the server map under `mcp.servers` so protocol-wide settings such as default timeout can live under the same subsystem. +Keep the opencode MCP server entry format instead of adopting the common `mcpServers` copy/paste shape. Local servers remain explicit `type: "local"` entries with command arrays and `environment`; remote servers remain explicit `type: "remote"` entries with `url`, `headers`, and optional `oauth`. Nest the server map under `mcp.servers` so protocol-wide settings such as timeout defaults can live under the same subsystem. + +MCP timeouts have separate startup and request budgets, expressed in milliseconds. `startup` covers establishing the transport and completing MCP initialization. `request` applies independently to each post-initialization MCP request. A server may override either default without repeating the other. ```jsonc { "mcp": { - "timeout": 5000, + "timeout": { "startup": 30000, "request": 300000 }, "servers": { "github": { "type": "local", "command": ["npx", "-y", "@github/github-mcp-server"], "environment": { "GITHUB_TOKEN": "{env:GITHUB_TOKEN}" }, "disabled": false, - "timeout": 10000, + "timeout": { "startup": 60000 }, }, "docs": { "type": "remote", @@ -334,6 +336,7 @@ Keep the opencode MCP server entry format instead of adopting the common `mcpSer "redirect_uri": "http://127.0.0.1:19876/mcp/oauth/callback", }, "disabled": false, + "timeout": { "request": 600000 }, }, }, }, @@ -375,7 +378,7 @@ Fields that should not be ported by inertia; each needs an explicit justificatio | `experimental.openTelemetry` | Enable AI SDK telemetry spans | remove | Do not port; observability is process-level and should use standard OpenTelemetry environment or declarative configuration. | | `experimental.primary_tools` | Restrict tools to primary agents | remove | Do not port obsolete gating; agent tool access is configured through permissions. | | `experimental.continue_loop_on_deny` | Continue loop after denied tool call | remove | Do not port legacy denied-tool loop behavior. | -| `experimental.mcp_timeout` | MCP request timeout | redesign | Move to `mcp.timeout` for the default and `mcp.servers..timeout` for per-server overrides. | +| `experimental.mcp_timeout` | MCP request timeout | redesign | Move to `mcp.timeout.request` for the default and `mcp.servers..timeout.request` for per-server overrides. | ## Review Order From ded29f03f0d575086f11eede64b4f13f92756d08 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 25 Jun 2026 20:31:53 -0500 Subject: [PATCH 24/26] fix(core): refine small model defaults (#33926) --- packages/core/src/catalog.ts | 5 + packages/opencode/src/provider/provider.ts | 48 +++++----- .../opencode/test/provider/provider.test.ts | 96 +++++++++++++++++++ 3 files changed, 125 insertions(+), 24 deletions(-) diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index 5f90b09f30..63d0ae37cb 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -235,6 +235,11 @@ export const layer = Layer.effect( if (!record) return const provider = record.provider + // TODO: Remove these provider-specific assumptions once model syncing reliably reports available deployments. + if (providerID === ProviderV2.ID.azure || providerID === ProviderV2.ID.make("azure-cognitive-services")) { + return + } + if (providerID === ProviderV2.ID.opencode) { const gpt5Nano = record.models.get(ModelV2.ID.make("gpt-5-nano")) if (gpt5Nano?.enabled && gpt5Nano.status === "active") return projectModel(gpt5Nano, provider) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 46c78dc0e6..a7fae742d7 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1868,44 +1868,43 @@ export const layer = Layer.effect( } } - const defaultPriority = [ - "claude-haiku-4-5", - "claude-haiku-4.5", - "3-5-haiku", - "3.5-haiku", - "gemini-3-flash", - "gemini-2.5-flash", - "gpt-5-nano", - ] + // TODO: Remove these provider-specific assumptions once model syncing reliably reports available deployments. + if (providerID === ProviderV2.ID.azure || providerID === ProviderV2.ID.make("azure-cognitive-services")) { + return undefined + } + const priority = providerID.startsWith("opencode") - ? ["gpt-5-nano"] + ? ["gpt-nano"] : providerID.startsWith("github-copilot") - ? ["gpt-5-mini", "claude-haiku-4.5", ...defaultPriority] - : defaultPriority - for (const item of priority) { + ? ["gpt-mini", ...smallModelFamilyPriority] + : smallModelFamilyPriority + const models = sortBy( + Object.values(provider.models), + [(model) => model.release_date, "desc"], + [(model) => model.id, "desc"], + ) + for (const family of priority) { + const candidates = models.filter((model) => model.family === family) if (providerID === ProviderV2.ID.amazonBedrock) { const crossRegionPrefixes = ["global.", "us.", "eu."] - const candidates = Object.keys(provider.models).filter((m) => m.includes(item)) - const globalMatch = candidates.find((m) => m.startsWith("global.")) - if (globalMatch) return provider.models[globalMatch] + const globalMatch = candidates.find((model) => model.id.startsWith("global.")) + if (globalMatch) return globalMatch const region = provider.options?.region if (region) { const regionPrefix = region.split("-")[0] if (regionPrefix === "us" || regionPrefix === "eu") { - const regionalMatch = candidates.find((m) => m.startsWith(`${regionPrefix}.`)) - if (regionalMatch) return provider.models[regionalMatch] + const regionalMatch = candidates.find((model) => model.id.startsWith(`${regionPrefix}.`)) + if (regionalMatch) return regionalMatch } } - const unprefixed = candidates.find((m) => !crossRegionPrefixes.some((p) => m.startsWith(p))) - if (unprefixed) return provider.models[unprefixed] - } else { - for (const model of Object.keys(provider.models)) { - if (model.includes(item)) return provider.models[model] - } + const unprefixed = candidates.find((model) => !crossRegionPrefixes.some((p) => model.id.startsWith(p))) + if (unprefixed) return unprefixed + continue } + if (candidates[0]) return candidates[0] } return undefined @@ -1962,6 +1961,7 @@ export const defaultLayer = Layer.suspend(() => ) const priority = ["gpt-5", "claude-sonnet-4", "big-pickle", "gemini-3-pro"] +const smallModelFamilyPriority = ["gemini-flash", "gpt-nano", "claude-haiku"] export function sort(models: T[]) { return sortBy( models, diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 05d22aec41..d3f29204be 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -652,6 +652,102 @@ it.instance("getSmallModel returns appropriate small model", () => }), ) +it.instance("getSmallModel prefers Gemini for Google Vertex", () => + Effect.gen(function* () { + yield* set("GOOGLE_VERTEX_PROJECT", "test-project") + const model = yield* Provider.use.getSmallModel(ProviderV2.ID.googleVertex) + expect(model).toBeDefined() + expect(model?.id).toContain("gemini") + }), +) + +it.instance( + "getSmallModel selects the latest model in the preferred family", + Effect.gen(function* () { + const model = yield* Provider.use.getSmallModel(ProviderV2.ID.make("test-provider")) + expect(model?.id).toBe(ModelV2.ID.make("new-flash")) + }), + { + config: { + provider: { + "test-provider": { + name: "Test Provider", + npm: "@ai-sdk/openai-compatible", + models: { + "old-flash": { family: "gemini-flash", release_date: "2025-01-01" }, + "new-flash": { family: "gemini-flash", release_date: "2026-01-01" }, + "newer-haiku": { family: "claude-haiku", release_date: "2026-06-01" }, + }, + options: { apiKey: "test-key" }, + }, + }, + }, + }, +) + +it.instance( + "getSmallModel matches exact model families", + Effect.gen(function* () { + const model = yield* Provider.use.getSmallModel(ProviderV2.ID.make("test-provider")) + expect(model?.id).toBe(ModelV2.ID.make("claude-haiku")) + }), + { + config: { + provider: { + "test-provider": { + name: "Test Provider", + npm: "@ai-sdk/openai-compatible", + models: { + "glm-flash": { family: "glm-flash", release_date: "2026-06-01" }, + "claude-haiku": { family: "claude-haiku", release_date: "2026-01-01" }, + }, + options: { apiKey: "test-key" }, + }, + }, + }, + }, +) + +it.instance( + "getSmallModel ignores model IDs without family metadata", + Effect.gen(function* () { + const model = yield* Provider.use.getSmallModel(ProviderV2.ID.make("test-provider")) + expect(model).toBeUndefined() + }), + { + config: { + provider: { + "test-provider": { + name: "Test Provider", + npm: "@ai-sdk/openai-compatible", + models: { + "gpt-5-nano": { release_date: "2026-01-01" }, + }, + options: { apiKey: "test-key" }, + }, + }, + }, + }, +) + +it.instance("getSmallModel skips inferred models for Azure", () => + Effect.gen(function* () { + yield* set("AZURE_RESOURCE_NAME", "test-resource") + yield* set("AZURE_API_KEY", "test-key") + const model = yield* Provider.use.getSmallModel(ProviderV2.ID.azure) + expect(model).toBeUndefined() + }), +) + +it.instance("getSmallModel skips inferred models for Azure Cognitive Services", () => + Effect.gen(function* () { + yield* set("AZURE_COGNITIVE_SERVICES_RESOURCE_NAME", "test-resource") + yield* set("AZURE_COGNITIVE_SERVICES_API_KEY", "test-key") + const model = yield* Provider.use.getSmallModel(ProviderV2.ID.make("azure-cognitive-services")) + expect(model).toBeUndefined() + }), +) + it.instance( "getSmallModel respects config small_model override", Effect.gen(function* () { From 0f272d931d806681a0f7f19538e2f3f1d52d1832 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 25 Jun 2026 20:32:36 -0500 Subject: [PATCH 25/26] tweak: hide MCP access token prefix (#33711) --- packages/opencode/src/cli/cmd/mcp.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index b24cae9aec..d26ac626b8 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -708,7 +708,9 @@ export const McpDebugCommand = effectCmd({ prompts.log.info(`Auth status: ${getAuthStatusIcon(authStatus)} ${getAuthStatusText(authStatus)}`) if (entry?.tokens) { - prompts.log.info(` Access token: ${entry.tokens.accessToken.substring(0, 20)}...`) + prompts.log.info( + ` Access token: ${entry.tokens.accessToken.length > 8 ? `${entry.tokens.accessToken.slice(0, 4)}***${entry.tokens.accessToken.slice(-4)}` : "***"}`, + ) if (entry.tokens.expiresAt) { const expiresDate = new Date(entry.tokens.expiresAt * 1000) const isExpired = entry.tokens.expiresAt < Date.now() / 1000 From 28a00ad6afaa3f96f561ad3aeb62f8b2aea170bb Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 11:55:21 +1000 Subject: [PATCH 26/26] fix(app): enable auto-accept in session settings (#33974) --- .../app/src/components/settings-dialog.tsx | 43 +++++++++++++++++++ .../settings-v2/dialog-settings-v2.tsx | 6 ++- .../src/components/settings-v2/general.tsx | 34 ++++++--------- packages/app/src/pages/home.tsx | 8 +--- packages/app/src/pages/layout-new.tsx | 20 --------- packages/app/src/pages/new-session.tsx | 2 + packages/app/src/pages/session.tsx | 2 + 7 files changed, 67 insertions(+), 48 deletions(-) create mode 100644 packages/app/src/components/settings-dialog.tsx diff --git a/packages/app/src/components/settings-dialog.tsx b/packages/app/src/components/settings-dialog.tsx new file mode 100644 index 0000000000..d2cc85c04e --- /dev/null +++ b/packages/app/src/components/settings-dialog.tsx @@ -0,0 +1,43 @@ +import { useParams } from "@solidjs/router" +import { onCleanup } from "solid-js" +import { useCommand } from "@/context/command" +import { useLanguage } from "@/context/language" +import { useDialog } from "@opencode-ai/ui/context/dialog" + +export function useSettingsDialog() { + const dialog = useDialog() + const params = useParams<{ id?: string }>() + let run = 0 + let dead = false + + onCleanup(() => { + dead = true + }) + + return () => { + const current = ++run + const sessionID = params.id + void import("@/components/settings-v2").then((module) => { + if (dead || run !== current) return + void dialog.show(() => ) + }) + } +} + +export function useSettingsCommand() { + const command = useCommand() + const language = useLanguage() + const show = useSettingsDialog() + + command.register("settings", () => [ + { + id: "settings.open", + title: language.t("command.settings.open"), + category: language.t("command.category.settings"), + keybind: "mod+comma", + onSelect: show, + }, + ]) + + return show +} diff --git a/packages/app/src/components/settings-v2/dialog-settings-v2.tsx b/packages/app/src/components/settings-v2/dialog-settings-v2.tsx index cfe6971b12..a93a10dcb2 100644 --- a/packages/app/src/components/settings-v2/dialog-settings-v2.tsx +++ b/packages/app/src/components/settings-v2/dialog-settings-v2.tsx @@ -11,7 +11,9 @@ import { SettingsModelsV2 } from "./models" import "./settings-v2.css" import { SettingsServersV2 } from "./servers" -export const DialogSettings: Component = () => { +export const DialogSettings: Component<{ + sessionID?: string +}> = (props) => { const language = useLanguage() const platform = usePlatform() @@ -62,7 +64,7 @@ export const DialogSettings: Component = () => {
- + diff --git a/packages/app/src/components/settings-v2/general.tsx b/packages/app/src/components/settings-v2/general.tsx index c4d3e0d9e7..0dd48a5523 100644 --- a/packages/app/src/components/settings-v2/general.tsx +++ b/packages/app/src/components/settings-v2/general.tsx @@ -6,7 +6,6 @@ import { Switch } from "@opencode-ai/ui/v2/switch-v2" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" import { useDialog } from "@opencode-ai/ui/context/dialog" -import { useParams } from "@solidjs/router" import { useLanguage } from "@/context/language" import { usePermission } from "@/context/permission" import { usePlatform } from "@/context/platform" @@ -25,7 +24,6 @@ import { terminalInput, useSettings, } from "@/context/settings" -import { decode64 } from "@/utils/base64" import { playSoundById, SOUND_OPTIONS } from "@/utils/sound" import { Link } from "../link" import { SettingsListV2 } from "./parts/list" @@ -82,50 +80,46 @@ const playDemoSound = (id: string | undefined) => { }, 100) } -export const SettingsGeneralV2: Component = () => { +export const SettingsGeneralV2: Component<{ + sessionID?: string +}> = (props) => { const theme = useTheme() const language = useLanguage() const permission = usePermission() const platform = usePlatform() const dialog = useDialog() - const params = useParams() const settings = useSettings() + const serverSync = useServerSync() + const serverSdk = useServerSDK() const mobile = createMediaQuery("(max-width: 767px)") const updater = useUpdaterAction() - const dir = createMemo(() => decode64(params.dir)) + const dir = createMemo(() => { + if (!props.sessionID) return undefined + return serverSync().session.lineage.peek(props.sessionID)?.session.directory + }) const accepting = createMemo(() => { const value = dir() - if (!value) return false - if (!params.id) return permission.isAutoAcceptingDirectory(value) - return permission.isAutoAccepting(params.id, value) + if (!value || !props.sessionID) return false + return permission.isAutoAccepting(props.sessionID, value) }) const toggleAccept = (checked: boolean) => { const value = dir() - if (!value) return - - if (!params.id) { - if (permission.isAutoAcceptingDirectory(value) === checked) return - permission.toggleAutoAcceptDirectory(value) - return - } + if (!value || !props.sessionID) return if (checked) { - permission.enableAutoAccept(params.id, value) + permission.enableAutoAccept(props.sessionID, value) return } - permission.disableAutoAccept(params.id, value) + permission.disableAutoAccept(props.sessionID, value) } const desktop = createMemo(() => platform.platform === "desktop") const themeOptions = createMemo(() => theme.ids().map((id) => ({ id, name: theme.name(id) }))) - const serverSync = useServerSync() - const serverSdk = useServerSDK() - const [shells] = createResource( () => serverSdk() diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 6686ab388e..9f61713ef7 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -33,6 +33,7 @@ import { usePlatform } from "@/context/platform" import { DateTime } from "luxon" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDirectoryPicker } from "@/components/directory-picker" +import { useSettingsCommand } from "@/components/settings-dialog" import { DialogSelectServer, useServerManagementController } from "@/components/dialog-select-server" import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2" import { ServerConnection, serverName, useServer } from "@/context/server" @@ -144,6 +145,7 @@ export function NewHome() { const command = useCommand() const notification = useNotification() const marked = useMarked() + const openSettings = useSettingsCommand() let focusSessionSearch: (() => void) | undefined const [state, setState] = createStore({ search: "", @@ -402,12 +404,6 @@ export function NewHome() { }) } - function openSettings() { - void import("@/components/settings-v2").then((x) => { - dialog.show(() => ) - }) - } - return (
diff --git a/packages/app/src/pages/layout-new.tsx b/packages/app/src/pages/layout-new.tsx index 33972f7ceb..f9222cdeb7 100644 --- a/packages/app/src/pages/layout-new.tsx +++ b/packages/app/src/pages/layout-new.tsx @@ -3,18 +3,12 @@ import { useNavigate, useParams } from "@solidjs/router" import { DebugBar } from "@/components/debug-bar" import { HelpButton } from "@/components/help-button" import { Titlebar, type TitlebarUpdate } from "@/components/titlebar" -import { useCommand } from "@/context/command" -import { useDialog } from "@opencode-ai/ui/context/dialog" -import { useLanguage } from "@/context/language" import { useNotification } from "@/context/notification" import { usePlatform } from "@/context/platform" import { setNavigate } from "@/utils/notification-click" import { setV2Toast, ToastRegion } from "@/utils/toast" export default function NewLayout(props: ParentProps) { - const command = useCommand() - const dialog = useDialog() - const language = useLanguage() const platform = usePlatform() const notification = useNotification() const navigate = useNavigate() @@ -28,20 +22,6 @@ export default function NewLayout(props: ParentProps) { notification.session.markViewed(params.id) }) - command.register("layout", () => [ - { - id: "settings.open", - title: language.t("command.settings.open"), - category: language.t("command.category.settings"), - keybind: "mod+comma", - onSelect: () => { - void import("@/components/settings-v2").then((x) => { - dialog.show(() => ) - }) - }, - }, - ]) - const update: TitlebarUpdate = { version: () => { const state = platform.updater?.state() diff --git a/packages/app/src/pages/new-session.tsx b/packages/app/src/pages/new-session.tsx index fb07221b69..7ea378e3ea 100644 --- a/packages/app/src/pages/new-session.tsx +++ b/packages/app/src/pages/new-session.tsx @@ -3,6 +3,7 @@ import { createStore } from "solid-js/store" import { useSearchParams } from "@solidjs/router" import { NewSessionDesignView } from "@/components/session" import { PromptInput } from "@/components/prompt-input" +import { useSettingsCommand } from "@/components/settings-dialog" import { PromptProjectAddButton, PromptProjectSelector, @@ -35,6 +36,7 @@ export default function NewSessionPage() { const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>() useComposerCommands() + useSettingsCommand() let inputRef: HTMLDivElement | undefined diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index b65c39d859..97560a179f 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -43,6 +43,7 @@ import { useSettings } from "@/context/settings" import { useSync } from "@/context/sync" import { useTerminal } from "@/context/terminal" import { PromptInput } from "@/components/prompt-input" +import { useSettingsCommand } from "@/components/settings-dialog" import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit" import { createPromptInputController, @@ -787,6 +788,7 @@ export default function Page() { } useComposerCommands() + useSettingsCommand() useSessionCommands({ navigateMessageByOffset, setActiveMessage,