Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d3ace428d6 | |||
| 8a819da177 | |||
| 89ef53537e | |||
| 70cecc6ba1 | |||
| 04c6bed240 | |||
| 94e3a29d2f | |||
| 41283933ff | |||
| 11bf8d8a42 | |||
| c22973ab9f | |||
| 612009e0f1 | |||
| a0a6c9545e | |||
| bceb6d0a9e | |||
| 49e7dc191f | |||
| f4720be08e | |||
| d1d7ebc2c6 | |||
| cd942d0669 | |||
| 4741e3ee6b | |||
| 4673bfbaa0 | |||
| b458dd8c63 | |||
| 7edd6c3a1d | |||
| f43f066741 | |||
| b6553d14e1 | |||
| ab0042a666 | |||
| 5df049d081 |
@@ -83,6 +83,7 @@
|
||||
"@types/luxon": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vite-plugin-icons-spritesheet": "3.0.1",
|
||||
@@ -105,12 +106,15 @@
|
||||
"@opentui/solid": "catalog:",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"effect": "catalog:",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"semver": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/semver": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
@@ -992,6 +996,7 @@
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vite-plugin-icons-spritesheet": "3.0.1",
|
||||
@@ -1050,6 +1055,7 @@
|
||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
|
||||
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
|
||||
@@ -5317,6 +5323,8 @@
|
||||
|
||||
"turndown": ["turndown@7.2.0", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A=="],
|
||||
|
||||
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
|
||||
|
||||
"tw-to-css": ["tw-to-css@0.0.12", "", { "dependencies": { "postcss": "8.4.31", "postcss-css-variables": "0.18.0", "tailwindcss": "3.3.2" } }, "sha512-rQAsQvOtV1lBkyCw+iypMygNHrShYAItES5r8fMsrhhaj5qrV2LkZyXc8ccEH+u5bFjHjQ9iuxe90I7Kykf6pw=="],
|
||||
|
||||
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
|
||||
@@ -5975,6 +5983,8 @@
|
||||
|
||||
"@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
|
||||
|
||||
"@opencode-ai/cli/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
|
||||
"@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
|
||||
|
||||
"@opencode-ai/core/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-OiWvZ57vuyHwiIKNtW1n1KX+MLmOXVG3x4fLKvUoGQw=",
|
||||
"aarch64-linux": "sha256-RnPLxVEg/UsL5IeIFWmXMSLUOG6rVrajYxhyDYj1vTA=",
|
||||
"aarch64-darwin": "sha256-KPIgcBA0pTFBPrCTSZgIbvEorbtWcMgXvyX9bFAypVs=",
|
||||
"x86_64-darwin": "sha256-6jVU7/uVId0VD24MVQ8s8Ill5b6PsKdlBgHg+oceKRg="
|
||||
"x86_64-linux": "sha256-JXQ9PAqqRlJtHa8T3ZxqdRRyxC+0ip+2wSnehvKXUbI=",
|
||||
"aarch64-linux": "sha256-nI+RaxDXmAcjhSjCtIvyi292xBEg0E5NXaoyGrJE69s=",
|
||||
"aarch64-darwin": "sha256-UleKpm7Khf3kibm4BqZJ9bmu0N2kOjNd77g1Bd2tmfw=",
|
||||
"x86_64-darwin": "sha256-V4AQH868dOfVhEj1mKTnZlhpZwFqBYsHS28Tx9VXHkE="
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -2,6 +2,7 @@
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode",
|
||||
"description": "AI-powered development tool",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.3.14",
|
||||
@@ -153,6 +154,7 @@
|
||||
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
|
||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||
"@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch"
|
||||
"@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch",
|
||||
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"@types/luxon": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vite-plugin-icons-spritesheet": "3.0.1",
|
||||
|
||||
+22
-12
@@ -38,7 +38,7 @@ import { HighlightsProvider } from "@/context/highlights"
|
||||
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
|
||||
import { LayoutProvider } from "@/context/layout"
|
||||
import { ModelsProvider } from "@/context/models"
|
||||
import { NotificationProvider } from "@/context/notification"
|
||||
import { NotificationProvider, useNotification } from "@/context/notification"
|
||||
import { PermissionProvider } from "@/context/permission"
|
||||
import { PromptProvider } from "@/context/prompt"
|
||||
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
|
||||
@@ -316,9 +316,7 @@ function ServerScopedProviders(props: ServerScopedShellProps) {
|
||||
return (
|
||||
<PermissionProvider directory={props.directory}>
|
||||
<LayoutProvider>
|
||||
<NotificationProvider directory={props.directory} sessionID={props.sessionID}>
|
||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||
</NotificationProvider>
|
||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||
</LayoutProvider>
|
||||
</PermissionProvider>
|
||||
)
|
||||
@@ -345,13 +343,23 @@ function NewAppLayout(props: ParentProps) {
|
||||
function TargetServerScopedProviders(props: ServerScopedShellProps) {
|
||||
return (
|
||||
<PermissionProvider directory={props.directory}>
|
||||
<NotificationProvider directory={props.directory} sessionID={props.sessionID}>
|
||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||
</NotificationProvider>
|
||||
<MarkSessionNotificationsViewed sessionID={props.sessionID} />
|
||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||
</PermissionProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function MarkSessionNotificationsViewed(props: { sessionID?: () => string | undefined }) {
|
||||
const notification = useNotification()
|
||||
createEffect(() => {
|
||||
const sessionID = props.sessionID?.()
|
||||
if (!notification.ready() || !sessionID) return
|
||||
if (notification.session.unseenCount(sessionID) === 0) return
|
||||
notification.session.markViewed(sessionID)
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
function SessionProviders(props: ParentProps) {
|
||||
return (
|
||||
<TerminalProvider>
|
||||
@@ -560,11 +568,13 @@ export function AppInterface(props: {
|
||||
component={props.router ?? Router}
|
||||
root={(routerProps) => (
|
||||
<TabsProvider>
|
||||
<ServerShell>
|
||||
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
|
||||
<NewAppLayout>{routerProps.children}</NewAppLayout>
|
||||
</Show>
|
||||
</ServerShell>
|
||||
<NotificationProvider>
|
||||
<ServerShell>
|
||||
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
|
||||
<NewAppLayout>{routerProps.children}</NewAppLayout>
|
||||
</Show>
|
||||
</ServerShell>
|
||||
</NotificationProvider>
|
||||
</TabsProvider>
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { batch, createEffect, onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
type Mem = Performance & {
|
||||
@@ -51,31 +52,62 @@ const bad = (n: number | undefined, limit: number, low = false) => {
|
||||
|
||||
const session = (path: string) => path.includes("/session")
|
||||
|
||||
function Cell(props: { bad?: boolean; dim?: boolean; label: string; tip: string; value: string; wide?: boolean }) {
|
||||
return (
|
||||
<Tooltip value={props.tip} placement="top">
|
||||
function Cell(props: {
|
||||
bad?: boolean
|
||||
dim?: boolean
|
||||
inline?: boolean
|
||||
label: string
|
||||
tip: string
|
||||
value: string
|
||||
wide?: boolean
|
||||
}) {
|
||||
const content = () => (
|
||||
<div
|
||||
classList={{
|
||||
"flex min-w-0 items-center": true,
|
||||
"min-h-[20px] w-fit flex-row justify-start gap-1.5 px-1.5 py-0.5 text-left": !!props.inline,
|
||||
"justify-center text-center": !props.inline,
|
||||
"min-h-[42px] w-full flex-col rounded-[8px] px-0.5 py-1": !props.inline,
|
||||
"col-span-2": !!props.wide && !props.inline,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"flex min-h-[42px] w-full min-w-0 flex-col items-center justify-center rounded-[8px] px-0.5 py-1 text-center": true,
|
||||
"col-span-2": !!props.wide,
|
||||
"text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70": true,
|
||||
}}
|
||||
>
|
||||
<div class="text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70">{props.label}</div>
|
||||
<div
|
||||
classList={{
|
||||
"text-[13px] leading-none font-bold tabular-nums sm:text-[14px]": true,
|
||||
"text-text-on-critical-base": !!props.bad,
|
||||
"opacity-70": !!props.dim,
|
||||
}}
|
||||
>
|
||||
{props.value}
|
||||
</div>
|
||||
{props.label}
|
||||
</div>
|
||||
<div
|
||||
classList={{
|
||||
"uppercase leading-none font-bold tabular-nums": true,
|
||||
"text-[11px]": !!props.inline,
|
||||
"text-[13px] sm:text-[14px]": !props.inline,
|
||||
"text-text-on-critical-base": !!props.bad,
|
||||
"opacity-70": !!props.dim,
|
||||
}}
|
||||
>
|
||||
{props.value}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (props.inline) {
|
||||
return (
|
||||
<TooltipV2 value={props.tip} placement="top">
|
||||
{content()}
|
||||
</TooltipV2>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip value={props.tip} placement="top">
|
||||
{content()}
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export function DebugBar() {
|
||||
export function DebugBar(props: { inline?: boolean } = {}) {
|
||||
const language = useLanguage()
|
||||
const location = useLocation()
|
||||
const routing = useIsRouting()
|
||||
@@ -101,7 +133,7 @@ export function DebugBar() {
|
||||
},
|
||||
})
|
||||
|
||||
const na = () => language.t("debugBar.na")
|
||||
const na = () => language.t("debugBar.na").toUpperCase()
|
||||
const heap = () => (state.heap.limit ? (state.heap.used ?? 0) / state.heap.limit : undefined)
|
||||
const heapv = () => {
|
||||
const value = heap()
|
||||
@@ -363,15 +395,30 @@ export function DebugBar() {
|
||||
return (
|
||||
<aside
|
||||
aria-label={language.t("debugBar.ariaLabel")}
|
||||
class="pointer-events-auto fixed bottom-3 right-3 z-50 hidden w-[308px] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl border border-border-base bg-surface-raised-stronger-non-alpha p-0.5 text-text-strong shadow-[var(--shadow-lg-border-base)] md:block sm:bottom-4 sm:right-4 sm:w-[324px]"
|
||||
classList={{
|
||||
"pointer-events-auto hidden overflow-hidden text-text-strong md:block": true,
|
||||
"mt-[-6px] w-full shrink-0 px-3 py-1": !!props.inline,
|
||||
"fixed bottom-3 right-3 z-50 w-[308px] max-w-[calc(100vw-1.5rem)] rounded-xl border border-border-base bg-surface-raised-stronger-non-alpha p-0.5 shadow-[var(--shadow-lg-border-base)] sm:bottom-4 sm:right-4 sm:w-[324px]":
|
||||
!props.inline,
|
||||
}}
|
||||
>
|
||||
<div class="grid grid-cols-5 gap-px font-mono">
|
||||
<div
|
||||
classList={{
|
||||
"font-mono": true,
|
||||
"gap-[9px]": !!props.inline,
|
||||
"gap-px": !props.inline,
|
||||
"flex w-full flex-nowrap items-center justify-start": !!props.inline,
|
||||
"grid-cols-5": !props.inline,
|
||||
grid: !props.inline,
|
||||
}}
|
||||
>
|
||||
<Cell
|
||||
label={language.t("debugBar.nav.label")}
|
||||
tip={language.t("debugBar.nav.tip")}
|
||||
value={navv()}
|
||||
bad={bad(state.nav.dur, 400)}
|
||||
dim={state.nav.dur === undefined && !state.nav.pending}
|
||||
inline={props.inline}
|
||||
/>
|
||||
<Cell
|
||||
label={language.t("debugBar.fps.label")}
|
||||
@@ -379,6 +426,7 @@ export function DebugBar() {
|
||||
value={state.fps === undefined ? na() : `${Math.round(state.fps)}`}
|
||||
bad={bad(state.fps, 50, true)}
|
||||
dim={state.fps === undefined}
|
||||
inline={props.inline}
|
||||
/>
|
||||
<Cell
|
||||
label={language.t("debugBar.frame.label")}
|
||||
@@ -386,6 +434,7 @@ export function DebugBar() {
|
||||
value={time(state.gap) ?? na()}
|
||||
bad={bad(state.gap, 50)}
|
||||
dim={state.gap === undefined}
|
||||
inline={props.inline}
|
||||
/>
|
||||
<Cell
|
||||
label={language.t("debugBar.jank.label")}
|
||||
@@ -393,6 +442,7 @@ export function DebugBar() {
|
||||
value={state.jank === undefined ? na() : `${state.jank}`}
|
||||
bad={bad(state.jank, 8)}
|
||||
dim={state.jank === undefined}
|
||||
inline={props.inline}
|
||||
/>
|
||||
<Cell
|
||||
label={language.t("debugBar.long.label")}
|
||||
@@ -400,6 +450,7 @@ export function DebugBar() {
|
||||
value={longv()}
|
||||
bad={bad(state.long.block, 200)}
|
||||
dim={state.long.count === undefined}
|
||||
inline={props.inline}
|
||||
/>
|
||||
<Cell
|
||||
label={language.t("debugBar.delay.label")}
|
||||
@@ -407,6 +458,7 @@ export function DebugBar() {
|
||||
value={time(state.delay) ?? na()}
|
||||
bad={bad(state.delay, 100)}
|
||||
dim={state.delay === undefined}
|
||||
inline={props.inline}
|
||||
/>
|
||||
<Cell
|
||||
label={language.t("debugBar.inp.label")}
|
||||
@@ -414,6 +466,7 @@ export function DebugBar() {
|
||||
value={time(state.inp) ?? na()}
|
||||
bad={bad(state.inp, 200)}
|
||||
dim={state.inp === undefined}
|
||||
inline={props.inline}
|
||||
/>
|
||||
<Cell
|
||||
label={language.t("debugBar.cls.label")}
|
||||
@@ -421,6 +474,7 @@ export function DebugBar() {
|
||||
value={state.cls === undefined ? na() : state.cls.toFixed(2)}
|
||||
bad={bad(state.cls, 0.1)}
|
||||
dim={state.cls === undefined}
|
||||
inline={props.inline}
|
||||
/>
|
||||
<Cell
|
||||
label={language.t("debugBar.mem.label")}
|
||||
@@ -435,6 +489,7 @@ export function DebugBar() {
|
||||
value={heapv()}
|
||||
bad={bad(heap(), 0.8)}
|
||||
dim={state.heap.used === undefined}
|
||||
inline={props.inline}
|
||||
wide
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -65,6 +65,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
||||
const [store, setStore] = createStore({
|
||||
methodIndex: undefined as undefined | number,
|
||||
authorization: undefined as undefined | ProviderAuthAuthorization,
|
||||
promptInputs: undefined as undefined | Record<string, string>,
|
||||
state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
|
||||
error: undefined as string | undefined,
|
||||
})
|
||||
@@ -73,6 +74,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
||||
| { type: "method.select"; index: number }
|
||||
| { type: "method.reset" }
|
||||
| { type: "auth.prompt" }
|
||||
| { type: "auth.inputs"; inputs: Record<string, string> }
|
||||
| { type: "auth.pending" }
|
||||
| { type: "auth.complete"; authorization: ProviderAuthAuthorization }
|
||||
| { type: "auth.error"; error: string }
|
||||
@@ -83,6 +85,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
||||
if (action.type === "method.select") {
|
||||
draft.methodIndex = action.index
|
||||
draft.authorization = undefined
|
||||
draft.promptInputs = undefined
|
||||
draft.state = undefined
|
||||
draft.error = undefined
|
||||
return
|
||||
@@ -90,6 +93,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
||||
if (action.type === "method.reset") {
|
||||
draft.methodIndex = undefined
|
||||
draft.authorization = undefined
|
||||
draft.promptInputs = undefined
|
||||
draft.state = undefined
|
||||
draft.error = undefined
|
||||
return
|
||||
@@ -99,6 +103,12 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
||||
draft.error = undefined
|
||||
return
|
||||
}
|
||||
if (action.type === "auth.inputs") {
|
||||
draft.promptInputs = action.inputs
|
||||
draft.state = undefined
|
||||
draft.error = undefined
|
||||
return
|
||||
}
|
||||
if (action.type === "auth.pending") {
|
||||
draft.state = "pending"
|
||||
draft.error = undefined
|
||||
@@ -151,6 +161,15 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
||||
const method = methods()[index]
|
||||
dispatch({ type: "method.select", index })
|
||||
|
||||
if (method.type === "api" && method.prompts?.length) {
|
||||
if (!inputs) {
|
||||
dispatch({ type: "auth.prompt" })
|
||||
return
|
||||
}
|
||||
dispatch({ type: "auth.inputs", inputs })
|
||||
return
|
||||
}
|
||||
|
||||
if (method.type === "oauth") {
|
||||
if (method.prompts?.length && !inputs) {
|
||||
dispatch({ type: "auth.prompt" })
|
||||
@@ -190,7 +209,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
||||
}
|
||||
}
|
||||
|
||||
function OAuthPromptsView() {
|
||||
function AuthPromptsView() {
|
||||
const [formStore, setFormStore] = createStore({
|
||||
value: {} as Record<string, string>,
|
||||
index: 0,
|
||||
@@ -198,8 +217,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
||||
|
||||
const prompts = createMemo<NonNullable<ProviderAuthMethod["prompts"]>>(() => {
|
||||
const value = method()
|
||||
if (value?.type !== "oauth") return []
|
||||
return value.prompts ?? []
|
||||
return value?.prompts ?? []
|
||||
})
|
||||
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
|
||||
if (!prompt.when) return true
|
||||
@@ -230,6 +248,10 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
||||
setFormStore("index", next)
|
||||
return
|
||||
}
|
||||
if (method()?.type === "api") {
|
||||
dispatch({ type: "auth.inputs", inputs: value })
|
||||
return
|
||||
}
|
||||
await selectMethod(store.methodIndex, value)
|
||||
}
|
||||
|
||||
@@ -414,6 +436,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
||||
auth: {
|
||||
type: "api",
|
||||
key: apiKey,
|
||||
...(store.promptInputs ? { metadata: store.promptInputs } : {}),
|
||||
},
|
||||
})
|
||||
await complete()
|
||||
@@ -622,7 +645,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={store.state === "prompt"}>
|
||||
<OAuthPromptsView />
|
||||
<AuthPromptsView />
|
||||
</Match>
|
||||
<Match when={store.state === "error"}>
|
||||
<div class="text-14-regular text-text-base">
|
||||
|
||||
@@ -59,6 +59,7 @@ const ModelList: Component<{
|
||||
class="w-full"
|
||||
placement="right-start"
|
||||
gutter={12}
|
||||
openDelay={0}
|
||||
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />}
|
||||
>
|
||||
{node}
|
||||
|
||||
@@ -35,6 +35,8 @@ import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
@@ -1343,9 +1345,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
|
||||
const agentsLoading = () => props.controls.agents.loading
|
||||
const agentsShouldFadeIn = createMemo((prev) => prev ?? agentsLoading())
|
||||
const agentsShouldFadeIn = createMemo<boolean>((prev) => prev ?? agentsLoading())
|
||||
const providersLoading = () => props.controls.model.loading
|
||||
const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading())
|
||||
const providersShouldFadeIn = createMemo<boolean>((prev) => prev ?? providersLoading())
|
||||
|
||||
const [promptReady] = createResource(
|
||||
() => prompt.ready.promise,
|
||||
@@ -1359,9 +1361,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
const modelControlState = createMemo<ComposerModelControlState>(() => ({
|
||||
loading: providersLoading(),
|
||||
shouldAnimate: providersShouldFadeIn(),
|
||||
paid: props.controls.model.paid,
|
||||
title: language.t("command.model.choose"),
|
||||
keybind: command.keybind("model.choose"),
|
||||
keybind: command.keybindParts("model.choose"),
|
||||
model: props.controls.model.selection,
|
||||
providerID: props.controls.model.selection.current()?.provider?.id,
|
||||
modelName: props.controls.model.selection.current()?.name ?? language.t("dialog.model.select.title"),
|
||||
@@ -1378,7 +1381,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const showAgentControl = createMemo(() => props.controls.agents.visible && props.controls.agents.options.length > 0)
|
||||
const agentControlState = createMemo<ComposerAgentControlState>(() => ({
|
||||
title: language.t("command.agent.cycle"),
|
||||
keybind: command.keybind("agent.cycle"),
|
||||
keybind: command.keybindParts("agent.cycle"),
|
||||
options: props.controls.agents.options,
|
||||
current: props.controls.agents.current,
|
||||
style: control(),
|
||||
@@ -1496,10 +1499,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<div class="flex h-11 items-center px-2">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-0">
|
||||
{fileAttachmentInput()}
|
||||
<TooltipKeybind
|
||||
<TooltipV2
|
||||
placement="top"
|
||||
title={language.t("prompt.action.attachFile")}
|
||||
keybind={command.keybind("file.attach")}
|
||||
value={
|
||||
<>
|
||||
{language.t("prompt.action.attachFile")}
|
||||
<KeybindV2 keys={command.keybindParts("file.attach")} variant="neutral" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<IconButton
|
||||
data-action="prompt-attach"
|
||||
@@ -1513,25 +1520,30 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||
aria-label={language.t("prompt.action.attachFile")}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</TooltipV2>
|
||||
<Show when={showAgentControl()}>
|
||||
<ComposerAgentControl state={agentControlState()} />
|
||||
</Show>
|
||||
{props.toolbar}
|
||||
<ComposerModelControl state={modelControlState()} />
|
||||
<Show when={store.mode !== "shell" && showVariantControl()}>
|
||||
<Show when={!providersLoading() && store.mode !== "shell" && showVariantControl()}>
|
||||
<div
|
||||
data-component="prompt-variant-control"
|
||||
classList={{
|
||||
"animate-in fade-in": providersShouldFadeIn(),
|
||||
"hidden group-hover/prompt-input:block group-focus-within/prompt-input:block":
|
||||
!props.controls.model.selection.variant.current() && !store.variantOpen,
|
||||
}}
|
||||
>
|
||||
<TooltipKeybind
|
||||
<TooltipV2
|
||||
placement="top"
|
||||
gutter={4}
|
||||
title={language.t("command.model.variant.cycle")}
|
||||
keybind={command.keybind("model.variant.cycle")}
|
||||
value={
|
||||
<>
|
||||
{language.t("command.model.variant.cycle")}
|
||||
<KeybindV2 keys={command.keybindParts("model.variant.cycle")} variant="neutral" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Select
|
||||
size="normal"
|
||||
@@ -1549,11 +1561,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
triggerProps={{ "data-action": "prompt-model-variant" }}
|
||||
variant="ghost"
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
|
||||
<TooltipV2 placement="top" inactive={!working() && blank()} value={tip()}>
|
||||
<IconButton
|
||||
data-action="prompt-submit"
|
||||
type="submit"
|
||||
@@ -1568,7 +1580,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}}
|
||||
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
</DockShellForm>
|
||||
</div>
|
||||
@@ -1765,7 +1777,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<Show when={!agentsLoading()}>
|
||||
<div
|
||||
data-component="prompt-agent-control"
|
||||
style={agentsShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
|
||||
classList={{ "animate-in fade-in duration-300": agentsShouldFadeIn() }}
|
||||
>
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
@@ -1794,7 +1806,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<Show when={store.mode !== "shell"}>
|
||||
<div
|
||||
data-component="prompt-model-control"
|
||||
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
|
||||
classList={{ "animate-in fade-in duration-300": providersShouldFadeIn() }}
|
||||
>
|
||||
<Show
|
||||
when={props.controls.model.paid}
|
||||
@@ -1873,7 +1885,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<Show when={showVariantControl()}>
|
||||
<div
|
||||
data-component="prompt-variant-control"
|
||||
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
|
||||
classList={{ "animate-in fade-in duration-300": providersShouldFadeIn() }}
|
||||
>
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
@@ -1914,7 +1926,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
type ComposerAgentControlState = {
|
||||
title: string
|
||||
keybind: string
|
||||
keybind: string[]
|
||||
options: string[]
|
||||
current: string
|
||||
style: JSX.CSSProperties | undefined
|
||||
@@ -1923,9 +1935,10 @@ type ComposerAgentControlState = {
|
||||
|
||||
type ComposerModelControlState = {
|
||||
loading: boolean
|
||||
shouldAnimate: boolean
|
||||
paid: boolean
|
||||
title: string
|
||||
keybind: string
|
||||
keybind: string[]
|
||||
model: ReturnType<typeof useLocal>["model"]
|
||||
providerID?: string
|
||||
modelName: string
|
||||
@@ -1940,7 +1953,16 @@ function ComposerAgentControl(props: { state: ComposerAgentControlState }) {
|
||||
<div class="pointer-events-none absolute left-2 top-1/2 z-10 flex size-4 -translate-y-1/2 items-center justify-center text-v2-icon-icon-muted">
|
||||
<Icon name="sliders" size="small" />
|
||||
</div>
|
||||
<TooltipKeybind placement="top" gutter={4} title={props.state.title} keybind={props.state.keybind}>
|
||||
<TooltipV2
|
||||
placement="top"
|
||||
gutter={4}
|
||||
value={
|
||||
<>
|
||||
{props.state.title}
|
||||
<KeybindV2 keys={props.state.keybind} variant="neutral" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Select
|
||||
size="normal"
|
||||
options={props.state.options}
|
||||
@@ -1952,7 +1974,7 @@ function ComposerAgentControl(props: { state: ComposerAgentControlState }) {
|
||||
triggerProps={{ "data-action": "prompt-agent" }}
|
||||
variant="ghost"
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1963,13 +1985,23 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
|
||||
<Show
|
||||
when={props.state.paid}
|
||||
fallback={
|
||||
<TooltipKeybind placement="top" gutter={4} title={props.state.title} keybind={props.state.keybind}>
|
||||
<TooltipV2
|
||||
placement="top"
|
||||
gutter={4}
|
||||
value={
|
||||
<>
|
||||
{props.state.title}
|
||||
<KeybindV2 keys={props.state.keybind} variant="neutral" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
data-action="prompt-model"
|
||||
as="div"
|
||||
variant="ghost"
|
||||
size="normal"
|
||||
class="min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group"
|
||||
classList={{ "animate-in fade-in": props.state.shouldAnimate }}
|
||||
style={props.state.style}
|
||||
onClick={props.state.onUnpaidClick}
|
||||
>
|
||||
@@ -1987,10 +2019,19 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
|
||||
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</TooltipV2>
|
||||
}
|
||||
>
|
||||
<TooltipKeybind placement="top" gutter={4} title={props.state.title} keybind={props.state.keybind}>
|
||||
<TooltipV2
|
||||
placement="top"
|
||||
gutter={4}
|
||||
value={
|
||||
<>
|
||||
{props.state.title}
|
||||
<KeybindV2 keys={props.state.keybind} variant="neutral" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<ModelSelectorPopover
|
||||
model={props.state.model}
|
||||
triggerAs={Button}
|
||||
@@ -2000,6 +2041,7 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
|
||||
style: props.state.style,
|
||||
class:
|
||||
"min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group",
|
||||
classList: { "animate-in fade-in": props.state.shouldAnimate },
|
||||
"data-action": "prompt-model",
|
||||
}}
|
||||
onClose={props.state.onClose}
|
||||
@@ -2018,7 +2060,7 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
|
||||
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
|
||||
</span>
|
||||
</ModelSelectorPopover>
|
||||
</TooltipKeybind>
|
||||
</TooltipV2>
|
||||
</Show>
|
||||
</Show>
|
||||
)
|
||||
|
||||
@@ -99,6 +99,8 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
|
||||
const path = () => `${location.pathname}${location.search}${location.hash}`
|
||||
const creating = createMemo(() => {
|
||||
const route = layout.route()
|
||||
if (route.type === "draft" || route.type === "dir-new-sesssion") return true
|
||||
if (!params.dir) return false
|
||||
if (params.id) return false
|
||||
const parts = location.pathname.replace(/\/+$/, "").split("/")
|
||||
@@ -466,7 +468,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
}}
|
||||
onReorder={(keys) => tabsStoreActions.reorder(keys)}
|
||||
/>
|
||||
<Show when={!(creating() && params.dir)}>
|
||||
<Show when={!creating()}>
|
||||
<TooltipV2
|
||||
placement="bottom"
|
||||
value={
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
|
||||
import { useParams, useSearchParams } from "@solidjs/router"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useServerSDK } from "./server-sdk"
|
||||
import { useServerSync } from "./server-sync"
|
||||
import type { ServerSDK } from "./server-sdk"
|
||||
import type { ServerSync } from "./server-sync"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
@@ -12,6 +12,11 @@ import { decode64 } from "@/utils/base64"
|
||||
import { EventSessionError } from "@opencode-ai/sdk/v2"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { playSoundById } from "@/utils/sound"
|
||||
import { useGlobal } from "./global"
|
||||
import { ServerConnection, useServer } from "./server"
|
||||
import { type DraftTab, useTabs } from "./tabs"
|
||||
import { requireServerKey } from "@/utils/session-route"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
|
||||
type NotificationBase = {
|
||||
directory?: string
|
||||
@@ -107,267 +112,360 @@ function buildNotificationIndex(list: Notification[]) {
|
||||
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
|
||||
name: "Notification",
|
||||
gate: false,
|
||||
init: (props: { directory?: Accessor<string | undefined>; sessionID?: Accessor<string | undefined> }) => {
|
||||
const params = useParams()
|
||||
const serverSDK = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
init: () => {
|
||||
const params = useParams<{ serverKey?: string; dir?: string; id?: string }>()
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const global = useGlobal()
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const platform = usePlatform()
|
||||
const settings = useSettings()
|
||||
const language = useLanguage()
|
||||
const owner = getOwner()
|
||||
const states = new Map<ServerScope, { dispose: () => void; state: NotificationState }>()
|
||||
|
||||
const empty: Notification[] = []
|
||||
|
||||
const currentDirectory = createMemo(() => {
|
||||
return props.directory?.() ?? decode64(params.dir)
|
||||
const activeServer = createMemo(() => {
|
||||
if (params.serverKey) return requireServerKey(params.serverKey)
|
||||
if (search.draftId) {
|
||||
const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)
|
||||
if (draft) return draft.server
|
||||
}
|
||||
return server.key
|
||||
})
|
||||
const activeDirectory = createMemo(() => decode64(params.dir))
|
||||
const activeSession = createMemo(() => params.id)
|
||||
|
||||
const currentSession = createMemo(() => props.sessionID?.() ?? params.id)
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]),
|
||||
createStore({
|
||||
list: [] as Notification[],
|
||||
}),
|
||||
)
|
||||
const [index, setIndex] = createStore<NotificationIndex>(buildNotificationIndex(store.list))
|
||||
|
||||
const meta = { pruned: false, disposed: false }
|
||||
|
||||
const updateUnseen = (scope: "session" | "project", key: string, unseen: Notification[]) => {
|
||||
setIndex(scope, "unseen", key, unseen)
|
||||
setIndex(scope, "unseenCount", key, unseen.length)
|
||||
setIndex(
|
||||
scope,
|
||||
"unseenHasError",
|
||||
key,
|
||||
unseen.some((notification) => notification.type === "error"),
|
||||
const ensure = (key: ServerConnection.Key) => {
|
||||
const conn = global.servers.list().find((item) => ServerConnection.key(item) === key)
|
||||
if (!conn) throw new Error(`Notification server not found: ${key}`)
|
||||
const ctx = global.ensureServerCtx(conn)
|
||||
const existing = states.get(ctx.sdk.scope)
|
||||
if (existing) return existing.state
|
||||
const root = createRoot(
|
||||
(dispose) => ({
|
||||
dispose,
|
||||
state: createServerNotificationState({
|
||||
sdk: ctx.sdk,
|
||||
sync: ctx.sync,
|
||||
active: () => server.scope(activeServer()) === ctx.sdk.scope,
|
||||
directory: activeDirectory,
|
||||
sessionID: activeSession,
|
||||
platform,
|
||||
settings,
|
||||
language,
|
||||
}),
|
||||
}),
|
||||
owner ?? undefined,
|
||||
)
|
||||
}
|
||||
|
||||
const appendToIndex = (notification: Notification) => {
|
||||
if (notification.session) {
|
||||
setIndex("session", "all", notification.session, (all = []) => [...all, notification])
|
||||
if (!notification.viewed) {
|
||||
setIndex("session", "unseen", notification.session, (unseen = []) => [...unseen, notification])
|
||||
setIndex("session", "unseenCount", notification.session, (count = 0) => count + 1)
|
||||
if (notification.type === "error") setIndex("session", "unseenHasError", notification.session, true)
|
||||
}
|
||||
}
|
||||
|
||||
if (notification.directory) {
|
||||
setIndex("project", "all", notification.directory, (all = []) => [...all, notification])
|
||||
if (!notification.viewed) {
|
||||
setIndex("project", "unseen", notification.directory, (unseen = []) => [...unseen, notification])
|
||||
setIndex("project", "unseenCount", notification.directory, (count = 0) => count + 1)
|
||||
if (notification.type === "error") setIndex("project", "unseenHasError", notification.directory, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const removeFromIndex = (notification: Notification) => {
|
||||
if (notification.session) {
|
||||
setIndex("session", "all", notification.session, (all = []) => all.filter((n) => n !== notification))
|
||||
if (!notification.viewed) {
|
||||
const unseen = (index.session.unseen[notification.session] ?? empty).filter((n) => n !== notification)
|
||||
updateUnseen("session", notification.session, unseen)
|
||||
}
|
||||
}
|
||||
|
||||
if (notification.directory) {
|
||||
setIndex("project", "all", notification.directory, (all = []) => all.filter((n) => n !== notification))
|
||||
if (!notification.viewed) {
|
||||
const unseen = (index.project.unseen[notification.directory] ?? empty).filter((n) => n !== notification)
|
||||
updateUnseen("project", notification.directory, unseen)
|
||||
}
|
||||
}
|
||||
states.set(ctx.sdk.scope, root)
|
||||
return root.state
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready()) return
|
||||
if (meta.pruned) return
|
||||
meta.pruned = true
|
||||
const list = pruneNotifications(store.list)
|
||||
batch(() => {
|
||||
setStore("list", list)
|
||||
setIndex(reconcile(buildNotificationIndex(list), { merge: false }))
|
||||
global.servers.list().forEach((conn) => ensure(ServerConnection.key(conn)))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const scopes = new Set(global.servers.list().map((conn) => server.scope(ServerConnection.key(conn))))
|
||||
states.forEach((value, scope) => {
|
||||
if (scopes.has(scope)) return
|
||||
value.dispose()
|
||||
states.delete(scope)
|
||||
})
|
||||
})
|
||||
|
||||
const append = (notification: Notification) => {
|
||||
const list = pruneNotifications([...store.list, notification])
|
||||
const keep = new Set(list)
|
||||
const removed = store.list.filter((n) => !keep.has(n))
|
||||
onCleanup(() => states.forEach((value) => value.dispose()))
|
||||
|
||||
batch(() => {
|
||||
if (keep.has(notification)) appendToIndex(notification)
|
||||
removed.forEach((n) => removeFromIndex(n))
|
||||
setStore("list", list)
|
||||
})
|
||||
}
|
||||
|
||||
const lookup = async (directory: string, sessionID?: string) => {
|
||||
if (!sessionID) return undefined
|
||||
const sync = serverSync().ensureDirSyncContext(directory)
|
||||
const session = sync.session.get(sessionID)
|
||||
if (session) return session
|
||||
return sync.session
|
||||
.sync(sessionID)
|
||||
.then(() => sync.session.get(sessionID))
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
const viewedInCurrentSession = (directory: string, sessionID?: string) => {
|
||||
const activeDirectory = currentDirectory()
|
||||
const activeSession = currentSession()
|
||||
if (!activeDirectory) return false
|
||||
if (!activeSession) return false
|
||||
if (!sessionID) return false
|
||||
if (directory !== activeDirectory) return false
|
||||
return sessionID === activeSession
|
||||
}
|
||||
|
||||
const handleSessionIdle = (directory: string, event: { properties: { sessionID?: string } }, time: number) => {
|
||||
const sessionID = event.properties.sessionID
|
||||
void lookup(directory, sessionID).then((session) => {
|
||||
if (meta.disposed) return
|
||||
if (!session) return
|
||||
if (session.parentID) return
|
||||
|
||||
if (settings.sounds.agentEnabled()) {
|
||||
void playSoundById(settings.sounds.agent())
|
||||
}
|
||||
|
||||
append({
|
||||
directory,
|
||||
time,
|
||||
viewed: viewedInCurrentSession(directory, sessionID),
|
||||
type: "turn-complete",
|
||||
session: sessionID,
|
||||
})
|
||||
|
||||
const href = `/${base64Encode(directory)}/session/${sessionID}`
|
||||
if (settings.notifications.agent()) {
|
||||
void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, href)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleSessionError = (
|
||||
directory: string,
|
||||
event: { properties: { sessionID?: string; error?: EventSessionError["properties"]["error"] } },
|
||||
time: number,
|
||||
) => {
|
||||
const sessionID = event.properties.sessionID
|
||||
void lookup(directory, sessionID).then((session) => {
|
||||
if (meta.disposed) return
|
||||
if (session?.parentID) return
|
||||
|
||||
if (settings.sounds.errorsEnabled()) {
|
||||
void playSoundById(settings.sounds.errors())
|
||||
}
|
||||
|
||||
const error = "error" in event.properties ? event.properties.error : undefined
|
||||
append({
|
||||
directory,
|
||||
time,
|
||||
viewed: viewedInCurrentSession(directory, sessionID),
|
||||
type: "error",
|
||||
session: sessionID ?? "global",
|
||||
error,
|
||||
})
|
||||
const description =
|
||||
session?.title ??
|
||||
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
|
||||
const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
|
||||
if (settings.notifications.errors()) {
|
||||
void platform.notify(language.t("notification.session.error.title"), description, href)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const unsub = serverSDK().event.listen((e) => {
|
||||
const event = e.details
|
||||
if (event.type !== "session.idle" && event.type !== "session.error") return
|
||||
|
||||
const directory = e.name
|
||||
const time = Date.now()
|
||||
if (event.type === "session.idle") {
|
||||
handleSessionIdle(directory, event, time)
|
||||
return
|
||||
}
|
||||
handleSessionError(directory, event, time)
|
||||
})
|
||||
onCleanup(() => {
|
||||
meta.disposed = true
|
||||
unsub()
|
||||
})
|
||||
const selected = () => ensure(activeServer())
|
||||
|
||||
return {
|
||||
ready,
|
||||
ready: () => selected().ready(),
|
||||
ensureServerState: ensure,
|
||||
session: {
|
||||
all(session: string) {
|
||||
return index.session.all[session] ?? empty
|
||||
},
|
||||
unseen(session: string) {
|
||||
return index.session.unseen[session] ?? empty
|
||||
},
|
||||
unseenCount(session: string) {
|
||||
return index.session.unseenCount[session] ?? 0
|
||||
},
|
||||
unseenHasError(session: string) {
|
||||
return index.session.unseenHasError[session] ?? false
|
||||
},
|
||||
markViewed(session: string) {
|
||||
const unseen = index.session.unseen[session] ?? empty
|
||||
if (!unseen.length) return
|
||||
|
||||
const projects = [
|
||||
...new Set(unseen.flatMap((notification) => (notification.directory ? [notification.directory] : []))),
|
||||
]
|
||||
batch(() => {
|
||||
setStore("list", (n) => n.session === session && !n.viewed, "viewed", true)
|
||||
updateUnseen("session", session, [])
|
||||
projects.forEach((directory) => {
|
||||
const next = (index.project.unseen[directory] ?? empty).filter(
|
||||
(notification) => notification.session !== session,
|
||||
)
|
||||
updateUnseen("project", directory, next)
|
||||
})
|
||||
})
|
||||
},
|
||||
all: (session: string) => selected().session.all(session),
|
||||
unseen: (session: string) => selected().session.unseen(session),
|
||||
unseenCount: (session: string) => selected().session.unseenCount(session),
|
||||
unseenHasError: (session: string) => selected().session.unseenHasError(session),
|
||||
markViewed: (session: string) => selected().session.markViewed(session),
|
||||
},
|
||||
project: {
|
||||
all(directory: string) {
|
||||
return index.project.all[directory] ?? empty
|
||||
},
|
||||
unseen(directory: string) {
|
||||
return index.project.unseen[directory] ?? empty
|
||||
},
|
||||
unseenCount(directory: string) {
|
||||
return index.project.unseenCount[directory] ?? 0
|
||||
},
|
||||
unseenHasError(directory: string) {
|
||||
return index.project.unseenHasError[directory] ?? false
|
||||
},
|
||||
markViewed(directory: string) {
|
||||
const unseen = index.project.unseen[directory] ?? empty
|
||||
if (!unseen.length) return
|
||||
|
||||
const sessions = [
|
||||
...new Set(unseen.flatMap((notification) => (notification.session ? [notification.session] : []))),
|
||||
]
|
||||
batch(() => {
|
||||
setStore("list", (n) => n.directory === directory && !n.viewed, "viewed", true)
|
||||
updateUnseen("project", directory, [])
|
||||
sessions.forEach((session) => {
|
||||
const next = (index.session.unseen[session] ?? empty).filter(
|
||||
(notification) => notification.directory !== directory,
|
||||
)
|
||||
updateUnseen("session", session, next)
|
||||
})
|
||||
})
|
||||
},
|
||||
all: (directory: string) => selected().project.all(directory),
|
||||
unseen: (directory: string) => selected().project.unseen(directory),
|
||||
unseenCount: (directory: string) => selected().project.unseenCount(directory),
|
||||
unseenHasError: (directory: string) => selected().project.unseenHasError(directory),
|
||||
markViewed: (directory: string) => selected().project.markViewed(directory),
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
type NotificationState = ReturnType<typeof createServerNotificationState>
|
||||
|
||||
function createServerNotificationState(input: {
|
||||
sdk: ServerSDK
|
||||
sync: ServerSync
|
||||
active: Accessor<boolean>
|
||||
directory: Accessor<string | undefined>
|
||||
sessionID: Accessor<string | undefined>
|
||||
platform: ReturnType<typeof usePlatform>
|
||||
settings: ReturnType<typeof useSettings>
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) {
|
||||
const serverSDK = () => input.sdk
|
||||
const serverSync = () => input.sync
|
||||
const platform = input.platform
|
||||
const settings = input.settings
|
||||
const language = input.language
|
||||
|
||||
const empty: Notification[] = []
|
||||
|
||||
const currentDirectory = input.directory
|
||||
const currentSession = input.sessionID
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]),
|
||||
createStore({
|
||||
list: [] as Notification[],
|
||||
}),
|
||||
)
|
||||
const [index, setIndex] = createStore<NotificationIndex>(buildNotificationIndex(store.list))
|
||||
|
||||
const meta = { pruned: false, disposed: false }
|
||||
|
||||
const updateUnseen = (scope: "session" | "project", key: string, unseen: Notification[]) => {
|
||||
setIndex(scope, "unseen", key, unseen)
|
||||
setIndex(scope, "unseenCount", key, unseen.length)
|
||||
setIndex(
|
||||
scope,
|
||||
"unseenHasError",
|
||||
key,
|
||||
unseen.some((notification) => notification.type === "error"),
|
||||
)
|
||||
}
|
||||
|
||||
const appendToIndex = (notification: Notification) => {
|
||||
if (notification.session) {
|
||||
setIndex("session", "all", notification.session, (all = []) => [...all, notification])
|
||||
if (!notification.viewed) {
|
||||
setIndex("session", "unseen", notification.session, (unseen = []) => [...unseen, notification])
|
||||
setIndex("session", "unseenCount", notification.session, (count = 0) => count + 1)
|
||||
if (notification.type === "error") setIndex("session", "unseenHasError", notification.session, true)
|
||||
}
|
||||
}
|
||||
|
||||
if (notification.directory) {
|
||||
setIndex("project", "all", notification.directory, (all = []) => [...all, notification])
|
||||
if (!notification.viewed) {
|
||||
setIndex("project", "unseen", notification.directory, (unseen = []) => [...unseen, notification])
|
||||
setIndex("project", "unseenCount", notification.directory, (count = 0) => count + 1)
|
||||
if (notification.type === "error") setIndex("project", "unseenHasError", notification.directory, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const removeFromIndex = (notification: Notification) => {
|
||||
if (notification.session) {
|
||||
setIndex("session", "all", notification.session, (all = []) => all.filter((n) => n !== notification))
|
||||
if (!notification.viewed) {
|
||||
const unseen = (index.session.unseen[notification.session] ?? empty).filter((n) => n !== notification)
|
||||
updateUnseen("session", notification.session, unseen)
|
||||
}
|
||||
}
|
||||
|
||||
if (notification.directory) {
|
||||
setIndex("project", "all", notification.directory, (all = []) => all.filter((n) => n !== notification))
|
||||
if (!notification.viewed) {
|
||||
const unseen = (index.project.unseen[notification.directory] ?? empty).filter((n) => n !== notification)
|
||||
updateUnseen("project", notification.directory, unseen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready()) return
|
||||
if (meta.pruned) return
|
||||
meta.pruned = true
|
||||
const list = pruneNotifications(store.list)
|
||||
batch(() => {
|
||||
setStore("list", list)
|
||||
setIndex(reconcile(buildNotificationIndex(list), { merge: false }))
|
||||
})
|
||||
})
|
||||
|
||||
const append = (notification: Notification) => {
|
||||
const list = pruneNotifications([...store.list, notification])
|
||||
const keep = new Set(list)
|
||||
const removed = store.list.filter((n) => !keep.has(n))
|
||||
|
||||
batch(() => {
|
||||
if (keep.has(notification)) appendToIndex(notification)
|
||||
removed.forEach((n) => removeFromIndex(n))
|
||||
setStore("list", list)
|
||||
})
|
||||
}
|
||||
|
||||
const lookup = async (directory: string, sessionID?: string) => {
|
||||
if (!sessionID) return undefined
|
||||
const sync = serverSync().ensureDirSyncContext(directory)
|
||||
const session = sync.session.get(sessionID)
|
||||
if (session) return session
|
||||
return sync.session
|
||||
.sync(sessionID)
|
||||
.then(() => sync.session.get(sessionID))
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
const viewedInCurrentSession = (directory: string, sessionID?: string) => {
|
||||
if (!input.active()) return false
|
||||
const activeDirectory = currentDirectory()
|
||||
const activeSession = currentSession()
|
||||
if (!activeSession) return false
|
||||
if (!sessionID) return false
|
||||
if (activeDirectory && directory !== activeDirectory) return false
|
||||
return sessionID === activeSession
|
||||
}
|
||||
|
||||
const handleSessionIdle = (directory: string, event: { properties: { sessionID?: string } }, time: number) => {
|
||||
const sessionID = event.properties.sessionID
|
||||
void lookup(directory, sessionID).then((session) => {
|
||||
if (meta.disposed) return
|
||||
if (!session) return
|
||||
if (session.parentID) return
|
||||
|
||||
if (settings.sounds.agentEnabled()) {
|
||||
void playSoundById(settings.sounds.agent())
|
||||
}
|
||||
|
||||
append({
|
||||
directory,
|
||||
time,
|
||||
viewed: viewedInCurrentSession(directory, sessionID),
|
||||
type: "turn-complete",
|
||||
session: sessionID,
|
||||
})
|
||||
|
||||
const href = `/${base64Encode(directory)}/session/${sessionID}`
|
||||
if (settings.notifications.agent()) {
|
||||
void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, href)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleSessionError = (
|
||||
directory: string,
|
||||
event: { properties: { sessionID?: string; error?: EventSessionError["properties"]["error"] } },
|
||||
time: number,
|
||||
) => {
|
||||
const sessionID = event.properties.sessionID
|
||||
void lookup(directory, sessionID).then((session) => {
|
||||
if (meta.disposed) return
|
||||
if (session?.parentID) return
|
||||
|
||||
if (settings.sounds.errorsEnabled()) {
|
||||
void playSoundById(settings.sounds.errors())
|
||||
}
|
||||
|
||||
const error = "error" in event.properties ? event.properties.error : undefined
|
||||
append({
|
||||
directory,
|
||||
time,
|
||||
viewed: viewedInCurrentSession(directory, sessionID),
|
||||
type: "error",
|
||||
session: sessionID ?? "global",
|
||||
error,
|
||||
})
|
||||
const description =
|
||||
session?.title ??
|
||||
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
|
||||
const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
|
||||
if (settings.notifications.errors()) {
|
||||
void platform.notify(language.t("notification.session.error.title"), description, href)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const unsub = serverSDK().event.listen((e) => {
|
||||
const event = e.details
|
||||
if (event.type !== "session.idle" && event.type !== "session.error") return
|
||||
|
||||
const directory = e.name
|
||||
const time = Date.now()
|
||||
if (event.type === "session.idle") {
|
||||
handleSessionIdle(directory, event, time)
|
||||
return
|
||||
}
|
||||
handleSessionError(directory, event, time)
|
||||
})
|
||||
onCleanup(() => {
|
||||
meta.disposed = true
|
||||
unsub()
|
||||
})
|
||||
|
||||
return {
|
||||
ready,
|
||||
session: {
|
||||
all(session: string) {
|
||||
return index.session.all[session] ?? empty
|
||||
},
|
||||
unseen(session: string) {
|
||||
return index.session.unseen[session] ?? empty
|
||||
},
|
||||
unseenCount(session: string) {
|
||||
return index.session.unseenCount[session] ?? 0
|
||||
},
|
||||
unseenHasError(session: string) {
|
||||
return index.session.unseenHasError[session] ?? false
|
||||
},
|
||||
markViewed(session: string) {
|
||||
const unseen = index.session.unseen[session] ?? empty
|
||||
if (!unseen.length) return
|
||||
|
||||
const projects = [
|
||||
...new Set(unseen.flatMap((notification) => (notification.directory ? [notification.directory] : []))),
|
||||
]
|
||||
batch(() => {
|
||||
setStore("list", (n) => n.session === session && !n.viewed, "viewed", true)
|
||||
updateUnseen("session", session, [])
|
||||
projects.forEach((directory) => {
|
||||
const next = (index.project.unseen[directory] ?? empty).filter(
|
||||
(notification) => notification.session !== session,
|
||||
)
|
||||
updateUnseen("project", directory, next)
|
||||
})
|
||||
})
|
||||
},
|
||||
},
|
||||
project: {
|
||||
all(directory: string) {
|
||||
return index.project.all[directory] ?? empty
|
||||
},
|
||||
unseen(directory: string) {
|
||||
return index.project.unseen[directory] ?? empty
|
||||
},
|
||||
unseenCount(directory: string) {
|
||||
return index.project.unseenCount[directory] ?? 0
|
||||
},
|
||||
unseenHasError(directory: string) {
|
||||
return index.project.unseenHasError[directory] ?? false
|
||||
},
|
||||
markViewed(directory: string) {
|
||||
const unseen = index.project.unseen[directory] ?? empty
|
||||
if (!unseen.length) return
|
||||
|
||||
const sessions = [
|
||||
...new Set(unseen.flatMap((notification) => (notification.session ? [notification.session] : []))),
|
||||
]
|
||||
batch(() => {
|
||||
setStore("list", (n) => n.directory === directory && !n.viewed, "viewed", true)
|
||||
updateUnseen("project", directory, [])
|
||||
sessions.forEach((session) => {
|
||||
const next = (index.session.unseen[session] ?? empty).filter(
|
||||
(notification) => notification.directory !== directory,
|
||||
)
|
||||
updateUnseen("session", session, next)
|
||||
})
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { coalesceServerEvents, resumeStreamAfterPageShow } from "./server-sdk"
|
||||
import { coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
|
||||
import type { Event } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
describe("resumeStreamAfterPageShow", () => {
|
||||
@@ -15,19 +15,23 @@ describe("resumeStreamAfterPageShow", () => {
|
||||
})
|
||||
|
||||
describe("coalesceServerEvents", () => {
|
||||
const delta = (value: string, field = "text") => ({
|
||||
const delta = (value: string, field = "text", partID = "part") => ({
|
||||
directory: "/repo",
|
||||
payload: {
|
||||
type: "message.part.delta",
|
||||
properties: { messageID: "msg", partID: "part", field, delta: value },
|
||||
properties: { messageID: "msg", partID, field, delta: value },
|
||||
} as Event,
|
||||
})
|
||||
|
||||
test("merges adjacent deltas for the same field", () => {
|
||||
const result = coalesceServerEvents([delta("hello "), delta("world")])
|
||||
const first = delta("hello ")
|
||||
const second = delta("world")
|
||||
first.payload.id = "first"
|
||||
second.payload.id = "second"
|
||||
const result = coalesceServerEvents([first, second])
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]?.payload).toMatchObject({ properties: { delta: "hello world" } })
|
||||
expect(result[0]?.payload).toMatchObject({ id: "second", properties: { delta: "hello world" } })
|
||||
})
|
||||
|
||||
test("preserves event boundaries and distinct fields", () => {
|
||||
@@ -45,9 +49,112 @@ describe("coalesceServerEvents", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("drops stale deltas", () => {
|
||||
const result = coalesceServerEvents([delta("stale")], new Set(["/repo:msg:part"]))
|
||||
test("preserves event ID order across interleaved deltas", () => {
|
||||
const first = delta("a")
|
||||
const other = delta("b", "text", "other")
|
||||
const last = delta("c")
|
||||
first.payload.id = "1"
|
||||
other.payload.id = "2"
|
||||
last.payload.id = "3"
|
||||
|
||||
expect(result).toEqual([])
|
||||
const result = coalesceServerEvents([first, other, last])
|
||||
|
||||
expect(result.map((event) => event.payload.id)).toEqual(["1", "2", "3"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("enqueueServerEvent", () => {
|
||||
const partUpdated = (text: string) =>
|
||||
({
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
sessionID: "session",
|
||||
part: { id: "part", sessionID: "session", messageID: "message", type: "text", text },
|
||||
},
|
||||
}) as Event
|
||||
|
||||
test("preserves part updates across message remove and re-add barriers", () => {
|
||||
const events: Array<{ directory: string; payload: Event }> = []
|
||||
const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload })
|
||||
|
||||
enqueue(partUpdated("old"))
|
||||
enqueue({ type: "message.removed", properties: { sessionID: "session", messageID: "message" } } as Event)
|
||||
enqueue({
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
sessionID: "session",
|
||||
info: {
|
||||
id: "message",
|
||||
sessionID: "session",
|
||||
role: "user",
|
||||
time: { created: 1 },
|
||||
agent: "build",
|
||||
model: { providerID: "provider", modelID: "model" },
|
||||
},
|
||||
},
|
||||
} as Event)
|
||||
enqueue(partUpdated("new"))
|
||||
|
||||
expect(events.map((event) => event.payload.type)).toEqual([
|
||||
"message.part.updated",
|
||||
"message.removed",
|
||||
"message.updated",
|
||||
"message.part.updated",
|
||||
])
|
||||
})
|
||||
|
||||
test("preserves deltas after a replacement snapshot", () => {
|
||||
const events: Array<{ directory: string; payload: Event }> = []
|
||||
const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload })
|
||||
|
||||
enqueue(partUpdated("a"))
|
||||
enqueue(partUpdated("ab"))
|
||||
enqueue({
|
||||
type: "message.part.delta",
|
||||
properties: { sessionID: "session", messageID: "message", partID: "part", field: "text", delta: "c" },
|
||||
} as Event)
|
||||
|
||||
const result = coalesceServerEvents(events)
|
||||
expect(result.map((event) => event.payload.type)).toEqual(["message.part.updated", "message.part.delta"])
|
||||
expect(result[0]?.payload).toMatchObject({ properties: { part: { text: "ab" } } })
|
||||
expect(result[1]?.payload).toMatchObject({ properties: { delta: "c" } })
|
||||
})
|
||||
|
||||
test("preserves updates after session deletion", () => {
|
||||
const events: Array<{ directory: string; payload: Event }> = []
|
||||
const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload })
|
||||
|
||||
enqueue(partUpdated("old"))
|
||||
enqueue({
|
||||
type: "session.deleted",
|
||||
properties: { sessionID: "session", info: { id: "session" } },
|
||||
} as Event)
|
||||
enqueue(partUpdated("new"))
|
||||
|
||||
expect(events.map((event) => event.payload.type)).toEqual([
|
||||
"message.part.updated",
|
||||
"session.deleted",
|
||||
"message.part.updated",
|
||||
])
|
||||
})
|
||||
|
||||
test("does not coalesce edge-triggered session statuses", () => {
|
||||
const events: Array<{ directory: string; payload: Event }> = []
|
||||
const enqueue = (status: "retry" | "busy") =>
|
||||
enqueueServerEvent(events, {
|
||||
directory: "/repo",
|
||||
payload: {
|
||||
type: "session.status",
|
||||
properties: {
|
||||
sessionID: "session",
|
||||
status: status === "retry" ? { type: "retry", attempt: 1, message: "retry", next: 1 } : { type: "busy" },
|
||||
},
|
||||
} as Event,
|
||||
})
|
||||
|
||||
enqueue("retry")
|
||||
enqueue("busy")
|
||||
|
||||
expect(events).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,34 +17,56 @@ const isAbortError = (error: unknown) =>
|
||||
const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true
|
||||
type QueuedServerEvent = { directory: string; payload: Event }
|
||||
|
||||
const deltaKey = (directory: string, messageID: string, partID: string) => `${directory}:${messageID}:${partID}`
|
||||
const coalescedKey = (event: QueuedServerEvent) => {
|
||||
if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}`
|
||||
if (event.payload.type === "message.part.updated") {
|
||||
const part = event.payload.properties.part
|
||||
return `message.part.updated:${event.directory}:${part.messageID}:${part.id}`
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function coalesceServerEvents(events: QueuedServerEvent[], stale?: Set<string>) {
|
||||
export function enqueueServerEvent(queue: QueuedServerEvent[], event: QueuedServerEvent) {
|
||||
const key = coalescedKey(event)
|
||||
const previous = queue[queue.length - 1]
|
||||
if (key && previous && coalescedKey(previous) === key) {
|
||||
queue[queue.length - 1] = event
|
||||
return false
|
||||
}
|
||||
queue.push(event)
|
||||
return true
|
||||
}
|
||||
|
||||
export function coalesceServerEvents(events: QueuedServerEvent[]) {
|
||||
const output: QueuedServerEvent[] = []
|
||||
const deltas = new Map<string, number>()
|
||||
events.forEach((event) => {
|
||||
if (stale && event.payload.type === "message.part.delta") {
|
||||
const props = event.payload.properties
|
||||
if (stale.has(deltaKey(event.directory, props.messageID, props.partID))) return
|
||||
}
|
||||
if (event.payload.type !== "message.part.delta") {
|
||||
deltas.clear()
|
||||
output.push(event)
|
||||
return
|
||||
}
|
||||
const props = event.payload.properties
|
||||
const id = `${deltaKey(event.directory, props.messageID, props.partID)}:${props.field}`
|
||||
const index = deltas.get(id)
|
||||
const existing = index === undefined ? undefined : output[index]
|
||||
if (!existing || existing.payload.type !== "message.part.delta") {
|
||||
deltas.set(id, output.length)
|
||||
const previous = output[output.length - 1]
|
||||
if (
|
||||
!previous ||
|
||||
previous.payload.type !== "message.part.delta" ||
|
||||
previous.directory !== event.directory ||
|
||||
previous.payload.properties.messageID !== props.messageID ||
|
||||
previous.payload.properties.partID !== props.partID ||
|
||||
previous.payload.properties.field !== props.field
|
||||
) {
|
||||
output.push({
|
||||
directory: event.directory,
|
||||
payload: { ...event.payload, properties: { ...props } },
|
||||
})
|
||||
return
|
||||
}
|
||||
existing.payload.properties.delta += props.delta
|
||||
output[output.length - 1] = {
|
||||
directory: event.directory,
|
||||
payload: {
|
||||
...event.payload,
|
||||
properties: { ...props, delta: previous.payload.properties.delta + props.delta },
|
||||
},
|
||||
}
|
||||
})
|
||||
return output
|
||||
}
|
||||
@@ -85,20 +107,9 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
|
||||
let queue: Queued[] = []
|
||||
let buffer: Queued[] = []
|
||||
const coalesced = new Map<string, number>()
|
||||
const staleDeltas = new Set<string>()
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let last = 0
|
||||
|
||||
const key = (directory: string, payload: Event) => {
|
||||
if (payload.type === "session.status") return `session.status:${directory}:${payload.properties.sessionID}`
|
||||
if (payload.type === "lsp.updated") return `lsp.updated:${directory}`
|
||||
if (payload.type === "message.part.updated") {
|
||||
const part = payload.properties.part
|
||||
return `message.part.updated:${directory}:${part.messageID}:${part.id}`
|
||||
}
|
||||
}
|
||||
|
||||
const flush = () => {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = undefined
|
||||
@@ -106,15 +117,12 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
if (queue.length === 0) return
|
||||
|
||||
const events = queue
|
||||
const skip = staleDeltas.size > 0 ? new Set(staleDeltas) : undefined
|
||||
queue = buffer
|
||||
buffer = events
|
||||
queue.length = 0
|
||||
coalesced.clear()
|
||||
staleDeltas.clear()
|
||||
|
||||
last = Date.now()
|
||||
const output = coalesceServerEvents(events, skip)
|
||||
const output = coalesceServerEvents(events)
|
||||
batch(() => {
|
||||
output.forEach((event) => emitter.emit(event.directory, event.payload))
|
||||
})
|
||||
@@ -184,29 +192,12 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
for await (const event of events.stream) {
|
||||
resetHeartbeat()
|
||||
streamErrorLogged = false
|
||||
const directory = event.directory ?? "global"
|
||||
if (event.payload.type === "sync") {
|
||||
continue
|
||||
if (event.payload.type !== "sync") {
|
||||
const directory = event.directory ?? "global"
|
||||
const payload = event.payload as Event
|
||||
if (enqueueServerEvent(queue, { directory, payload })) schedule()
|
||||
}
|
||||
|
||||
const payload = event.payload as Event
|
||||
|
||||
const k = key(directory, payload)
|
||||
if (k) {
|
||||
const i = coalesced.get(k)
|
||||
if (i !== undefined) {
|
||||
queue[i] = { directory, payload }
|
||||
if (payload.type === "message.part.updated") {
|
||||
const part = payload.properties.part
|
||||
staleDeltas.add(deltaKey(directory, part.messageID, part.id))
|
||||
}
|
||||
continue
|
||||
}
|
||||
coalesced.set(k, queue.length)
|
||||
}
|
||||
queue.push({ directory, payload })
|
||||
schedule()
|
||||
|
||||
if (Date.now() - yielded < STREAM_YIELD_MS) continue
|
||||
yielded = Date.now()
|
||||
await wait(0)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { OpencodeClient, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import type { retry } from "@opencode-ai/core/util/retry"
|
||||
import type { Message, OpencodeClient, Part, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { createServerSession } from "./server-session"
|
||||
|
||||
const session = (id: string, parentID?: string): Session => ({
|
||||
@@ -13,6 +14,74 @@ const session = (id: string, parentID?: string): Session => ({
|
||||
time: { created: 1, updated: 1 },
|
||||
})
|
||||
|
||||
type UserMessage = Extract<Message, { role: "user" }>
|
||||
type TextPart = Extract<Part, { type: "text" }>
|
||||
type MessageResponse = {
|
||||
data: { info: Message; parts: Part[] }[]
|
||||
response: { headers: Headers }
|
||||
}
|
||||
|
||||
const userMessage = (id: string, input: Partial<UserMessage> = {}): UserMessage => ({
|
||||
id,
|
||||
sessionID: "child",
|
||||
role: "user",
|
||||
time: { created: 1 },
|
||||
agent: "build",
|
||||
model: { providerID: "provider", modelID: "model" },
|
||||
...input,
|
||||
})
|
||||
|
||||
const textPart = (messageID: string, input: Partial<TextPart> = {}): TextPart => ({
|
||||
id: "part",
|
||||
sessionID: "child",
|
||||
messageID,
|
||||
type: "text",
|
||||
text: "text",
|
||||
...input,
|
||||
})
|
||||
|
||||
const response = (data: MessageResponse["data"] = [], cursor?: string): MessageResponse => ({
|
||||
data,
|
||||
response: { headers: new Headers(cursor ? { "x-next-cursor": cursor } : undefined) },
|
||||
})
|
||||
|
||||
const deferredResponse = () => Promise.withResolvers<MessageResponse>()
|
||||
|
||||
function messageClient(...responses: Array<MessageResponse | Promise<MessageResponse>>) {
|
||||
let index = 0
|
||||
const requests: unknown[] = []
|
||||
const waiting = new Map<number, () => void>()
|
||||
const client = {
|
||||
session: {
|
||||
get: async () => ({ data: session("child", "root") }),
|
||||
messages: (input: unknown) => {
|
||||
requests.push(input)
|
||||
waiting.get(requests.length)?.()
|
||||
waiting.delete(requests.length)
|
||||
return responses[index++]
|
||||
},
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
return Object.assign(client, {
|
||||
requests,
|
||||
requested(count: number) {
|
||||
if (requests.length >= count) return Promise.resolve()
|
||||
return new Promise<void>((resolve) => waiting.set(count, resolve))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const retryImmediately: typeof retry = async (task, options = {}) => {
|
||||
const attempts = options.attempts ?? 3
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
return await task()
|
||||
} catch (error) {
|
||||
if (attempt === attempts - 1) throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setup(sessions: Record<string, Session>) {
|
||||
const get: unknown[] = []
|
||||
const messages: unknown[] = []
|
||||
@@ -25,7 +94,7 @@ function setup(sessions: Record<string, Session>) {
|
||||
},
|
||||
messages: async (input: unknown) => {
|
||||
messages.push(input)
|
||||
return { data: [], response: { headers: new Headers() } }
|
||||
return response()
|
||||
},
|
||||
diff: async () => ({ data: [] }),
|
||||
todo: async () => ({ data: [] }),
|
||||
@@ -55,13 +124,971 @@ describe("server session", () => {
|
||||
expect(ctx.store.data.message.root).toEqual([])
|
||||
})
|
||||
|
||||
test("merges live events into the initial page", async () => {
|
||||
const pending = deferredResponse()
|
||||
const user = userMessage("message-1")
|
||||
const live = userMessage("message-2", { time: { created: 2 } })
|
||||
const livePart = textPart(live.id, { text: "live" })
|
||||
const store = createServerSession(messageClient(pending.promise))
|
||||
const loading = store.sync("child")
|
||||
|
||||
store.apply({ type: "message.updated", properties: { info: live } })
|
||||
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: livePart, time: 2 } })
|
||||
pending.resolve(response([{ info: user, parts: [] }]))
|
||||
await loading
|
||||
|
||||
expect(store.data.message.child).toEqual([user, live])
|
||||
expect(store.data.part[live.id]).toEqual([livePart])
|
||||
})
|
||||
|
||||
test("preserves same-ID live updates over the initial page", async () => {
|
||||
const pending = deferredResponse()
|
||||
const fetched = userMessage("message")
|
||||
const fetchedPart = textPart(fetched.id, { text: "fetched" })
|
||||
const live = { ...fetched, time: { created: 2 } }
|
||||
const livePart = { ...fetchedPart, text: "live" }
|
||||
const store = createServerSession(messageClient(pending.promise))
|
||||
const loading = store.sync("child")
|
||||
|
||||
store.apply({ type: "message.updated", properties: { info: live } })
|
||||
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: livePart, time: 2 } })
|
||||
pending.resolve(response([{ info: fetched, parts: [fetchedPart] }]))
|
||||
await loading
|
||||
|
||||
expect(store.data.message.child).toEqual([live])
|
||||
expect(store.data.part[live.id]).toEqual([livePart])
|
||||
})
|
||||
|
||||
test("preserves removals received during the initial load", async () => {
|
||||
const pending = deferredResponse()
|
||||
const removed = userMessage("message-1")
|
||||
const kept = { ...removed, id: "message-2" }
|
||||
const part = textPart(kept.id, { text: "removed" })
|
||||
const store = createServerSession(messageClient(pending.promise))
|
||||
const loading = store.sync("child")
|
||||
|
||||
store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: removed.id } })
|
||||
store.apply({
|
||||
type: "message.part.removed",
|
||||
properties: { sessionID: "child", messageID: kept.id, partID: part.id },
|
||||
})
|
||||
pending.resolve(
|
||||
response([
|
||||
{ info: removed, parts: [] },
|
||||
{ info: kept, parts: [part] },
|
||||
]),
|
||||
)
|
||||
await loading
|
||||
|
||||
expect(store.data.message.child).toEqual([kept])
|
||||
expect(store.data.part[kept.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("keeps removal tracking isolated across load generations", async () => {
|
||||
const firstResponse = deferredResponse()
|
||||
const secondResponse = deferredResponse()
|
||||
const message = userMessage("message")
|
||||
const store = createServerSession(messageClient(firstResponse.promise, secondResponse.promise))
|
||||
const first = store.sync("child")
|
||||
|
||||
store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } })
|
||||
store.apply({
|
||||
type: "session.deleted",
|
||||
properties: { sessionID: "child", info: session("child", "root") },
|
||||
})
|
||||
const second = store.sync("child")
|
||||
|
||||
firstResponse.resolve(response())
|
||||
await first
|
||||
secondResponse.resolve(response([{ info: message, parts: [] }]))
|
||||
await second
|
||||
|
||||
expect(store.data.message.child).toEqual([message])
|
||||
})
|
||||
|
||||
test("tracks removals in a replacement load generation", async () => {
|
||||
const firstResponse = deferredResponse()
|
||||
const secondResponse = deferredResponse()
|
||||
const message = userMessage("message")
|
||||
const store = createServerSession(messageClient(firstResponse.promise, secondResponse.promise))
|
||||
const first = store.sync("child")
|
||||
store.apply({
|
||||
type: "session.deleted",
|
||||
properties: { sessionID: "child", info: session("child", "root") },
|
||||
})
|
||||
const second = store.sync("child")
|
||||
|
||||
store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } })
|
||||
firstResponse.resolve(response())
|
||||
await first
|
||||
secondResponse.resolve(response([{ info: message, parts: [] }]))
|
||||
await second
|
||||
|
||||
expect(store.data.message.child).toEqual([])
|
||||
})
|
||||
|
||||
test("preserves remove then re-add when a refresh omits the message", async () => {
|
||||
const pending = deferredResponse()
|
||||
const message = userMessage("message")
|
||||
const store = createServerSession(messageClient(response([{ info: message, parts: [] }]), pending.promise))
|
||||
await store.sync("child")
|
||||
const refreshing = store.sync("child", { force: true })
|
||||
|
||||
store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } })
|
||||
store.apply({ type: "message.updated", properties: { info: message } })
|
||||
pending.resolve(response())
|
||||
await refreshing
|
||||
|
||||
expect(store.data.message.child).toEqual([message])
|
||||
})
|
||||
|
||||
test("preserves a re-added message without restoring removed parts", async () => {
|
||||
const pending = deferredResponse()
|
||||
const message = userMessage("message")
|
||||
const part = textPart(message.id, { text: "stale" })
|
||||
const store = createServerSession(messageClient(response([{ info: message, parts: [] }]), pending.promise))
|
||||
await store.sync("child")
|
||||
const refreshing = store.sync("child", { force: true })
|
||||
|
||||
store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } })
|
||||
store.apply({ type: "message.updated", properties: { info: message } })
|
||||
pending.resolve(response([{ info: message, parts: [part] }]))
|
||||
await refreshing
|
||||
|
||||
expect(store.data.message.child).toEqual([message])
|
||||
expect(store.data.part[message.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("preserves optimistic parts re-added after removal during a refresh", async () => {
|
||||
const pending = deferredResponse()
|
||||
const message = userMessage("message")
|
||||
const stale = textPart(message.id, { id: "stale", text: "stale" })
|
||||
const part = textPart(message.id, { id: "optimistic", text: "optimistic" })
|
||||
const store = createServerSession(
|
||||
messageClient(response([{ info: message, parts: [] }]), pending.promise, response()),
|
||||
)
|
||||
await store.sync("child")
|
||||
const refreshing = store.sync("child", { force: true })
|
||||
|
||||
store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } })
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [part] })
|
||||
pending.resolve(response([{ info: message, parts: [stale] }]))
|
||||
await refreshing
|
||||
|
||||
expect(store.data.message.child).toEqual([message])
|
||||
expect(store.data.part[message.id]).toEqual([part])
|
||||
|
||||
await store.sync("child", { force: true })
|
||||
expect(store.data.message.child).toEqual([message])
|
||||
expect(store.data.part[message.id]).toEqual([part])
|
||||
})
|
||||
|
||||
test("drops stale event content omitted by a complete initial page", async () => {
|
||||
const stale = userMessage("stale")
|
||||
const store = createServerSession(messageClient(response()))
|
||||
store.apply({ type: "message.updated", properties: { info: stale } })
|
||||
|
||||
await store.sync("child")
|
||||
|
||||
expect(store.data.message.child).toEqual([])
|
||||
})
|
||||
|
||||
test("preserves event content outside an incomplete initial page", async () => {
|
||||
const live = userMessage("message-1")
|
||||
const fetched = userMessage("message-2", { time: { created: 2 } })
|
||||
const store = createServerSession(messageClient(response([{ info: fetched, parts: [] }], "older")))
|
||||
store.apply({ type: "message.updated", properties: { info: live } })
|
||||
|
||||
await store.sync("child")
|
||||
|
||||
expect(store.data.message.child).toEqual([live, fetched])
|
||||
})
|
||||
|
||||
test("does not restore removed optimistic content on refresh", async () => {
|
||||
const message = userMessage("message")
|
||||
const part = textPart(message.id, { text: "removed" })
|
||||
const kept = { ...message, id: "kept" }
|
||||
const keptPart = { ...part, id: "kept-part", messageID: kept.id }
|
||||
const store = createServerSession(messageClient(response([{ info: kept, parts: [] }])))
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [part] })
|
||||
store.optimistic.add({ sessionID: "child", message: kept, parts: [keptPart] })
|
||||
|
||||
store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } })
|
||||
store.apply({
|
||||
type: "message.part.removed",
|
||||
properties: { sessionID: "child", messageID: kept.id, partID: keptPart.id },
|
||||
})
|
||||
await store.sync("child", { force: true })
|
||||
|
||||
expect(store.data.message.child).toEqual([kept])
|
||||
expect(store.data.part[message.id]).toBeUndefined()
|
||||
expect(store.data.part[kept.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("replaces confirmed optimistic content with the initial page", async () => {
|
||||
const optimistic = userMessage("message")
|
||||
const fetched = { ...optimistic, time: { created: 2 } }
|
||||
const store = createServerSession(messageClient(response([{ info: fetched, parts: [] }])))
|
||||
store.optimistic.add({ sessionID: "child", message: optimistic, parts: [] })
|
||||
|
||||
await store.sync("child")
|
||||
|
||||
expect(store.data.message.child).toEqual([fetched])
|
||||
})
|
||||
|
||||
test("replaces a confirmed optimistic part with fetched content", async () => {
|
||||
const pending = deferredResponse()
|
||||
const message = userMessage("message")
|
||||
const optimistic = textPart(message.id, { text: "optimistic" })
|
||||
const fetched = { ...optimistic, text: "fetched" }
|
||||
const store = createServerSession(messageClient(pending.promise))
|
||||
const loading = store.sync("child")
|
||||
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [optimistic] })
|
||||
pending.resolve(response([{ info: message, parts: [fetched] }]))
|
||||
await loading
|
||||
|
||||
expect(store.data.part[message.id]).toEqual([fetched])
|
||||
})
|
||||
|
||||
test("rolls back only unconfirmed optimistic parts", async () => {
|
||||
const pending = deferredResponse()
|
||||
const message = userMessage("message")
|
||||
const confirmed = textPart(message.id, { id: "confirmed", text: "confirmed" })
|
||||
const pendingPart = textPart(message.id, { id: "pending", text: "pending" })
|
||||
const store = createServerSession(messageClient(pending.promise))
|
||||
const loading = store.sync("child")
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [confirmed, pendingPart] })
|
||||
|
||||
pending.resolve(response([{ info: message, parts: [confirmed] }]))
|
||||
await loading
|
||||
store.optimistic.remove({ sessionID: "child", messageID: message.id })
|
||||
|
||||
expect(store.data.message.child).toEqual([message])
|
||||
expect(store.data.part[message.id]).toEqual([confirmed])
|
||||
})
|
||||
|
||||
test("updates confirmed optimistic parts from later pages", async () => {
|
||||
const message = userMessage("message")
|
||||
const confirmed = textPart(message.id, { id: "confirmed", text: "first" })
|
||||
const updated = { ...confirmed, text: "updated" }
|
||||
const pendingPart = textPart(message.id, { id: "pending", text: "pending" })
|
||||
const store = createServerSession(
|
||||
messageClient(response([{ info: message, parts: [confirmed] }]), response([{ info: message, parts: [updated] }])),
|
||||
)
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [confirmed, pendingPart] })
|
||||
await store.sync("child")
|
||||
|
||||
await store.sync("child", { force: true })
|
||||
store.optimistic.remove({ sessionID: "child", messageID: message.id })
|
||||
|
||||
expect(store.data.part[message.id]).toEqual([updated])
|
||||
})
|
||||
|
||||
test("does not restore a confirmed optimistic part after its removal event", async () => {
|
||||
const message = userMessage("message")
|
||||
const confirmed = textPart(message.id, { id: "confirmed", text: "confirmed" })
|
||||
const pendingPart = textPart(message.id, { id: "pending", text: "pending" })
|
||||
const store = createServerSession(
|
||||
messageClient(response([{ info: message, parts: [confirmed] }]), response([{ info: message, parts: [] }])),
|
||||
)
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [confirmed, pendingPart] })
|
||||
await store.sync("child")
|
||||
store.apply({
|
||||
type: "message.part.removed",
|
||||
properties: { sessionID: "child", messageID: message.id, partID: confirmed.id },
|
||||
})
|
||||
|
||||
await store.sync("child", { force: true })
|
||||
|
||||
expect(store.data.part[message.id]).toEqual([pendingPart])
|
||||
})
|
||||
|
||||
test("clears delta buffers when removing optimistic content", () => {
|
||||
const message = userMessage("message")
|
||||
const part = textPart(message.id, { text: "optimistic" })
|
||||
const store = setup({ child: session("child") }).store
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [part] })
|
||||
store.apply({
|
||||
type: "message.part.delta",
|
||||
properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: " delta" },
|
||||
})
|
||||
|
||||
store.optimistic.remove({ sessionID: "child", messageID: message.id })
|
||||
|
||||
expect(store.data.part[message.id]).toBeUndefined()
|
||||
expect(store.data.part_text_accum_delta[part.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("does not remove content confirmed by a message event", () => {
|
||||
const message = userMessage("message")
|
||||
const part = textPart(message.id)
|
||||
const store = setup({ child: session("child") }).store
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [part] })
|
||||
store.apply({ type: "message.updated", properties: { sessionID: "child", info: message } })
|
||||
|
||||
store.optimistic.remove({ sessionID: "child", messageID: message.id })
|
||||
|
||||
expect(store.data.message.child).toEqual([message])
|
||||
expect(store.data.part[message.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("does not remove parts confirmed by part events", () => {
|
||||
const message = userMessage("message")
|
||||
const part = textPart(message.id)
|
||||
const store = setup({ child: session("child") }).store
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [part] })
|
||||
store.apply({ type: "message.updated", properties: { sessionID: "child", info: message } })
|
||||
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } })
|
||||
|
||||
store.optimistic.remove({ sessionID: "child", messageID: message.id })
|
||||
|
||||
expect(store.data.message.child).toEqual([message])
|
||||
expect(store.data.part[message.id]).toEqual([part])
|
||||
})
|
||||
|
||||
test("treats a part event as confirmation when it precedes the message event", () => {
|
||||
const message = userMessage("message")
|
||||
const part = textPart(message.id)
|
||||
const store = setup({ child: session("child") }).store
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [part] })
|
||||
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } })
|
||||
|
||||
store.optimistic.remove({ sessionID: "child", messageID: message.id })
|
||||
|
||||
expect(store.data.message.child).toEqual([message])
|
||||
expect(store.data.part[message.id]).toEqual([part])
|
||||
})
|
||||
|
||||
test("clears stale parts when the initial page has none", async () => {
|
||||
const pending = deferredResponse()
|
||||
const message = userMessage("message")
|
||||
const part = textPart(message.id, { text: "stale" })
|
||||
const store = createServerSession(messageClient(pending.promise))
|
||||
store.apply({ type: "message.updated", properties: { info: message } })
|
||||
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 1 } })
|
||||
const loading = store.sync("child")
|
||||
|
||||
pending.resolve(response([{ info: message, parts: [] }]))
|
||||
await loading
|
||||
|
||||
expect(store.data.part[message.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("clears delta buffers for parts omitted by the initial page", async () => {
|
||||
const pending = deferredResponse()
|
||||
const message = userMessage("message")
|
||||
const kept = textPart(message.id, { id: "part-1", text: "kept" })
|
||||
const removed: Part = { ...kept, id: "part-2", text: "removed" }
|
||||
const store = createServerSession(messageClient(pending.promise))
|
||||
store.apply({ type: "message.updated", properties: { info: message } })
|
||||
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: kept, time: 1 } })
|
||||
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: removed, time: 1 } })
|
||||
store.apply({
|
||||
type: "message.part.delta",
|
||||
properties: { sessionID: "child", messageID: message.id, partID: removed.id, field: "text", delta: " delta" },
|
||||
})
|
||||
const loading = store.sync("child")
|
||||
|
||||
pending.resolve(response([{ info: message, parts: [kept] }]))
|
||||
await loading
|
||||
|
||||
expect(store.data.part[message.id]).toEqual([kept])
|
||||
expect(store.data.part_text_accum_delta[removed.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("clears a stale delta buffer when a refresh replaces its part", async () => {
|
||||
const message = userMessage("message")
|
||||
const stale = textPart(message.id, { text: "stale" })
|
||||
const fetched = { ...stale, text: "fetched" }
|
||||
const store = createServerSession(
|
||||
messageClient(response([{ info: message, parts: [stale] }]), response([{ info: message, parts: [fetched] }])),
|
||||
)
|
||||
await store.sync("child")
|
||||
store.apply({
|
||||
type: "message.part.delta",
|
||||
properties: { sessionID: "child", messageID: message.id, partID: stale.id, field: "text", delta: " delta" },
|
||||
})
|
||||
|
||||
await store.sync("child", { force: true })
|
||||
|
||||
expect(store.data.part[message.id]).toEqual([fetched])
|
||||
expect(store.data.part_text_accum_delta[stale.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("preserves a non-durable delta received before refresh", async () => {
|
||||
const message = userMessage("message")
|
||||
const part = textPart(message.id, { text: "stale" })
|
||||
const store = createServerSession(
|
||||
messageClient(response([{ info: message, parts: [part] }]), response([{ info: message, parts: [{ ...part }] }])),
|
||||
)
|
||||
await store.sync("child")
|
||||
store.apply({
|
||||
type: "message.part.delta",
|
||||
properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: " delta" },
|
||||
})
|
||||
|
||||
await store.sync("child", { force: true })
|
||||
|
||||
expect(store.data.part[message.id]).toEqual([{ ...part, text: "stale delta" }])
|
||||
expect(store.data.part_text_accum_delta[part.id]).toBe("stale delta")
|
||||
})
|
||||
|
||||
test("accepts fetched text that intentionally replaces an accumulated prefix", async () => {
|
||||
const message = userMessage("message")
|
||||
const part = textPart(message.id, { text: "abc" })
|
||||
const fetched = { ...part, text: "ab" }
|
||||
const store = createServerSession(
|
||||
messageClient(response([{ info: message, parts: [part] }]), response([{ info: message, parts: [fetched] }])),
|
||||
)
|
||||
await store.sync("child")
|
||||
store.apply({
|
||||
type: "message.part.delta",
|
||||
properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: "def" },
|
||||
})
|
||||
|
||||
await store.sync("child", { force: true })
|
||||
|
||||
expect(store.data.part[message.id]).toEqual([fetched])
|
||||
expect(store.data.part_text_accum_delta[part.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("preserves an unpersisted delta suffix after partial server catch-up", async () => {
|
||||
const message = userMessage("message")
|
||||
const part = textPart(message.id, { text: "a" })
|
||||
const fetched = { ...part, text: "ab" }
|
||||
const store = createServerSession(
|
||||
messageClient(response([{ info: message, parts: [part] }]), response([{ info: message, parts: [fetched] }])),
|
||||
)
|
||||
await store.sync("child")
|
||||
store.apply({
|
||||
type: "message.part.delta",
|
||||
properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: "bc" },
|
||||
})
|
||||
|
||||
await store.sync("child", { force: true })
|
||||
|
||||
expect(store.data.part[message.id]).toEqual([{ ...part, text: "abc" }])
|
||||
expect(store.data.part_text_accum_delta[part.id]).toBe("abc")
|
||||
})
|
||||
|
||||
test("clears delta state after exact server catch-up", async () => {
|
||||
const message = userMessage("message")
|
||||
const part = textPart(message.id, { text: "a" })
|
||||
const fetched = { ...part, text: "ab" }
|
||||
const store = createServerSession(
|
||||
messageClient(response([{ info: message, parts: [part] }]), response([{ info: message, parts: [fetched] }])),
|
||||
)
|
||||
await store.sync("child")
|
||||
store.apply({
|
||||
type: "message.part.delta",
|
||||
properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: "b" },
|
||||
})
|
||||
|
||||
await store.sync("child", { force: true })
|
||||
|
||||
expect(store.data.part[message.id]).toEqual([fetched])
|
||||
expect(store.data.part_text_accum_delta[part.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("uses the successful retry response over events from a failed attempt", async () => {
|
||||
const failed = Promise.withResolvers<MessageResponse>()
|
||||
const retried = Promise.withResolvers<MessageResponse>()
|
||||
const message = userMessage("message")
|
||||
const stale = textPart(message.id, { text: "stale" })
|
||||
const intermediate = { ...stale, text: "intermediate" }
|
||||
const fetched = { ...stale, text: "fetched" }
|
||||
const client = messageClient(failed.promise, retried.promise)
|
||||
const store = createServerSession(client, { retry: retryImmediately })
|
||||
store.apply({ type: "message.updated", properties: { info: message } })
|
||||
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: stale, time: 1 } })
|
||||
const loading = store.sync("child")
|
||||
|
||||
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: intermediate, time: 2 } })
|
||||
failed.reject(new Error("failed to fetch"))
|
||||
await client.requested(2)
|
||||
retried.resolve(response([{ info: message, parts: [fetched] }]))
|
||||
await loading
|
||||
|
||||
expect(store.data.part[message.id]).toEqual([fetched])
|
||||
})
|
||||
|
||||
test("preserves non-durable deltas across message retries", async () => {
|
||||
const failed = Promise.withResolvers<MessageResponse>()
|
||||
const retried = Promise.withResolvers<MessageResponse>()
|
||||
const message = userMessage("message")
|
||||
const part = textPart(message.id, { text: "stale" })
|
||||
const client = messageClient(failed.promise, retried.promise)
|
||||
const store = createServerSession(client, { retry: retryImmediately })
|
||||
store.apply({ type: "message.updated", properties: { info: message } })
|
||||
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 1 } })
|
||||
const loading = store.sync("child")
|
||||
|
||||
store.apply({
|
||||
type: "message.part.delta",
|
||||
properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: " delta" },
|
||||
})
|
||||
failed.reject(new Error("failed to fetch"))
|
||||
await client.requested(2)
|
||||
retried.resolve(response([{ info: message, parts: [part] }]))
|
||||
await loading
|
||||
|
||||
expect(store.data.part[message.id]).toEqual([{ ...part, text: "stale delta" }])
|
||||
})
|
||||
|
||||
test("preserves part removals across message retries", async () => {
|
||||
const failed = Promise.withResolvers<MessageResponse>()
|
||||
const retried = Promise.withResolvers<MessageResponse>()
|
||||
const message = userMessage("message")
|
||||
const part = textPart(message.id)
|
||||
const client = messageClient(response([{ info: message, parts: [part] }]), failed.promise, retried.promise)
|
||||
const store = createServerSession(client, { retry: retryImmediately })
|
||||
await store.sync("child")
|
||||
const loading = store.sync("child", { force: true })
|
||||
|
||||
store.apply({
|
||||
type: "message.part.removed",
|
||||
properties: { sessionID: "child", messageID: message.id, partID: part.id },
|
||||
})
|
||||
failed.reject(new Error("failed to fetch"))
|
||||
await client.requested(3)
|
||||
retried.resolve(response([{ info: message, parts: [part] }]))
|
||||
await loading
|
||||
|
||||
expect(store.data.part[message.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("preserves message removals across message retries", async () => {
|
||||
const failed = Promise.withResolvers<MessageResponse>()
|
||||
const retried = Promise.withResolvers<MessageResponse>()
|
||||
const message = userMessage("message")
|
||||
const part = textPart(message.id)
|
||||
const client = messageClient(response([{ info: message, parts: [part] }]), failed.promise, retried.promise)
|
||||
const store = createServerSession(client, { retry: retryImmediately })
|
||||
await store.sync("child")
|
||||
const loading = store.sync("child", { force: true })
|
||||
|
||||
store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } })
|
||||
failed.reject(new Error("failed to fetch"))
|
||||
await client.requested(3)
|
||||
retried.resolve(response([{ info: message, parts: [part] }]))
|
||||
await loading
|
||||
|
||||
expect(store.data.message.child).toEqual([])
|
||||
expect(store.data.part[message.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("preserves optimistic re-adds across message retries", async () => {
|
||||
const failed = Promise.withResolvers<MessageResponse>()
|
||||
const retried = Promise.withResolvers<MessageResponse>()
|
||||
const message = userMessage("message")
|
||||
const stale = textPart(message.id, { id: "stale", text: "stale" })
|
||||
const optimistic = textPart(message.id, { id: "optimistic", text: "optimistic" })
|
||||
const client = messageClient(response([{ info: message, parts: [stale] }]), failed.promise, retried.promise)
|
||||
const store = createServerSession(client, { retry: retryImmediately })
|
||||
await store.sync("child")
|
||||
const loading = store.sync("child", { force: true })
|
||||
|
||||
store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } })
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [optimistic] })
|
||||
failed.reject(new Error("failed to fetch"))
|
||||
await client.requested(3)
|
||||
retried.resolve(response([{ info: message, parts: [stale] }]))
|
||||
await loading
|
||||
|
||||
expect(store.data.message.child).toEqual([message])
|
||||
expect(store.data.part[message.id]).toEqual([optimistic])
|
||||
})
|
||||
|
||||
test("accepts part omission from a successful retry after an earlier delta", async () => {
|
||||
const failed = Promise.withResolvers<MessageResponse>()
|
||||
const retried = Promise.withResolvers<MessageResponse>()
|
||||
const message = userMessage("message")
|
||||
const part = textPart(message.id)
|
||||
const client = messageClient(response([{ info: message, parts: [part] }]), failed.promise, retried.promise)
|
||||
const store = createServerSession(client, { retry: retryImmediately })
|
||||
await store.sync("child")
|
||||
const loading = store.sync("child", { force: true })
|
||||
|
||||
store.apply({
|
||||
type: "message.part.delta",
|
||||
properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: " delta" },
|
||||
})
|
||||
failed.reject(new Error("failed to fetch"))
|
||||
await client.requested(3)
|
||||
retried.resolve(response([{ info: message, parts: [] }]))
|
||||
await loading
|
||||
|
||||
expect(store.data.part[message.id]).toBeUndefined()
|
||||
expect(store.data.part_text_accum_delta[part.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("clears load-owned orphan parts when all retries fail", async () => {
|
||||
const first = Promise.withResolvers<MessageResponse>()
|
||||
const second = Promise.withResolvers<MessageResponse>()
|
||||
const third = Promise.withResolvers<MessageResponse>()
|
||||
const message = userMessage("message")
|
||||
const part = textPart(message.id)
|
||||
const client = messageClient(first.promise, second.promise, third.promise)
|
||||
const store = createServerSession(client, { retry: retryImmediately })
|
||||
const loading = store.sync("child").catch((error) => error)
|
||||
|
||||
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } })
|
||||
first.reject(new Error("failed to fetch"))
|
||||
await client.requested(2)
|
||||
second.reject(new Error("failed to fetch"))
|
||||
await client.requested(3)
|
||||
third.reject(new Error("failed to fetch"))
|
||||
await loading
|
||||
|
||||
expect(store.data.part[message.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("preserves live updates during a forced refresh", async () => {
|
||||
const pending = deferredResponse()
|
||||
const stale = userMessage("message")
|
||||
const stalePart = textPart(stale.id, { text: "stale" })
|
||||
const store = createServerSession(messageClient(response([{ info: stale, parts: [stalePart] }]), pending.promise))
|
||||
await store.sync("child")
|
||||
const refreshing = store.sync("child", { force: true })
|
||||
const live = { ...stale, time: { created: 2 } }
|
||||
|
||||
store.apply({ type: "message.updated", properties: { info: live } })
|
||||
store.apply({
|
||||
type: "message.part.delta",
|
||||
properties: { sessionID: "child", messageID: stale.id, partID: stalePart.id, field: "text", delta: " live" },
|
||||
})
|
||||
pending.resolve(response([{ info: stale, parts: [stalePart] }]))
|
||||
await refreshing
|
||||
|
||||
expect(store.data.message.child).toEqual([live])
|
||||
expect(store.data.part[stale.id]).toEqual([{ ...stalePart, text: "stale live" }])
|
||||
})
|
||||
|
||||
test("keeps fetched message metadata when only a part changes", async () => {
|
||||
const pending = deferredResponse()
|
||||
const stale = userMessage("message")
|
||||
const fetched = { ...stale, time: { created: 2 } }
|
||||
const part = textPart(stale.id, { text: "stale" })
|
||||
const store = createServerSession(messageClient(response([{ info: stale, parts: [part] }]), pending.promise))
|
||||
await store.sync("child")
|
||||
const refreshing = store.sync("child", { force: true })
|
||||
|
||||
store.apply({
|
||||
type: "message.part.delta",
|
||||
properties: { sessionID: "child", messageID: stale.id, partID: part.id, field: "text", delta: " live" },
|
||||
})
|
||||
pending.resolve(response([{ info: fetched, parts: [part] }]))
|
||||
await refreshing
|
||||
|
||||
expect(store.data.message.child).toEqual([fetched])
|
||||
expect(store.data.part[stale.id]).toEqual([{ ...part, text: "stale live" }])
|
||||
})
|
||||
|
||||
test("preserves a part update when a forced refresh omits its message", async () => {
|
||||
const pending = deferredResponse()
|
||||
const message = userMessage("message")
|
||||
const stale = textPart(message.id, { text: "stale" })
|
||||
const live = { ...stale, text: "live" }
|
||||
const store = createServerSession(messageClient(response([{ info: message, parts: [stale] }]), pending.promise))
|
||||
await store.sync("child")
|
||||
const refreshing = store.sync("child", { force: true })
|
||||
|
||||
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: live, time: 2 } })
|
||||
pending.resolve(response())
|
||||
await refreshing
|
||||
|
||||
expect(store.data.message.child).toEqual([message])
|
||||
expect(store.data.part[message.id]).toEqual([live])
|
||||
})
|
||||
|
||||
test("ignores a late part update after its message is removed", async () => {
|
||||
const pending = deferredResponse()
|
||||
const message = userMessage("message")
|
||||
const part = textPart(message.id)
|
||||
const store = createServerSession(messageClient(pending.promise))
|
||||
const loading = store.sync("child")
|
||||
|
||||
store.apply({ type: "message.updated", properties: { info: message } })
|
||||
store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } })
|
||||
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } })
|
||||
pending.resolve(response([{ info: message, parts: [part] }]))
|
||||
await loading
|
||||
|
||||
expect(store.data.message.child).toEqual([])
|
||||
expect(store.data.part[message.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("ignores a late part update after a completed message removal", () => {
|
||||
const message = userMessage("message")
|
||||
const part = textPart(message.id)
|
||||
const store = setup({ child: session("child") }).store
|
||||
store.apply({ type: "message.updated", properties: { info: message } })
|
||||
store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } })
|
||||
|
||||
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } })
|
||||
|
||||
expect(store.data.part[message.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("does not restore a completed message removal from a stale refresh", async () => {
|
||||
const message = userMessage("message")
|
||||
const part = textPart(message.id)
|
||||
const store = createServerSession(
|
||||
messageClient(response([{ info: message, parts: [part] }]), response([{ info: message, parts: [part] }])),
|
||||
)
|
||||
await store.sync("child")
|
||||
store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } })
|
||||
|
||||
await store.sync("child", { force: true })
|
||||
|
||||
expect(store.data.message.child).toEqual([])
|
||||
expect(store.data.part[message.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("does not restore a completed part removal from a stale refresh", async () => {
|
||||
const message = userMessage("message")
|
||||
const part = textPart(message.id)
|
||||
const store = createServerSession(
|
||||
messageClient(response([{ info: message, parts: [part] }]), response([{ info: message, parts: [part] }])),
|
||||
)
|
||||
await store.sync("child")
|
||||
store.apply({
|
||||
type: "message.part.removed",
|
||||
properties: { sessionID: "child", messageID: message.id, partID: part.id },
|
||||
})
|
||||
|
||||
await store.sync("child", { force: true })
|
||||
|
||||
expect(store.data.part[message.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("does not cache skipped optimistic parts", () => {
|
||||
const message = userMessage("message")
|
||||
const part = { id: "part", sessionID: "child", messageID: message.id, type: "step-start" as const }
|
||||
const store = setup({ child: session("child") }).store
|
||||
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [part] })
|
||||
|
||||
expect(store.data.part[message.id]).toEqual([])
|
||||
})
|
||||
|
||||
test("clears stale delta buffers when replacing optimistic parts", () => {
|
||||
const message = userMessage("message")
|
||||
const stale = textPart(message.id, { id: "stale", text: "stale" })
|
||||
const optimistic = textPart(message.id, { id: "optimistic", text: "optimistic" })
|
||||
const store = setup({ child: session("child") }).store
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [stale] })
|
||||
store.apply({
|
||||
type: "message.part.delta",
|
||||
properties: { sessionID: "child", messageID: message.id, partID: stale.id, field: "text", delta: " delta" },
|
||||
})
|
||||
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [optimistic] })
|
||||
|
||||
expect(store.data.part_text_accum_delta[stale.id]).toBeUndefined()
|
||||
expect(store.data.part_text_accum_delta[optimistic.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("preserves removals during history prepend", async () => {
|
||||
const pending = deferredResponse()
|
||||
const latest = userMessage("message-2", { time: { created: 2 } })
|
||||
const older = { ...latest, id: "message-1", time: { created: 1 } }
|
||||
const store = createServerSession(messageClient(response([{ info: latest, parts: [] }], "older"), pending.promise))
|
||||
await store.sync("child")
|
||||
const loading = store.history.loadMore("child")
|
||||
|
||||
store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: older.id } })
|
||||
pending.resolve(response([{ info: older, parts: [] }]))
|
||||
await loading
|
||||
|
||||
expect(store.data.message.child).toEqual([latest])
|
||||
})
|
||||
|
||||
test("preserves loaded history during an incomplete refresh", async () => {
|
||||
const older = userMessage("message-1")
|
||||
const latest = userMessage("message-2", { time: { created: 2 } })
|
||||
const fresh = userMessage("message-3", { time: { created: 3 } })
|
||||
const store = createServerSession(
|
||||
messageClient(
|
||||
response(
|
||||
[
|
||||
{ info: older, parts: [] },
|
||||
{ info: latest, parts: [] },
|
||||
],
|
||||
"older",
|
||||
),
|
||||
response(
|
||||
[
|
||||
{ info: latest, parts: [] },
|
||||
{ info: fresh, parts: [] },
|
||||
],
|
||||
"older",
|
||||
),
|
||||
),
|
||||
)
|
||||
await store.sync("child")
|
||||
|
||||
await store.sync("child", { force: true })
|
||||
|
||||
expect(store.data.message.child).toEqual([older, latest, fresh])
|
||||
})
|
||||
|
||||
test("drops stale recent messages omitted by an incomplete refresh", async () => {
|
||||
const third = userMessage("message-3", { time: { created: 3 } })
|
||||
const fourth = userMessage("message-4", { time: { created: 4 } })
|
||||
const stale = userMessage("message-5", { time: { created: 5 } })
|
||||
const store = createServerSession(
|
||||
messageClient(
|
||||
response(
|
||||
[
|
||||
{ info: fourth, parts: [] },
|
||||
{ info: stale, parts: [] },
|
||||
],
|
||||
"older",
|
||||
),
|
||||
response(
|
||||
[
|
||||
{ info: third, parts: [] },
|
||||
{ info: fourth, parts: [] },
|
||||
],
|
||||
"older",
|
||||
),
|
||||
),
|
||||
)
|
||||
await store.sync("child")
|
||||
|
||||
await store.sync("child", { force: true })
|
||||
|
||||
expect(store.data.message.child).toEqual([third, fourth])
|
||||
})
|
||||
|
||||
test("uses message creation time for incomplete refresh boundaries", async () => {
|
||||
const older = userMessage("msg_z", { time: { created: 1 } })
|
||||
const boundary = userMessage("msg_m", { time: { created: 2 } })
|
||||
const stale = userMessage("msg_a", { time: { created: 3 } })
|
||||
const store = createServerSession(
|
||||
messageClient(
|
||||
response(
|
||||
[
|
||||
{ info: older, parts: [] },
|
||||
{ info: stale, parts: [] },
|
||||
],
|
||||
"older",
|
||||
),
|
||||
response([{ info: boundary, parts: [] }], "older"),
|
||||
),
|
||||
)
|
||||
await store.sync("child")
|
||||
|
||||
await store.sync("child", { force: true })
|
||||
|
||||
expect(store.data.message.child).toEqual([boundary, older])
|
||||
})
|
||||
|
||||
test("preserves a part update for a message being loaded from history", async () => {
|
||||
const pending = deferredResponse()
|
||||
const latest = userMessage("message-2", { time: { created: 2 } })
|
||||
const older = userMessage("message-1")
|
||||
const stale = textPart(older.id, { text: "stale" })
|
||||
const live = { ...stale, text: "live" }
|
||||
const store = createServerSession(messageClient(response([{ info: latest, parts: [] }], "older"), pending.promise))
|
||||
await store.sync("child")
|
||||
const loading = store.history.loadMore("child")
|
||||
|
||||
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: live, time: 2 } })
|
||||
pending.resolve(response([{ info: older, parts: [stale] }]))
|
||||
await loading
|
||||
|
||||
expect(store.data.part[older.id]).toEqual([live])
|
||||
})
|
||||
|
||||
test("does not clear newer orphan parts after terminal history prepend", async () => {
|
||||
const pending = deferredResponse()
|
||||
const latest = userMessage("message-2", { time: { created: 2 } })
|
||||
const older = userMessage("message-1")
|
||||
const newer = userMessage("message-3", { time: { created: 3 } })
|
||||
const part = textPart(newer.id, { text: "live" })
|
||||
const store = createServerSession(messageClient(response([{ info: latest, parts: [] }], "older"), pending.promise))
|
||||
await store.sync("child")
|
||||
const loading = store.history.loadMore("child")
|
||||
|
||||
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 3 } })
|
||||
pending.resolve(response([{ info: older, parts: [] }]))
|
||||
await loading
|
||||
store.apply({ type: "message.updated", properties: { sessionID: "child", info: newer } })
|
||||
|
||||
expect(store.data.part[newer.id]).toEqual([part])
|
||||
})
|
||||
|
||||
test("accepts an authoritative history part after an earlier unknown-parent update", async () => {
|
||||
const pending = deferredResponse()
|
||||
const history = deferredResponse()
|
||||
const latest = userMessage("message-2", { time: { created: 2 } })
|
||||
const older = userMessage("message-1")
|
||||
const part = textPart(older.id, { text: "live" })
|
||||
const store = createServerSession(messageClient(pending.promise, history.promise))
|
||||
const loading = store.sync("child")
|
||||
|
||||
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } })
|
||||
pending.resolve(response([{ info: latest, parts: [] }], "older"))
|
||||
await loading
|
||||
|
||||
expect(store.data.part[older.id]).toEqual([part])
|
||||
|
||||
const loadingHistory = store.history.loadMore("child")
|
||||
history.resolve(response([{ info: older, parts: [{ ...part, text: "stale" }] }]))
|
||||
await loadingHistory
|
||||
|
||||
expect(store.data.part[older.id]).toEqual([{ ...part, text: "stale" }])
|
||||
})
|
||||
|
||||
test("preserves an unknown-parent part removal across pages", async () => {
|
||||
const initial = deferredResponse()
|
||||
const history = deferredResponse()
|
||||
const latest = userMessage("message-2", { time: { created: 2 } })
|
||||
const older = userMessage("message-1")
|
||||
const part = textPart(older.id)
|
||||
const store = createServerSession(messageClient(initial.promise, history.promise))
|
||||
const loading = store.sync("child")
|
||||
|
||||
store.apply({
|
||||
type: "message.part.removed",
|
||||
properties: { sessionID: "child", messageID: older.id, partID: part.id },
|
||||
})
|
||||
initial.resolve(response([{ info: latest, parts: [] }], "older"))
|
||||
await loading
|
||||
const loadingHistory = store.history.loadMore("child")
|
||||
history.resolve(response([{ info: older, parts: [part] }]))
|
||||
await loadingHistory
|
||||
|
||||
expect(store.data.part[older.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("clears orphaned parts when a refresh drops a message", async () => {
|
||||
const message = userMessage("message")
|
||||
const part = textPart(message.id, { text: "stale" })
|
||||
const store = createServerSession(messageClient(response([{ info: message, parts: [part] }]), response()))
|
||||
await store.sync("child")
|
||||
store.apply({
|
||||
type: "message.part.delta",
|
||||
properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: " delta" },
|
||||
})
|
||||
await store.sync("child", { force: true })
|
||||
|
||||
expect(store.data.message.child).toEqual([])
|
||||
expect(store.data.part[message.id]).toBeUndefined()
|
||||
expect(store.data.part_text_accum_delta[part.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("applies events without a directory store", () => {
|
||||
const ctx = setup({})
|
||||
ctx.store.apply({ type: "session.created", properties: { info: session("root") } })
|
||||
ctx.store.apply({ type: "session.created", properties: { sessionID: "root", info: session("root") } })
|
||||
ctx.store.apply({ type: "session.status", properties: { sessionID: "root", status: { type: "busy" } } })
|
||||
|
||||
expect(ctx.store.get("root")?.directory).toBe("/repo")
|
||||
expect(ctx.store.data.session_working("root")).toBe(true)
|
||||
expect(ctx.get).toEqual([])
|
||||
})
|
||||
|
||||
test("preserves pinned session content under server-wide cache pressure", () => {
|
||||
@@ -87,12 +1114,14 @@ describe("server session", () => {
|
||||
})
|
||||
|
||||
for (let index = 0; index < 50; index++) {
|
||||
ctx.store.remember(session(`session-${index}`))
|
||||
ctx.store.apply({
|
||||
type: "session.status",
|
||||
properties: { sessionID: `session-${index}`, status: { type: "busy" } },
|
||||
properties: { sessionID: `session-${index}`, status: { type: "idle" } },
|
||||
})
|
||||
}
|
||||
|
||||
expect(ctx.store.data.message.active?.map((message) => message.id)).toEqual(["message"])
|
||||
expect(ctx.store.data.session_status["session-0"]).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,44 +18,67 @@ import { rootSession } from "@/utils/session-route"
|
||||
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
|
||||
|
||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
const cmpMessage = (a: Message, b: Message) => a.time.created - b.time.created || cmp(a.id, b.id)
|
||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||
const initialMessagePageSize = 2
|
||||
const historyMessagePageSize = 200
|
||||
const sessionInfoLimit = 2_048
|
||||
const emptyIDs: ReadonlySet<string> = new Set()
|
||||
|
||||
type OptimisticItem = {
|
||||
message: Message
|
||||
parts: Part[]
|
||||
confirmedParts?: Part[]
|
||||
confirmedMessage?: boolean
|
||||
}
|
||||
|
||||
const hasParts = (parts: Part[] | undefined, want: Part[]) => {
|
||||
if (!parts) return want.length === 0
|
||||
return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found)
|
||||
type MessagePage = {
|
||||
session: Message[]
|
||||
part: { id: string; part: Part[] }[]
|
||||
cursor?: string
|
||||
complete: boolean
|
||||
}
|
||||
|
||||
function mergeOptimisticPage(
|
||||
page: { session: Message[]; part: { id: string; part: Part[] }[]; cursor?: string; complete: boolean },
|
||||
items: OptimisticItem[],
|
||||
) {
|
||||
if (items.length === 0) return { ...page, confirmed: [] as string[] }
|
||||
// Most markers describe the current HTTP attempt; deltaParts persists non-durable stream state across retries.
|
||||
type MessageLoadState = {
|
||||
touchedMessages: Set<string>
|
||||
removedMessages: Set<string>
|
||||
retainedMessages: Set<string>
|
||||
touchedParts: Map<string, Set<string>>
|
||||
deltaParts: Map<string, Set<string>>
|
||||
carriedDeltaParts: Map<string, Set<string>>
|
||||
removedParts: Map<string, Set<string>>
|
||||
optimisticParts: Map<string, Set<string>>
|
||||
orphanParents: Set<string>
|
||||
clearedMessageParts: Set<string>
|
||||
}
|
||||
|
||||
function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
|
||||
if (items.length === 0) return { ...page, observed: [] as { messageID: string; parts: Part[] }[] }
|
||||
const session = [...page.session]
|
||||
const part = new Map(page.part.map((item) => [item.id, item.part]))
|
||||
const confirmed: string[] = []
|
||||
const observed: { messageID: string; parts: Part[] }[] = []
|
||||
for (const item of items) {
|
||||
const result = Binary.search(session, item.message.id, (message) => message.id)
|
||||
if (!result.found) session.splice(result.index, 0, item.message)
|
||||
const current = part.get(item.message.id)
|
||||
if (result.found && hasParts(current, item.parts)) {
|
||||
confirmed.push(item.message.id)
|
||||
continue
|
||||
}
|
||||
part.set(item.message.id, merge(current ?? [], item.parts))
|
||||
const confirmed = result.found
|
||||
? item.parts.filter((part) => Binary.search(current ?? [], part.id, (value) => value.id).found)
|
||||
: []
|
||||
if (result.found) observed.push({ messageID: item.message.id, parts: confirmed })
|
||||
part.set(
|
||||
item.message.id,
|
||||
merge(
|
||||
result.found ? (current ?? []) : merge(item.confirmedParts ?? [], current ?? []),
|
||||
item.parts.filter((part) => !confirmed.includes(part)),
|
||||
),
|
||||
)
|
||||
}
|
||||
return {
|
||||
...page,
|
||||
session,
|
||||
part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, parts]) => ({ id, part: parts })),
|
||||
confirmed,
|
||||
observed,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +98,38 @@ function merge<T extends { id: string }>(a: readonly T[], b: readonly T[]) {
|
||||
return [...items.values()].sort((x, y) => cmp(x.id, y.id))
|
||||
}
|
||||
|
||||
export function createServerSession(client: OpencodeClient) {
|
||||
function reconcileFetched<T extends { id: string }>(
|
||||
fetched: T[],
|
||||
current: readonly T[],
|
||||
options: {
|
||||
touched?: ReadonlySet<string>
|
||||
retained?: ReadonlySet<string>
|
||||
preserveUnfetched?: boolean | ((item: T) => boolean)
|
||||
} = {},
|
||||
) {
|
||||
const result = new Map(fetched.map((item) => [item.id, item]))
|
||||
const live = new Map(current.map((item) => [item.id, item]))
|
||||
if (options.preserveUnfetched) {
|
||||
for (const item of current) {
|
||||
if (!result.has(item.id) && (options.preserveUnfetched === true || options.preserveUnfetched(item)))
|
||||
result.set(item.id, item)
|
||||
}
|
||||
}
|
||||
for (const id of options.retained ?? emptyIDs) {
|
||||
if (result.has(id)) continue
|
||||
const item = live.get(id)
|
||||
if (item) result.set(id, item)
|
||||
}
|
||||
// Events observed while the request is pending are the freshest client state for those identities.
|
||||
for (const id of options.touched ?? emptyIDs) {
|
||||
const item = live.get(id)
|
||||
if (item) result.set(id, item)
|
||||
if (!item) result.delete(id)
|
||||
}
|
||||
return [...result.values()].sort((a, b) => cmp(a.id, b.id))
|
||||
}
|
||||
|
||||
export function createServerSession(client: OpencodeClient, options?: { retry?: typeof retry }) {
|
||||
const [data, setData] = createStore({
|
||||
info: {} as Record<string, Session | undefined>,
|
||||
session_status: {} as Record<string, SessionStatus>,
|
||||
@@ -95,10 +149,32 @@ export function createServerSession(client: OpencodeClient) {
|
||||
const inflightDiff = new Map<string, Promise<void>>()
|
||||
const inflightTodo = new Map<string, Promise<void>>()
|
||||
const optimistic = new Map<string, Map<string, OptimisticItem>>()
|
||||
const messageLoads = new Map<string, MessageLoadState>()
|
||||
const pendingParts = new Map<string, Map<string, Set<string>>>()
|
||||
const orphanParts = new Map<string, Set<string>>()
|
||||
const removedMessages = new Map<string, Set<string>>()
|
||||
const deltaBases = new Map<string, { base: string; sessionID: string }>()
|
||||
const deleteMessageParts = (
|
||||
cache: { part: Record<string, Part[] | undefined>; part_text_accum_delta: Record<string, string | undefined> },
|
||||
messageID: string,
|
||||
) => {
|
||||
for (const part of cache.part[messageID] ?? []) {
|
||||
delete cache.part_text_accum_delta[part.id]
|
||||
deltaBases.delete(part.id)
|
||||
}
|
||||
delete cache.part[messageID]
|
||||
}
|
||||
const seen = new Set<string>()
|
||||
const infoSeen = new Set<string>()
|
||||
const pinned = new Map<string, number>()
|
||||
const generations = new Map<string, number>()
|
||||
const generations = new Map<string, object>()
|
||||
const generation = (sessionID: string) => {
|
||||
const current = generations.get(sessionID)
|
||||
if (current) return current
|
||||
const created = {}
|
||||
generations.set(sessionID, created)
|
||||
return created
|
||||
}
|
||||
const [meta, setMeta] = createStore({
|
||||
limit: {} as Record<string, number | undefined>,
|
||||
cursor: {} as Record<string, string | undefined>,
|
||||
@@ -115,6 +191,11 @@ export function createServerSession(client: OpencodeClient) {
|
||||
const preserve = new Set([
|
||||
...pinned.keys(),
|
||||
...requests.keys(),
|
||||
...inflight.keys(),
|
||||
...inflightDiff.keys(),
|
||||
...inflightTodo.keys(),
|
||||
...messageLoads.keys(),
|
||||
...optimistic.keys(),
|
||||
...Object.entries(data.permission)
|
||||
.filter(([, items]) => items.length > 0)
|
||||
.map(([sessionID]) => sessionID),
|
||||
@@ -138,6 +219,7 @@ export function createServerSession(client: OpencodeClient) {
|
||||
if (!preserve.has(sessionID)) stale.push(sessionID)
|
||||
}
|
||||
stale.forEach((sessionID) => infoSeen.delete(sessionID))
|
||||
stale.forEach((sessionID) => generations.delete(sessionID))
|
||||
setData(
|
||||
"info",
|
||||
produce((draft) => stale.forEach((sessionID) => delete draft[sessionID])),
|
||||
@@ -151,21 +233,27 @@ export function createServerSession(client: OpencodeClient) {
|
||||
if (cached && !options?.force) return Promise.resolve(cached)
|
||||
const pending = requests.get(sessionID)
|
||||
if (pending) return pending
|
||||
const generation = generations.get(sessionID) ?? 0
|
||||
const active = generation(sessionID)
|
||||
const request = client.session.get({ sessionID }).then((result) => {
|
||||
if (!result.data) throw new Error(`Session not found: ${sessionID}`)
|
||||
if ((generations.get(sessionID) ?? 0) !== generation) return result.data
|
||||
if (generations.get(sessionID) !== active) return result.data
|
||||
return remember(result.data)
|
||||
})
|
||||
requests.set(sessionID, request)
|
||||
void request.then(
|
||||
() => {
|
||||
if (requests.get(sessionID) === request) requests.delete(sessionID)
|
||||
},
|
||||
() => {
|
||||
if (requests.get(sessionID) === request) requests.delete(sessionID)
|
||||
},
|
||||
)
|
||||
const cleanup = () => {
|
||||
if (requests.get(sessionID) === request) requests.delete(sessionID)
|
||||
if (
|
||||
generations.get(sessionID) === active &&
|
||||
!data.info[sessionID] &&
|
||||
!requests.has(sessionID) &&
|
||||
!messageLoads.has(sessionID) &&
|
||||
!inflight.has(sessionID) &&
|
||||
!inflightDiff.has(sessionID) &&
|
||||
!inflightTodo.has(sessionID)
|
||||
)
|
||||
generations.delete(sessionID)
|
||||
}
|
||||
void request.then(cleanup, cleanup)
|
||||
return request
|
||||
}
|
||||
|
||||
@@ -195,15 +283,121 @@ export function createServerSession(client: OpencodeClient) {
|
||||
if (items.size === 0) optimistic.delete(sessionID)
|
||||
}
|
||||
|
||||
const clearOptimisticPart = (sessionID: string, messageID: string, partID: string) => {
|
||||
const items = optimistic.get(sessionID)
|
||||
const item = items?.get(messageID)
|
||||
if (!items || !item) return
|
||||
const parts = item.parts.filter((part) => part.id !== partID)
|
||||
const confirmedParts = item.confirmedParts?.filter((part) => part.id !== partID)
|
||||
if (parts.length === 0) {
|
||||
clearOptimistic(sessionID, messageID)
|
||||
return
|
||||
}
|
||||
items.set(messageID, { ...item, parts, confirmedParts, confirmedMessage: true })
|
||||
}
|
||||
|
||||
const confirmOptimisticPart = (sessionID: string, messageID: string, part: Part) => {
|
||||
const items = optimistic.get(sessionID)
|
||||
const item = items?.get(messageID)
|
||||
if (!items || !item) return
|
||||
const parts = item.parts.filter((value) => value.id !== part.id)
|
||||
if (parts.length === 0) {
|
||||
clearOptimistic(sessionID, messageID)
|
||||
return
|
||||
}
|
||||
items.set(messageID, {
|
||||
...item,
|
||||
parts,
|
||||
confirmedParts: merge(item.confirmedParts ?? [], [part]),
|
||||
confirmedMessage: true,
|
||||
})
|
||||
}
|
||||
|
||||
const confirmOptimistic = (sessionID: string, messageID: string, confirmedParts: Part[]) => {
|
||||
const items = optimistic.get(sessionID)
|
||||
const item = items?.get(messageID)
|
||||
if (!items || !item) return
|
||||
const confirmed = new Set(confirmedParts.map((part) => part.id))
|
||||
const parts = item.parts.filter((part) => !confirmed.has(part.id))
|
||||
if (parts.length === 0) {
|
||||
clearOptimistic(sessionID, messageID)
|
||||
return
|
||||
}
|
||||
items.set(messageID, {
|
||||
...item,
|
||||
parts,
|
||||
confirmedParts: merge(item.confirmedParts ?? [], confirmedParts),
|
||||
confirmedMessage: true,
|
||||
})
|
||||
}
|
||||
|
||||
const trackPartChange = (sessionID: string, messageID: string, partID: string) => {
|
||||
const load = messageLoads.get(sessionID)
|
||||
if (!load) return
|
||||
// A part event keeps an existing parent when the fetched page omits it without overriding fetched metadata.
|
||||
const messages = data.message[sessionID]
|
||||
if (messages && Binary.search(messages, messageID, (message) => message.id).found)
|
||||
load.retainedMessages.add(messageID)
|
||||
const parts = load.touchedParts.get(messageID)
|
||||
if (parts) {
|
||||
parts.add(partID)
|
||||
return
|
||||
}
|
||||
load.touchedParts.set(messageID, new Set([partID]))
|
||||
}
|
||||
|
||||
const resetMessageLoad = (sessionID: string, load: MessageLoadState) => {
|
||||
load.touchedMessages.clear()
|
||||
load.retainedMessages.clear()
|
||||
load.touchedParts.clear()
|
||||
load.carriedDeltaParts.clear()
|
||||
load.clearedMessageParts.clear()
|
||||
for (const messageID of load.removedMessages) {
|
||||
load.touchedMessages.add(messageID)
|
||||
load.clearedMessageParts.add(messageID)
|
||||
}
|
||||
for (const [messageID, parts] of load.deltaParts) {
|
||||
load.touchedParts.set(messageID, new Set(parts))
|
||||
load.carriedDeltaParts.set(messageID, new Set(parts))
|
||||
const messages = data.message[sessionID]
|
||||
if (messages && Binary.search(messages, messageID, (message) => message.id).found)
|
||||
load.retainedMessages.add(messageID)
|
||||
}
|
||||
for (const [messageID, parts] of load.removedParts) {
|
||||
const touched = load.touchedParts.get(messageID) ?? new Set<string>()
|
||||
parts.forEach((partID) => touched.add(partID))
|
||||
load.touchedParts.set(messageID, touched)
|
||||
const messages = data.message[sessionID]
|
||||
if (messages && Binary.search(messages, messageID, (message) => message.id).found)
|
||||
load.retainedMessages.add(messageID)
|
||||
}
|
||||
for (const [messageID, parts] of load.optimisticParts) {
|
||||
load.removedMessages.delete(messageID)
|
||||
load.clearedMessageParts.add(messageID)
|
||||
load.touchedMessages.add(messageID)
|
||||
const touched = load.touchedParts.get(messageID) ?? new Set<string>()
|
||||
parts.forEach((partID) => touched.add(partID))
|
||||
load.touchedParts.set(messageID, touched)
|
||||
}
|
||||
}
|
||||
|
||||
const evict = (sessionIDs: string[]) => {
|
||||
if (sessionIDs.length === 0) return
|
||||
const evicted = new Set(sessionIDs)
|
||||
for (const [partID, item] of deltaBases) {
|
||||
if (evicted.has(item.sessionID)) deltaBases.delete(partID)
|
||||
}
|
||||
sessionIDs.forEach((sessionID) => {
|
||||
generations.set(sessionID, (generations.get(sessionID) ?? 0) + 1)
|
||||
generations.delete(sessionID)
|
||||
clearOptimistic(sessionID)
|
||||
requests.delete(sessionID)
|
||||
inflight.delete(sessionID)
|
||||
inflightDiff.delete(sessionID)
|
||||
inflightTodo.delete(sessionID)
|
||||
messageLoads.delete(sessionID)
|
||||
pendingParts.delete(sessionID)
|
||||
orphanParts.delete(sessionID)
|
||||
removedMessages.delete(sessionID)
|
||||
})
|
||||
setData(
|
||||
produce((draft) => {
|
||||
@@ -230,6 +424,7 @@ export function createServerSession(client: OpencodeClient) {
|
||||
...inflight.keys(),
|
||||
...inflightDiff.keys(),
|
||||
...inflightTodo.keys(),
|
||||
...messageLoads.keys(),
|
||||
...optimistic.keys(),
|
||||
...Object.entries(data.permission)
|
||||
.filter(([, items]) => items.length > 0)
|
||||
@@ -247,8 +442,11 @@ export function createServerSession(client: OpencodeClient) {
|
||||
pickSessionCacheEvictions({ seen, keep: sessionID, limit: SESSION_CACHE_LIMIT, preserve: protectedSessions() }),
|
||||
)
|
||||
|
||||
const fetchMessages = async (sessionID: string, limit: number, before?: string) => {
|
||||
const response = await retry(() => client.session.messages({ sessionID, limit, before }))
|
||||
const fetchMessages = async (sessionID: string, limit: number, before?: string, onAttempt?: () => void) => {
|
||||
const response = await (options?.retry ?? retry)(() => {
|
||||
onAttempt?.()
|
||||
return client.session.messages({ sessionID, limit, before })
|
||||
})
|
||||
const items = (response.data ?? []).filter((item) => !!item?.info?.id)
|
||||
return {
|
||||
session: items.map((item) => cleanMessage(item.info)).sort((a, b) => cmp(a.id, b.id)),
|
||||
@@ -261,30 +459,153 @@ export function createServerSession(client: OpencodeClient) {
|
||||
}
|
||||
}
|
||||
|
||||
const replaceMessages = (sessionID: string, messages: Message[]) => {
|
||||
const messageIDs = new Set(messages.map((message) => message.id))
|
||||
const dropped = (data.message[sessionID] ?? []).filter((message) => !messageIDs.has(message.id))
|
||||
setData("message", sessionID, reconcile(messages, { key: "id" }))
|
||||
setData(
|
||||
produce((draft) => {
|
||||
for (const message of dropped) deleteMessageParts(draft, message.id)
|
||||
}),
|
||||
)
|
||||
return messageIDs
|
||||
}
|
||||
|
||||
const replaceParts = (
|
||||
sessionID: string,
|
||||
items: MessagePage["part"],
|
||||
messageIDs: Set<string>,
|
||||
load?: MessageLoadState,
|
||||
) => {
|
||||
for (const item of items) {
|
||||
if (!messageIDs.has(item.id)) continue
|
||||
const fetched = load?.clearedMessageParts.has(item.id)
|
||||
? []
|
||||
: item.part.filter((part) => !SKIP_PARTS.has(part.type))
|
||||
const fetchedIDs = new Set(fetched.map((part) => part.id))
|
||||
const pending = pendingParts.get(sessionID)?.get(item.id)
|
||||
const touched = new Set([...(load?.touchedParts.get(item.id) ?? []), ...(pending ?? [])])
|
||||
for (const part of fetched) {
|
||||
const accumulated = data.part_text_accum_delta[part.id]
|
||||
const base = deltaBases.get(part.id)?.base
|
||||
const preserveDelta =
|
||||
base !== undefined &&
|
||||
accumulated !== undefined &&
|
||||
"text" in part &&
|
||||
typeof part.text === "string" &&
|
||||
part.text.startsWith(base) &&
|
||||
accumulated.startsWith(part.text) &&
|
||||
accumulated !== part.text
|
||||
if (preserveDelta) touched.add(part.id)
|
||||
if (load?.carriedDeltaParts.get(item.id)?.has(part.id) && !preserveDelta) touched.delete(part.id)
|
||||
}
|
||||
for (const partID of load?.carriedDeltaParts.get(item.id) ?? []) {
|
||||
if (!fetchedIDs.has(partID)) touched.delete(partID)
|
||||
}
|
||||
const parts = reconcileFetched(fetched, data.part[item.id] ?? [], { touched })
|
||||
if (!parts.length) {
|
||||
orphanParts.get(sessionID)?.delete(item.id)
|
||||
setData(produce((draft) => deleteMessageParts(draft, item.id)))
|
||||
continue
|
||||
}
|
||||
const partIDs = new Set(parts.map((part) => part.id))
|
||||
setData(
|
||||
"part_text_accum_delta",
|
||||
produce((draft) => {
|
||||
for (const part of data.part[item.id] ?? []) {
|
||||
if (!partIDs.has(part.id) || !touched.has(part.id)) {
|
||||
delete draft[part.id]
|
||||
deltaBases.delete(part.id)
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
setData("part", item.id, reconcile(parts, { key: "id" }))
|
||||
orphanParts.get(sessionID)?.delete(item.id)
|
||||
}
|
||||
}
|
||||
|
||||
const applyMessagePage = (
|
||||
sessionID: string,
|
||||
page: MessagePage,
|
||||
load: MessageLoadState | undefined,
|
||||
preserveUnfetched: boolean | ((message: Message) => boolean),
|
||||
cleanupOrphans: boolean,
|
||||
) => {
|
||||
const merged = mergeOptimisticPage(page, [...(optimistic.get(sessionID)?.values() ?? [])])
|
||||
merged.observed.forEach((item) => {
|
||||
if (!load?.clearedMessageParts.has(item.messageID)) confirmOptimistic(sessionID, item.messageID, item.parts)
|
||||
})
|
||||
const touchedMessages = new Set([...(load?.touchedMessages ?? []), ...(removedMessages.get(sessionID) ?? [])])
|
||||
const messages = reconcileFetched(merged.session, data.message[sessionID] ?? [], {
|
||||
touched: touchedMessages,
|
||||
retained: load?.retainedMessages,
|
||||
preserveUnfetched,
|
||||
})
|
||||
batch(() => {
|
||||
const messageIDs = replaceMessages(sessionID, messages)
|
||||
replaceParts(sessionID, merged.part, messageIDs, load)
|
||||
const orphans = orphanParts.get(sessionID)
|
||||
if (cleanupOrphans && page.complete && orphans) {
|
||||
for (const messageID of orphans) {
|
||||
if (!messageIDs.has(messageID)) setData(produce((draft) => deleteMessageParts(draft, messageID)))
|
||||
}
|
||||
orphanParts.delete(sessionID)
|
||||
}
|
||||
setMeta("limit", sessionID, messages.length)
|
||||
setMeta("cursor", sessionID, merged.cursor)
|
||||
setMeta("complete", sessionID, merged.complete)
|
||||
setMeta("at", sessionID, Date.now())
|
||||
})
|
||||
}
|
||||
|
||||
const loadMessages = async (sessionID: string, limit: number, before?: string, mode?: "replace" | "prepend") => {
|
||||
if (meta.loading[sessionID]) return
|
||||
const generation = generations.get(sessionID) ?? 0
|
||||
const active = generation(sessionID)
|
||||
const load: MessageLoadState = {
|
||||
touchedMessages: new Set(),
|
||||
removedMessages: new Set(),
|
||||
retainedMessages: new Set(),
|
||||
touchedParts: new Map(),
|
||||
deltaParts: new Map(),
|
||||
carriedDeltaParts: new Map(),
|
||||
removedParts: new Map(),
|
||||
optimisticParts: new Map(),
|
||||
orphanParents: new Set(),
|
||||
clearedMessageParts: new Set(),
|
||||
}
|
||||
messageLoads.set(sessionID, load)
|
||||
setMeta("loading", sessionID, true)
|
||||
await fetchMessages(sessionID, limit, before)
|
||||
let applied = false
|
||||
await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load))
|
||||
.then((page) => {
|
||||
if ((generations.get(sessionID) ?? 0) !== generation) return
|
||||
const next = mergeOptimisticPage(page, [...(optimistic.get(sessionID)?.values() ?? [])])
|
||||
next.confirmed.forEach((messageID) => clearOptimistic(sessionID, messageID))
|
||||
const messages = mode === "prepend" ? merge(data.message[sessionID] ?? [], next.session) : next.session
|
||||
batch(() => {
|
||||
setData("message", sessionID, reconcile(messages, { key: "id" }))
|
||||
for (const item of next.part) {
|
||||
const parts = item.part.filter((part) => !SKIP_PARTS.has(part.type))
|
||||
if (parts.length) setData("part", item.id, reconcile(parts, { key: "id" }))
|
||||
}
|
||||
setMeta("limit", sessionID, messages.length)
|
||||
setMeta("cursor", sessionID, next.cursor)
|
||||
setMeta("complete", sessionID, next.complete)
|
||||
setMeta("at", sessionID, Date.now())
|
||||
})
|
||||
if (generations.get(sessionID) !== active) return
|
||||
const first = page.session.reduce<Message | undefined>(
|
||||
(oldest, message) => (!oldest || cmpMessage(message, oldest) < 0 ? message : oldest),
|
||||
undefined,
|
||||
)
|
||||
const preserveUnfetched =
|
||||
mode === "prepend" || (!page.complete && (!first || ((message: Message) => cmpMessage(message, first) < 0)))
|
||||
applyMessagePage(
|
||||
sessionID,
|
||||
page,
|
||||
messageLoads.get(sessionID) === load ? load : undefined,
|
||||
preserveUnfetched,
|
||||
mode !== "prepend",
|
||||
)
|
||||
applied = true
|
||||
})
|
||||
.finally(() => {
|
||||
if ((generations.get(sessionID) ?? 0) === generation) setMeta("loading", sessionID, false)
|
||||
if (!applied && generations.get(sessionID) === active && messageLoads.get(sessionID) === load) {
|
||||
for (const messageID of load.orphanParents) {
|
||||
if (!orphanParts.get(sessionID)?.has(messageID)) continue
|
||||
setData(produce((draft) => deleteMessageParts(draft, messageID)))
|
||||
orphanParts.get(sessionID)?.delete(messageID)
|
||||
}
|
||||
if (orphanParts.get(sessionID)?.size === 0) orphanParts.delete(sessionID)
|
||||
}
|
||||
if (messageLoads.get(sessionID) === load) messageLoads.delete(sessionID)
|
||||
if (generations.get(sessionID) === active) setMeta("loading", sessionID, false)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -339,7 +660,13 @@ export function createServerSession(client: OpencodeClient) {
|
||||
const eventID = eventSessionID(event)
|
||||
if (eventID) {
|
||||
touch(eventID)
|
||||
if (!data.info[eventID]) void resolve(eventID).catch(() => {})
|
||||
if (
|
||||
!data.info[eventID] &&
|
||||
event.type !== "session.created" &&
|
||||
event.type !== "session.updated" &&
|
||||
event.type !== "session.deleted"
|
||||
)
|
||||
void resolve(eventID).catch(() => {})
|
||||
}
|
||||
switch (event.type) {
|
||||
case "session.created":
|
||||
@@ -378,6 +705,21 @@ export function createServerSession(client: OpencodeClient) {
|
||||
}
|
||||
case "message.updated": {
|
||||
const info = cleanMessage((event.properties as { info: Message }).info)
|
||||
const load = messageLoads.get(info.sessionID)
|
||||
load?.touchedMessages.add(info.id)
|
||||
load?.removedMessages.delete(info.id)
|
||||
const items = optimistic.get(info.sessionID)
|
||||
const item = items?.get(info.id)
|
||||
if (items && item) {
|
||||
if (item.parts.length === 0) clearOptimistic(info.sessionID, info.id)
|
||||
if (item.parts.length > 0) items.set(info.id, { ...item, confirmedMessage: true })
|
||||
}
|
||||
const orphans = orphanParts.get(info.sessionID)
|
||||
orphans?.delete(info.id)
|
||||
if (orphans?.size === 0) orphanParts.delete(info.sessionID)
|
||||
const removedMessagesForSession = removedMessages.get(info.sessionID)
|
||||
removedMessagesForSession?.delete(info.id)
|
||||
if (removedMessagesForSession?.size === 0) removedMessages.delete(info.sessionID)
|
||||
const messages = data.message[info.sessionID]
|
||||
if (!messages) {
|
||||
setData("message", info.sessionID, [info])
|
||||
@@ -395,6 +737,20 @@ export function createServerSession(client: OpencodeClient) {
|
||||
}
|
||||
case "message.removed": {
|
||||
const props = event.properties as { sessionID: string; messageID: string }
|
||||
const load = messageLoads.get(props.sessionID)
|
||||
load?.touchedMessages.add(props.messageID)
|
||||
load?.removedMessages.add(props.messageID)
|
||||
load?.clearedMessageParts.add(props.messageID)
|
||||
load?.deltaParts.delete(props.messageID)
|
||||
load?.carriedDeltaParts.delete(props.messageID)
|
||||
load?.removedParts.delete(props.messageID)
|
||||
load?.optimisticParts.delete(props.messageID)
|
||||
pendingParts.get(props.sessionID)?.delete(props.messageID)
|
||||
if (pendingParts.get(props.sessionID)?.size === 0) pendingParts.delete(props.sessionID)
|
||||
const removedMessagesForSession = removedMessages.get(props.sessionID) ?? new Set<string>()
|
||||
removedMessagesForSession.add(props.messageID)
|
||||
removedMessages.set(props.sessionID, removedMessagesForSession)
|
||||
clearOptimistic(props.sessionID, props.messageID)
|
||||
setData(
|
||||
produce((draft) => {
|
||||
const messages = draft.message[props.sessionID]
|
||||
@@ -402,8 +758,7 @@ export function createServerSession(client: OpencodeClient) {
|
||||
const result = Binary.search(messages, props.messageID, (message) => message.id)
|
||||
if (result.found) messages.splice(result.index, 1)
|
||||
}
|
||||
for (const part of draft.part[props.messageID] ?? []) delete draft.part_text_accum_delta[part.id]
|
||||
delete draft.part[props.messageID]
|
||||
deleteMessageParts(draft, props.messageID)
|
||||
}),
|
||||
)
|
||||
return
|
||||
@@ -411,6 +766,42 @@ export function createServerSession(client: OpencodeClient) {
|
||||
case "message.part.updated": {
|
||||
const part = (event.properties as { part: Part }).part
|
||||
if (SKIP_PARTS.has(part.type)) return
|
||||
const messages = data.message[part.sessionID]
|
||||
const load = messageLoads.get(part.sessionID)
|
||||
const missing = !messages || !Binary.search(messages, part.messageID, (message) => message.id).found
|
||||
// Outside a page load, accepting a part without its ordered parent event would create an unbounded orphan.
|
||||
if (
|
||||
missing &&
|
||||
(!load ||
|
||||
load.clearedMessageParts.has(part.messageID) ||
|
||||
removedMessages.get(part.sessionID)?.has(part.messageID))
|
||||
)
|
||||
return
|
||||
if (missing) {
|
||||
const orphans = orphanParts.get(part.sessionID) ?? new Set<string>()
|
||||
orphans.add(part.messageID)
|
||||
orphanParts.set(part.sessionID, orphans)
|
||||
load?.orphanParents.add(part.messageID)
|
||||
}
|
||||
const deltas = load?.deltaParts.get(part.messageID)
|
||||
deltas?.delete(part.id)
|
||||
if (deltas?.size === 0) load?.deltaParts.delete(part.messageID)
|
||||
const carried = load?.carriedDeltaParts.get(part.messageID)
|
||||
carried?.delete(part.id)
|
||||
if (carried?.size === 0) load?.carriedDeltaParts.delete(part.messageID)
|
||||
const removed = load?.removedParts.get(part.messageID)
|
||||
removed?.delete(part.id)
|
||||
if (removed?.size === 0) load?.removedParts.delete(part.messageID)
|
||||
const pending = pendingParts.get(part.sessionID)?.get(part.messageID)
|
||||
pending?.delete(part.id)
|
||||
if (pending?.size === 0) pendingParts.get(part.sessionID)?.delete(part.messageID)
|
||||
if (pendingParts.get(part.sessionID)?.size === 0) pendingParts.delete(part.sessionID)
|
||||
const optimistic = load?.optimisticParts.get(part.messageID)
|
||||
optimistic?.delete(part.id)
|
||||
if (optimistic?.size === 0) load?.optimisticParts.delete(part.messageID)
|
||||
deltaBases.delete(part.id)
|
||||
trackPartChange(part.sessionID, part.messageID, part.id)
|
||||
confirmOptimisticPart(part.sessionID, part.messageID, part)
|
||||
setData(
|
||||
"part_text_accum_delta",
|
||||
produce((draft) => void delete draft[part.id]),
|
||||
@@ -431,10 +822,34 @@ export function createServerSession(client: OpencodeClient) {
|
||||
return
|
||||
}
|
||||
case "message.part.removed": {
|
||||
const props = event.properties as { messageID: string; partID: string }
|
||||
const props = event.properties as { sessionID: string; messageID: string; partID: string }
|
||||
// Part removal is event-only on the server, so its tombstone lasts until a later update or eviction.
|
||||
const pending = pendingParts.get(props.sessionID) ?? new Map<string, Set<string>>()
|
||||
const parts = pending.get(props.messageID) ?? new Set<string>()
|
||||
parts.add(props.partID)
|
||||
pending.set(props.messageID, parts)
|
||||
pendingParts.set(props.sessionID, pending)
|
||||
const deltas = messageLoads.get(props.sessionID)?.deltaParts.get(props.messageID)
|
||||
deltas?.delete(props.partID)
|
||||
if (deltas?.size === 0) messageLoads.get(props.sessionID)?.deltaParts.delete(props.messageID)
|
||||
const load = messageLoads.get(props.sessionID)
|
||||
const carried = load?.carriedDeltaParts.get(props.messageID)
|
||||
carried?.delete(props.partID)
|
||||
if (carried?.size === 0) load?.carriedDeltaParts.delete(props.messageID)
|
||||
if (load) {
|
||||
const parts = load.removedParts.get(props.messageID) ?? new Set<string>()
|
||||
parts.add(props.partID)
|
||||
load.removedParts.set(props.messageID, parts)
|
||||
const optimistic = load.optimisticParts.get(props.messageID)
|
||||
optimistic?.delete(props.partID)
|
||||
if (optimistic?.size === 0) load.optimisticParts.delete(props.messageID)
|
||||
}
|
||||
trackPartChange(props.sessionID, props.messageID, props.partID)
|
||||
clearOptimisticPart(props.sessionID, props.messageID, props.partID)
|
||||
setData(
|
||||
produce((draft) => {
|
||||
delete draft.part_text_accum_delta[props.partID]
|
||||
deltaBases.delete(props.partID)
|
||||
const parts = draft.part[props.messageID]
|
||||
if (!parts) return
|
||||
const result = Binary.search(parts, props.partID, (part) => part.id)
|
||||
@@ -445,13 +860,31 @@ export function createServerSession(client: OpencodeClient) {
|
||||
return
|
||||
}
|
||||
case "message.part.delta": {
|
||||
const props = event.properties as { messageID: string; partID: string; field: string; delta: string }
|
||||
const props = event.properties as {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
partID: string
|
||||
field: string
|
||||
delta: string
|
||||
}
|
||||
const parts = data.part[props.messageID]
|
||||
if (!parts) return
|
||||
const result = Binary.search(parts, props.partID, (part) => part.id)
|
||||
if (!result.found) return
|
||||
trackPartChange(props.sessionID, props.messageID, props.partID)
|
||||
const load = messageLoads.get(props.sessionID)
|
||||
if (load) {
|
||||
const parts = load.deltaParts.get(props.messageID) ?? new Set<string>()
|
||||
parts.add(props.partID)
|
||||
load.deltaParts.set(props.messageID, parts)
|
||||
const carried = load.carriedDeltaParts.get(props.messageID)
|
||||
carried?.delete(props.partID)
|
||||
if (carried?.size === 0) load.carriedDeltaParts.delete(props.messageID)
|
||||
}
|
||||
const field = props.field as keyof (typeof parts)[number]
|
||||
const current = parts[result.index]?.[field]
|
||||
if (!deltaBases.has(props.partID) && typeof current === "string")
|
||||
deltaBases.set(props.partID, { base: current, sessionID: props.sessionID })
|
||||
setData(
|
||||
"part_text_accum_delta",
|
||||
props.partID,
|
||||
@@ -559,32 +992,70 @@ export function createServerSession(client: OpencodeClient) {
|
||||
},
|
||||
optimistic: {
|
||||
add(input: { sessionID: string; message: Message; parts: Part[] }) {
|
||||
const parts = input.parts
|
||||
.filter((part) => !!part?.id && !SKIP_PARTS.has(part.type))
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
const load = messageLoads.get(input.sessionID)
|
||||
if (load?.clearedMessageParts.has(input.message.id)) {
|
||||
const touched = load.touchedParts.get(input.message.id) ?? new Set<string>()
|
||||
parts.forEach((part) => touched.add(part.id))
|
||||
load.touchedParts.set(input.message.id, touched)
|
||||
}
|
||||
if (load) {
|
||||
load.removedMessages.delete(input.message.id)
|
||||
load.optimisticParts.set(input.message.id, new Set(parts.map((part) => part.id)))
|
||||
}
|
||||
const items = optimistic.get(input.sessionID)
|
||||
if (items) items.set(input.message.id, input)
|
||||
if (!items) optimistic.set(input.sessionID, new Map([[input.message.id, input]]))
|
||||
const removedMessagesForSession = removedMessages.get(input.sessionID)
|
||||
removedMessagesForSession?.delete(input.message.id)
|
||||
if (removedMessagesForSession?.size === 0) removedMessages.delete(input.sessionID)
|
||||
if (items) items.set(input.message.id, { ...input, parts, confirmedParts: [] })
|
||||
if (!items)
|
||||
optimistic.set(input.sessionID, new Map([[input.message.id, { ...input, parts, confirmedParts: [] }]]))
|
||||
setData("message", input.sessionID, (messages = []) => merge(messages, [input.message]))
|
||||
setData(
|
||||
"part",
|
||||
input.message.id,
|
||||
input.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
"part_text_accum_delta",
|
||||
produce((draft) => {
|
||||
for (const part of [...(data.part[input.message.id] ?? []), ...parts]) {
|
||||
delete draft[part.id]
|
||||
deltaBases.delete(part.id)
|
||||
}
|
||||
}),
|
||||
)
|
||||
setData("part", input.message.id, parts)
|
||||
},
|
||||
remove(input: { sessionID: string; messageID: string }) {
|
||||
const item = optimistic.get(input.sessionID)?.get(input.messageID)
|
||||
if (!item) return
|
||||
messageLoads.get(input.sessionID)?.optimisticParts.delete(input.messageID)
|
||||
clearOptimistic(input.sessionID, input.messageID)
|
||||
if (item.confirmedMessage) {
|
||||
const partIDs = new Set(item.parts.map((part) => part.id))
|
||||
setData(
|
||||
produce((draft) => {
|
||||
for (const part of item.parts) {
|
||||
delete draft.part_text_accum_delta[part.id]
|
||||
deltaBases.delete(part.id)
|
||||
}
|
||||
const parts = draft.part[input.messageID]
|
||||
if (!parts) return
|
||||
draft.part[input.messageID] = parts.filter((part) => !partIDs.has(part.id))
|
||||
if (draft.part[input.messageID]?.length === 0) delete draft.part[input.messageID]
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
setData("message", input.sessionID, (messages) => messages?.filter((message) => message.id !== input.messageID))
|
||||
setData(
|
||||
"part",
|
||||
produce((draft) => void delete draft[input.messageID]),
|
||||
)
|
||||
setData(produce((draft) => deleteMessageParts(draft, input.messageID)))
|
||||
},
|
||||
},
|
||||
diff(sessionID: string, options?: { force?: boolean }) {
|
||||
touch(sessionID)
|
||||
if (data.session_diff[sessionID] !== undefined && !options?.force) return Promise.resolve()
|
||||
return runInflight(inflightDiff, sessionID, () => {
|
||||
const generation = generations.get(sessionID) ?? 0
|
||||
const active = generation(sessionID)
|
||||
return retry(() => client.session.diff({ sessionID })).then((result) => {
|
||||
if ((generations.get(sessionID) ?? 0) !== generation) return
|
||||
if (generations.get(sessionID) !== active) return
|
||||
setData("session_diff", sessionID, reconcile(cleanDiffs(result.data), { key: "file" }))
|
||||
})
|
||||
})
|
||||
@@ -593,9 +1064,9 @@ export function createServerSession(client: OpencodeClient) {
|
||||
touch(sessionID)
|
||||
if (data.todo[sessionID] !== undefined && !options?.force) return Promise.resolve()
|
||||
return runInflight(inflightTodo, sessionID, () => {
|
||||
const generation = generations.get(sessionID) ?? 0
|
||||
const active = generation(sessionID)
|
||||
return retry(() => client.session.todo({ sessionID })).then((result) => {
|
||||
if ((generations.get(sessionID) ?? 0) !== generation) return
|
||||
if (generations.get(sessionID) !== active) return
|
||||
setData("todo", sessionID, reconcile(result.data ?? [], { key: "id" }))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -163,27 +163,29 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
},
|
||||
newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string) {
|
||||
const draftID = uuid()
|
||||
setStore(
|
||||
produce((tabs) => {
|
||||
tabs.push({ type: "draft", draftID, ...draft })
|
||||
}),
|
||||
)
|
||||
navigate(prompt ? `${draftHref(draftID)}&prompt=${encodeURIComponent(prompt)}` : draftHref(draftID))
|
||||
void startTransition(() => {
|
||||
setStore(
|
||||
produce((tabs) => {
|
||||
tabs.push({ type: "draft", draftID, ...draft })
|
||||
}),
|
||||
)
|
||||
navigate(prompt ? `${draftHref(draftID)}&prompt=${encodeURIComponent(prompt)}` : draftHref(draftID))
|
||||
})
|
||||
},
|
||||
updateDraft(draftID: string, draft: Partial<Omit<DraftTab, "type" | "draftID">>) {
|
||||
setStore(
|
||||
(tab) => tab.type === "draft" && tab.draftID === draftID,
|
||||
produce((tab) => Object.assign(tab, draft)),
|
||||
)
|
||||
void startTransition(() => {
|
||||
setStore(
|
||||
(tab) => tab.type === "draft" && tab.draftID === draftID,
|
||||
produce((tab) => Object.assign(tab, draft)),
|
||||
)
|
||||
})
|
||||
},
|
||||
promoteDraft(draftID: string, session: Omit<SessionTab, "type">) {
|
||||
// We're viewing this draft when /new-session?draftId=… points at it. Promoting
|
||||
// replaces the draft tab with a session tab, so the draft route would stop resolving
|
||||
// and fall back home. Navigate to the new session first so we leave /new-session
|
||||
// before the draft is removed from the store.
|
||||
// Keep the replacement and navigation atomic so /new-session never renders
|
||||
// after its backing draft tab has been removed from the store.
|
||||
const active = location.pathname === "/new-session" && location.query.draftId === draftID
|
||||
const next = { type: "session" as const, ...session }
|
||||
startTransition(() => {
|
||||
void startTransition(() => {
|
||||
setStore(
|
||||
produce((tabs) => {
|
||||
const index = tabs.findIndex((tab) => tab.type === "draft" && tab.draftID === draftID)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
@import "@opencode-ai/ui/styles/tailwind";
|
||||
@import "@opencode-ai/session-ui/styles";
|
||||
@import "@opencode-ai/ui/v2/styles/tailwind.css";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@font-face {
|
||||
font-family: "JetBrainsMono Nerd Font Mono";
|
||||
@@ -131,13 +132,4 @@
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ import { archiveHomeSession } from "./home-session-archive"
|
||||
import { showToast } from "@/utils/toast"
|
||||
|
||||
const HOME_SESSION_LIMIT = 64
|
||||
const SHOW_HOME_SESSION_ARCHIVE = false
|
||||
const HOME_ROW_LAYOUT =
|
||||
"flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none"
|
||||
const HOME_ROW_BASE = `${HOME_ROW_LAYOUT} border-0`
|
||||
@@ -301,6 +302,7 @@ export function NewHome() {
|
||||
|
||||
function selectProject(conn: ServerConnection.Any, directory: string) {
|
||||
const key = ServerConnection.key(conn)
|
||||
if (global.servers.health[key]?.healthy === false) return
|
||||
if (
|
||||
!global
|
||||
.ensureServerCtx(conn)
|
||||
@@ -341,15 +343,15 @@ export function NewHome() {
|
||||
}
|
||||
|
||||
function unseenCount(conn: ServerConnection.Any, project: LocalProject) {
|
||||
if (ServerConnection.key(conn) !== server.key) return 0
|
||||
return directories(project).reduce((total, directory) => total + notification.project.unseenCount(directory), 0)
|
||||
const state = notification.ensureServerState(ServerConnection.key(conn))
|
||||
return directories(project).reduce((total, directory) => total + state.project.unseenCount(directory), 0)
|
||||
}
|
||||
|
||||
function clearNotifications(conn: ServerConnection.Any, project: LocalProject) {
|
||||
if (ServerConnection.key(conn) !== server.key) return
|
||||
const state = notification.ensureServerState(ServerConnection.key(conn))
|
||||
directories(project)
|
||||
.filter((directory) => notification.project.unseenCount(directory) > 0)
|
||||
.forEach((directory) => notification.project.markViewed(directory))
|
||||
.filter((directory) => state.project.unseenCount(directory) > 0)
|
||||
.forEach((directory) => state.project.markViewed(directory))
|
||||
}
|
||||
|
||||
function openSession(session: Session) {
|
||||
@@ -407,7 +409,7 @@ export function NewHome() {
|
||||
|
||||
return (
|
||||
<div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 min-h-0 lg:overflow-hidden bg-v2-background-bg-base self-stretch flex-1">
|
||||
<div class="mx-auto grid h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3 pb-3 lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6 lg:pb-16">
|
||||
<div class="mx-auto grid h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3 lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6">
|
||||
<HomeProjectColumn
|
||||
projects={projects()}
|
||||
selected={selection()}
|
||||
@@ -454,7 +456,7 @@ export function NewHome() {
|
||||
onClose={closeSearch}
|
||||
onSelect={selectSearchSession}
|
||||
/>
|
||||
<ScrollView class="mt-3 min-h-0 flex-1">
|
||||
<ScrollView class="mt-3 -mr-3 min-h-0 flex-1">
|
||||
<Show
|
||||
when={!sessionLoad.isLoading}
|
||||
fallback={
|
||||
@@ -467,7 +469,7 @@ export function NewHome() {
|
||||
when={groups().length > 0}
|
||||
fallback={<HomeSessionsEmpty onNewSession={newSessionProject() ? openNewSession : undefined} />}
|
||||
>
|
||||
<div class="pt-3 flex flex-col gap-6">
|
||||
<div class="flex flex-col gap-6 pt-3 pr-3 pb-16">
|
||||
<For each={groups()}>
|
||||
{(group, index) => (
|
||||
<div class="flex min-w-0 flex-col gap-4">
|
||||
@@ -766,15 +768,18 @@ function HomeProjectRow(props: {
|
||||
clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) {
|
||||
const global = useGlobal()
|
||||
const serverUnreachable = () => global.servers.health[ServerConnection.key(props.server)]?.healthy === false
|
||||
const [state, setState] = createStore({ menuOpen: false })
|
||||
return (
|
||||
<div class="group/project relative flex h-7 min-w-0 items-center rounded-[6px]">
|
||||
<button
|
||||
type="button"
|
||||
data-component="home-project-row"
|
||||
class={`${HOME_PROJECT_NAV_ROW} pr-16`}
|
||||
class={`${HOME_PROJECT_NAV_ROW} pr-16 disabled:opacity-60`}
|
||||
data-selected={props.selected ? "" : undefined}
|
||||
aria-current={props.selected ? "page" : undefined}
|
||||
disabled={serverUnreachable()}
|
||||
onClick={() => props.selectProject(props.server, props.project.worktree)}
|
||||
>
|
||||
<HomeProjectAvatar project={props.project} />
|
||||
@@ -964,7 +969,7 @@ function HomeSessionSearch(props: {
|
||||
style={{
|
||||
top: "-6px",
|
||||
left: "-6px",
|
||||
width: "calc(100% + 14px)",
|
||||
width: "calc(100% + 12px)",
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col pt-9">
|
||||
@@ -1132,7 +1137,7 @@ function HomeSessionSearchResultRow(props: {
|
||||
function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => void }) {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<div class="flex h-7 min-w-0 items-center justify-between pl-[18px]">
|
||||
<div class="flex h-7 min-w-0 items-center justify-between pl-3">
|
||||
<div class={HOME_SECTION_LABEL}>{props.title}</div>
|
||||
<Show when={props.onNewSession}>
|
||||
{(onNewSession) => (
|
||||
@@ -1189,22 +1194,24 @@ function HomeSessionRow(props: {
|
||||
</span>
|
||||
</Show>
|
||||
</button>
|
||||
<div class="hover-reveal absolute right-1.5 top-1/2 flex -translate-y-1/2 items-center gap-1 group-hover/session:opacity-100 focus-within:opacity-100">
|
||||
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={language.t("common.archive")}>
|
||||
<IconButtonV2
|
||||
data-action="home-session-archive"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
icon={<IconV2 name="archive" />}
|
||||
aria-label={language.t("common.archive")}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void props.archiveSession(props.record.session)
|
||||
}}
|
||||
/>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
<Show when={SHOW_HOME_SESSION_ARCHIVE}>
|
||||
<div class="hover-reveal absolute right-1.5 top-1/2 flex -translate-y-1/2 items-center gap-1 group-hover/session:opacity-100 focus-within:opacity-100">
|
||||
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={language.t("common.archive")}>
|
||||
<IconButtonV2
|
||||
data-action="home-session-archive"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
icon={<IconV2 name="archive" />}
|
||||
aria-label={language.t("common.archive")}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void props.archiveSession(props.record.session)
|
||||
}}
|
||||
/>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,26 +1,18 @@
|
||||
import { createEffect, Suspense, type ParentProps } from "solid-js"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { DebugBar } from "@/components/debug-bar"
|
||||
import { HelpButton } from "@/components/help-button"
|
||||
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
|
||||
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 platform = usePlatform()
|
||||
const notification = useNotification()
|
||||
const navigate = useNavigate()
|
||||
const params = useParams<{ id?: string }>()
|
||||
setNavigate(navigate)
|
||||
|
||||
createEffect(() => setV2Toast(true))
|
||||
createEffect(() => {
|
||||
if (!notification.ready() || !params.id) return
|
||||
if (notification.session.unseenCount(params.id) === 0) return
|
||||
notification.session.markViewed(params.id)
|
||||
})
|
||||
|
||||
const update: TitlebarUpdate = {
|
||||
version: () => {
|
||||
@@ -44,7 +36,7 @@ export default function NewLayout(props: ParentProps) {
|
||||
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
|
||||
<Suspense>{props.children}</Suspense>
|
||||
</main>
|
||||
{import.meta.env.DEV && <DebugBar />}
|
||||
{import.meta.env.DEV && <DebugBar inline />}
|
||||
<HelpButton />
|
||||
<ToastRegion v2 />
|
||||
</div>
|
||||
|
||||
@@ -63,6 +63,22 @@ termctrl show opencode-v2-dev
|
||||
termctrl stop opencode-v2-dev
|
||||
```
|
||||
|
||||
## Server/API debugging
|
||||
|
||||
- Use `bun dev api --help` from `packages/cli` to inspect the API debugging command. It sends one request to the V2 server using the same daemon discovery/auth path as the CLI.
|
||||
- Use `bun dev api` to introspect the server-side data backing the TUI. This is useful when debugging UI bugs: compare what the screen renders with the raw session, message, event, agent, or health data returned by the API to determine whether the bug is in the server state, the client data layer, or the TUI rendering.
|
||||
- `bun dev api` accepts either an OpenAPI operation ID or a raw HTTP method plus path:
|
||||
|
||||
```bash
|
||||
bun dev api get /health
|
||||
bun dev api get /openapi.json
|
||||
bun dev api <operationId> --param key=value
|
||||
```
|
||||
|
||||
- Pass JSON request bodies with `--data`/`-d`; the command sets `content-type: application/json` automatically unless you provide a header. Add extra headers with `--header`/`-H name:value`.
|
||||
- If no compatible background server is registered, `bun dev api` starts one through the daemon service. Use `bun dev service status`, `bun dev service restart`, and `bun dev service stop` when you need explicit lifecycle control.
|
||||
- Prefer raw method/path calls for quick server debugging and operation IDs when exercising documented OpenAPI routes with path or query parameters.
|
||||
|
||||
## Debugger
|
||||
|
||||
- To debug the V2 CLI or TUI with Bun's inspector, launch the CLI entrypoint through Terminal Control with an inspector URL, then attach a debugger to that URL:
|
||||
|
||||
@@ -25,12 +25,15 @@
|
||||
"@opentui/solid": "catalog:",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"effect": "catalog:",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"semver": "catalog:",
|
||||
"solid-js": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/semver": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,14 @@ import { Runtime } from "../../framework/runtime"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Daemon } from "../../services/daemon"
|
||||
import { Standalone } from "../../services/standalone"
|
||||
import { Updater } from "../../services/updater"
|
||||
|
||||
export default Runtime.handler(Commands, (input) =>
|
||||
Effect.gen(function* () {
|
||||
const directory = Option.getOrUndefined(input.directory)
|
||||
if (directory !== undefined) process.chdir(directory)
|
||||
const updater = yield* Updater.Service
|
||||
yield* updater.check()
|
||||
const daemon = yield* Daemon.Service
|
||||
const transport = yield* (input.standalone ? Standalone.transport() : daemon.transport())
|
||||
const { runTui } = yield* Effect.promise(() => import("../../tui"))
|
||||
|
||||
@@ -11,6 +11,7 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { Daemon } from "../../services/daemon"
|
||||
import { Updater } from "../../services/updater"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.serve,
|
||||
@@ -32,6 +33,8 @@ export default Runtime.handler(
|
||||
if (input.register) yield* daemon.register(address)
|
||||
const url = HttpServer.formatAddress(address)
|
||||
console.log(input.stdio ? JSON.stringify({ url }) : `server listening on ${url}`)
|
||||
const updater = yield* Updater.Service
|
||||
yield* updater.check().pipe(Effect.forkScoped)
|
||||
return yield* (input.stdio ? waitForStdinClose() : Effect.never)
|
||||
}).pipe(Effect.annotateLogs({ role: "server" })),
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as Effect from "effect/Effect"
|
||||
import * as Command from "effect/unstable/cli/Command"
|
||||
import { Spec } from "./spec"
|
||||
import { Daemon } from "../services/daemon"
|
||||
import { Updater } from "../services/updater"
|
||||
import { Scope } from "effect"
|
||||
|
||||
export type Input<Value> =
|
||||
@@ -11,11 +12,11 @@ export type Input<Value> =
|
||||
? Input
|
||||
: never
|
||||
|
||||
type RuntimeHandler = (input: unknown) => Effect.Effect<void, unknown, Daemon.Service | Scope.Scope>
|
||||
type RuntimeHandler = (input: unknown) => Effect.Effect<void, unknown, Daemon.Service | Updater.Service | Scope.Scope>
|
||||
type Loader<Node extends Spec.Any> = () => Promise<{
|
||||
default: (input: Input<Node>) => Effect.Effect<void, any, Daemon.Service | Scope.Scope>
|
||||
default: (input: Input<Node>) => Effect.Effect<void, any, Daemon.Service | Updater.Service | Scope.Scope>
|
||||
}>
|
||||
type ProvidedCommand = Command.Command<string, unknown, unknown, unknown, Daemon.Service | Scope.Scope>
|
||||
type ProvidedCommand = Command.Command<string, unknown, unknown, unknown, Daemon.Service | Updater.Service | Scope.Scope>
|
||||
|
||||
export type Handlers<Node extends Spec.Any> = keyof Node["commands"] extends never
|
||||
? Loader<Node>
|
||||
|
||||
@@ -9,6 +9,8 @@ import { Commands } from "./commands/commands"
|
||||
import { Runtime } from "./framework/runtime"
|
||||
import { Daemon } from "./services/daemon"
|
||||
import { Logging } from "@opencode-ai/core/observability/logging"
|
||||
import { Updater } from "./services/updater"
|
||||
import { InstallationChannel, InstallationVersion, InstallationLocal } from "@opencode-ai/core/installation/version"
|
||||
|
||||
const LoggingLayer = Logger.layer(Logging.loggers(), { mergeWithExisting: false }).pipe(
|
||||
Layer.provide(NodeFileSystem.layer),
|
||||
@@ -33,9 +35,11 @@ const Handlers = Runtime.handlers(Commands, {
|
||||
serve: () => import("./commands/handlers/serve"),
|
||||
})
|
||||
|
||||
Runtime.run(Commands, Handlers, { version: "local" }).pipe(
|
||||
Effect.logInfo("cli starting", { version: InstallationVersion, channel: InstallationChannel, local: InstallationLocal }).pipe(
|
||||
Effect.flatMap(() => Runtime.run(Commands, Handlers, { version: InstallationVersion })),
|
||||
Effect.annotateLogs({ role: "cli" }),
|
||||
Effect.provide(Daemon.defaultLayer),
|
||||
Effect.provide(Updater.defaultLayer),
|
||||
Effect.provide(LoggingLayer),
|
||||
Effect.provide(NodeServices.layer),
|
||||
Effect.scoped,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { action, decodePolicy } from "./updater"
|
||||
|
||||
describe("updater", () => {
|
||||
test("reads autoupdate from JSONC", () => {
|
||||
expect(decodePolicy('{ // preference\n "autoupdate": "notify",\n}')).toBe("notify")
|
||||
expect(decodePolicy('{ "autoupdate": false }')).toBe(false)
|
||||
expect(decodePolicy('{ "autoupdate": "invalid" }')).toBeUndefined()
|
||||
})
|
||||
|
||||
test("automatically updates patches and minors", () => {
|
||||
expect(action("1.2.3", "1.2.4", true)).toBe("upgrade")
|
||||
expect(action("1.2.3", "1.3.0", true)).toBe("upgrade")
|
||||
expect(action("1.2.3", "1.2.4", "notify")).toBe("upgrade")
|
||||
expect(action("1.2.3", "1.3.0", "notify")).toBe("upgrade")
|
||||
})
|
||||
|
||||
test("skips when autoupdate is disabled", () => {
|
||||
expect(action("1.2.3", "1.2.4", false)).toBe("none")
|
||||
})
|
||||
|
||||
test("never automatically updates majors", () => {
|
||||
expect(action("1.2.3", "2.0.0", true)).toBe("none")
|
||||
})
|
||||
|
||||
test("reports up-to-date only when versions match", () => {
|
||||
expect(action("1.2.3", "1.2.3", true)).toBe("none")
|
||||
})
|
||||
|
||||
test("upgrades when latest is lower (rollback)", () => {
|
||||
expect(action("1.2.4", "1.2.3", true)).toBe("upgrade")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { AppProcess } from "@opencode-ai/core/process"
|
||||
import {
|
||||
InstallationChannel,
|
||||
InstallationLocal,
|
||||
InstallationVersion,
|
||||
} from "@opencode-ai/core/installation/version"
|
||||
import { Context, Duration, Effect, FileSystem, Layer } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { parse, type ParseError } from "jsonc-parser"
|
||||
import path from "node:path"
|
||||
import semver from "semver"
|
||||
|
||||
export type Policy = boolean | "notify"
|
||||
export type Action = "none" | "upgrade"
|
||||
type Method = "npm" | "pnpm" | "bun" | "yarn"
|
||||
|
||||
const packageName = "@opencode-ai/cli"
|
||||
|
||||
export interface Interface {
|
||||
readonly check: () => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Updater") {}
|
||||
|
||||
export function decodePolicy(text: string): Policy | undefined {
|
||||
// The CLI only projects this host-level preference instead of initializing
|
||||
// the location-scoped server configuration graph.
|
||||
const errors: ParseError[] = []
|
||||
const input: unknown = parse(text, errors, { allowTrailingComma: true })
|
||||
if (errors.length || typeof input !== "object" || input === null || !("autoupdate" in input)) return
|
||||
const value = input.autoupdate
|
||||
if (typeof value === "boolean" || value === "notify") return value
|
||||
}
|
||||
|
||||
export function action(current: string, latest: string, policy: Policy): Action {
|
||||
if (policy === false) return "none"
|
||||
if (!semver.valid(current) || !semver.valid(latest) || semver.eq(latest, current)) return "none"
|
||||
// Major upgrades are never installed automatically.
|
||||
if (semver.major(latest) !== semver.major(current)) return "none"
|
||||
return "upgrade"
|
||||
}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const global = yield* Global.Service
|
||||
const appProcess = yield* AppProcess.Service
|
||||
const channel = InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||
|
||||
const readPolicy = Effect.fnUntraced(function* () {
|
||||
const values = yield* Effect.forEach(["config.json", "opencode.json", "opencode.jsonc"], (name) =>
|
||||
fs
|
||||
.readFileString(path.join(global.config, name))
|
||||
.pipe(Effect.map(decodePolicy), Effect.catch(() => Effect.succeed(undefined))),
|
||||
)
|
||||
return values.findLast((value) => value !== undefined) ?? true
|
||||
})
|
||||
|
||||
const run = Effect.fnUntraced(function* (command: string[], timeout: Duration.Input = "10 seconds") {
|
||||
return yield* appProcess
|
||||
.run(ChildProcess.make(command[0], command.slice(1)), {
|
||||
timeout,
|
||||
maxOutputBytes: 100_000,
|
||||
maxErrorBytes: 100_000,
|
||||
})
|
||||
.pipe(
|
||||
Effect.map((result) => ({
|
||||
code: result.exitCode,
|
||||
stdout: result.stdout.toString("utf8"),
|
||||
stderr: result.stderr.toString("utf8"),
|
||||
})),
|
||||
Effect.catch(() => Effect.succeed({ code: 1, stdout: "", stderr: "" })),
|
||||
)
|
||||
})
|
||||
|
||||
const method = Effect.fnUntraced(function* () {
|
||||
const checks: ReadonlyArray<{ method: Method; command: string[] }> = [
|
||||
{ method: "npm", command: ["npm", "list", "-g", "--depth=0", packageName] },
|
||||
{ method: "pnpm", command: ["pnpm", "list", "-g", "--depth=0", packageName] },
|
||||
{ method: "bun", command: ["bun", "pm", "ls", "-g"] },
|
||||
{ method: "yarn", command: ["yarn", "global", "list"] },
|
||||
]
|
||||
const results = yield* Effect.forEach(
|
||||
checks,
|
||||
(check) => run(check.command).pipe(Effect.map((result) => ({ check, result }))),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
return results.find((result) => result.result.stdout.includes(packageName))?.check.method
|
||||
})
|
||||
|
||||
const latest = Effect.fnUntraced(function* () {
|
||||
const response = yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
fetch(
|
||||
`https://registry.npmjs.org/${encodeURIComponent(packageName)}/${encodeURIComponent(InstallationChannel)}`,
|
||||
{ headers: { "User-Agent": `opencode/${InstallationVersion}` }, signal: AbortSignal.timeout(10_000) },
|
||||
),
|
||||
catch: (cause) => new Error("Failed to check for updates", { cause }),
|
||||
})
|
||||
if (!response.ok) return yield* Effect.fail(new Error(`Update check failed with status ${response.status}`))
|
||||
const data = yield* Effect.tryPromise({
|
||||
try: () => response.json(),
|
||||
catch: (cause) => new Error("Failed to read update information", { cause }),
|
||||
})
|
||||
if (typeof data !== "object" || data === null || !("version" in data) || typeof data.version !== "string") {
|
||||
return yield* Effect.fail(new Error("Update information did not include a version"))
|
||||
}
|
||||
return data.version
|
||||
})
|
||||
|
||||
const upgrade = Effect.fnUntraced(function* (method: Method, version: string) {
|
||||
const target = `${packageName}@${version}`
|
||||
const commands: Record<Method, string[]> = {
|
||||
npm: ["npm", "install", "--global", target],
|
||||
pnpm: ["pnpm", "install", "--global", target],
|
||||
bun: ["bun", "install", "--global", target],
|
||||
yarn: ["yarn", "global", "add", target],
|
||||
}
|
||||
const result = yield* run(commands[method], "5 minutes")
|
||||
if (result.code === 0) return
|
||||
return yield* Effect.fail(new Error(result.stderr.trim() || `Failed to update with ${method}`))
|
||||
})
|
||||
|
||||
const check = Effect.fn("cli.updater.check")(function* () {
|
||||
if (InstallationLocal || Flag.OPENCODE_DISABLE_AUTOUPDATE)
|
||||
return yield* Effect.logInfo("update check skipped", {
|
||||
reason: InstallationLocal ? "local-install" : "disabled",
|
||||
version: InstallationVersion,
|
||||
channel: InstallationChannel,
|
||||
})
|
||||
const policy = yield* readPolicy()
|
||||
if (policy === false) return yield* Effect.logInfo("update check skipped", { reason: "policy-disabled" })
|
||||
|
||||
return yield* Effect.gen(function* () {
|
||||
const version = yield* latest()
|
||||
yield* Effect.logInfo("update check", {
|
||||
current: InstallationVersion,
|
||||
latest: version,
|
||||
})
|
||||
const next = action(InstallationVersion, version, policy)
|
||||
if (next === "none") return yield* Effect.logInfo("update check done", { action: "up-to-date" })
|
||||
const detected = yield* method()
|
||||
if (!detected) return yield* Effect.logWarning("automatic update skipped: installation method not found")
|
||||
yield* upgrade(detected, version)
|
||||
yield* Effect.logInfo("updated OpenCode", { from: InstallationVersion, to: version, method: detected })
|
||||
})
|
||||
}, Effect.catchCause((cause) => Effect.logWarning("automatic update failed", { cause })))
|
||||
|
||||
return Service.of({ check })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AppProcess.defaultLayer), Layer.provide(Global.defaultLayer))
|
||||
|
||||
export * as Updater from "./updater"
|
||||
@@ -7,7 +7,7 @@ Private generation target for clients derived directly from OpenCode's authorita
|
||||
- `@opencode-ai/client`: zero-Effect Promise client using `fetch`.
|
||||
- `@opencode-ai/client/effect`: rich Effect network client using an environment-provided `HttpClient`.
|
||||
|
||||
The generated surface starts with the Session group from Server's concrete API. The build compiler reads `@opencode-ai/server/api`; the generated Effect runtime imports a client-local projection built from Protocol, with a generation-equivalence test preventing transport drift. Run `bun run generate` after changing the contract and `bun run check:generated` to detect committed-output drift.
|
||||
The generated surface includes every standard HTTP group from Server's concrete API. The build compiler reads `@opencode-ai/server/api`; the generated Effect runtime imports a client-local projection built from Protocol, with a generation-equivalence test preventing transport drift. Custom transports such as the PTY WebSocket connection remain outside the generic HTTP client. Run `bun run generate` after changing the contract and `bun run check:generated` to detect committed-output drift.
|
||||
|
||||
The Effect entrypoint uses canonical decoded values such as `Session.ID`, `Location.Ref`, and `Prompt`. These datatypes come from the lightweight `@opencode-ai/schema` package and are re-exported so callers depend only on the client surface. Protocol owns endpoint construction and middleware placement; Server supplies the concrete middleware keys used by the build-time API.
|
||||
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { compile, emitEffectImported, emitPromise, write } from "@opencode-ai/httpapi-codegen"
|
||||
import { Api } from "@opencode-ai/server/api"
|
||||
import { ClientApi, endpointNames, groupNames, omitEndpoints } from "../src/contract"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApi } from "effect/unstable/httpapi"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const contract = compile(HttpApi.make("opencode-client").add(Api.groups["server.session"]), {
|
||||
groupNames: { "server.session": "sessions" },
|
||||
})
|
||||
const contract = compile(ClientApi, { groupNames, endpointNames, omitEndpoints })
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.all(
|
||||
[
|
||||
write(emitPromise(contract), fileURLToPath(new URL("../src/generated", import.meta.url))),
|
||||
write(
|
||||
emitEffectImported(contract, { module: "../contract", group: "SessionGroup" }),
|
||||
emitPromise(contract, {
|
||||
outputTypes: {
|
||||
"events.subscribe": {
|
||||
name: "OpenCodeEventEncoded",
|
||||
import: 'import type { OpenCodeEventEncoded } from "@opencode-ai/protocol/groups/event"',
|
||||
},
|
||||
},
|
||||
}),
|
||||
fileURLToPath(new URL("../src/generated", import.meta.url)),
|
||||
),
|
||||
write(
|
||||
emitEffectImported(contract, { module: "../contract", api: "ClientApi" }),
|
||||
fileURLToPath(new URL("../src/generated-effect", import.meta.url)),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -11,9 +11,43 @@ class SessionLocationMiddleware extends HttpApiMiddleware.Service<SessionLocatio
|
||||
{ error: [InvalidRequestError, SessionNotFoundError] },
|
||||
) {}
|
||||
|
||||
const Api = makeDefaultApi({
|
||||
export const ClientApi = makeDefaultApi({
|
||||
locationMiddleware: LocationMiddleware,
|
||||
sessionLocationMiddleware: SessionLocationMiddleware,
|
||||
})
|
||||
|
||||
export const SessionGroup = Api.groups["server.session"]
|
||||
export const groupNames = {
|
||||
"server.health": "health",
|
||||
"server.location": "location",
|
||||
"server.agent": "agents",
|
||||
"server.session": "sessions",
|
||||
"server.message": "messages",
|
||||
"server.model": "models",
|
||||
"server.provider": "providers",
|
||||
"server.integration": "integrations",
|
||||
"server.credential": "credentials",
|
||||
"server.permission": "permissions",
|
||||
"server.fs": "files",
|
||||
"server.command": "commands",
|
||||
"server.skill": "skills",
|
||||
"server.event": "events",
|
||||
"server.pty": "ptys",
|
||||
"server.question": "questions",
|
||||
"server.reference": "references",
|
||||
"server.projectCopy": "projectCopies",
|
||||
} as const
|
||||
|
||||
export const endpointNames = {
|
||||
"session.messages": "list",
|
||||
"integration.connect.key": "connectKey",
|
||||
"integration.connect.oauth": "connectOauth",
|
||||
"integration.attempt.status": "attemptStatus",
|
||||
"integration.attempt.complete": "attemptComplete",
|
||||
"integration.attempt.cancel": "attemptCancel",
|
||||
"permission.request.list": "listRequests",
|
||||
"permission.saved.list": "listSaved",
|
||||
"permission.saved.remove": "removeSaved",
|
||||
"question.request.list": "listRequests",
|
||||
} as const
|
||||
|
||||
export const omitEndpoints = new Set(["fs.read", "pty.connect", "pty.connectToken"])
|
||||
|
||||
@@ -2,11 +2,24 @@
|
||||
// Core or Server. Preserve these datatype exports so internal model reorganizations do not require caller migrations.
|
||||
export * from "./generated-effect/index"
|
||||
export { Agent } from "@opencode-ai/schema/agent"
|
||||
export { Command } from "@opencode-ai/schema/command"
|
||||
export { Credential } from "@opencode-ai/schema/credential"
|
||||
export { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
export { Integration } from "@opencode-ai/schema/integration"
|
||||
export { Location } from "@opencode-ai/schema/location"
|
||||
export { Model } from "@opencode-ai/schema/model"
|
||||
export { Permission } from "@opencode-ai/schema/permission"
|
||||
export { PermissionSaved } from "@opencode-ai/schema/permission-saved"
|
||||
export { Project } from "@opencode-ai/schema/project"
|
||||
export { ProjectCopy } from "@opencode-ai/schema/project-copy"
|
||||
export { Provider } from "@opencode-ai/schema/provider"
|
||||
export { Pty } from "@opencode-ai/schema/pty"
|
||||
export { Question } from "@opencode-ai/schema/question"
|
||||
export { Reference } from "@opencode-ai/schema/reference"
|
||||
export { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema"
|
||||
export { Session } from "@opencode-ai/schema/session"
|
||||
export { SessionInput } from "@opencode-ai/schema/session-input"
|
||||
export { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
export { Skill } from "@opencode-ai/schema/skill"
|
||||
export { Prompt } from "@opencode-ai/schema/prompt"
|
||||
export type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
|
||||
|
||||
@@ -2,202 +2,716 @@
|
||||
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"
|
||||
import { SessionGroup } from "../contract"
|
||||
import { HttpApiClient } from "effect/unstable/httpapi"
|
||||
import { ClientApi } from "../contract"
|
||||
import { ClientError } from "./client-error"
|
||||
|
||||
const Api = HttpApi.make("generated").add(SessionGroup)
|
||||
|
||||
type RawClient = HttpApiClient.ForApi<typeof Api>
|
||||
type RawClient = HttpApiClient.ForApi<typeof ClientApi>
|
||||
|
||||
const mapClientError = <E>(error: E) =>
|
||||
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
|
||||
? new ClientError({ cause: error })
|
||||
: error
|
||||
|
||||
type Endpoint0_0Request = Parameters<RawClient["server.session"]["session.list"]>[0]
|
||||
type Endpoint0_0Input = {
|
||||
readonly workspace?: Endpoint0_0Request["query"]["workspace"]
|
||||
readonly limit?: Endpoint0_0Request["query"]["limit"]
|
||||
readonly order?: Endpoint0_0Request["query"]["order"]
|
||||
readonly search?: Endpoint0_0Request["query"]["search"]
|
||||
readonly directory?: Endpoint0_0Request["query"]["directory"]
|
||||
readonly project?: Endpoint0_0Request["query"]["project"]
|
||||
readonly subpath?: Endpoint0_0Request["query"]["subpath"]
|
||||
readonly cursor?: Endpoint0_0Request["query"]["cursor"]
|
||||
const Endpoint0_0 = (raw: RawClient["server.health"]) => () =>
|
||||
raw["health.get"]({}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
const adaptGroup0 = (raw: RawClient["server.health"]) => ({ get: Endpoint0_0(raw) })
|
||||
|
||||
type Endpoint1_0Request = Parameters<RawClient["server.location"]["location.get"]>[0]
|
||||
type Endpoint1_0Input = { readonly location?: Endpoint1_0Request["query"]["location"] }
|
||||
const Endpoint1_0 = (raw: RawClient["server.location"]) => (input?: Endpoint1_0Input) =>
|
||||
raw["location.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
const adaptGroup1 = (raw: RawClient["server.location"]) => ({ get: Endpoint1_0(raw) })
|
||||
|
||||
type Endpoint2_0Request = Parameters<RawClient["server.agent"]["agent.list"]>[0]
|
||||
type Endpoint2_0Input = { readonly location?: Endpoint2_0Request["query"]["location"] }
|
||||
const Endpoint2_0 = (raw: RawClient["server.agent"]) => (input?: Endpoint2_0Input) =>
|
||||
raw["agent.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
const adaptGroup2 = (raw: RawClient["server.agent"]) => ({ list: Endpoint2_0(raw) })
|
||||
|
||||
type Endpoint3_0Request = Parameters<RawClient["server.session"]["session.list"]>[0]
|
||||
type Endpoint3_0Input = {
|
||||
readonly workspace?: Endpoint3_0Request["query"]["workspace"]
|
||||
readonly limit?: Endpoint3_0Request["query"]["limit"]
|
||||
readonly order?: Endpoint3_0Request["query"]["order"]
|
||||
readonly search?: Endpoint3_0Request["query"]["search"]
|
||||
readonly directory?: Endpoint3_0Request["query"]["directory"]
|
||||
readonly project?: Endpoint3_0Request["query"]["project"]
|
||||
readonly subpath?: Endpoint3_0Request["query"]["subpath"]
|
||||
readonly cursor?: Endpoint3_0Request["query"]["cursor"]
|
||||
}
|
||||
const Endpoint0_0 = (raw: RawClient["server.session"]) => (input?: Endpoint0_0Input) =>
|
||||
const Endpoint3_0 = (raw: RawClient["server.session"]) => (input?: Endpoint3_0Input) =>
|
||||
raw["session.list"]({
|
||||
query: {
|
||||
workspace: input?.workspace,
|
||||
limit: input?.limit,
|
||||
order: input?.order,
|
||||
search: input?.search,
|
||||
directory: input?.directory,
|
||||
project: input?.project,
|
||||
subpath: input?.subpath,
|
||||
cursor: input?.cursor,
|
||||
workspace: input?.["workspace"],
|
||||
limit: input?.["limit"],
|
||||
order: input?.["order"],
|
||||
search: input?.["search"],
|
||||
directory: input?.["directory"],
|
||||
project: input?.["project"],
|
||||
subpath: input?.["subpath"],
|
||||
cursor: input?.["cursor"],
|
||||
},
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint0_1Request = Parameters<RawClient["server.session"]["session.create"]>[0]
|
||||
type Endpoint0_1Input = {
|
||||
readonly id?: Endpoint0_1Request["payload"]["id"]
|
||||
readonly agent?: Endpoint0_1Request["payload"]["agent"]
|
||||
readonly model?: Endpoint0_1Request["payload"]["model"]
|
||||
readonly location?: Endpoint0_1Request["payload"]["location"]
|
||||
type Endpoint3_1Request = Parameters<RawClient["server.session"]["session.create"]>[0]
|
||||
type Endpoint3_1Input = {
|
||||
readonly id?: Endpoint3_1Request["payload"]["id"]
|
||||
readonly agent?: Endpoint3_1Request["payload"]["agent"]
|
||||
readonly model?: Endpoint3_1Request["payload"]["model"]
|
||||
readonly location?: Endpoint3_1Request["payload"]["location"]
|
||||
}
|
||||
const Endpoint0_1 = (raw: RawClient["server.session"]) => (input?: Endpoint0_1Input) =>
|
||||
const Endpoint3_1 = (raw: RawClient["server.session"]) => (input?: Endpoint3_1Input) =>
|
||||
raw["session.create"]({
|
||||
payload: { id: input?.id, agent: input?.agent, model: input?.model, location: input?.location },
|
||||
payload: { id: input?.["id"], agent: input?.["agent"], model: input?.["model"], location: input?.["location"] },
|
||||
}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
const Endpoint0_2 = (raw: RawClient["server.session"]) => () =>
|
||||
const Endpoint3_2 = (raw: RawClient["server.session"]) => () =>
|
||||
raw["session.active"]({}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint0_3Request = Parameters<RawClient["server.session"]["session.get"]>[0]
|
||||
type Endpoint0_3Input = { readonly sessionID: Endpoint0_3Request["params"]["sessionID"] }
|
||||
const Endpoint0_3 = (raw: RawClient["server.session"]) => (input: Endpoint0_3Input) =>
|
||||
raw["session.get"]({ params: { sessionID: input.sessionID } }).pipe(
|
||||
type Endpoint3_3Request = Parameters<RawClient["server.session"]["session.get"]>[0]
|
||||
type Endpoint3_3Input = { readonly sessionID: Endpoint3_3Request["params"]["sessionID"] }
|
||||
const Endpoint3_3 = (raw: RawClient["server.session"]) => (input: Endpoint3_3Input) =>
|
||||
raw["session.get"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint0_4Request = Parameters<RawClient["server.session"]["session.switchAgent"]>[0]
|
||||
type Endpoint0_4Input = {
|
||||
readonly sessionID: Endpoint0_4Request["params"]["sessionID"]
|
||||
readonly agent: Endpoint0_4Request["payload"]["agent"]
|
||||
type Endpoint3_4Request = Parameters<RawClient["server.session"]["session.switchAgent"]>[0]
|
||||
type Endpoint3_4Input = {
|
||||
readonly sessionID: Endpoint3_4Request["params"]["sessionID"]
|
||||
readonly agent: Endpoint3_4Request["payload"]["agent"]
|
||||
}
|
||||
const Endpoint0_4 = (raw: RawClient["server.session"]) => (input: Endpoint0_4Input) =>
|
||||
raw["session.switchAgent"]({ params: { sessionID: input.sessionID }, payload: { agent: input.agent } }).pipe(
|
||||
const Endpoint3_4 = (raw: RawClient["server.session"]) => (input: Endpoint3_4Input) =>
|
||||
raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
)
|
||||
|
||||
type Endpoint0_5Request = Parameters<RawClient["server.session"]["session.switchModel"]>[0]
|
||||
type Endpoint0_5Input = {
|
||||
readonly sessionID: Endpoint0_5Request["params"]["sessionID"]
|
||||
readonly model: Endpoint0_5Request["payload"]["model"]
|
||||
type Endpoint3_5Request = Parameters<RawClient["server.session"]["session.switchModel"]>[0]
|
||||
type Endpoint3_5Input = {
|
||||
readonly sessionID: Endpoint3_5Request["params"]["sessionID"]
|
||||
readonly model: Endpoint3_5Request["payload"]["model"]
|
||||
}
|
||||
const Endpoint0_5 = (raw: RawClient["server.session"]) => (input: Endpoint0_5Input) =>
|
||||
raw["session.switchModel"]({ params: { sessionID: input.sessionID }, payload: { model: input.model } }).pipe(
|
||||
const Endpoint3_5 = (raw: RawClient["server.session"]) => (input: Endpoint3_5Input) =>
|
||||
raw["session.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
)
|
||||
|
||||
type Endpoint0_6Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
|
||||
type Endpoint0_6Input = {
|
||||
readonly sessionID: Endpoint0_6Request["params"]["sessionID"]
|
||||
readonly id?: Endpoint0_6Request["payload"]["id"]
|
||||
readonly prompt: Endpoint0_6Request["payload"]["prompt"]
|
||||
readonly delivery?: Endpoint0_6Request["payload"]["delivery"]
|
||||
readonly resume?: Endpoint0_6Request["payload"]["resume"]
|
||||
type Endpoint3_6Request = Parameters<RawClient["server.session"]["session.rename"]>[0]
|
||||
type Endpoint3_6Input = {
|
||||
readonly sessionID: Endpoint3_6Request["params"]["sessionID"]
|
||||
readonly title: Endpoint3_6Request["payload"]["title"]
|
||||
}
|
||||
const Endpoint0_6 = (raw: RawClient["server.session"]) => (input: Endpoint0_6Input) =>
|
||||
const Endpoint3_6 = (raw: RawClient["server.session"]) => (input: Endpoint3_6Input) =>
|
||||
raw["session.rename"]({ params: { sessionID: input["sessionID"] }, payload: { title: input["title"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
)
|
||||
|
||||
type Endpoint3_7Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
|
||||
type Endpoint3_7Input = {
|
||||
readonly sessionID: Endpoint3_7Request["params"]["sessionID"]
|
||||
readonly id?: Endpoint3_7Request["payload"]["id"]
|
||||
readonly prompt: Endpoint3_7Request["payload"]["prompt"]
|
||||
readonly delivery?: Endpoint3_7Request["payload"]["delivery"]
|
||||
readonly resume?: Endpoint3_7Request["payload"]["resume"]
|
||||
}
|
||||
const Endpoint3_7 = (raw: RawClient["server.session"]) => (input: Endpoint3_7Input) =>
|
||||
raw["session.prompt"]({
|
||||
params: { sessionID: input.sessionID },
|
||||
payload: { id: input.id, prompt: input.prompt, delivery: input.delivery, resume: input.resume },
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { id: input["id"], prompt: input["prompt"], delivery: input["delivery"], resume: input["resume"] },
|
||||
}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint0_7Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
|
||||
type Endpoint0_7Input = { readonly sessionID: Endpoint0_7Request["params"]["sessionID"] }
|
||||
const Endpoint0_7 = (raw: RawClient["server.session"]) => (input: Endpoint0_7Input) =>
|
||||
raw["session.compact"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
|
||||
type Endpoint3_8Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
|
||||
type Endpoint3_8Input = { readonly sessionID: Endpoint3_8Request["params"]["sessionID"] }
|
||||
const Endpoint3_8 = (raw: RawClient["server.session"]) => (input: Endpoint3_8Input) =>
|
||||
raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint0_8Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
|
||||
type Endpoint0_8Input = { readonly sessionID: Endpoint0_8Request["params"]["sessionID"] }
|
||||
const Endpoint0_8 = (raw: RawClient["server.session"]) => (input: Endpoint0_8Input) =>
|
||||
raw["session.wait"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
|
||||
type Endpoint3_9Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
|
||||
type Endpoint3_9Input = { readonly sessionID: Endpoint3_9Request["params"]["sessionID"] }
|
||||
const Endpoint3_9 = (raw: RawClient["server.session"]) => (input: Endpoint3_9Input) =>
|
||||
raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint0_9Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
|
||||
type Endpoint0_9Input = {
|
||||
readonly sessionID: Endpoint0_9Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint0_9Request["payload"]["messageID"]
|
||||
readonly files?: Endpoint0_9Request["payload"]["files"]
|
||||
type Endpoint3_10Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
|
||||
type Endpoint3_10Input = {
|
||||
readonly sessionID: Endpoint3_10Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint3_10Request["payload"]["messageID"]
|
||||
readonly files?: Endpoint3_10Request["payload"]["files"]
|
||||
}
|
||||
const Endpoint0_9 = (raw: RawClient["server.session"]) => (input: Endpoint0_9Input) =>
|
||||
const Endpoint3_10 = (raw: RawClient["server.session"]) => (input: Endpoint3_10Input) =>
|
||||
raw["session.revert.stage"]({
|
||||
params: { sessionID: input.sessionID },
|
||||
payload: { messageID: input.messageID, files: input.files },
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { messageID: input["messageID"], files: input["files"] },
|
||||
}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint0_10Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
|
||||
type Endpoint0_10Input = { readonly sessionID: Endpoint0_10Request["params"]["sessionID"] }
|
||||
const Endpoint0_10 = (raw: RawClient["server.session"]) => (input: Endpoint0_10Input) =>
|
||||
raw["session.revert.clear"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
|
||||
type Endpoint3_11Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
|
||||
type Endpoint3_11Input = { readonly sessionID: Endpoint3_11Request["params"]["sessionID"] }
|
||||
const Endpoint3_11 = (raw: RawClient["server.session"]) => (input: Endpoint3_11Input) =>
|
||||
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint0_11Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
|
||||
type Endpoint0_11Input = { readonly sessionID: Endpoint0_11Request["params"]["sessionID"] }
|
||||
const Endpoint0_11 = (raw: RawClient["server.session"]) => (input: Endpoint0_11Input) =>
|
||||
raw["session.revert.commit"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
|
||||
type Endpoint3_12Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
|
||||
type Endpoint3_12Input = { readonly sessionID: Endpoint3_12Request["params"]["sessionID"] }
|
||||
const Endpoint3_12 = (raw: RawClient["server.session"]) => (input: Endpoint3_12Input) =>
|
||||
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint0_12Request = Parameters<RawClient["server.session"]["session.context"]>[0]
|
||||
type Endpoint0_12Input = { readonly sessionID: Endpoint0_12Request["params"]["sessionID"] }
|
||||
const Endpoint0_12 = (raw: RawClient["server.session"]) => (input: Endpoint0_12Input) =>
|
||||
raw["session.context"]({ params: { sessionID: input.sessionID } }).pipe(
|
||||
type Endpoint3_13Request = Parameters<RawClient["server.session"]["session.context"]>[0]
|
||||
type Endpoint3_13Input = { readonly sessionID: Endpoint3_13Request["params"]["sessionID"] }
|
||||
const Endpoint3_13 = (raw: RawClient["server.session"]) => (input: Endpoint3_13Input) =>
|
||||
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint0_13Request = Parameters<RawClient["server.session"]["session.events"]>[0]
|
||||
type Endpoint0_13Input = {
|
||||
readonly sessionID: Endpoint0_13Request["params"]["sessionID"]
|
||||
readonly after?: Endpoint0_13Request["query"]["after"]
|
||||
type Endpoint3_14Request = Parameters<RawClient["server.session"]["session.history"]>[0]
|
||||
type Endpoint3_14Input = {
|
||||
readonly sessionID: Endpoint3_14Request["params"]["sessionID"]
|
||||
readonly limit?: Endpoint3_14Request["query"]["limit"]
|
||||
readonly after?: Endpoint3_14Request["query"]["after"]
|
||||
}
|
||||
const Endpoint0_13 = (raw: RawClient["server.session"]) => (input: Endpoint0_13Input) =>
|
||||
const Endpoint3_14 = (raw: RawClient["server.session"]) => (input: Endpoint3_14Input) =>
|
||||
raw["session.history"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
query: { limit: input["limit"], after: input["after"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint3_15Request = Parameters<RawClient["server.session"]["session.events"]>[0]
|
||||
type Endpoint3_15Input = {
|
||||
readonly sessionID: Endpoint3_15Request["params"]["sessionID"]
|
||||
readonly after?: Endpoint3_15Request["query"]["after"]
|
||||
}
|
||||
const Endpoint3_15 = (raw: RawClient["server.session"]) => (input: Endpoint3_15Input) =>
|
||||
Stream.unwrap(
|
||||
raw["session.events"]({ params: { sessionID: input.sessionID }, query: { after: input.after } }).pipe(
|
||||
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_14Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
||||
type Endpoint0_14Input = { readonly sessionID: Endpoint0_14Request["params"]["sessionID"] }
|
||||
const Endpoint0_14 = (raw: RawClient["server.session"]) => (input: Endpoint0_14Input) =>
|
||||
raw["session.interrupt"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
|
||||
type Endpoint3_16Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
||||
type Endpoint3_16Input = { readonly sessionID: Endpoint3_16Request["params"]["sessionID"] }
|
||||
const Endpoint3_16 = (raw: RawClient["server.session"]) => (input: Endpoint3_16Input) =>
|
||||
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint0_15Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
||||
type Endpoint0_15Input = {
|
||||
readonly sessionID: Endpoint0_15Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint0_15Request["params"]["messageID"]
|
||||
type Endpoint3_17Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
||||
type Endpoint3_17Input = {
|
||||
readonly sessionID: Endpoint3_17Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint3_17Request["params"]["messageID"]
|
||||
}
|
||||
const Endpoint0_15 = (raw: RawClient["server.session"]) => (input: Endpoint0_15Input) =>
|
||||
raw["session.message"]({ params: { sessionID: input.sessionID, messageID: input.messageID } }).pipe(
|
||||
const Endpoint3_17 = (raw: RawClient["server.session"]) => (input: Endpoint3_17Input) =>
|
||||
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),
|
||||
active: Endpoint0_2(raw),
|
||||
get: Endpoint0_3(raw),
|
||||
switchAgent: Endpoint0_4(raw),
|
||||
switchModel: Endpoint0_5(raw),
|
||||
prompt: Endpoint0_6(raw),
|
||||
compact: Endpoint0_7(raw),
|
||||
wait: Endpoint0_8(raw),
|
||||
stage: Endpoint0_9(raw),
|
||||
clear: Endpoint0_10(raw),
|
||||
commit: Endpoint0_11(raw),
|
||||
context: Endpoint0_12(raw),
|
||||
events: Endpoint0_13(raw),
|
||||
interrupt: Endpoint0_14(raw),
|
||||
message: Endpoint0_15(raw),
|
||||
const adaptGroup3 = (raw: RawClient["server.session"]) => ({
|
||||
list: Endpoint3_0(raw),
|
||||
create: Endpoint3_1(raw),
|
||||
active: Endpoint3_2(raw),
|
||||
get: Endpoint3_3(raw),
|
||||
switchAgent: Endpoint3_4(raw),
|
||||
switchModel: Endpoint3_5(raw),
|
||||
rename: Endpoint3_6(raw),
|
||||
prompt: Endpoint3_7(raw),
|
||||
compact: Endpoint3_8(raw),
|
||||
wait: Endpoint3_9(raw),
|
||||
stage: Endpoint3_10(raw),
|
||||
clear: Endpoint3_11(raw),
|
||||
commit: Endpoint3_12(raw),
|
||||
context: Endpoint3_13(raw),
|
||||
history: Endpoint3_14(raw),
|
||||
events: Endpoint3_15(raw),
|
||||
interrupt: Endpoint3_16(raw),
|
||||
message: Endpoint3_17(raw),
|
||||
})
|
||||
|
||||
const adaptClient = (raw: RawClient) => ({ sessions: adaptGroup0(raw["server.session"]) })
|
||||
type Endpoint4_0Request = Parameters<RawClient["server.message"]["session.messages"]>[0]
|
||||
type Endpoint4_0Input = {
|
||||
readonly sessionID: Endpoint4_0Request["params"]["sessionID"]
|
||||
readonly limit?: Endpoint4_0Request["query"]["limit"]
|
||||
readonly order?: Endpoint4_0Request["query"]["order"]
|
||||
readonly cursor?: Endpoint4_0Request["query"]["cursor"]
|
||||
}
|
||||
const Endpoint4_0 = (raw: RawClient["server.message"]) => (input: Endpoint4_0Input) =>
|
||||
raw["session.messages"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
query: { limit: input["limit"], order: input["order"], cursor: input["cursor"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
const adaptGroup4 = (raw: RawClient["server.message"]) => ({ list: Endpoint4_0(raw) })
|
||||
|
||||
type Endpoint5_0Request = Parameters<RawClient["server.model"]["model.list"]>[0]
|
||||
type Endpoint5_0Input = { readonly location?: Endpoint5_0Request["query"]["location"] }
|
||||
const Endpoint5_0 = (raw: RawClient["server.model"]) => (input?: Endpoint5_0Input) =>
|
||||
raw["model.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
const adaptGroup5 = (raw: RawClient["server.model"]) => ({ list: Endpoint5_0(raw) })
|
||||
|
||||
type Endpoint6_0Request = Parameters<RawClient["server.provider"]["provider.list"]>[0]
|
||||
type Endpoint6_0Input = { readonly location?: Endpoint6_0Request["query"]["location"] }
|
||||
const Endpoint6_0 = (raw: RawClient["server.provider"]) => (input?: Endpoint6_0Input) =>
|
||||
raw["provider.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint6_1Request = Parameters<RawClient["server.provider"]["provider.get"]>[0]
|
||||
type Endpoint6_1Input = {
|
||||
readonly providerID: Endpoint6_1Request["params"]["providerID"]
|
||||
readonly location?: Endpoint6_1Request["query"]["location"]
|
||||
}
|
||||
const Endpoint6_1 = (raw: RawClient["server.provider"]) => (input: Endpoint6_1Input) =>
|
||||
raw["provider.get"]({ params: { providerID: input["providerID"] }, query: { location: input["location"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
)
|
||||
|
||||
const adaptGroup6 = (raw: RawClient["server.provider"]) => ({ list: Endpoint6_0(raw), get: Endpoint6_1(raw) })
|
||||
|
||||
type Endpoint7_0Request = Parameters<RawClient["server.integration"]["integration.list"]>[0]
|
||||
type Endpoint7_0Input = { readonly location?: Endpoint7_0Request["query"]["location"] }
|
||||
const Endpoint7_0 = (raw: RawClient["server.integration"]) => (input?: Endpoint7_0Input) =>
|
||||
raw["integration.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint7_1Request = Parameters<RawClient["server.integration"]["integration.get"]>[0]
|
||||
type Endpoint7_1Input = {
|
||||
readonly integrationID: Endpoint7_1Request["params"]["integrationID"]
|
||||
readonly location?: Endpoint7_1Request["query"]["location"]
|
||||
}
|
||||
const Endpoint7_1 = (raw: RawClient["server.integration"]) => (input: Endpoint7_1Input) =>
|
||||
raw["integration.get"]({
|
||||
params: { integrationID: input["integrationID"] },
|
||||
query: { location: input["location"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint7_2Request = Parameters<RawClient["server.integration"]["integration.connect.key"]>[0]
|
||||
type Endpoint7_2Input = {
|
||||
readonly integrationID: Endpoint7_2Request["params"]["integrationID"]
|
||||
readonly location?: Endpoint7_2Request["query"]["location"]
|
||||
readonly key: Endpoint7_2Request["payload"]["key"]
|
||||
readonly label?: Endpoint7_2Request["payload"]["label"]
|
||||
}
|
||||
const Endpoint7_2 = (raw: RawClient["server.integration"]) => (input: Endpoint7_2Input) =>
|
||||
raw["integration.connect.key"]({
|
||||
params: { integrationID: input["integrationID"] },
|
||||
query: { location: input["location"] },
|
||||
payload: { key: input["key"], label: input["label"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint7_3Request = Parameters<RawClient["server.integration"]["integration.connect.oauth"]>[0]
|
||||
type Endpoint7_3Input = {
|
||||
readonly integrationID: Endpoint7_3Request["params"]["integrationID"]
|
||||
readonly location?: Endpoint7_3Request["query"]["location"]
|
||||
readonly methodID: Endpoint7_3Request["payload"]["methodID"]
|
||||
readonly inputs: Endpoint7_3Request["payload"]["inputs"]
|
||||
readonly label?: Endpoint7_3Request["payload"]["label"]
|
||||
}
|
||||
const Endpoint7_3 = (raw: RawClient["server.integration"]) => (input: Endpoint7_3Input) =>
|
||||
raw["integration.connect.oauth"]({
|
||||
params: { integrationID: input["integrationID"] },
|
||||
query: { location: input["location"] },
|
||||
payload: { methodID: input["methodID"], inputs: input["inputs"], label: input["label"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint7_4Request = Parameters<RawClient["server.integration"]["integration.attempt.status"]>[0]
|
||||
type Endpoint7_4Input = {
|
||||
readonly attemptID: Endpoint7_4Request["params"]["attemptID"]
|
||||
readonly location?: Endpoint7_4Request["query"]["location"]
|
||||
}
|
||||
const Endpoint7_4 = (raw: RawClient["server.integration"]) => (input: Endpoint7_4Input) =>
|
||||
raw["integration.attempt.status"]({
|
||||
params: { attemptID: input["attemptID"] },
|
||||
query: { location: input["location"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint7_5Request = Parameters<RawClient["server.integration"]["integration.attempt.complete"]>[0]
|
||||
type Endpoint7_5Input = {
|
||||
readonly attemptID: Endpoint7_5Request["params"]["attemptID"]
|
||||
readonly location?: Endpoint7_5Request["query"]["location"]
|
||||
readonly code?: Endpoint7_5Request["payload"]["code"]
|
||||
}
|
||||
const Endpoint7_5 = (raw: RawClient["server.integration"]) => (input: Endpoint7_5Input) =>
|
||||
raw["integration.attempt.complete"]({
|
||||
params: { attemptID: input["attemptID"] },
|
||||
query: { location: input["location"] },
|
||||
payload: { code: input["code"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint7_6Request = Parameters<RawClient["server.integration"]["integration.attempt.cancel"]>[0]
|
||||
type Endpoint7_6Input = {
|
||||
readonly attemptID: Endpoint7_6Request["params"]["attemptID"]
|
||||
readonly location?: Endpoint7_6Request["query"]["location"]
|
||||
}
|
||||
const Endpoint7_6 = (raw: RawClient["server.integration"]) => (input: Endpoint7_6Input) =>
|
||||
raw["integration.attempt.cancel"]({
|
||||
params: { attemptID: input["attemptID"] },
|
||||
query: { location: input["location"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
const adaptGroup7 = (raw: RawClient["server.integration"]) => ({
|
||||
list: Endpoint7_0(raw),
|
||||
get: Endpoint7_1(raw),
|
||||
connectKey: Endpoint7_2(raw),
|
||||
connectOauth: Endpoint7_3(raw),
|
||||
attemptStatus: Endpoint7_4(raw),
|
||||
attemptComplete: Endpoint7_5(raw),
|
||||
attemptCancel: Endpoint7_6(raw),
|
||||
})
|
||||
|
||||
type Endpoint8_0Request = Parameters<RawClient["server.credential"]["credential.update"]>[0]
|
||||
type Endpoint8_0Input = {
|
||||
readonly credentialID: Endpoint8_0Request["params"]["credentialID"]
|
||||
readonly location?: Endpoint8_0Request["query"]["location"]
|
||||
readonly label: Endpoint8_0Request["payload"]["label"]
|
||||
}
|
||||
const Endpoint8_0 = (raw: RawClient["server.credential"]) => (input: Endpoint8_0Input) =>
|
||||
raw["credential.update"]({
|
||||
params: { credentialID: input["credentialID"] },
|
||||
query: { location: input["location"] },
|
||||
payload: { label: input["label"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint8_1Request = Parameters<RawClient["server.credential"]["credential.remove"]>[0]
|
||||
type Endpoint8_1Input = {
|
||||
readonly credentialID: Endpoint8_1Request["params"]["credentialID"]
|
||||
readonly location?: Endpoint8_1Request["query"]["location"]
|
||||
}
|
||||
const Endpoint8_1 = (raw: RawClient["server.credential"]) => (input: Endpoint8_1Input) =>
|
||||
raw["credential.remove"]({
|
||||
params: { credentialID: input["credentialID"] },
|
||||
query: { location: input["location"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
const adaptGroup8 = (raw: RawClient["server.credential"]) => ({ update: Endpoint8_0(raw), remove: Endpoint8_1(raw) })
|
||||
|
||||
type Endpoint9_0Request = Parameters<RawClient["server.permission"]["permission.request.list"]>[0]
|
||||
type Endpoint9_0Input = { readonly location?: Endpoint9_0Request["query"]["location"] }
|
||||
const Endpoint9_0 = (raw: RawClient["server.permission"]) => (input?: Endpoint9_0Input) =>
|
||||
raw["permission.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint9_1Request = Parameters<RawClient["server.permission"]["permission.saved.list"]>[0]
|
||||
type Endpoint9_1Input = { readonly projectID?: Endpoint9_1Request["query"]["projectID"] }
|
||||
const Endpoint9_1 = (raw: RawClient["server.permission"]) => (input?: Endpoint9_1Input) =>
|
||||
raw["permission.saved.list"]({ query: { projectID: input?.["projectID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint9_2Request = Parameters<RawClient["server.permission"]["permission.saved.remove"]>[0]
|
||||
type Endpoint9_2Input = { readonly id: Endpoint9_2Request["params"]["id"] }
|
||||
const Endpoint9_2 = (raw: RawClient["server.permission"]) => (input: Endpoint9_2Input) =>
|
||||
raw["permission.saved.remove"]({ params: { id: input["id"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint9_3Request = Parameters<RawClient["server.permission"]["session.permission.create"]>[0]
|
||||
type Endpoint9_3Input = {
|
||||
readonly sessionID: Endpoint9_3Request["params"]["sessionID"]
|
||||
readonly id?: Endpoint9_3Request["payload"]["id"]
|
||||
readonly action: Endpoint9_3Request["payload"]["action"]
|
||||
readonly resources: Endpoint9_3Request["payload"]["resources"]
|
||||
readonly save?: Endpoint9_3Request["payload"]["save"]
|
||||
readonly metadata?: Endpoint9_3Request["payload"]["metadata"]
|
||||
readonly source?: Endpoint9_3Request["payload"]["source"]
|
||||
readonly agent?: Endpoint9_3Request["payload"]["agent"]
|
||||
}
|
||||
const Endpoint9_3 = (raw: RawClient["server.permission"]) => (input: Endpoint9_3Input) =>
|
||||
raw["session.permission.create"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: {
|
||||
id: input["id"],
|
||||
action: input["action"],
|
||||
resources: input["resources"],
|
||||
save: input["save"],
|
||||
metadata: input["metadata"],
|
||||
source: input["source"],
|
||||
agent: input["agent"],
|
||||
},
|
||||
}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint9_4Request = Parameters<RawClient["server.permission"]["session.permission.list"]>[0]
|
||||
type Endpoint9_4Input = { readonly sessionID: Endpoint9_4Request["params"]["sessionID"] }
|
||||
const Endpoint9_4 = (raw: RawClient["server.permission"]) => (input: Endpoint9_4Input) =>
|
||||
raw["session.permission.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint9_5Request = Parameters<RawClient["server.permission"]["session.permission.get"]>[0]
|
||||
type Endpoint9_5Input = {
|
||||
readonly sessionID: Endpoint9_5Request["params"]["sessionID"]
|
||||
readonly requestID: Endpoint9_5Request["params"]["requestID"]
|
||||
}
|
||||
const Endpoint9_5 = (raw: RawClient["server.permission"]) => (input: Endpoint9_5Input) =>
|
||||
raw["session.permission.get"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint9_6Request = Parameters<RawClient["server.permission"]["session.permission.reply"]>[0]
|
||||
type Endpoint9_6Input = {
|
||||
readonly sessionID: Endpoint9_6Request["params"]["sessionID"]
|
||||
readonly requestID: Endpoint9_6Request["params"]["requestID"]
|
||||
readonly reply: Endpoint9_6Request["payload"]["reply"]
|
||||
readonly message?: Endpoint9_6Request["payload"]["message"]
|
||||
}
|
||||
const Endpoint9_6 = (raw: RawClient["server.permission"]) => (input: Endpoint9_6Input) =>
|
||||
raw["session.permission.reply"]({
|
||||
params: { sessionID: input["sessionID"], requestID: input["requestID"] },
|
||||
payload: { reply: input["reply"], message: input["message"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
const adaptGroup9 = (raw: RawClient["server.permission"]) => ({
|
||||
listRequests: Endpoint9_0(raw),
|
||||
listSaved: Endpoint9_1(raw),
|
||||
removeSaved: Endpoint9_2(raw),
|
||||
create: Endpoint9_3(raw),
|
||||
list: Endpoint9_4(raw),
|
||||
get: Endpoint9_5(raw),
|
||||
reply: Endpoint9_6(raw),
|
||||
})
|
||||
|
||||
type Endpoint10_0Request = Parameters<RawClient["server.fs"]["fs.list"]>[0]
|
||||
type Endpoint10_0Input = {
|
||||
readonly location?: Endpoint10_0Request["query"]["location"]
|
||||
readonly path?: Endpoint10_0Request["query"]["path"]
|
||||
}
|
||||
const Endpoint10_0 = (raw: RawClient["server.fs"]) => (input?: Endpoint10_0Input) =>
|
||||
raw["fs.list"]({ query: { location: input?.["location"], path: input?.["path"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
)
|
||||
|
||||
type Endpoint10_1Request = Parameters<RawClient["server.fs"]["fs.find"]>[0]
|
||||
type Endpoint10_1Input = {
|
||||
readonly location?: Endpoint10_1Request["query"]["location"]
|
||||
readonly query: Endpoint10_1Request["query"]["query"]
|
||||
readonly type?: Endpoint10_1Request["query"]["type"]
|
||||
readonly limit?: Endpoint10_1Request["query"]["limit"]
|
||||
}
|
||||
const Endpoint10_1 = (raw: RawClient["server.fs"]) => (input: Endpoint10_1Input) =>
|
||||
raw["fs.find"]({
|
||||
query: { location: input["location"], query: input["query"], type: input["type"], limit: input["limit"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
const adaptGroup10 = (raw: RawClient["server.fs"]) => ({ list: Endpoint10_0(raw), find: Endpoint10_1(raw) })
|
||||
|
||||
type Endpoint11_0Request = Parameters<RawClient["server.command"]["command.list"]>[0]
|
||||
type Endpoint11_0Input = { readonly location?: Endpoint11_0Request["query"]["location"] }
|
||||
const Endpoint11_0 = (raw: RawClient["server.command"]) => (input?: Endpoint11_0Input) =>
|
||||
raw["command.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
const adaptGroup11 = (raw: RawClient["server.command"]) => ({ list: Endpoint11_0(raw) })
|
||||
|
||||
type Endpoint12_0Request = Parameters<RawClient["server.skill"]["skill.list"]>[0]
|
||||
type Endpoint12_0Input = { readonly location?: Endpoint12_0Request["query"]["location"] }
|
||||
const Endpoint12_0 = (raw: RawClient["server.skill"]) => (input?: Endpoint12_0Input) =>
|
||||
raw["skill.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
const adaptGroup12 = (raw: RawClient["server.skill"]) => ({ list: Endpoint12_0(raw) })
|
||||
|
||||
const Endpoint13_0 = (raw: RawClient["server.event"]) => () =>
|
||||
Stream.unwrap(
|
||||
raw["event.subscribe"]({}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError))),
|
||||
),
|
||||
)
|
||||
|
||||
const adaptGroup13 = (raw: RawClient["server.event"]) => ({ subscribe: Endpoint13_0(raw) })
|
||||
|
||||
type Endpoint14_0Request = Parameters<RawClient["server.pty"]["pty.list"]>[0]
|
||||
type Endpoint14_0Input = { readonly location?: Endpoint14_0Request["query"]["location"] }
|
||||
const Endpoint14_0 = (raw: RawClient["server.pty"]) => (input?: Endpoint14_0Input) =>
|
||||
raw["pty.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint14_1Request = Parameters<RawClient["server.pty"]["pty.create"]>[0]
|
||||
type Endpoint14_1Input = {
|
||||
readonly location?: Endpoint14_1Request["query"]["location"]
|
||||
readonly command?: Endpoint14_1Request["payload"]["command"]
|
||||
readonly args?: Endpoint14_1Request["payload"]["args"]
|
||||
readonly cwd?: Endpoint14_1Request["payload"]["cwd"]
|
||||
readonly title?: Endpoint14_1Request["payload"]["title"]
|
||||
readonly env?: Endpoint14_1Request["payload"]["env"]
|
||||
}
|
||||
const Endpoint14_1 = (raw: RawClient["server.pty"]) => (input?: Endpoint14_1Input) =>
|
||||
raw["pty.create"]({
|
||||
query: { location: input?.["location"] },
|
||||
payload: {
|
||||
command: input?.["command"],
|
||||
args: input?.["args"],
|
||||
cwd: input?.["cwd"],
|
||||
title: input?.["title"],
|
||||
env: input?.["env"],
|
||||
},
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint14_2Request = Parameters<RawClient["server.pty"]["pty.get"]>[0]
|
||||
type Endpoint14_2Input = {
|
||||
readonly ptyID: Endpoint14_2Request["params"]["ptyID"]
|
||||
readonly location?: Endpoint14_2Request["query"]["location"]
|
||||
}
|
||||
const Endpoint14_2 = (raw: RawClient["server.pty"]) => (input: Endpoint14_2Input) =>
|
||||
raw["pty.get"]({ params: { ptyID: input["ptyID"] }, query: { location: input["location"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
)
|
||||
|
||||
type Endpoint14_3Request = Parameters<RawClient["server.pty"]["pty.update"]>[0]
|
||||
type Endpoint14_3Input = {
|
||||
readonly ptyID: Endpoint14_3Request["params"]["ptyID"]
|
||||
readonly location?: Endpoint14_3Request["query"]["location"]
|
||||
readonly title?: Endpoint14_3Request["payload"]["title"]
|
||||
readonly size?: Endpoint14_3Request["payload"]["size"]
|
||||
}
|
||||
const Endpoint14_3 = (raw: RawClient["server.pty"]) => (input: Endpoint14_3Input) =>
|
||||
raw["pty.update"]({
|
||||
params: { ptyID: input["ptyID"] },
|
||||
query: { location: input["location"] },
|
||||
payload: { title: input["title"], size: input["size"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint14_4Request = Parameters<RawClient["server.pty"]["pty.remove"]>[0]
|
||||
type Endpoint14_4Input = {
|
||||
readonly ptyID: Endpoint14_4Request["params"]["ptyID"]
|
||||
readonly location?: Endpoint14_4Request["query"]["location"]
|
||||
}
|
||||
const Endpoint14_4 = (raw: RawClient["server.pty"]) => (input: Endpoint14_4Input) =>
|
||||
raw["pty.remove"]({ params: { ptyID: input["ptyID"] }, query: { location: input["location"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
)
|
||||
|
||||
const adaptGroup14 = (raw: RawClient["server.pty"]) => ({
|
||||
list: Endpoint14_0(raw),
|
||||
create: Endpoint14_1(raw),
|
||||
get: Endpoint14_2(raw),
|
||||
update: Endpoint14_3(raw),
|
||||
remove: Endpoint14_4(raw),
|
||||
})
|
||||
|
||||
type Endpoint15_0Request = Parameters<RawClient["server.question"]["question.request.list"]>[0]
|
||||
type Endpoint15_0Input = { readonly location?: Endpoint15_0Request["query"]["location"] }
|
||||
const Endpoint15_0 = (raw: RawClient["server.question"]) => (input?: Endpoint15_0Input) =>
|
||||
raw["question.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint15_1Request = Parameters<RawClient["server.question"]["session.question.list"]>[0]
|
||||
type Endpoint15_1Input = { readonly sessionID: Endpoint15_1Request["params"]["sessionID"] }
|
||||
const Endpoint15_1 = (raw: RawClient["server.question"]) => (input: Endpoint15_1Input) =>
|
||||
raw["session.question.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint15_2Request = Parameters<RawClient["server.question"]["session.question.reply"]>[0]
|
||||
type Endpoint15_2Input = {
|
||||
readonly sessionID: Endpoint15_2Request["params"]["sessionID"]
|
||||
readonly requestID: Endpoint15_2Request["params"]["requestID"]
|
||||
readonly answers: Endpoint15_2Request["payload"]["answers"]
|
||||
}
|
||||
const Endpoint15_2 = (raw: RawClient["server.question"]) => (input: Endpoint15_2Input) =>
|
||||
raw["session.question.reply"]({
|
||||
params: { sessionID: input["sessionID"], requestID: input["requestID"] },
|
||||
payload: { answers: input["answers"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint15_3Request = Parameters<RawClient["server.question"]["session.question.reject"]>[0]
|
||||
type Endpoint15_3Input = {
|
||||
readonly sessionID: Endpoint15_3Request["params"]["sessionID"]
|
||||
readonly requestID: Endpoint15_3Request["params"]["requestID"]
|
||||
}
|
||||
const Endpoint15_3 = (raw: RawClient["server.question"]) => (input: Endpoint15_3Input) =>
|
||||
raw["session.question.reject"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
)
|
||||
|
||||
const adaptGroup15 = (raw: RawClient["server.question"]) => ({
|
||||
listRequests: Endpoint15_0(raw),
|
||||
list: Endpoint15_1(raw),
|
||||
reply: Endpoint15_2(raw),
|
||||
reject: Endpoint15_3(raw),
|
||||
})
|
||||
|
||||
type Endpoint16_0Request = Parameters<RawClient["server.reference"]["reference.list"]>[0]
|
||||
type Endpoint16_0Input = { readonly location?: Endpoint16_0Request["query"]["location"] }
|
||||
const Endpoint16_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint16_0Input) =>
|
||||
raw["reference.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
const adaptGroup16 = (raw: RawClient["server.reference"]) => ({ list: Endpoint16_0(raw) })
|
||||
|
||||
type Endpoint17_0Request = Parameters<RawClient["server.projectCopy"]["projectCopy.create"]>[0]
|
||||
type Endpoint17_0Input = {
|
||||
readonly projectID: Endpoint17_0Request["params"]["projectID"]
|
||||
readonly location?: Endpoint17_0Request["query"]["location"]
|
||||
readonly strategy: Endpoint17_0Request["payload"]["strategy"]
|
||||
readonly directory: Endpoint17_0Request["payload"]["directory"]
|
||||
readonly name?: Endpoint17_0Request["payload"]["name"]
|
||||
}
|
||||
const Endpoint17_0 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint17_0Input) =>
|
||||
raw["projectCopy.create"]({
|
||||
params: { projectID: input["projectID"] },
|
||||
query: { location: input["location"] },
|
||||
payload: { strategy: input["strategy"], directory: input["directory"], name: input["name"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint17_1Request = Parameters<RawClient["server.projectCopy"]["projectCopy.remove"]>[0]
|
||||
type Endpoint17_1Input = {
|
||||
readonly projectID: Endpoint17_1Request["params"]["projectID"]
|
||||
readonly location?: Endpoint17_1Request["query"]["location"]
|
||||
readonly directory: Endpoint17_1Request["payload"]["directory"]
|
||||
readonly force: Endpoint17_1Request["payload"]["force"]
|
||||
}
|
||||
const Endpoint17_1 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint17_1Input) =>
|
||||
raw["projectCopy.remove"]({
|
||||
params: { projectID: input["projectID"] },
|
||||
query: { location: input["location"] },
|
||||
payload: { directory: input["directory"], force: input["force"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint17_2Request = Parameters<RawClient["server.projectCopy"]["projectCopy.refresh"]>[0]
|
||||
type Endpoint17_2Input = {
|
||||
readonly projectID: Endpoint17_2Request["params"]["projectID"]
|
||||
readonly location?: Endpoint17_2Request["query"]["location"]
|
||||
}
|
||||
const Endpoint17_2 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint17_2Input) =>
|
||||
raw["projectCopy.refresh"]({
|
||||
params: { projectID: input["projectID"] },
|
||||
query: { location: input["location"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
const adaptGroup17 = (raw: RawClient["server.projectCopy"]) => ({
|
||||
create: Endpoint17_0(raw),
|
||||
remove: Endpoint17_1(raw),
|
||||
refresh: Endpoint17_2(raw),
|
||||
})
|
||||
|
||||
const adaptClient = (raw: RawClient) => ({
|
||||
health: adaptGroup0(raw["server.health"]),
|
||||
location: adaptGroup1(raw["server.location"]),
|
||||
agents: adaptGroup2(raw["server.agent"]),
|
||||
sessions: adaptGroup3(raw["server.session"]),
|
||||
messages: adaptGroup4(raw["server.message"]),
|
||||
models: adaptGroup5(raw["server.model"]),
|
||||
providers: adaptGroup6(raw["server.provider"]),
|
||||
integrations: adaptGroup7(raw["server.integration"]),
|
||||
credentials: adaptGroup8(raw["server.credential"]),
|
||||
permissions: adaptGroup9(raw["server.permission"]),
|
||||
files: adaptGroup10(raw["server.fs"]),
|
||||
commands: adaptGroup11(raw["server.command"]),
|
||||
skills: adaptGroup12(raw["server.skill"]),
|
||||
events: adaptGroup13(raw["server.event"]),
|
||||
ptys: adaptGroup14(raw["server.pty"]),
|
||||
questions: adaptGroup15(raw["server.question"]),
|
||||
references: adaptGroup16(raw["server.reference"]),
|
||||
projectCopies: adaptGroup17(raw["server.projectCopy"]),
|
||||
})
|
||||
|
||||
export const make = (options?: { readonly baseUrl?: URL | string }) =>
|
||||
HttpApiClient.make(Api, options).pipe(Effect.map(adaptClient))
|
||||
HttpApiClient.make(ClientApi, options).pipe(Effect.map(adaptClient))
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type {
|
||||
HealthGetOutput,
|
||||
LocationGetInput,
|
||||
LocationGetOutput,
|
||||
AgentsListInput,
|
||||
AgentsListOutput,
|
||||
SessionsListInput,
|
||||
SessionsListOutput,
|
||||
SessionsCreateInput,
|
||||
@@ -10,6 +15,8 @@ import type {
|
||||
SessionsSwitchAgentOutput,
|
||||
SessionsSwitchModelInput,
|
||||
SessionsSwitchModelOutput,
|
||||
SessionsRenameInput,
|
||||
SessionsRenameOutput,
|
||||
SessionsPromptInput,
|
||||
SessionsPromptOutput,
|
||||
SessionsCompactInput,
|
||||
@@ -24,12 +31,89 @@ import type {
|
||||
SessionsCommitOutput,
|
||||
SessionsContextInput,
|
||||
SessionsContextOutput,
|
||||
SessionsHistoryInput,
|
||||
SessionsHistoryOutput,
|
||||
SessionsEventsInput,
|
||||
SessionsEventsOutput,
|
||||
SessionsInterruptInput,
|
||||
SessionsInterruptOutput,
|
||||
SessionsMessageInput,
|
||||
SessionsMessageOutput,
|
||||
MessagesListInput,
|
||||
MessagesListOutput,
|
||||
ModelsListInput,
|
||||
ModelsListOutput,
|
||||
ProvidersListInput,
|
||||
ProvidersListOutput,
|
||||
ProvidersGetInput,
|
||||
ProvidersGetOutput,
|
||||
IntegrationsListInput,
|
||||
IntegrationsListOutput,
|
||||
IntegrationsGetInput,
|
||||
IntegrationsGetOutput,
|
||||
IntegrationsConnectKeyInput,
|
||||
IntegrationsConnectKeyOutput,
|
||||
IntegrationsConnectOauthInput,
|
||||
IntegrationsConnectOauthOutput,
|
||||
IntegrationsAttemptStatusInput,
|
||||
IntegrationsAttemptStatusOutput,
|
||||
IntegrationsAttemptCompleteInput,
|
||||
IntegrationsAttemptCompleteOutput,
|
||||
IntegrationsAttemptCancelInput,
|
||||
IntegrationsAttemptCancelOutput,
|
||||
CredentialsUpdateInput,
|
||||
CredentialsUpdateOutput,
|
||||
CredentialsRemoveInput,
|
||||
CredentialsRemoveOutput,
|
||||
PermissionsListRequestsInput,
|
||||
PermissionsListRequestsOutput,
|
||||
PermissionsListSavedInput,
|
||||
PermissionsListSavedOutput,
|
||||
PermissionsRemoveSavedInput,
|
||||
PermissionsRemoveSavedOutput,
|
||||
PermissionsCreateInput,
|
||||
PermissionsCreateOutput,
|
||||
PermissionsListInput,
|
||||
PermissionsListOutput,
|
||||
PermissionsGetInput,
|
||||
PermissionsGetOutput,
|
||||
PermissionsReplyInput,
|
||||
PermissionsReplyOutput,
|
||||
FilesListInput,
|
||||
FilesListOutput,
|
||||
FilesFindInput,
|
||||
FilesFindOutput,
|
||||
CommandsListInput,
|
||||
CommandsListOutput,
|
||||
SkillsListInput,
|
||||
SkillsListOutput,
|
||||
EventsSubscribeOutput,
|
||||
PtysListInput,
|
||||
PtysListOutput,
|
||||
PtysCreateInput,
|
||||
PtysCreateOutput,
|
||||
PtysGetInput,
|
||||
PtysGetOutput,
|
||||
PtysUpdateInput,
|
||||
PtysUpdateOutput,
|
||||
PtysRemoveInput,
|
||||
PtysRemoveOutput,
|
||||
QuestionsListRequestsInput,
|
||||
QuestionsListRequestsOutput,
|
||||
QuestionsListInput,
|
||||
QuestionsListOutput,
|
||||
QuestionsReplyInput,
|
||||
QuestionsReplyOutput,
|
||||
QuestionsRejectInput,
|
||||
QuestionsRejectOutput,
|
||||
ReferencesListInput,
|
||||
ReferencesListOutput,
|
||||
ProjectCopiesCreateInput,
|
||||
ProjectCopiesCreateOutput,
|
||||
ProjectCopiesRemoveInput,
|
||||
ProjectCopiesRemoveOutput,
|
||||
ProjectCopiesRefreshInput,
|
||||
ProjectCopiesRefreshOutput,
|
||||
} from "./types"
|
||||
import { ClientError } from "./client-error"
|
||||
|
||||
@@ -165,6 +249,41 @@ export function make(options: ClientOptions) {
|
||||
})
|
||||
|
||||
return {
|
||||
health: {
|
||||
get: (requestOptions?: RequestOptions) =>
|
||||
request<HealthGetOutput>(
|
||||
{ method: "GET", path: `/api/health`, successStatus: 200, declaredStatuses: [401, 400], empty: false },
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
location: {
|
||||
get: (input?: LocationGetInput, requestOptions?: RequestOptions) =>
|
||||
request<LocationGetOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/location`,
|
||||
query: { location: input?.["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
agents: {
|
||||
list: (input?: AgentsListInput, requestOptions?: RequestOptions) =>
|
||||
request<AgentsListOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/agent`,
|
||||
query: { location: input?.["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
sessions: {
|
||||
list: (input?: SessionsListInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionsListOutput>(
|
||||
@@ -172,14 +291,14 @@ export function make(options: ClientOptions) {
|
||||
method: "GET",
|
||||
path: `/api/session`,
|
||||
query: {
|
||||
workspace: input?.workspace,
|
||||
limit: input?.limit,
|
||||
order: input?.order,
|
||||
search: input?.search,
|
||||
directory: input?.directory,
|
||||
project: input?.project,
|
||||
subpath: input?.subpath,
|
||||
cursor: input?.cursor,
|
||||
workspace: input?.["workspace"],
|
||||
limit: input?.["limit"],
|
||||
order: input?.["order"],
|
||||
search: input?.["search"],
|
||||
directory: input?.["directory"],
|
||||
project: input?.["project"],
|
||||
subpath: input?.["subpath"],
|
||||
cursor: input?.["cursor"],
|
||||
},
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401],
|
||||
@@ -192,7 +311,12 @@ export function make(options: ClientOptions) {
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session`,
|
||||
body: { id: input?.id, agent: input?.agent, model: input?.model, location: input?.location },
|
||||
body: {
|
||||
id: input?.["id"],
|
||||
agent: input?.["agent"],
|
||||
model: input?.["model"],
|
||||
location: input?.["location"],
|
||||
},
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
@@ -226,7 +350,7 @@ export function make(options: ClientOptions) {
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/agent`,
|
||||
body: { agent: input.agent },
|
||||
body: { agent: input["agent"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
empty: true,
|
||||
@@ -238,7 +362,19 @@ export function make(options: ClientOptions) {
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/model`,
|
||||
body: { model: input.model },
|
||||
body: { model: input["model"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
rename: (input: SessionsRenameInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionsRenameOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/rename`,
|
||||
body: { title: input["title"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
empty: true,
|
||||
@@ -250,7 +386,7 @@ export function make(options: ClientOptions) {
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/prompt`,
|
||||
body: { id: input.id, prompt: input.prompt, delivery: input.delivery, resume: input.resume },
|
||||
body: { id: input["id"], prompt: input["prompt"], delivery: input["delivery"], resume: input["resume"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [409, 404, 400, 401],
|
||||
empty: false,
|
||||
@@ -284,9 +420,9 @@ export function make(options: ClientOptions) {
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/stage`,
|
||||
body: { messageID: input.messageID, files: input.files },
|
||||
body: { messageID: input["messageID"], files: input["files"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 500, 400, 401],
|
||||
declaredStatuses: [404, 409, 500, 400, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
@@ -297,7 +433,7 @@ export function make(options: ClientOptions) {
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/clear`,
|
||||
successStatus: 204,
|
||||
declaredStatuses: [404, 500, 400, 401],
|
||||
declaredStatuses: [404, 409, 500, 400, 401],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
@@ -308,7 +444,7 @@ export function make(options: ClientOptions) {
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/commit`,
|
||||
successStatus: 204,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
declaredStatuses: [404, 409, 400, 401],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
@@ -324,12 +460,24 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
history: (input: SessionsHistoryInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionsHistoryOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/history`,
|
||||
query: { limit: input["limit"], after: input["after"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
events: (input: SessionsEventsInput, requestOptions?: RequestOptions): AsyncIterable<SessionsEventsOutput> =>
|
||||
sse<SessionsEventsOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/event`,
|
||||
query: { after: input.after },
|
||||
query: { after: input["after"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
empty: false,
|
||||
@@ -359,6 +507,500 @@ export function make(options: ClientOptions) {
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
},
|
||||
messages: {
|
||||
list: (input: MessagesListInput, requestOptions?: RequestOptions) =>
|
||||
request<MessagesListOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/message`,
|
||||
query: { limit: input["limit"], order: input["order"], cursor: input["cursor"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 404, 500, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
models: {
|
||||
list: (input?: ModelsListInput, requestOptions?: RequestOptions) =>
|
||||
request<ModelsListOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/model`,
|
||||
query: { location: input?.["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [503, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
providers: {
|
||||
list: (input?: ProvidersListInput, requestOptions?: RequestOptions) =>
|
||||
request<ProvidersListOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/provider`,
|
||||
query: { location: input?.["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [503, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
get: (input: ProvidersGetInput, requestOptions?: RequestOptions) =>
|
||||
request<ProvidersGetOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/provider/${encodeURIComponent(input.providerID)}`,
|
||||
query: { location: input["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 503, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
integrations: {
|
||||
list: (input?: IntegrationsListInput, requestOptions?: RequestOptions) =>
|
||||
request<IntegrationsListOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/integration`,
|
||||
query: { location: input?.["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
get: (input: IntegrationsGetInput, requestOptions?: RequestOptions) =>
|
||||
request<IntegrationsGetOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/integration/${encodeURIComponent(input.integrationID)}`,
|
||||
query: { location: input["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
connectKey: (input: IntegrationsConnectKeyInput, requestOptions?: RequestOptions) =>
|
||||
request<IntegrationsConnectKeyOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/key`,
|
||||
query: { location: input["location"] },
|
||||
body: { key: input["key"], label: input["label"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [400, 401],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
connectOauth: (input: IntegrationsConnectOauthInput, requestOptions?: RequestOptions) =>
|
||||
request<IntegrationsConnectOauthOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/oauth`,
|
||||
query: { location: input["location"] },
|
||||
body: { methodID: input["methodID"], inputs: input["inputs"], label: input["label"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
attemptStatus: (input: IntegrationsAttemptStatusInput, requestOptions?: RequestOptions) =>
|
||||
request<IntegrationsAttemptStatusOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`,
|
||||
query: { location: input["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
attemptComplete: (input: IntegrationsAttemptCompleteInput, requestOptions?: RequestOptions) =>
|
||||
request<IntegrationsAttemptCompleteOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}/complete`,
|
||||
query: { location: input["location"] },
|
||||
body: { code: input["code"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [400, 401],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
attemptCancel: (input: IntegrationsAttemptCancelInput, requestOptions?: RequestOptions) =>
|
||||
request<IntegrationsAttemptCancelOutput>(
|
||||
{
|
||||
method: "DELETE",
|
||||
path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`,
|
||||
query: { location: input["location"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
credentials: {
|
||||
update: (input: CredentialsUpdateInput, requestOptions?: RequestOptions) =>
|
||||
request<CredentialsUpdateOutput>(
|
||||
{
|
||||
method: "PATCH",
|
||||
path: `/api/credential/${encodeURIComponent(input.credentialID)}`,
|
||||
query: { location: input["location"] },
|
||||
body: { label: input["label"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
remove: (input: CredentialsRemoveInput, requestOptions?: RequestOptions) =>
|
||||
request<CredentialsRemoveOutput>(
|
||||
{
|
||||
method: "DELETE",
|
||||
path: `/api/credential/${encodeURIComponent(input.credentialID)}`,
|
||||
query: { location: input["location"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
permissions: {
|
||||
listRequests: (input?: PermissionsListRequestsInput, requestOptions?: RequestOptions) =>
|
||||
request<PermissionsListRequestsOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/permission/request`,
|
||||
query: { location: input?.["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
listSaved: (input?: PermissionsListSavedInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: PermissionsListSavedOutput }>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/permission/saved`,
|
||||
query: { projectID: input?.["projectID"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
removeSaved: (input: PermissionsRemoveSavedInput, requestOptions?: RequestOptions) =>
|
||||
request<PermissionsRemoveSavedOutput>(
|
||||
{
|
||||
method: "DELETE",
|
||||
path: `/api/permission/saved/${encodeURIComponent(input.id)}`,
|
||||
successStatus: 204,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
create: (input: PermissionsCreateInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: PermissionsCreateOutput }>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission`,
|
||||
body: {
|
||||
id: input["id"],
|
||||
action: input["action"],
|
||||
resources: input["resources"],
|
||||
save: input["save"],
|
||||
metadata: input["metadata"],
|
||||
source: input["source"],
|
||||
agent: input["agent"],
|
||||
},
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
list: (input: PermissionsListInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: PermissionsListOutput }>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
get: (input: PermissionsGetInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: PermissionsGetOutput }>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission/${encodeURIComponent(input.requestID)}`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
reply: (input: PermissionsReplyInput, requestOptions?: RequestOptions) =>
|
||||
request<PermissionsReplyOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission/${encodeURIComponent(input.requestID)}/reply`,
|
||||
body: { reply: input["reply"], message: input["message"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
files: {
|
||||
list: (input?: FilesListInput, requestOptions?: RequestOptions) =>
|
||||
request<FilesListOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/fs/list`,
|
||||
query: { location: input?.["location"], path: input?.["path"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
find: (input: FilesFindInput, requestOptions?: RequestOptions) =>
|
||||
request<FilesFindOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/fs/find`,
|
||||
query: { location: input["location"], query: input["query"], type: input["type"], limit: input["limit"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
commands: {
|
||||
list: (input?: CommandsListInput, requestOptions?: RequestOptions) =>
|
||||
request<CommandsListOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/command`,
|
||||
query: { location: input?.["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
skills: {
|
||||
list: (input?: SkillsListInput, requestOptions?: RequestOptions) =>
|
||||
request<SkillsListOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/skill`,
|
||||
query: { location: input?.["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
events: {
|
||||
subscribe: (requestOptions?: RequestOptions): AsyncIterable<EventsSubscribeOutput> =>
|
||||
sse<EventsSubscribeOutput>(
|
||||
{ method: "GET", path: `/api/event`, successStatus: 200, declaredStatuses: [401, 400], empty: false },
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
ptys: {
|
||||
list: (input?: PtysListInput, requestOptions?: RequestOptions) =>
|
||||
request<PtysListOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/pty`,
|
||||
query: { location: input?.["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
create: (input?: PtysCreateInput, requestOptions?: RequestOptions) =>
|
||||
request<PtysCreateOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/pty`,
|
||||
query: { location: input?.["location"] },
|
||||
body: {
|
||||
command: input?.["command"],
|
||||
args: input?.["args"],
|
||||
cwd: input?.["cwd"],
|
||||
title: input?.["title"],
|
||||
env: input?.["env"],
|
||||
},
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
get: (input: PtysGetInput, requestOptions?: RequestOptions) =>
|
||||
request<PtysGetOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/pty/${encodeURIComponent(input.ptyID)}`,
|
||||
query: { location: input["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
update: (input: PtysUpdateInput, requestOptions?: RequestOptions) =>
|
||||
request<PtysUpdateOutput>(
|
||||
{
|
||||
method: "PUT",
|
||||
path: `/api/pty/${encodeURIComponent(input.ptyID)}`,
|
||||
query: { location: input["location"] },
|
||||
body: { title: input["title"], size: input["size"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
remove: (input: PtysRemoveInput, requestOptions?: RequestOptions) =>
|
||||
request<PtysRemoveOutput>(
|
||||
{
|
||||
method: "DELETE",
|
||||
path: `/api/pty/${encodeURIComponent(input.ptyID)}`,
|
||||
query: { location: input["location"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [404, 401, 400],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
questions: {
|
||||
listRequests: (input?: QuestionsListRequestsInput, requestOptions?: RequestOptions) =>
|
||||
request<QuestionsListRequestsOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/question/request`,
|
||||
query: { location: input?.["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
list: (input: QuestionsListInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: QuestionsListOutput }>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/question`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
reply: (input: QuestionsReplyInput, requestOptions?: RequestOptions) =>
|
||||
request<QuestionsReplyOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/question/${encodeURIComponent(input.requestID)}/reply`,
|
||||
body: { answers: input["answers"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
reject: (input: QuestionsRejectInput, requestOptions?: RequestOptions) =>
|
||||
request<QuestionsRejectOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/question/${encodeURIComponent(input.requestID)}/reject`,
|
||||
successStatus: 204,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
references: {
|
||||
list: (input?: ReferencesListInput, requestOptions?: RequestOptions) =>
|
||||
request<ReferencesListOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/reference`,
|
||||
query: { location: input?.["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
projectCopies: {
|
||||
create: (input: ProjectCopiesCreateInput, requestOptions?: RequestOptions) =>
|
||||
request<ProjectCopiesCreateOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/experimental/project/${encodeURIComponent(input.projectID)}/copy`,
|
||||
query: { location: input["location"] },
|
||||
body: { strategy: input["strategy"], directory: input["directory"], name: input["name"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
remove: (input: ProjectCopiesRemoveInput, requestOptions?: RequestOptions) =>
|
||||
request<ProjectCopiesRemoveOutput>(
|
||||
{
|
||||
method: "DELETE",
|
||||
path: `/experimental/project/${encodeURIComponent(input.projectID)}/copy`,
|
||||
query: { location: input["location"] },
|
||||
body: { directory: input["directory"], force: input["force"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [400, 401],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
refresh: (input: ProjectCopiesRefreshInput, requestOptions?: RequestOptions) =>
|
||||
request<ProjectCopiesRefreshOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/experimental/project/${encodeURIComponent(input.projectID)}/copy/refresh`,
|
||||
query: { location: input["location"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [400, 401],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { OpenCodeEventEncoded } from "@opencode-ai/protocol/groups/event"
|
||||
|
||||
export type JsonValue =
|
||||
| null
|
||||
| boolean
|
||||
@@ -6,9 +8,9 @@ export type JsonValue =
|
||||
| ReadonlyArray<JsonValue>
|
||||
| { readonly [key: string]: JsonValue }
|
||||
|
||||
export type InvalidCursorError = { readonly _tag: "InvalidCursorError"; readonly message: string }
|
||||
export const isInvalidCursorError = (value: unknown): value is InvalidCursorError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value._tag === "InvalidCursorError"
|
||||
export type UnauthorizedError = { readonly _tag: "UnauthorizedError"; readonly message: string }
|
||||
export const isUnauthorizedError = (value: unknown): value is UnauthorizedError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "UnauthorizedError"
|
||||
|
||||
export type InvalidRequestError = {
|
||||
readonly _tag: "InvalidRequestError"
|
||||
@@ -17,11 +19,11 @@ export type InvalidRequestError = {
|
||||
readonly field?: string | undefined
|
||||
}
|
||||
export const isInvalidRequestError = (value: unknown): value is InvalidRequestError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value._tag === "InvalidRequestError"
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidRequestError"
|
||||
|
||||
export type UnauthorizedError = { readonly _tag: "UnauthorizedError"; readonly message: string }
|
||||
export const isUnauthorizedError = (value: unknown): value is UnauthorizedError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value._tag === "UnauthorizedError"
|
||||
export type InvalidCursorError = { readonly _tag: "InvalidCursorError"; readonly message: string }
|
||||
export const isInvalidCursorError = (value: unknown): value is InvalidCursorError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidCursorError"
|
||||
|
||||
export type SessionNotFoundError = {
|
||||
readonly _tag: "SessionNotFoundError"
|
||||
@@ -29,7 +31,7 @@ export type SessionNotFoundError = {
|
||||
readonly message: string
|
||||
}
|
||||
export const isSessionNotFoundError = (value: unknown): value is SessionNotFoundError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value._tag === "SessionNotFoundError"
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SessionNotFoundError"
|
||||
|
||||
export type ConflictError = {
|
||||
readonly _tag: "ConflictError"
|
||||
@@ -37,7 +39,7 @@ export type ConflictError = {
|
||||
readonly resource?: string | undefined
|
||||
}
|
||||
export const isConflictError = (value: unknown): value is ConflictError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value._tag === "ConflictError"
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ConflictError"
|
||||
|
||||
export type ServiceUnavailableError = {
|
||||
readonly _tag: "ServiceUnavailableError"
|
||||
@@ -45,7 +47,7 @@ export type ServiceUnavailableError = {
|
||||
readonly service?: string | undefined
|
||||
}
|
||||
export const isServiceUnavailableError = (value: unknown): value is ServiceUnavailableError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value._tag === "ServiceUnavailableError"
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ServiceUnavailableError"
|
||||
|
||||
export type MessageNotFoundError = {
|
||||
readonly _tag: "MessageNotFoundError"
|
||||
@@ -54,7 +56,15 @@ export type MessageNotFoundError = {
|
||||
readonly message: string
|
||||
}
|
||||
export const isMessageNotFoundError = (value: unknown): value is MessageNotFoundError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value._tag === "MessageNotFoundError"
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "MessageNotFoundError"
|
||||
|
||||
export type SessionBusyError = {
|
||||
readonly _tag: "SessionBusyError"
|
||||
readonly sessionID: string
|
||||
readonly message: string
|
||||
}
|
||||
export const isSessionBusyError = (value: unknown): value is SessionBusyError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SessionBusyError"
|
||||
|
||||
export type UnknownError = {
|
||||
readonly _tag: "UnknownError"
|
||||
@@ -62,12 +72,94 @@ export type UnknownError = {
|
||||
readonly ref?: string | undefined
|
||||
}
|
||||
export const isUnknownError = (value: unknown): value is UnknownError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value._tag === "UnknownError"
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "UnknownError"
|
||||
|
||||
export type ProviderNotFoundError = {
|
||||
readonly _tag: "ProviderNotFoundError"
|
||||
readonly providerID: string
|
||||
readonly message: string
|
||||
}
|
||||
export const isProviderNotFoundError = (value: unknown): value is ProviderNotFoundError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ProviderNotFoundError"
|
||||
|
||||
export type PermissionNotFoundError = {
|
||||
readonly _tag: "PermissionNotFoundError"
|
||||
readonly requestID: string
|
||||
readonly message: string
|
||||
}
|
||||
export const isPermissionNotFoundError = (value: unknown): value is PermissionNotFoundError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "PermissionNotFoundError"
|
||||
|
||||
export type PtyNotFoundError = { readonly _tag: "PtyNotFoundError"; readonly ptyID: string; readonly message: string }
|
||||
export const isPtyNotFoundError = (value: unknown): value is PtyNotFoundError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "PtyNotFoundError"
|
||||
|
||||
export type QuestionNotFoundError = {
|
||||
readonly _tag: "QuestionNotFoundError"
|
||||
readonly requestID: string
|
||||
readonly message: string
|
||||
}
|
||||
export const isQuestionNotFoundError = (value: unknown): value is QuestionNotFoundError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "QuestionNotFoundError"
|
||||
|
||||
export type ProjectCopyError = {
|
||||
readonly name: "ProjectCopyError"
|
||||
readonly data: { readonly message: string; readonly forceRequired?: boolean | undefined }
|
||||
}
|
||||
export const isProjectCopyError = (value: unknown): value is ProjectCopyError =>
|
||||
typeof value === "object" && value !== null && "name" in value && value["name"] === "ProjectCopyError"
|
||||
|
||||
export type HealthGetOutput = { readonly healthy: true }
|
||||
|
||||
export type LocationGetInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type LocationGetOutput = {
|
||||
readonly directory: string
|
||||
readonly workspaceID?: string
|
||||
readonly project: { readonly id: string; readonly directory: string }
|
||||
}
|
||||
|
||||
export type AgentsListInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type AgentsListOutput = {
|
||||
readonly location: {
|
||||
readonly directory: string
|
||||
readonly workspaceID?: string
|
||||
readonly project: { readonly id: string; readonly directory: string }
|
||||
}
|
||||
readonly data: ReadonlyArray<{
|
||||
readonly id: string
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||
readonly request: {
|
||||
readonly headers: { readonly [x: string]: string }
|
||||
readonly body: { readonly [x: string]: JsonValue }
|
||||
}
|
||||
readonly system?: string
|
||||
readonly description?: string
|
||||
readonly mode: "subagent" | "primary" | "all"
|
||||
readonly hidden: boolean
|
||||
readonly color?: string | "primary" | "secondary" | "accent" | "success" | "warning" | "error" | "info"
|
||||
readonly steps?: number
|
||||
readonly permissions: ReadonlyArray<{
|
||||
readonly action: string
|
||||
readonly resource: string
|
||||
readonly effect: "allow" | "deny" | "ask"
|
||||
}>
|
||||
}>
|
||||
}
|
||||
|
||||
export type SessionsListInput = {
|
||||
readonly workspace?: {
|
||||
readonly workspace?: string | undefined
|
||||
readonly limit?: string | undefined
|
||||
readonly limit?: number | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly directory?: string | undefined
|
||||
@@ -77,7 +169,7 @@ export type SessionsListInput = {
|
||||
}["workspace"]
|
||||
readonly limit?: {
|
||||
readonly workspace?: string | undefined
|
||||
readonly limit?: string | undefined
|
||||
readonly limit?: number | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly directory?: string | undefined
|
||||
@@ -87,7 +179,7 @@ export type SessionsListInput = {
|
||||
}["limit"]
|
||||
readonly order?: {
|
||||
readonly workspace?: string | undefined
|
||||
readonly limit?: string | undefined
|
||||
readonly limit?: number | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly directory?: string | undefined
|
||||
@@ -97,7 +189,7 @@ export type SessionsListInput = {
|
||||
}["order"]
|
||||
readonly search?: {
|
||||
readonly workspace?: string | undefined
|
||||
readonly limit?: string | undefined
|
||||
readonly limit?: number | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly directory?: string | undefined
|
||||
@@ -107,7 +199,7 @@ export type SessionsListInput = {
|
||||
}["search"]
|
||||
readonly directory?: {
|
||||
readonly workspace?: string | undefined
|
||||
readonly limit?: string | undefined
|
||||
readonly limit?: number | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly directory?: string | undefined
|
||||
@@ -117,7 +209,7 @@ export type SessionsListInput = {
|
||||
}["directory"]
|
||||
readonly project?: {
|
||||
readonly workspace?: string | undefined
|
||||
readonly limit?: string | undefined
|
||||
readonly limit?: number | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly directory?: string | undefined
|
||||
@@ -127,7 +219,7 @@ export type SessionsListInput = {
|
||||
}["project"]
|
||||
readonly subpath?: {
|
||||
readonly workspace?: string | undefined
|
||||
readonly limit?: string | undefined
|
||||
readonly limit?: number | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly directory?: string | undefined
|
||||
@@ -137,7 +229,7 @@ export type SessionsListInput = {
|
||||
}["subpath"]
|
||||
readonly cursor?: {
|
||||
readonly workspace?: string | undefined
|
||||
readonly limit?: string | undefined
|
||||
readonly limit?: number | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly directory?: string | undefined
|
||||
@@ -297,6 +389,13 @@ export type SessionsSwitchModelInput = {
|
||||
|
||||
export type SessionsSwitchModelOutput = void
|
||||
|
||||
export type SessionsRenameInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly title: { readonly title: string }["title"]
|
||||
}
|
||||
|
||||
export type SessionsRenameOutput = void
|
||||
|
||||
export type SessionsPromptInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly id?: {
|
||||
@@ -313,6 +412,7 @@ export type SessionsPromptInput = {
|
||||
readonly name: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly system?: string
|
||||
}
|
||||
readonly delivery?: "steer" | "queue" | null
|
||||
readonly resume?: boolean | null
|
||||
@@ -331,6 +431,7 @@ export type SessionsPromptInput = {
|
||||
readonly name: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly system?: string
|
||||
}
|
||||
readonly delivery?: "steer" | "queue" | null
|
||||
readonly resume?: boolean | null
|
||||
@@ -349,6 +450,7 @@ export type SessionsPromptInput = {
|
||||
readonly name: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly system?: string
|
||||
}
|
||||
readonly delivery?: "steer" | "queue" | null
|
||||
readonly resume?: boolean | null
|
||||
@@ -367,6 +469,7 @@ export type SessionsPromptInput = {
|
||||
readonly name: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly system?: string
|
||||
}
|
||||
readonly delivery?: "steer" | "queue" | null
|
||||
readonly resume?: boolean | null
|
||||
@@ -391,6 +494,7 @@ export type SessionsPromptOutput = {
|
||||
readonly name: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly system?: string
|
||||
}
|
||||
readonly delivery: "steer" | "queue"
|
||||
readonly timeCreated: number
|
||||
@@ -470,6 +574,7 @@ export type SessionsContextOutput = {
|
||||
readonly name: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly system?: string
|
||||
readonly type: "user"
|
||||
}
|
||||
| {
|
||||
@@ -592,9 +697,479 @@ export type SessionsContextOutput = {
|
||||
>
|
||||
}["data"]
|
||||
|
||||
export type SessionsHistoryInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly limit?: { readonly limit?: number | undefined; readonly after?: number | undefined }["limit"]
|
||||
readonly after?: { readonly limit?: number | undefined; readonly after?: number | undefined }["after"]
|
||||
}
|
||||
|
||||
export type SessionsHistoryOutput = {
|
||||
readonly data: ReadonlyArray<
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
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]: JsonValue }
|
||||
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]: JsonValue }
|
||||
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]: JsonValue }
|
||||
readonly type: "session.next.renamed"
|
||||
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 title: string }
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
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 system?: string
|
||||
}
|
||||
readonly delivery: "steer" | "queue"
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
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 system?: string
|
||||
}
|
||||
readonly delivery: "steer" | "queue"
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
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]: JsonValue }
|
||||
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]: JsonValue }
|
||||
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]: JsonValue }
|
||||
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]: JsonValue }
|
||||
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]: JsonValue }
|
||||
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<string>
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
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]: JsonValue }
|
||||
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]: JsonValue }
|
||||
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]: JsonValue }
|
||||
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]: JsonValue }
|
||||
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]: JsonValue }
|
||||
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]: JsonValue }
|
||||
readonly provider: {
|
||||
readonly executed: boolean
|
||||
readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
|
||||
}
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
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]: JsonValue }
|
||||
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]: JsonValue }
|
||||
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]: JsonValue }
|
||||
readonly content: ReadonlyArray<
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
| { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string }
|
||||
>
|
||||
readonly outputPaths?: ReadonlyArray<string>
|
||||
readonly result?: JsonValue
|
||||
readonly provider: {
|
||||
readonly executed: boolean
|
||||
readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
|
||||
}
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
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?: JsonValue
|
||||
readonly provider: {
|
||||
readonly executed: boolean
|
||||
readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
|
||||
}
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
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]: JsonValue } }
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
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]: JsonValue } }
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
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]: JsonValue }
|
||||
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]: JsonValue }
|
||||
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]: JsonValue }
|
||||
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]: JsonValue }
|
||||
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]: JsonValue }
|
||||
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 }
|
||||
}
|
||||
>
|
||||
readonly hasMore: boolean
|
||||
}
|
||||
|
||||
export type SessionsEventsInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly after?: { readonly after?: string | undefined }["after"]
|
||||
readonly after?: { readonly after?: number | undefined }["after"]
|
||||
}
|
||||
|
||||
export type SessionsEventsOutput =
|
||||
@@ -637,6 +1212,14 @@ export type SessionsEventsOutput =
|
||||
readonly subdirectory?: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.renamed"
|
||||
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 title: string }
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
@@ -660,6 +1243,7 @@ export type SessionsEventsOutput =
|
||||
readonly name: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly system?: string
|
||||
}
|
||||
readonly delivery: "steer" | "queue"
|
||||
}
|
||||
@@ -687,6 +1271,7 @@ export type SessionsEventsOutput =
|
||||
readonly name: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly system?: string
|
||||
}
|
||||
readonly delivery: "steer" | "queue"
|
||||
}
|
||||
@@ -1088,6 +1673,7 @@ export type SessionsMessageOutput = {
|
||||
readonly name: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly system?: string
|
||||
readonly type: "user"
|
||||
}
|
||||
| {
|
||||
@@ -1208,3 +1794,1057 @@ export type SessionsMessageOutput = {
|
||||
readonly time: { readonly created: number }
|
||||
}
|
||||
}["data"]
|
||||
|
||||
export type MessagesListInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly limit?: {
|
||||
readonly limit?: number | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly cursor?: string | undefined
|
||||
}["limit"]
|
||||
readonly order?: {
|
||||
readonly limit?: number | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly cursor?: string | undefined
|
||||
}["order"]
|
||||
readonly cursor?: {
|
||||
readonly limit?: number | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly cursor?: string | undefined
|
||||
}["cursor"]
|
||||
}
|
||||
|
||||
export type MessagesListOutput = {
|
||||
readonly data: ReadonlyArray<
|
||||
| {
|
||||
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 system?: 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 time?: { readonly created: number; readonly completed?: number }
|
||||
}
|
||||
| {
|
||||
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<string>
|
||||
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<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 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 }
|
||||
}
|
||||
>
|
||||
readonly cursor: { readonly previous?: string | null; readonly next?: string | null }
|
||||
}
|
||||
|
||||
export type ModelsListInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type ModelsListOutput = {
|
||||
readonly location: {
|
||||
readonly directory: string
|
||||
readonly workspaceID?: string
|
||||
readonly project: { readonly id: string; readonly directory: string }
|
||||
}
|
||||
readonly data: ReadonlyArray<{
|
||||
readonly id: string
|
||||
readonly providerID: string
|
||||
readonly family?: string
|
||||
readonly name: string
|
||||
readonly api:
|
||||
| {
|
||||
readonly id: string
|
||||
readonly type: "aisdk"
|
||||
readonly package: string
|
||||
readonly url?: string
|
||||
readonly settings?: { readonly [x: string]: JsonValue }
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly type: "native"
|
||||
readonly url?: string
|
||||
readonly settings: { readonly [x: string]: JsonValue }
|
||||
}
|
||||
readonly capabilities: {
|
||||
readonly tools: boolean
|
||||
readonly input: ReadonlyArray<string>
|
||||
readonly output: ReadonlyArray<string>
|
||||
}
|
||||
readonly request: {
|
||||
readonly headers: { readonly [x: string]: string }
|
||||
readonly body: { readonly [x: string]: JsonValue }
|
||||
readonly variant?: string
|
||||
}
|
||||
readonly variants: ReadonlyArray<{
|
||||
readonly id: string
|
||||
readonly headers: { readonly [x: string]: string }
|
||||
readonly body: { readonly [x: string]: JsonValue }
|
||||
}>
|
||||
readonly time: { readonly released: number }
|
||||
readonly cost: ReadonlyArray<{
|
||||
readonly tier?: { readonly type: "context"; readonly size: number }
|
||||
readonly input: number
|
||||
readonly output: number
|
||||
readonly cache: { readonly read: number; readonly write: number }
|
||||
}>
|
||||
readonly status: "alpha" | "beta" | "deprecated" | "active"
|
||||
readonly enabled: boolean
|
||||
readonly limit: { readonly context: number; readonly input?: number; readonly output: number }
|
||||
}>
|
||||
}
|
||||
|
||||
export type ProvidersListInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type ProvidersListOutput = {
|
||||
readonly location: {
|
||||
readonly directory: string
|
||||
readonly workspaceID?: string
|
||||
readonly project: { readonly id: string; readonly directory: string }
|
||||
}
|
||||
readonly data: ReadonlyArray<{
|
||||
readonly id: string
|
||||
readonly integrationID?: string
|
||||
readonly name: string
|
||||
readonly disabled?: boolean
|
||||
readonly api:
|
||||
| {
|
||||
readonly type: "aisdk"
|
||||
readonly package: string
|
||||
readonly url?: string
|
||||
readonly settings?: { readonly [x: string]: JsonValue }
|
||||
}
|
||||
| { readonly type: "native"; readonly url?: string; readonly settings: { readonly [x: string]: JsonValue } }
|
||||
readonly request: {
|
||||
readonly headers: { readonly [x: string]: string }
|
||||
readonly body: { readonly [x: string]: JsonValue }
|
||||
}
|
||||
}>
|
||||
}
|
||||
|
||||
export type ProvidersGetInput = {
|
||||
readonly providerID: { readonly providerID: string }["providerID"]
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type ProvidersGetOutput = {
|
||||
readonly location: {
|
||||
readonly directory: string
|
||||
readonly workspaceID?: string
|
||||
readonly project: { readonly id: string; readonly directory: string }
|
||||
}
|
||||
readonly data: {
|
||||
readonly id: string
|
||||
readonly integrationID?: string
|
||||
readonly name: string
|
||||
readonly disabled?: boolean
|
||||
readonly api:
|
||||
| {
|
||||
readonly type: "aisdk"
|
||||
readonly package: string
|
||||
readonly url?: string
|
||||
readonly settings?: { readonly [x: string]: JsonValue }
|
||||
}
|
||||
| { readonly type: "native"; readonly url?: string; readonly settings: { readonly [x: string]: JsonValue } }
|
||||
readonly request: {
|
||||
readonly headers: { readonly [x: string]: string }
|
||||
readonly body: { readonly [x: string]: JsonValue }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type IntegrationsListInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type IntegrationsListOutput = {
|
||||
readonly location: {
|
||||
readonly directory: string
|
||||
readonly workspaceID?: string
|
||||
readonly project: { readonly id: string; readonly directory: string }
|
||||
}
|
||||
readonly data: ReadonlyArray<{
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly methods: ReadonlyArray<
|
||||
| {
|
||||
readonly id: string
|
||||
readonly type: "oauth"
|
||||
readonly label: string
|
||||
readonly prompts?: ReadonlyArray<
|
||||
| {
|
||||
readonly type: "text"
|
||||
readonly key: string
|
||||
readonly message: string
|
||||
readonly placeholder?: string
|
||||
readonly when?: { readonly key: string; readonly op: "eq" | "neq"; readonly value: string }
|
||||
}
|
||||
| {
|
||||
readonly type: "select"
|
||||
readonly key: string
|
||||
readonly message: string
|
||||
readonly options: ReadonlyArray<{
|
||||
readonly label: string
|
||||
readonly value: string
|
||||
readonly hint?: string
|
||||
}>
|
||||
readonly when?: { readonly key: string; readonly op: "eq" | "neq"; readonly value: string }
|
||||
}
|
||||
>
|
||||
}
|
||||
| { readonly type: "key"; readonly label?: string }
|
||||
| { readonly type: "env"; readonly names: ReadonlyArray<string> }
|
||||
>
|
||||
readonly connections: ReadonlyArray<
|
||||
| { readonly type: "credential"; readonly id: string; readonly label: string }
|
||||
| { readonly type: "env"; readonly name: string }
|
||||
>
|
||||
}>
|
||||
}
|
||||
|
||||
export type IntegrationsGetInput = {
|
||||
readonly integrationID: { readonly integrationID: string }["integrationID"]
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type IntegrationsGetOutput = {
|
||||
readonly location: {
|
||||
readonly directory: string
|
||||
readonly workspaceID?: string
|
||||
readonly project: { readonly id: string; readonly directory: string }
|
||||
}
|
||||
readonly data: {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly methods: ReadonlyArray<
|
||||
| {
|
||||
readonly id: string
|
||||
readonly type: "oauth"
|
||||
readonly label: string
|
||||
readonly prompts?: ReadonlyArray<
|
||||
| {
|
||||
readonly type: "text"
|
||||
readonly key: string
|
||||
readonly message: string
|
||||
readonly placeholder?: string
|
||||
readonly when?: { readonly key: string; readonly op: "eq" | "neq"; readonly value: string }
|
||||
}
|
||||
| {
|
||||
readonly type: "select"
|
||||
readonly key: string
|
||||
readonly message: string
|
||||
readonly options: ReadonlyArray<{
|
||||
readonly label: string
|
||||
readonly value: string
|
||||
readonly hint?: string
|
||||
}>
|
||||
readonly when?: { readonly key: string; readonly op: "eq" | "neq"; readonly value: string }
|
||||
}
|
||||
>
|
||||
}
|
||||
| { readonly type: "key"; readonly label?: string }
|
||||
| { readonly type: "env"; readonly names: ReadonlyArray<string> }
|
||||
>
|
||||
readonly connections: ReadonlyArray<
|
||||
| { readonly type: "credential"; readonly id: string; readonly label: string }
|
||||
| { readonly type: "env"; readonly name: string }
|
||||
>
|
||||
} | null
|
||||
}
|
||||
|
||||
export type IntegrationsConnectKeyInput = {
|
||||
readonly integrationID: { readonly integrationID: string }["integrationID"]
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
readonly key: { readonly key: string; readonly label?: string | undefined }["key"]
|
||||
readonly label?: { readonly key: string; readonly label?: string | undefined }["label"]
|
||||
}
|
||||
|
||||
export type IntegrationsConnectKeyOutput = void
|
||||
|
||||
export type IntegrationsConnectOauthInput = {
|
||||
readonly integrationID: { readonly integrationID: string }["integrationID"]
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
readonly methodID: {
|
||||
readonly methodID: string
|
||||
readonly inputs: { readonly [x: string]: string }
|
||||
readonly label?: string | undefined
|
||||
}["methodID"]
|
||||
readonly inputs: {
|
||||
readonly methodID: string
|
||||
readonly inputs: { readonly [x: string]: string }
|
||||
readonly label?: string | undefined
|
||||
}["inputs"]
|
||||
readonly label?: {
|
||||
readonly methodID: string
|
||||
readonly inputs: { readonly [x: string]: string }
|
||||
readonly label?: string | undefined
|
||||
}["label"]
|
||||
}
|
||||
|
||||
export type IntegrationsConnectOauthOutput = {
|
||||
readonly location: {
|
||||
readonly directory: string
|
||||
readonly workspaceID?: string
|
||||
readonly project: { readonly id: string; readonly directory: string }
|
||||
}
|
||||
readonly data: {
|
||||
readonly attemptID: string
|
||||
readonly url: string
|
||||
readonly instructions: string
|
||||
readonly mode: "auto" | "code"
|
||||
readonly time: {
|
||||
readonly created: number | "Infinity" | "-Infinity" | "NaN"
|
||||
readonly expires: number | "Infinity" | "-Infinity" | "NaN"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type IntegrationsAttemptStatusInput = {
|
||||
readonly attemptID: { readonly attemptID: string }["attemptID"]
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type IntegrationsAttemptStatusOutput = {
|
||||
readonly location: {
|
||||
readonly directory: string
|
||||
readonly workspaceID?: string
|
||||
readonly project: { readonly id: string; readonly directory: string }
|
||||
}
|
||||
readonly data:
|
||||
| {
|
||||
readonly status: "pending"
|
||||
readonly time: {
|
||||
readonly created: number | "Infinity" | "-Infinity" | "NaN"
|
||||
readonly expires: number | "Infinity" | "-Infinity" | "NaN"
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly status: "complete"
|
||||
readonly time: {
|
||||
readonly created: number | "Infinity" | "-Infinity" | "NaN"
|
||||
readonly expires: number | "Infinity" | "-Infinity" | "NaN"
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly status: "failed"
|
||||
readonly message: string
|
||||
readonly time: {
|
||||
readonly created: number | "Infinity" | "-Infinity" | "NaN"
|
||||
readonly expires: number | "Infinity" | "-Infinity" | "NaN"
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly status: "expired"
|
||||
readonly time: {
|
||||
readonly created: number | "Infinity" | "-Infinity" | "NaN"
|
||||
readonly expires: number | "Infinity" | "-Infinity" | "NaN"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type IntegrationsAttemptCompleteInput = {
|
||||
readonly attemptID: { readonly attemptID: string }["attemptID"]
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
readonly code?: { readonly code?: string | undefined }["code"]
|
||||
}
|
||||
|
||||
export type IntegrationsAttemptCompleteOutput = void
|
||||
|
||||
export type IntegrationsAttemptCancelInput = {
|
||||
readonly attemptID: { readonly attemptID: string }["attemptID"]
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type IntegrationsAttemptCancelOutput = void
|
||||
|
||||
export type CredentialsUpdateInput = {
|
||||
readonly credentialID: { readonly credentialID: string }["credentialID"]
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
readonly label: { readonly label: string }["label"]
|
||||
}
|
||||
|
||||
export type CredentialsUpdateOutput = void
|
||||
|
||||
export type CredentialsRemoveInput = {
|
||||
readonly credentialID: { readonly credentialID: string }["credentialID"]
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type CredentialsRemoveOutput = void
|
||||
|
||||
export type PermissionsListRequestsInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type PermissionsListRequestsOutput = {
|
||||
readonly location: {
|
||||
readonly directory: string
|
||||
readonly workspaceID?: string
|
||||
readonly project: { readonly id: string; readonly directory: string }
|
||||
}
|
||||
readonly data: ReadonlyArray<{
|
||||
readonly id: string
|
||||
readonly sessionID: string
|
||||
readonly action: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
readonly save?: ReadonlyArray<string>
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
|
||||
}>
|
||||
}
|
||||
|
||||
export type PermissionsListSavedInput = {
|
||||
readonly projectID?: { readonly projectID?: string | undefined }["projectID"]
|
||||
}
|
||||
|
||||
export type PermissionsListSavedOutput = {
|
||||
readonly data: ReadonlyArray<{
|
||||
readonly id: string
|
||||
readonly projectID: string
|
||||
readonly action: string
|
||||
readonly resource: string
|
||||
}>
|
||||
}["data"]
|
||||
|
||||
export type PermissionsRemoveSavedInput = { readonly id: { readonly id: string }["id"] }
|
||||
|
||||
export type PermissionsRemoveSavedOutput = void
|
||||
|
||||
export type PermissionsCreateInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly id?: {
|
||||
readonly id?: string | null
|
||||
readonly action: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
readonly save?: ReadonlyArray<string>
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
|
||||
readonly agent?: string | null
|
||||
}["id"]
|
||||
readonly action: {
|
||||
readonly id?: string | null
|
||||
readonly action: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
readonly save?: ReadonlyArray<string>
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
|
||||
readonly agent?: string | null
|
||||
}["action"]
|
||||
readonly resources: {
|
||||
readonly id?: string | null
|
||||
readonly action: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
readonly save?: ReadonlyArray<string>
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
|
||||
readonly agent?: string | null
|
||||
}["resources"]
|
||||
readonly save?: {
|
||||
readonly id?: string | null
|
||||
readonly action: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
readonly save?: ReadonlyArray<string>
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
|
||||
readonly agent?: string | null
|
||||
}["save"]
|
||||
readonly metadata?: {
|
||||
readonly id?: string | null
|
||||
readonly action: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
readonly save?: ReadonlyArray<string>
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
|
||||
readonly agent?: string | null
|
||||
}["metadata"]
|
||||
readonly source?: {
|
||||
readonly id?: string | null
|
||||
readonly action: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
readonly save?: ReadonlyArray<string>
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
|
||||
readonly agent?: string | null
|
||||
}["source"]
|
||||
readonly agent?: {
|
||||
readonly id?: string | null
|
||||
readonly action: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
readonly save?: ReadonlyArray<string>
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
|
||||
readonly agent?: string | null
|
||||
}["agent"]
|
||||
}
|
||||
|
||||
export type PermissionsCreateOutput = {
|
||||
readonly data: { readonly id: string; readonly effect: "allow" | "deny" | "ask" }
|
||||
}["data"]
|
||||
|
||||
export type PermissionsListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||
|
||||
export type PermissionsListOutput = {
|
||||
readonly data: ReadonlyArray<{
|
||||
readonly id: string
|
||||
readonly sessionID: string
|
||||
readonly action: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
readonly save?: ReadonlyArray<string>
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
|
||||
}>
|
||||
}["data"]
|
||||
|
||||
export type PermissionsGetInput = {
|
||||
readonly sessionID: { readonly sessionID: string; readonly requestID: string }["sessionID"]
|
||||
readonly requestID: { readonly sessionID: string; readonly requestID: string }["requestID"]
|
||||
}
|
||||
|
||||
export type PermissionsGetOutput = {
|
||||
readonly data: {
|
||||
readonly id: string
|
||||
readonly sessionID: string
|
||||
readonly action: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
readonly save?: ReadonlyArray<string>
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
|
||||
}
|
||||
}["data"]
|
||||
|
||||
export type PermissionsReplyInput = {
|
||||
readonly sessionID: { readonly sessionID: string; readonly requestID: string }["sessionID"]
|
||||
readonly requestID: { readonly sessionID: string; readonly requestID: string }["requestID"]
|
||||
readonly reply: { readonly reply: "once" | "always" | "reject"; readonly message?: string | undefined }["reply"]
|
||||
readonly message?: { readonly reply: "once" | "always" | "reject"; readonly message?: string | undefined }["message"]
|
||||
}
|
||||
|
||||
export type PermissionsReplyOutput = void
|
||||
|
||||
export type FilesListInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly path?: string | undefined
|
||||
}["location"]
|
||||
readonly path?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly path?: string | undefined
|
||||
}["path"]
|
||||
}
|
||||
|
||||
export type FilesListOutput = {
|
||||
readonly location: {
|
||||
readonly directory: string
|
||||
readonly workspaceID?: string
|
||||
readonly project: { readonly id: string; readonly directory: string }
|
||||
}
|
||||
readonly data: ReadonlyArray<{ readonly path: string; readonly type: "file" | "directory" }>
|
||||
}
|
||||
|
||||
export type FilesFindInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly query: string
|
||||
readonly type?: "file" | "directory" | undefined
|
||||
readonly limit?: number | undefined
|
||||
}["location"]
|
||||
readonly query: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly query: string
|
||||
readonly type?: "file" | "directory" | undefined
|
||||
readonly limit?: number | undefined
|
||||
}["query"]
|
||||
readonly type?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly query: string
|
||||
readonly type?: "file" | "directory" | undefined
|
||||
readonly limit?: number | undefined
|
||||
}["type"]
|
||||
readonly limit?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly query: string
|
||||
readonly type?: "file" | "directory" | undefined
|
||||
readonly limit?: number | undefined
|
||||
}["limit"]
|
||||
}
|
||||
|
||||
export type FilesFindOutput = {
|
||||
readonly location: {
|
||||
readonly directory: string
|
||||
readonly workspaceID?: string
|
||||
readonly project: { readonly id: string; readonly directory: string }
|
||||
}
|
||||
readonly data: ReadonlyArray<{ readonly path: string; readonly type: "file" | "directory" }>
|
||||
}
|
||||
|
||||
export type CommandsListInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type CommandsListOutput = {
|
||||
readonly location: {
|
||||
readonly directory: string
|
||||
readonly workspaceID?: string
|
||||
readonly project: { readonly id: string; readonly directory: string }
|
||||
}
|
||||
readonly data: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly template: string
|
||||
readonly description?: string
|
||||
readonly agent?: string
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||
readonly subtask?: boolean
|
||||
}>
|
||||
}
|
||||
|
||||
export type SkillsListInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type SkillsListOutput = {
|
||||
readonly location: {
|
||||
readonly directory: string
|
||||
readonly workspaceID?: string
|
||||
readonly project: { readonly id: string; readonly directory: string }
|
||||
}
|
||||
readonly data: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly description?: string
|
||||
readonly slash?: boolean
|
||||
readonly location: string
|
||||
readonly content: string
|
||||
}>
|
||||
}
|
||||
|
||||
export type EventsSubscribeOutput = OpenCodeEventEncoded
|
||||
|
||||
export type PtysListInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type PtysListOutput = {
|
||||
readonly location: {
|
||||
readonly directory: string
|
||||
readonly workspaceID?: string
|
||||
readonly project: { readonly id: string; readonly directory: string }
|
||||
}
|
||||
readonly data: ReadonlyArray<{
|
||||
readonly id: string
|
||||
readonly title: string
|
||||
readonly command: string
|
||||
readonly args: ReadonlyArray<string>
|
||||
readonly cwd: string
|
||||
readonly status: "running" | "exited"
|
||||
readonly pid: number
|
||||
readonly exitCode?: number
|
||||
}>
|
||||
}
|
||||
|
||||
export type PtysCreateInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
readonly command?: {
|
||||
readonly command?: string
|
||||
readonly args?: ReadonlyArray<string>
|
||||
readonly cwd?: string
|
||||
readonly title?: string
|
||||
readonly env?: { readonly [x: string]: string }
|
||||
}["command"]
|
||||
readonly args?: {
|
||||
readonly command?: string
|
||||
readonly args?: ReadonlyArray<string>
|
||||
readonly cwd?: string
|
||||
readonly title?: string
|
||||
readonly env?: { readonly [x: string]: string }
|
||||
}["args"]
|
||||
readonly cwd?: {
|
||||
readonly command?: string
|
||||
readonly args?: ReadonlyArray<string>
|
||||
readonly cwd?: string
|
||||
readonly title?: string
|
||||
readonly env?: { readonly [x: string]: string }
|
||||
}["cwd"]
|
||||
readonly title?: {
|
||||
readonly command?: string
|
||||
readonly args?: ReadonlyArray<string>
|
||||
readonly cwd?: string
|
||||
readonly title?: string
|
||||
readonly env?: { readonly [x: string]: string }
|
||||
}["title"]
|
||||
readonly env?: {
|
||||
readonly command?: string
|
||||
readonly args?: ReadonlyArray<string>
|
||||
readonly cwd?: string
|
||||
readonly title?: string
|
||||
readonly env?: { readonly [x: string]: string }
|
||||
}["env"]
|
||||
}
|
||||
|
||||
export type PtysCreateOutput = {
|
||||
readonly location: {
|
||||
readonly directory: string
|
||||
readonly workspaceID?: string
|
||||
readonly project: { readonly id: string; readonly directory: string }
|
||||
}
|
||||
readonly data: {
|
||||
readonly id: string
|
||||
readonly title: string
|
||||
readonly command: string
|
||||
readonly args: ReadonlyArray<string>
|
||||
readonly cwd: string
|
||||
readonly status: "running" | "exited"
|
||||
readonly pid: number
|
||||
readonly exitCode?: number
|
||||
}
|
||||
}
|
||||
|
||||
export type PtysGetInput = {
|
||||
readonly ptyID: { readonly ptyID: string }["ptyID"]
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type PtysGetOutput = {
|
||||
readonly location: {
|
||||
readonly directory: string
|
||||
readonly workspaceID?: string
|
||||
readonly project: { readonly id: string; readonly directory: string }
|
||||
}
|
||||
readonly data: {
|
||||
readonly id: string
|
||||
readonly title: string
|
||||
readonly command: string
|
||||
readonly args: ReadonlyArray<string>
|
||||
readonly cwd: string
|
||||
readonly status: "running" | "exited"
|
||||
readonly pid: number
|
||||
readonly exitCode?: number
|
||||
}
|
||||
}
|
||||
|
||||
export type PtysUpdateInput = {
|
||||
readonly ptyID: { readonly ptyID: string }["ptyID"]
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
readonly title?: {
|
||||
readonly title?: string
|
||||
readonly size?: { readonly rows: number; readonly cols: number }
|
||||
}["title"]
|
||||
readonly size?: { readonly title?: string; readonly size?: { readonly rows: number; readonly cols: number } }["size"]
|
||||
}
|
||||
|
||||
export type PtysUpdateOutput = {
|
||||
readonly location: {
|
||||
readonly directory: string
|
||||
readonly workspaceID?: string
|
||||
readonly project: { readonly id: string; readonly directory: string }
|
||||
}
|
||||
readonly data: {
|
||||
readonly id: string
|
||||
readonly title: string
|
||||
readonly command: string
|
||||
readonly args: ReadonlyArray<string>
|
||||
readonly cwd: string
|
||||
readonly status: "running" | "exited"
|
||||
readonly pid: number
|
||||
readonly exitCode?: number
|
||||
}
|
||||
}
|
||||
|
||||
export type PtysRemoveInput = {
|
||||
readonly ptyID: { readonly ptyID: string }["ptyID"]
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type PtysRemoveOutput = void
|
||||
|
||||
export type QuestionsListRequestsInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type QuestionsListRequestsOutput = {
|
||||
readonly location: {
|
||||
readonly directory: string
|
||||
readonly workspaceID?: string
|
||||
readonly project: { readonly id: string; readonly directory: string }
|
||||
}
|
||||
readonly data: ReadonlyArray<{
|
||||
readonly id: string
|
||||
readonly sessionID: string
|
||||
readonly questions: ReadonlyArray<{
|
||||
readonly question: string
|
||||
readonly header: string
|
||||
readonly options: ReadonlyArray<{ readonly label: string; readonly description: string }>
|
||||
readonly multiple?: boolean
|
||||
readonly custom?: boolean
|
||||
}>
|
||||
readonly tool?: { readonly messageID: string; readonly callID: string }
|
||||
}>
|
||||
}
|
||||
|
||||
export type QuestionsListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||
|
||||
export type QuestionsListOutput = {
|
||||
readonly data: ReadonlyArray<{
|
||||
readonly id: string
|
||||
readonly sessionID: string
|
||||
readonly questions: ReadonlyArray<{
|
||||
readonly question: string
|
||||
readonly header: string
|
||||
readonly options: ReadonlyArray<{ readonly label: string; readonly description: string }>
|
||||
readonly multiple?: boolean
|
||||
readonly custom?: boolean
|
||||
}>
|
||||
readonly tool?: { readonly messageID: string; readonly callID: string }
|
||||
}>
|
||||
}["data"]
|
||||
|
||||
export type QuestionsReplyInput = {
|
||||
readonly sessionID: { readonly sessionID: string; readonly requestID: string }["sessionID"]
|
||||
readonly requestID: { readonly sessionID: string; readonly requestID: string }["requestID"]
|
||||
readonly answers: { readonly answers: ReadonlyArray<ReadonlyArray<string>> }["answers"]
|
||||
}
|
||||
|
||||
export type QuestionsReplyOutput = void
|
||||
|
||||
export type QuestionsRejectInput = {
|
||||
readonly sessionID: { readonly sessionID: string; readonly requestID: string }["sessionID"]
|
||||
readonly requestID: { readonly sessionID: string; readonly requestID: string }["requestID"]
|
||||
}
|
||||
|
||||
export type QuestionsRejectOutput = void
|
||||
|
||||
export type ReferencesListInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type ReferencesListOutput = {
|
||||
readonly location: {
|
||||
readonly directory: string
|
||||
readonly workspaceID?: string
|
||||
readonly project: { readonly id: string; readonly directory: string }
|
||||
}
|
||||
readonly data: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly path: string
|
||||
readonly description?: string
|
||||
readonly hidden?: boolean
|
||||
readonly source:
|
||||
| { readonly type: "local"; readonly path: string; readonly description?: string; readonly hidden?: boolean }
|
||||
| {
|
||||
readonly type: "git"
|
||||
readonly repository: string
|
||||
readonly branch?: string
|
||||
readonly description?: string
|
||||
readonly hidden?: boolean
|
||||
}
|
||||
}>
|
||||
}
|
||||
|
||||
export type ProjectCopiesCreateInput = {
|
||||
readonly projectID: { readonly projectID: string }["projectID"]
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
readonly strategy: { readonly strategy: string; readonly directory: string; readonly name?: string }["strategy"]
|
||||
readonly directory: { readonly strategy: string; readonly directory: string; readonly name?: string }["directory"]
|
||||
readonly name?: { readonly strategy: string; readonly directory: string; readonly name?: string }["name"]
|
||||
}
|
||||
|
||||
export type ProjectCopiesCreateOutput = { readonly directory: string }
|
||||
|
||||
export type ProjectCopiesRemoveInput = {
|
||||
readonly projectID: { readonly projectID: string }["projectID"]
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
readonly directory: { readonly directory: string; readonly force: boolean }["directory"]
|
||||
readonly force: { readonly directory: string; readonly force: boolean }["force"]
|
||||
}
|
||||
|
||||
export type ProjectCopiesRemoveOutput = void
|
||||
|
||||
export type ProjectCopiesRefreshInput = {
|
||||
readonly projectID: { readonly projectID: string }["projectID"]
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type ProjectCopiesRefreshOutput = void
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./generated/index"
|
||||
export type { EventsSubscribeOutput as OpenCodeEvent } from "./generated/types"
|
||||
|
||||
@@ -19,8 +19,7 @@ import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { Api } from "@opencode-ai/server/api"
|
||||
import { compile, emitPromise } from "@opencode-ai/httpapi-codegen"
|
||||
import { HttpApi } from "effect/unstable/httpapi"
|
||||
import { SessionGroup } from "../src/contract"
|
||||
import { ClientApi, endpointNames, groupNames, omitEndpoints } from "../src/contract"
|
||||
|
||||
test("Core and Server reuse the authoritative Schema and Protocol values", () => {
|
||||
expect(AgentV2.ID).toBe(Agent.ID)
|
||||
@@ -31,17 +30,16 @@ test("Core and Server reuse the authoritative Schema and Protocol values", () =>
|
||||
expect(CoreSessionMessage.Message).toBe(SessionMessage.Message)
|
||||
expect(CorePrompt).toBe(Prompt)
|
||||
expect(Api.groups["server.session"].identifier).toBe("server.session")
|
||||
expect(SessionGroup.identifier).toBe(Api.groups["server.session"].identifier)
|
||||
expect(Object.keys(ClientApi.groups)).toEqual(Object.keys(Api.groups))
|
||||
expect(Session.ID.create()).toStartWith("ses_")
|
||||
expect(Project.ID.global).toBe("global")
|
||||
expect(Provider.ID.anthropic).toBe("anthropic")
|
||||
expect(Workspace.ID.create()).toStartWith("wrk_")
|
||||
})
|
||||
|
||||
test("client and Server Session contracts generate identically", () => {
|
||||
const options = { groupNames: { "server.session": "sessions" } }
|
||||
const server = compile(HttpApi.make("server").add(Api.groups["server.session"]), options)
|
||||
const client = compile(HttpApi.make("client").add(SessionGroup), options)
|
||||
test("client and Server contracts generate identically", () => {
|
||||
const server = compile(Api, { groupNames, endpointNames, omitEndpoints })
|
||||
const client = compile(ClientApi, { groupNames, endpointNames, omitEndpoints })
|
||||
|
||||
expect(emitPromise(client)).toEqual(emitPromise(server))
|
||||
})
|
||||
|
||||
@@ -15,7 +15,53 @@ test("sessions.get returns the decoded Effect projection", async () => {
|
||||
expect(DateTime.toEpochMillis(result.time.created)).toBe(1_717_171_717_000)
|
||||
})
|
||||
|
||||
test("events.subscribe exposes and decodes the native Effect event stream", async () => {
|
||||
const httpClient = HttpClient.make((request) =>
|
||||
Effect.succeed(
|
||||
HttpClientResponse.fromWeb(
|
||||
request,
|
||||
new Response(
|
||||
`data: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n` +
|
||||
`data: ${JSON.stringify(modelSwitchedEvent)}\n\n`,
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
const events = await Effect.gen(function* () {
|
||||
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||
return yield* client.events.subscribe().pipe(Stream.runCollect)
|
||||
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||
|
||||
expect(Array.from(events).map((event) => event.type)).toEqual(["server.connected", "session.next.model.switched"])
|
||||
const durable = events[1]
|
||||
if (durable?.type !== "session.next.model.switched") throw new Error("Expected model event")
|
||||
expect(DateTime.toEpochMillis(durable.data.timestamp)).toBe(1_717_171_717_000)
|
||||
expect(durable.durable).toEqual({ aggregateID: "ses_test", seq: 1, version: 1 })
|
||||
})
|
||||
|
||||
test("events.subscribe terminates on Effect protocol decode failures", async () => {
|
||||
const httpClient = HttpClient.make((request) =>
|
||||
Effect.succeed(
|
||||
HttpClientResponse.fromWeb(
|
||||
request,
|
||||
new Response(`data: {"type":"server.connected"}\n\n`, {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
const error = await Effect.gen(function* () {
|
||||
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||
return yield* client.events.subscribe().pipe(Stream.runCollect, Effect.flip)
|
||||
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||
|
||||
expect(error._tag).toBe("ClientError")
|
||||
})
|
||||
|
||||
test("session methods retain decoded Effect inputs and outputs", async () => {
|
||||
const historyQueries: Array<Record<string, string>> = []
|
||||
let historyPage = 0
|
||||
const httpClient = HttpClient.make((request) => {
|
||||
const url = request.url
|
||||
if (url.includes("/event")) {
|
||||
@@ -28,6 +74,18 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
||||
),
|
||||
)
|
||||
}
|
||||
if (url.includes("/history")) {
|
||||
historyPage++
|
||||
historyQueries.push(Object.fromEntries(request.urlParams.params))
|
||||
return Effect.succeed(
|
||||
HttpClientResponse.fromWeb(
|
||||
request,
|
||||
Response.json(
|
||||
historyPage === 1 ? { data: [modelSwitchedEvent], hasMore: true } : { data: [], hasMore: false },
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
if (url.includes("/prompt")) {
|
||||
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(admission)))
|
||||
}
|
||||
@@ -72,6 +130,18 @@ 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") })
|
||||
const history = yield* client.sessions.history({
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
after: 0,
|
||||
limit: 1,
|
||||
})
|
||||
const historyNext = history.hasMore
|
||||
? yield* client.sessions.history({
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
after: history.data.at(-1)?.durable?.seq,
|
||||
limit: 2,
|
||||
})
|
||||
: undefined
|
||||
const events = yield* client.sessions
|
||||
.events({ sessionID: Session.ID.make("ses_test"), after: 0 })
|
||||
.pipe(Stream.runCollect)
|
||||
@@ -80,7 +150,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
messageID: SessionMessage.ID.make("msg_model"),
|
||||
})
|
||||
return { page, active, created, admitted, context, events, message }
|
||||
return { page, active, created, admitted, context, history, historyNext, 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)
|
||||
@@ -92,10 +162,39 @@ 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.history.data[0].data.timestamp)).toBe(1_717_171_717_000)
|
||||
expect(result.history).toEqual(expect.objectContaining({ hasMore: true }))
|
||||
expect(result.historyNext).toEqual({ data: [], hasMore: false })
|
||||
expect(historyQueries[0]).toEqual({ limit: "1", after: "0" })
|
||||
expect(historyQueries[1]).toEqual({ limit: "2", after: "1" })
|
||||
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" }))
|
||||
})
|
||||
|
||||
test("sessions.history retains the typed SessionNotFoundError", async () => {
|
||||
const httpClient = HttpClient.make((request) =>
|
||||
Effect.succeed(
|
||||
HttpClientResponse.fromWeb(
|
||||
request,
|
||||
Response.json(
|
||||
{ _tag: "SessionNotFoundError", sessionID: "ses_missing", message: "Session not found" },
|
||||
{ status: 404 },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
const error = await Effect.gen(function* () {
|
||||
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||
return yield* client.sessions
|
||||
.history({
|
||||
sessionID: Session.ID.make("ses_missing"),
|
||||
})
|
||||
.pipe(Effect.flip)
|
||||
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||
|
||||
expect(error._tag).toBe("SessionNotFoundError")
|
||||
})
|
||||
|
||||
const session = {
|
||||
data: {
|
||||
id: "ses_test",
|
||||
|
||||
@@ -1,5 +1,42 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { isUnauthorizedError, OpenCode } from "../src"
|
||||
import { isSessionNotFoundError, isUnauthorizedError, OpenCode } from "../src"
|
||||
|
||||
test("exposes every standard HTTP API group", () => {
|
||||
const client = OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||
|
||||
expect(Object.keys(client)).toEqual([
|
||||
"health",
|
||||
"location",
|
||||
"agents",
|
||||
"sessions",
|
||||
"messages",
|
||||
"models",
|
||||
"providers",
|
||||
"integrations",
|
||||
"credentials",
|
||||
"permissions",
|
||||
"files",
|
||||
"commands",
|
||||
"skills",
|
||||
"events",
|
||||
"ptys",
|
||||
"questions",
|
||||
"references",
|
||||
"projectCopies",
|
||||
])
|
||||
expect(Object.keys(client.messages)).toEqual(["list"])
|
||||
expect(Object.keys(client.integrations)).toEqual([
|
||||
"list",
|
||||
"get",
|
||||
"connectKey",
|
||||
"connectOauth",
|
||||
"attemptStatus",
|
||||
"attemptComplete",
|
||||
"attemptCancel",
|
||||
])
|
||||
expect(Object.keys(client.files)).toEqual(["list", "find"])
|
||||
expect(Object.keys(client.ptys)).toEqual(["list", "create", "get", "update", "remove"])
|
||||
})
|
||||
|
||||
test("sessions.get returns the wire projection", async () => {
|
||||
const client = OpenCode.make({
|
||||
@@ -17,8 +54,38 @@ test("sessions.get returns the wire projection", async () => {
|
||||
expect(result.time.created).toBe(1_717_171_717_000)
|
||||
})
|
||||
|
||||
test("events.subscribe exposes the Promise event stream wire projection", async () => {
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async () =>
|
||||
new Response(
|
||||
`: heartbeat\n\ndata: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n` +
|
||||
`data: ${JSON.stringify(modelSwitchedEvent)}\n\n`,
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
),
|
||||
})
|
||||
const events = []
|
||||
for await (const event of client.events.subscribe()) events.push(event)
|
||||
|
||||
expect(events).toEqual([{ id: "evt_connected", type: "server.connected", data: {} }, modelSwitchedEvent])
|
||||
expect(events[1]?.type === "session.next.model.switched" && events[1].data.timestamp).toBe(1_717_171_717_000)
|
||||
})
|
||||
|
||||
test("events.subscribe terminates on malformed Promise SSE data", async () => {
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async () => new Response("data: {not-json}\n\n", { headers: { "content-type": "text/event-stream" } }),
|
||||
})
|
||||
|
||||
await expect(client.events.subscribe()[Symbol.asyncIterator]().next()).rejects.toMatchObject({
|
||||
name: "ClientError",
|
||||
reason: "MalformedResponse",
|
||||
})
|
||||
})
|
||||
|
||||
test("session methods use the public HTTP contract", async () => {
|
||||
const requests: Array<{ url: string; init?: RequestInit }> = []
|
||||
let historyPage = 0
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async (input, init) => {
|
||||
@@ -29,6 +96,12 @@ test("session methods use the public HTTP contract", async () => {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
}
|
||||
if (url.includes("/history")) {
|
||||
historyPage++
|
||||
return Response.json(
|
||||
historyPage === 1 ? { data: [modelSwitchedEvent], hasMore: true } : { data: [], hasMore: false },
|
||||
)
|
||||
}
|
||||
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 })
|
||||
@@ -39,7 +112,7 @@ test("session methods use the public HTTP contract", async () => {
|
||||
},
|
||||
})
|
||||
|
||||
const page = await client.sessions.list({ limit: "10", order: "desc" })
|
||||
const page = await client.sessions.list({ limit: 10, order: "desc" })
|
||||
const active = await client.sessions.active()
|
||||
const created = await client.sessions.create({ location: { directory: "/tmp/project" } })
|
||||
await client.sessions.switchAgent({ sessionID: "ses_test", agent: "build" })
|
||||
@@ -55,8 +128,13 @@ 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 history = await client.sessions.history({ sessionID: "ses_test", after: 0, limit: 1 })
|
||||
const historyAfter = history.data.at(-1)?.durable?.seq
|
||||
const historyNext = history.hasMore
|
||||
? await client.sessions.history({ sessionID: "ses_test", after: historyAfter, limit: 2 })
|
||||
: undefined
|
||||
const events = []
|
||||
for await (const event of client.sessions.events({ sessionID: "ses_test", after: "0" })) events.push(event)
|
||||
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" })
|
||||
|
||||
@@ -65,6 +143,8 @@ test("session methods use the public HTTP contract", async () => {
|
||||
expect(created.id).toBe("ses_test")
|
||||
expect(admitted.id).toBe("msg_test")
|
||||
expect(context).toEqual([])
|
||||
expect(history).toEqual({ data: [modelSwitchedEvent], hasMore: true })
|
||||
expect(historyNext).toEqual({ data: [], hasMore: false })
|
||||
expect(events).toEqual([modelSwitchedEvent])
|
||||
expect(message).toEqual(modelSwitchedMessage)
|
||||
expect(requests.map((request) => [request.init?.method, request.url])).toEqual([
|
||||
@@ -77,6 +157,8 @@ 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/history?limit=1&after=0"],
|
||||
["GET", "http://localhost:3000/api/session/ses_test/history?limit=2&after=1"],
|
||||
["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"],
|
||||
@@ -104,6 +186,24 @@ test("middleware errors remain declared client errors", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("sessions.history decodes SessionNotFoundError", async () => {
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async () =>
|
||||
Response.json(
|
||||
{ _tag: "SessionNotFoundError", sessionID: "ses_missing", message: "Session not found" },
|
||||
{ status: 404 },
|
||||
),
|
||||
})
|
||||
|
||||
try {
|
||||
await client.sessions.history({ sessionID: "ses_missing" })
|
||||
throw new Error("Expected request to fail")
|
||||
} catch (error) {
|
||||
expect(isSessionNotFoundError(error)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
const session = {
|
||||
data: {
|
||||
id: "ses_test",
|
||||
|
||||
@@ -215,6 +215,16 @@ export async function handler(
|
||||
body: reqBody,
|
||||
})
|
||||
|
||||
if (providerInfo.id.startsWith("console.")) {
|
||||
const resEndpointId = res.headers.get("x-opencode-endpoint-id")
|
||||
const resEndpointModelId = res.headers.get("x-opencode-upstream-model-id")
|
||||
if (resEndpointId && resEndpointModelId)
|
||||
logger.metric({
|
||||
provider: resEndpointId,
|
||||
"provider.model": resEndpointModelId,
|
||||
})
|
||||
}
|
||||
|
||||
if (res.status !== 200) {
|
||||
logger.metric({
|
||||
"llm.error.code": res.status,
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
"opencode": "./bin/opencode"
|
||||
},
|
||||
"exports": {
|
||||
"./effect/layer-node": "./src/effect/layer-node.ts",
|
||||
"./effect/app-node": "./src/effect/app-node.ts",
|
||||
"./session/runner": "./src/session/runner/index.ts",
|
||||
"./system-context": "./src/system-context/index.ts",
|
||||
"./*": "./src/*.ts"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * as AgentV2 from "./agent"
|
||||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import { Array, Context, Effect, Layer, Types } from "effect"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { State } from "./state"
|
||||
@@ -106,3 +107,5 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [] })
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * as AISDK from "./aisdk"
|
||||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import { Cause, Context, Effect, Layer, Schema, Scope } from "effect"
|
||||
import { ModelV2 } from "./model"
|
||||
@@ -231,4 +232,6 @@ export const locationLayer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [] })
|
||||
|
||||
export const defaultLayer = locationLayer
|
||||
|
||||
@@ -2,6 +2,7 @@ export * as BackgroundJob from "./background-job"
|
||||
|
||||
import { Cause, Clock, Context, Deferred, Effect, Exit, Layer, Scope, SynchronizedRef } from "effect"
|
||||
import { Identifier } from "./id/id"
|
||||
import { makeGlobalNode } from "./effect/app-node"
|
||||
|
||||
export type Status = "running" | "completed" | "error" | "cancelled"
|
||||
|
||||
@@ -362,3 +363,5 @@ export const make = Effect.gen(function* () {
|
||||
export const layer = Layer.effect(Service, make)
|
||||
|
||||
export const defaultLayer = layer
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * as Catalog from "./catalog"
|
||||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import { Array, Context, Effect, Layer, Option, Order, pipe, Schema } from "effect"
|
||||
import { Catalog } from "@opencode-ai/schema/catalog"
|
||||
import { ModelV2 } from "./model"
|
||||
@@ -296,3 +297,5 @@ export const locationLayer = layer.pipe(
|
||||
Layer.provideMerge(Integration.locationLayer),
|
||||
Layer.provideMerge(Policy.locationLayer),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Policy.node, Integration.node] })
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * as CommandV2 from "./command"
|
||||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import { Context, Effect, Layer, Types } from "effect"
|
||||
import { Command } from "@opencode-ai/schema/command"
|
||||
import { State } from "./state"
|
||||
@@ -59,3 +60,5 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [] })
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * as Config from "./config"
|
||||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import path from "path"
|
||||
import { type ParseError, parse } from "jsonc-parser"
|
||||
import { Context, Effect, Layer, Option, Schema } from "effect"
|
||||
@@ -218,3 +219,9 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
export const locationLayer = layer.pipe(Layer.provideMerge(Policy.locationLayer))
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [FSUtil.node, Global.node, Location.node, Policy.node],
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Credential } from "@opencode-ai/schema/credential"
|
||||
import { Integration } from "@opencode-ai/schema/integration"
|
||||
import { Database } from "./database/database"
|
||||
import { makeGlobalNode } from "./effect/app-node"
|
||||
import { CredentialTable } from "./credential/sql"
|
||||
|
||||
export const ID = Credential.ID
|
||||
@@ -135,3 +136,5 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node] })
|
||||
|
||||
@@ -24,8 +24,8 @@ import {
|
||||
import * as NodeChildProcess from "node:child_process"
|
||||
import { PassThrough } from "node:stream"
|
||||
import launch from "cross-spawn"
|
||||
import { LayerNode } from "./effect/layer-node"
|
||||
import { filesystem, path } from "./effect/layer-node-platform"
|
||||
import { makeGlobalNode } from "./effect/app-node"
|
||||
import { filesystem, path } from "./effect/app-node-platform"
|
||||
|
||||
const toError = (err: unknown): Error => (err instanceof globalThis.Error ? err : new globalThis.Error(String(err)))
|
||||
|
||||
@@ -503,6 +503,6 @@ export const layer: Layer.Layer<ChildProcessSpawner, never, FileSystem.FileSyste
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))
|
||||
export const node = LayerNode.make({ service: ChildProcessSpawner, layer, deps: [filesystem, path] })
|
||||
export const node = makeGlobalNode({ service: ChildProcessSpawner, layer, deps: [filesystem, path] })
|
||||
|
||||
export * as CrossSpawnSpawner from "./cross-spawn-spawner"
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Flag } from "../flag/flag"
|
||||
import { isAbsolute, join } from "path"
|
||||
import { DatabaseMigration } from "./migration"
|
||||
import { InstallationChannel } from "../installation/version"
|
||||
import { LayerNode } from "../effect/layer-node"
|
||||
import { makeGlobalNode } from "../effect/app-node"
|
||||
|
||||
const makeDatabase = EffectDrizzleSqlite.makeWithDefaults()
|
||||
type DatabaseShape = Effect.Success<typeof makeDatabase>
|
||||
@@ -60,4 +60,4 @@ export const defaultLayer = Layer.unwrap(
|
||||
}),
|
||||
).pipe(Layer.provide(Global.defaultLayer))
|
||||
|
||||
export const node = LayerNode.make({ service: Service, layer: layerFromPath(path()), deps: [] })
|
||||
export const node = makeGlobalNode({ service: Service, layer: layerFromPath(path()), deps: [] })
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Layer } from "effect"
|
||||
import { buildLocationServiceMap } from "../location-services"
|
||||
import { LocationServiceMap } from "../location-service-map"
|
||||
import { LayerNode } from "./layer-node"
|
||||
import { makeGlobalNode } from "./app-node"
|
||||
|
||||
export function build<A, E>(root: LayerNode.Node<A, E, any>, replacements?: readonly LayerNode.Replacement[]) {
|
||||
const replacementMap = new Map(replacements?.map((item) => [item.source, item.replacement]))
|
||||
|
||||
if (!LayerNode.hasUnbound(root, LocationServiceMap.node)) {
|
||||
// If the location service map is not needed, we shouldn't pull it
|
||||
// in. Compile the graph normally
|
||||
return LayerNode.compile(root, replacementMap)
|
||||
}
|
||||
|
||||
const locationMap = buildLocationServiceMap(replacementMap)
|
||||
const locationMapNode = makeGlobalNode({ service: LocationServiceMap.Service, layer: locationMap, deps: [] })
|
||||
|
||||
const app = LayerNode.bind(root, LocationServiceMap.node, locationMapNode)
|
||||
|
||||
return LayerNode.compile(app, replacementMap)
|
||||
}
|
||||
|
||||
export * as AppNodeBuilder from "./app-node-builder"
|
||||
+7
-7
@@ -3,16 +3,16 @@ import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
|
||||
import { FileSystem, Path } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import { LayerNode } from "./layer-node"
|
||||
import { makeGlobalNode } from "./app-node"
|
||||
|
||||
export const filesystem = LayerNode.make({ service: FileSystem.FileSystem, layer: NodeFileSystem.layer, deps: [] })
|
||||
export const path = LayerNode.make({ service: Path.Path, layer: NodePath.layer, deps: [] })
|
||||
export const httpClient = LayerNode.make({ service: HttpClient.HttpClient, layer: FetchHttpClient.layer, deps: [] })
|
||||
export const requestExecutor = LayerNode.make({
|
||||
export const filesystem = makeGlobalNode({ service: FileSystem.FileSystem, layer: NodeFileSystem.layer, deps: [] })
|
||||
export const path = makeGlobalNode({ service: Path.Path, layer: NodePath.layer, deps: [] })
|
||||
export const httpClient = makeGlobalNode({ service: HttpClient.HttpClient, layer: FetchHttpClient.layer, deps: [] })
|
||||
export const requestExecutor = makeGlobalNode({
|
||||
service: RequestExecutor.Service,
|
||||
layer: RequestExecutor.layer,
|
||||
deps: [httpClient],
|
||||
})
|
||||
export const llmClient = LayerNode.make({ service: LLMClient.Service, layer: LLMClient.layer, deps: [requestExecutor] })
|
||||
export const llmClient = makeGlobalNode({ service: LLMClient.Service, layer: LLMClient.layer, deps: [requestExecutor] })
|
||||
|
||||
export * as LayerNodePlatform from "./layer-node-platform"
|
||||
export * as LayerNodePlatform from "./app-node-platform"
|
||||
@@ -0,0 +1,14 @@
|
||||
import { LayerNode } from "./layer-node"
|
||||
|
||||
export const tags = LayerNode.tags({
|
||||
location: ["global"],
|
||||
global: [],
|
||||
})
|
||||
|
||||
export type GlobalNode<A, E = never> = LayerNode.Node<A, E, (typeof tags.values)["global"]>
|
||||
export type LocationNode<A, E = never> = LayerNode.Node<A, E, (typeof tags.values)["location"]>
|
||||
|
||||
export const makeGlobalNode = tags.make("global")
|
||||
export const makeLocationNode = tags.make("location")
|
||||
|
||||
export * as Node from "./app-node"
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Brand, Context, Layer } from "effect"
|
||||
|
||||
type RuntimeLayer = Layer.Layer<never, unknown, unknown>
|
||||
type AnyNode = Node<unknown, unknown, any>
|
||||
type RuntimeLayer = Layer.Layer<never, unknown, unknown>
|
||||
type NodeList<Item extends AnyNode = AnyNode> = readonly [] | readonly [Item, ...Item[]]
|
||||
type Output<Item> = [Item] extends [never] ? never : Item extends Node<infer A, unknown, any> ? A : never
|
||||
type Error<Item> = [Item] extends [never] ? never : Item extends Node<unknown, infer E, any> ? E : never
|
||||
export type Output<Item> = [Item] extends [never] ? never : Item extends Node<infer A, unknown, any> ? A : never
|
||||
export type Error<Item> = [Item] extends [never] ? never : Item extends Node<unknown, infer E, any> ? E : never
|
||||
type NodeTag<Item> = [Item] extends [never] ? undefined : Item extends Node<unknown, unknown, infer T> ? T : never
|
||||
type Missing<Required, Dependencies extends NodeList> = Exclude<Required, Output<Dependencies[number]>>
|
||||
type CheckDependencies<Implementation extends Layer.Any, Dependencies extends NodeList> = [
|
||||
Missing<Layer.Services<Implementation>, Dependencies>,
|
||||
@@ -14,17 +15,17 @@ type CheckDependencies<Implementation extends Layer.Any, Dependencies extends No
|
||||
declare const $OutputType: unique symbol
|
||||
declare const $ErrorType: unique symbol
|
||||
|
||||
export type Tier<Name extends string = string> = Name & Brand.Brand<"LayerNode.Tier">
|
||||
export type Tag<Name extends string = string> = Name & Brand.Brand<"LayerNode.Tag">
|
||||
|
||||
const makeTier = Brand.nominal<Tier>()
|
||||
const makeTag = Brand.nominal<Tag>()
|
||||
|
||||
export type Node<A, E = never, T extends Tier | undefined = undefined> = {
|
||||
readonly kind: "layer" | "group"
|
||||
export interface Node<A, E = never, T extends Tag | undefined = undefined> {
|
||||
readonly kind: "layer" | "unbound" | "group"
|
||||
readonly name: string
|
||||
readonly service?: Context.Service.Any
|
||||
readonly implementation?: Layer.Any
|
||||
readonly dependencies: readonly AnyNode[]
|
||||
readonly tier?: T
|
||||
readonly tag?: T
|
||||
readonly [$OutputType]?: () => A
|
||||
readonly [$ErrorType]?: () => E
|
||||
}
|
||||
@@ -34,22 +35,55 @@ type NodeIdentity =
|
||||
| { readonly name: string; readonly service?: never }
|
||||
type DistributiveOmit<A, K extends PropertyKey> = A extends unknown ? Omit<A, K> : never
|
||||
|
||||
type NodeInput<
|
||||
export type TagConfig = Readonly<Record<string, readonly string[]>>
|
||||
type TagNames<Config extends TagConfig> = keyof Config & string
|
||||
type NodeInTags<Names extends string> = Node<unknown, unknown, Tag<Names> | undefined>
|
||||
type CheckTags<Items extends NodeList, Names extends string> = [Exclude<Items[number], NodeInTags<Names>>] extends [
|
||||
never,
|
||||
]
|
||||
? unknown
|
||||
: { readonly "Invalid tag dependencies": Exclude<Items[number], NodeInTags<Names>> }
|
||||
|
||||
export interface Tags<Config extends TagConfig> {
|
||||
readonly values: { readonly [Name in TagNames<Config>]: Tag<Name> }
|
||||
readonly make: <Name extends TagNames<Config>>(
|
||||
name: Name,
|
||||
) => <const Implementation extends Layer.Any, const Items extends NodeList>(
|
||||
input: DistributiveOmit<MakeInput<Implementation, Items, Tag<Name>>, "tag"> &
|
||||
CheckTags<Items, Name | Extract<Config[Name][number], string>>,
|
||||
) => Node<Layer.Success<Implementation>, Layer.Error<Implementation> | Error<Items[number]>, Tag<Name>>
|
||||
}
|
||||
|
||||
export function tags<const Config extends { readonly [Name in keyof Config]: readonly (keyof Config & string)[] }>(
|
||||
config: Config,
|
||||
): Tags<Config> {
|
||||
const names = Object.keys(config) as TagNames<Config>[]
|
||||
const values = Object.fromEntries(names.map((name) => [name, makeTag(name)])) as Tags<Config>["values"]
|
||||
return {
|
||||
values,
|
||||
make: ((name: TagNames<Config>) => (input: DistributiveOmit<MakeInput<Layer.Any, NodeList, Tag>, "tag">) =>
|
||||
make({ ...input, tag: values[name] })) as Tags<Config>["make"],
|
||||
}
|
||||
}
|
||||
|
||||
// Nodes ---------------------------------------------------------------------
|
||||
|
||||
type MakeInput<
|
||||
Implementation extends Layer.Any,
|
||||
Items extends NodeList,
|
||||
T extends Tier | undefined = undefined,
|
||||
T extends Tag | undefined = undefined,
|
||||
> = NodeIdentity & {
|
||||
readonly layer: Implementation
|
||||
readonly deps: Items & CheckDependencies<Implementation, NoInfer<Items>>
|
||||
readonly tier?: T
|
||||
readonly tag?: T
|
||||
}
|
||||
|
||||
export function make<
|
||||
const Implementation extends Layer.Any,
|
||||
const Items extends NodeList,
|
||||
const T extends Tier | undefined = undefined,
|
||||
const T extends Tag | undefined = undefined,
|
||||
>(
|
||||
input: NodeInput<Implementation, Items, T>,
|
||||
input: MakeInput<Implementation, Items, T>,
|
||||
): Node<Layer.Success<Implementation>, Layer.Error<Implementation> | Error<Items[number]>, T> {
|
||||
return {
|
||||
kind: "layer",
|
||||
@@ -57,53 +91,26 @@ export function make<
|
||||
service: input.service,
|
||||
implementation: input.layer,
|
||||
dependencies: input.deps,
|
||||
tier: input.tier,
|
||||
tag: input.tag,
|
||||
}
|
||||
}
|
||||
|
||||
export function group<const Items extends NodeList>(
|
||||
export function unbound<R, Shape, const T extends Tag>(service: Context.Key<R, Shape>, tag: T): Node<R, never, T> {
|
||||
return {
|
||||
kind: "unbound",
|
||||
name: service.key,
|
||||
service,
|
||||
dependencies: [],
|
||||
tag,
|
||||
}
|
||||
}
|
||||
|
||||
export function group<const Items extends readonly AnyNode[]>(
|
||||
dependencies: Items,
|
||||
): Node<Output<Items[number]>, Error<Items[number]>> {
|
||||
): Node<Output<Items[number]>, Error<Items[number]>, NodeTag<Items[number]>> {
|
||||
return { kind: "group", name: "group", dependencies }
|
||||
}
|
||||
|
||||
type AllowedTierNames<Names extends readonly string[], Name extends Names[number]> = Names extends readonly [
|
||||
infer Head extends string,
|
||||
...infer Tail extends readonly string[],
|
||||
]
|
||||
? Head extends Name
|
||||
? Head | Tail[number]
|
||||
: AllowedTierNames<Tail, Name>
|
||||
: never
|
||||
|
||||
type NodeInTiers<Names extends string> = Node<unknown, unknown, Tier<Names>>
|
||||
|
||||
export interface Tiers<Names extends readonly [string, ...string[]]> {
|
||||
readonly names: Names
|
||||
readonly values: { readonly [K in Names[number]]: Tier<K> }
|
||||
readonly make: <Name extends Names[number]>(
|
||||
name: Name,
|
||||
) => <
|
||||
const Implementation extends Layer.Any,
|
||||
const Items extends NodeList<NodeInTiers<AllowedTierNames<Names, Name>>>,
|
||||
>(
|
||||
input: DistributiveOmit<NodeInput<Implementation, Items, Tier<Name>>, "tier">,
|
||||
) => Node<Layer.Success<Implementation>, Layer.Error<Implementation> | Error<Items[number]>, Tier<Name>>
|
||||
}
|
||||
|
||||
export function tiers<const Names extends readonly [string, ...string[]]>(names: Names): Tiers<Names> {
|
||||
const values = Object.fromEntries(names.map((name) => [name, makeTier(name)])) as Tiers<Names>["values"]
|
||||
return {
|
||||
names,
|
||||
values,
|
||||
make: ((name: Names[number]) => (input: DistributiveOmit<NodeInput<Layer.Any, NodeList, Tier>, "tier">) =>
|
||||
make({ ...input, tier: values[name] })) as Tiers<Names>["make"],
|
||||
}
|
||||
}
|
||||
|
||||
const defaultTiers = tiers(["untiered"])
|
||||
const untiered = defaultTiers.values.untiered
|
||||
|
||||
export type Replacement = {
|
||||
readonly source: Layer.Any
|
||||
readonly replacement: Layer.Any
|
||||
@@ -120,129 +127,147 @@ export function replace<A, E, R, E2>(
|
||||
return { source, replacement }
|
||||
}
|
||||
|
||||
export function buildLayer<
|
||||
A,
|
||||
E,
|
||||
const Names extends readonly [string, ...string[]] = readonly ["untiered"],
|
||||
const Built extends Layer.Any = Layer.Layer<never, never, never>,
|
||||
>(
|
||||
node: Node<A, E, any>,
|
||||
options?: {
|
||||
readonly tiers?: Tiers<Names>
|
||||
readonly buildTier?: (tier: Names[number], layers: readonly Layer.Any[]) => Built
|
||||
readonly replacements?: readonly Replacement[]
|
||||
},
|
||||
): Layer.Layer<A | Layer.Success<Built>, E | Layer.Error<Built>, never> {
|
||||
const tiers = options?.tiers ?? (defaultTiers as unknown as Tiers<Names>)
|
||||
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
|
||||
// Tree -----------------------------------------------------------------------
|
||||
|
||||
type Visit<Result> = (node: AnyNode, context: VisitContext<Result>) => Result
|
||||
|
||||
type VisitContext<Result> = {
|
||||
readonly cache: Map<AnyNode, Result>
|
||||
readonly visit: (node: AnyNode) => Result
|
||||
}
|
||||
|
||||
export function combine(layers: readonly Layer.Any[]): RuntimeLayer {
|
||||
return layers.reduce<RuntimeLayer>(
|
||||
(result, layer) => (layer as RuntimeLayer).pipe(Layer.provideMerge(result)),
|
||||
Layer.empty as RuntimeLayer,
|
||||
)
|
||||
}
|
||||
|
||||
function plan(
|
||||
function walk<Result>(
|
||||
root: AnyNode,
|
||||
tiers: Tiers<readonly [string, ...string[]]>,
|
||||
replacements: ReadonlyMap<Layer.Any, Layer.Any>,
|
||||
visit: Visit<Result>,
|
||||
options: {
|
||||
readonly cache?: Map<AnyNode, Result>
|
||||
readonly resolve?: (node: AnyNode) => AnyNode
|
||||
readonly detectCycles?: boolean
|
||||
} = {},
|
||||
) {
|
||||
const indexes = new Map(tiers.names.map((name, index) => [tiers.values[name], index]))
|
||||
const plans = new Map<Tier, Layer.Any[]>()
|
||||
const activeImplementations = new Map<Tier, Map<string, AnyNode>>()
|
||||
const serviceTiers = new Map<string, Tier>()
|
||||
const cache = options.cache ?? new Map<AnyNode, Result>()
|
||||
const visiting = new Set<AnyNode>()
|
||||
const stack: AnyNode[] = []
|
||||
const boundaryVisited = new Map<AnyNode, Set<Tier>>()
|
||||
const boundaryServices = new Map<Tier, Map<string, AnyNode>>()
|
||||
|
||||
const validateBoundary = (node: AnyNode, origin: Tier) => {
|
||||
const checked = boundaryVisited.get(node) ?? new Set<Tier>()
|
||||
boundaryVisited.set(node, checked)
|
||||
if (checked.has(origin)) return false
|
||||
checked.add(origin)
|
||||
const services = boundaryServices.get(origin) ?? new Map<string, AnyNode>()
|
||||
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 recur = (node: AnyNode): Result => {
|
||||
const target = options.resolve?.(node) ?? node
|
||||
const cached = cache.get(target)
|
||||
if (cached !== undefined || cache.has(target)) return cached!
|
||||
|
||||
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<string, AnyNode>()
|
||||
activeImplementations.set(tier, implementations)
|
||||
if (implementations.get(key) === node && unseenOrigins.length === 0) return
|
||||
|
||||
if (visiting.has(node)) {
|
||||
const start = stack.indexOf(node)
|
||||
if (options.detectCycles !== false && visiting.has(target)) {
|
||||
const start = stack.indexOf(target)
|
||||
throw new Error(
|
||||
`Cycle detected in layer graph: ${[...stack.slice(start), node].map((item) => item.name).join(" -> ")}`,
|
||||
`Cycle detected in layer tree: ${[...stack.slice(start), target].map((item) => item.name).join(" -> ")}`,
|
||||
)
|
||||
}
|
||||
|
||||
visiting.add(node)
|
||||
stack.push(node)
|
||||
visiting.add(target)
|
||||
stack.push(target)
|
||||
try {
|
||||
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)
|
||||
const result = visit(target, { cache, visit: recur })
|
||||
if (!cache.has(target)) cache.set(target, result)
|
||||
return result
|
||||
} finally {
|
||||
stack.pop()
|
||||
visiting.delete(node)
|
||||
visiting.delete(target)
|
||||
}
|
||||
}
|
||||
|
||||
visit(root)
|
||||
return plans
|
||||
return recur(root)
|
||||
}
|
||||
|
||||
function requireTier(node: AnyNode, indexes: ReadonlyMap<Tier, number>) {
|
||||
if (!node.tier || !indexes.has(node.tier)) throw new Error(`Node ${node.name} is not in the tier configuration`)
|
||||
export function hoist<A, E, T extends Tag>(
|
||||
root: Node<A, E, any>,
|
||||
tag: T,
|
||||
): {
|
||||
readonly node: Node<A, E>
|
||||
readonly hoisted: Node<unknown, E>
|
||||
} {
|
||||
const hoisted = new Map<string, AnyNode>()
|
||||
|
||||
const node = walk<AnyNode>(root, (node, context) => {
|
||||
if (node.kind === "group") {
|
||||
return { ...node, dependencies: node.dependencies.map(context.visit) }
|
||||
}
|
||||
if (node.tag === tag) {
|
||||
const existing = hoisted.get(node.name)
|
||||
if (existing && existing !== node) {
|
||||
throw new Error(`Tag ${tag} has conflicting implementations for ${node.name}`)
|
||||
}
|
||||
hoisted.set(node.name, node)
|
||||
return group([])
|
||||
}
|
||||
if (node.kind === "unbound") {
|
||||
return node
|
||||
}
|
||||
return { ...node, dependencies: node.dependencies.map(context.visit) }
|
||||
})
|
||||
|
||||
return {
|
||||
node: node as Node<A, E>,
|
||||
hoisted: group(Array.from(hoisted.values())) as Node<unknown, E>,
|
||||
}
|
||||
}
|
||||
|
||||
export function compile<A, E>(
|
||||
root: Node<A, E, any>,
|
||||
replacements?: ReadonlyMap<Layer.Any, Layer.Any>,
|
||||
): Layer.Layer<A, E> {
|
||||
const cache = new Map<AnyNode, RuntimeLayer>()
|
||||
const compileNode = (node: AnyNode) =>
|
||||
walk<RuntimeLayer>(
|
||||
node,
|
||||
(node, context) => {
|
||||
if (node.kind === "unbound") throw new Error(`Unbound layer node: ${node.name}`)
|
||||
const dependencies = node.dependencies.flatMap(flatten).map(context.visit)
|
||||
const implementation = (replacements?.get(node.implementation!) ?? node.implementation!) as RuntimeLayer
|
||||
return dependencies.length === 0
|
||||
? implementation
|
||||
: implementation.pipe(Layer.provide(dependencies as [RuntimeLayer, ...RuntimeLayer[]]))
|
||||
},
|
||||
{ cache },
|
||||
)
|
||||
const layers = flatten(root).map((node) => compileNode(node))
|
||||
const layer = layers.reduce<RuntimeLayer>((result, layer) => layer.pipe(Layer.provideMerge(result)), Layer.empty)
|
||||
return layer as Layer.Layer<A, E>
|
||||
}
|
||||
|
||||
export function hasUnbound(root: Node<unknown, unknown, any>, source: AnyNode): boolean {
|
||||
if (source.kind !== "unbound") throw new Error(`Cannot check non-unbound layer node: ${source.name}`)
|
||||
return walk<boolean>(root, (node, context) => {
|
||||
if (node === source) return true
|
||||
return node.dependencies.some(context.visit)
|
||||
})
|
||||
}
|
||||
|
||||
export function bind<A, E, T extends Tag | undefined>(
|
||||
root: Node<A, E, T>,
|
||||
source: AnyNode,
|
||||
replacement: AnyNode,
|
||||
): Node<A, E, T> {
|
||||
if (source.kind !== "unbound") throw new Error(`Cannot bind non-unbound layer node: ${source.name}`)
|
||||
if (source.name !== replacement.name) {
|
||||
throw new Error(`Cannot bind ${source.name} to ${replacement.name}`)
|
||||
}
|
||||
if (source.tag !== replacement.tag) {
|
||||
throw new Error(`Cannot bind ${source.name} across tags`)
|
||||
}
|
||||
return walk<AnyNode>(
|
||||
root,
|
||||
(target, context) => {
|
||||
if (target.kind === "unbound") return target
|
||||
const dependencies: AnyNode[] = []
|
||||
const clone = { ...target, dependencies }
|
||||
context.cache.set(target, clone)
|
||||
dependencies.push(...target.dependencies.map(context.visit))
|
||||
return clone
|
||||
},
|
||||
{ detectCycles: false, resolve: (node) => (node === source ? replacement : node) },
|
||||
) as Node<A, E, T>
|
||||
}
|
||||
|
||||
function flatten(node: AnyNode): readonly AnyNode[] {
|
||||
return node.kind === "group" ? node.dependencies.flatMap(flatten) : [node]
|
||||
}
|
||||
|
||||
export * as LayerNode from "./layer-node"
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { LayerNode } from "./layer-node"
|
||||
|
||||
export const tiers = LayerNode.tiers(["location", "global"])
|
||||
|
||||
export type GlobalNode<A, E = never> = LayerNode.Node<A, E, (typeof tiers.values)["global"]>
|
||||
export type LocationNode<A, E = never> = LayerNode.Node<A, E, (typeof tiers.values)["location"]>
|
||||
|
||||
export const makeGlobalNode = tiers.make("global")
|
||||
export const makeLocationNode = tiers.make("location")
|
||||
|
||||
export * as ScopedNode from "./scoped-node"
|
||||
+83
-17
@@ -1,13 +1,13 @@
|
||||
export * as EventV2 from "./event"
|
||||
|
||||
import { Cause, Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
|
||||
import { Cause, Context, Effect, Layer, Option, PubSub, Queue, Schema, Stream } from "effect"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import type { Data, Definition, Payload } from "@opencode-ai/schema/event"
|
||||
import { and, asc, eq, gt } from "drizzle-orm"
|
||||
import { and, asc, eq, gt, inArray } from "drizzle-orm"
|
||||
import { Database } from "./database/database"
|
||||
import { EventSequenceTable, EventTable } from "./event/sql"
|
||||
import { Location } from "./location"
|
||||
import { LayerNode } from "./effect/layer-node"
|
||||
import { makeGlobalNode } from "./effect/app-node"
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
import { Durable } from "@opencode-ai/schema/durable-event-manifest"
|
||||
|
||||
@@ -47,6 +47,71 @@ export class InvalidDurableEventError extends Schema.TaggedErrorClass<InvalidDur
|
||||
},
|
||||
) {}
|
||||
|
||||
const decodeSerializedEvent = (event: SerializedEvent): Payload => {
|
||||
const definition = Durable.get(event.type)
|
||||
if (!definition?.durable) {
|
||||
throw new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` })
|
||||
}
|
||||
return {
|
||||
id: event.id,
|
||||
type: definition.type,
|
||||
durable: { aggregateID: event.aggregateID, seq: event.seq, version: definition.durable.version },
|
||||
data: Schema.decodeUnknownSync(definition.data)(event.data),
|
||||
}
|
||||
}
|
||||
|
||||
export const readAggregate = Effect.fn("EventV2.readAggregate")(function* <A>(
|
||||
db: Database.Interface["db"],
|
||||
input: {
|
||||
readonly aggregateID: string
|
||||
readonly after?: number
|
||||
readonly limit: number
|
||||
readonly manifest: {
|
||||
readonly definitions: ReadonlyMap<string, Definition>
|
||||
readonly schema: Schema.Decoder<A, never>
|
||||
}
|
||||
},
|
||||
) {
|
||||
const after = input.after ?? -1
|
||||
const rows = yield* db
|
||||
.select()
|
||||
.from(EventTable)
|
||||
.where(
|
||||
and(
|
||||
eq(EventTable.aggregate_id, input.aggregateID),
|
||||
gt(EventTable.seq, after),
|
||||
inArray(EventTable.type, Array.from(input.manifest.definitions.keys())),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(EventTable.seq))
|
||||
.limit(input.limit + 1)
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const page = rows.slice(0, input.limit)
|
||||
const decode = Schema.decodeUnknownSync(input.manifest.schema)
|
||||
const events = page.map((event) =>
|
||||
decode({
|
||||
id: event.id,
|
||||
type: input.manifest.definitions.get(event.type)?.type ?? event.type,
|
||||
durable: {
|
||||
aggregateID: event.aggregate_id,
|
||||
seq: event.seq,
|
||||
version: input.manifest.definitions.get(event.type)?.durable?.version,
|
||||
},
|
||||
data: event.data,
|
||||
}),
|
||||
)
|
||||
return {
|
||||
events,
|
||||
hasMore: rows.length > input.limit,
|
||||
}
|
||||
})
|
||||
|
||||
export class SubscriberOverflowError extends Schema.TaggedErrorClass<SubscriberOverflowError>()(
|
||||
"EventV2.SubscriberOverflow",
|
||||
{ capacity: Schema.Int },
|
||||
) {}
|
||||
|
||||
export const define = Event.define
|
||||
export const versionedType = Event.versionedType
|
||||
|
||||
@@ -84,6 +149,20 @@ export interface Interface {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Event") {}
|
||||
|
||||
export const allBounded = (events: Interface, capacity: number) =>
|
||||
Effect.gen(function* () {
|
||||
const queue = yield* Queue.dropping<Payload, SubscriberOverflowError>(capacity)
|
||||
const unsubscribe = yield* events.listen((event) =>
|
||||
Queue.offer(queue, event).pipe(
|
||||
Effect.flatMap((accepted) =>
|
||||
accepted ? Effect.void : Queue.fail(queue, new SubscriberOverflowError({ capacity })).pipe(Effect.asVoid),
|
||||
),
|
||||
),
|
||||
)
|
||||
yield* Effect.addFinalizer(() => unsubscribe.pipe(Effect.andThen(Queue.shutdown(queue)), Effect.asVoid))
|
||||
return Stream.fromQueue(queue)
|
||||
})
|
||||
|
||||
export interface LayerOptions {
|
||||
readonly beforeAggregateRead?: (aggregateID: string) => Effect.Effect<void>
|
||||
}
|
||||
@@ -459,19 +538,6 @@ export const layerWith = (options?: LayerOptions) =>
|
||||
|
||||
const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(pubsub.all)
|
||||
|
||||
const decodeSerializedEvent = (event: SerializedEvent) => {
|
||||
const definition = Durable.get(event.type)
|
||||
if (!definition?.durable) {
|
||||
throw new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` })
|
||||
}
|
||||
return {
|
||||
id: event.id,
|
||||
type: definition.type,
|
||||
durable: { aggregateID: event.aggregateID, seq: event.seq, version: definition.durable.version },
|
||||
data: Schema.decodeUnknownSync(definition.data)(event.data),
|
||||
}
|
||||
}
|
||||
|
||||
const readAfter = (aggregateID: string, after: number) =>
|
||||
(options?.beforeAggregateRead?.(aggregateID) ?? Effect.void).pipe(
|
||||
Effect.andThen(
|
||||
@@ -569,6 +635,6 @@ export const layerWith = (options?: LayerOptions) =>
|
||||
)
|
||||
|
||||
export const layer = layerWith()
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [Database.node] })
|
||||
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [Database.node] })
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * as FileMutation from "./file-mutation"
|
||||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { dirname } from "path"
|
||||
import { KeyedMutex } from "./effect/keyed-mutex"
|
||||
@@ -192,6 +193,8 @@ function sameBytes(left: Uint8Array, right: Uint8Array) {
|
||||
|
||||
export const locationLayer = layer
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] })
|
||||
|
||||
/**
|
||||
* Deferred until the corresponding V2 integrations exist.
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * as FileSystem from "./filesystem"
|
||||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { FSUtil } from "./fs-util"
|
||||
@@ -113,3 +114,9 @@ const baseLayer = Layer.effect(
|
||||
export const layer = baseLayer.pipe(Layer.provide(FileSystemSearch.locationLayer), Layer.provide(FSUtil.defaultLayer))
|
||||
|
||||
export const locationLayer = layer
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer: baseLayer,
|
||||
deps: [FSUtil.node, Location.node, FileSystemSearch.node],
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * as FileSystemSearch from "./search"
|
||||
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer, Scope } from "effect"
|
||||
import { Fff } from "#fff"
|
||||
@@ -229,6 +230,8 @@ export const fffLayer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const locationLayer = Layer.unwrap(
|
||||
Effect.sync(() => (Flag.OPENCODE_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer)),
|
||||
)
|
||||
const layer = Layer.unwrap(Effect.sync(() => (Flag.OPENCODE_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer)))
|
||||
|
||||
export const locationLayer = layer
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Location.node, Ripgrep.node] })
|
||||
|
||||
@@ -3,6 +3,7 @@ export * as Watcher from "./watcher"
|
||||
// @ts-ignore
|
||||
import { createWrapper } from "@parcel/watcher/wrapper"
|
||||
import type ParcelWatcher from "@parcel/watcher"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { Cause, Context, Effect, Layer } from "effect"
|
||||
import { FileSystemWatcher } from "@opencode-ai/schema/filesystem-watcher"
|
||||
import path from "path"
|
||||
@@ -133,3 +134,9 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer), Layer.provide(Git.defaultLayer))
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [FSUtil.node, Location.node, Config.node, Git.node, EventV2.node],
|
||||
})
|
||||
|
||||
@@ -7,8 +7,8 @@ import { Context, Effect, FileSystem, Layer, Schema } from "effect"
|
||||
import type { PlatformError } from "effect/PlatformError"
|
||||
import { Glob } from "./util/glob"
|
||||
import { serviceUse } from "./effect/service-use"
|
||||
import { LayerNode } from "./effect/layer-node"
|
||||
import { filesystem } from "./effect/layer-node-platform"
|
||||
import { makeGlobalNode } from "./effect/app-node"
|
||||
import { filesystem } from "./effect/app-node-platform"
|
||||
|
||||
export namespace FSUtil {
|
||||
export class FileSystemError extends Schema.TaggedErrorClass<FileSystemError>()("FileSystemError", {
|
||||
@@ -201,7 +201,7 @@ export namespace FSUtil {
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer))
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [filesystem] })
|
||||
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [filesystem] })
|
||||
|
||||
// Pure helpers that don't need Effect (path manipulation, sync operations)
|
||||
export function mimeType(p: string): string {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { ChildProcess } from "effect/unstable/process"
|
||||
import { AbsolutePath, RelativePath } from "./schema"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { AppProcess } from "./process"
|
||||
import { LayerNode } from "./effect/layer-node"
|
||||
import { makeGlobalNode } from "./effect/app-node"
|
||||
import { File } from "./file"
|
||||
import { KeyedMutex } from "./effect/keyed-mutex"
|
||||
|
||||
@@ -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({ service: Service, layer: layer, deps: [FSUtil.node, AppProcess.node] })
|
||||
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [FSUtil.node, AppProcess.node] })
|
||||
|
||||
interface Result {
|
||||
readonly exitCode: number
|
||||
|
||||
@@ -5,7 +5,7 @@ import os from "os"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Flock } from "./util/flock"
|
||||
import { Flag } from "./flag/flag"
|
||||
import { LayerNode } from "./effect/layer-node"
|
||||
import { makeGlobalNode } from "./effect/app-node"
|
||||
|
||||
const app = "opencode"
|
||||
const data = path.join(xdgData!, app)
|
||||
@@ -77,7 +77,7 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
export const defaultLayer = layer
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [] })
|
||||
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [] })
|
||||
|
||||
export const layerWith = (input: Partial<Interface>) =>
|
||||
Layer.effect(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * as Image from "./image"
|
||||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Config } from "./config"
|
||||
import { FileSystem } from "./filesystem"
|
||||
@@ -76,3 +77,5 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer))
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Config.node] })
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Location } from "./location"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { SystemContext } from "./system-context/index"
|
||||
import { SystemContextRegistry } from "./system-context/registry"
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
|
||||
class File extends Schema.Class<File>("InstructionContext.File")({
|
||||
path: AbsolutePath,
|
||||
@@ -87,6 +88,12 @@ export const layer = Layer.effectDiscard(
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
name: "instruction-context",
|
||||
layer,
|
||||
deps: [FSUtil.node, Global.node, Location.node, SystemContextRegistry.node],
|
||||
})
|
||||
|
||||
function render(files: ReadonlyArray<File>) {
|
||||
return files.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n")
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * as Integration from "./integration"
|
||||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import {
|
||||
Cause,
|
||||
Clock,
|
||||
@@ -515,3 +516,5 @@ export const locationLayer = Layer.effect(
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [Credential.node, EventV2.node] })
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
import { Effect, Layer, LayerMap } from "effect"
|
||||
import { Location } from "./location"
|
||||
import { Policy } from "./policy"
|
||||
import { Config } from "./config"
|
||||
import { PluginV2 } from "./plugin"
|
||||
import { Catalog } from "./catalog"
|
||||
import { Integration } from "./integration"
|
||||
import { CommandV2 } from "./command"
|
||||
import { AgentV2 } from "./agent"
|
||||
import { PluginInternal } from "./plugin/internal"
|
||||
import { Project } from "./project"
|
||||
import { ProjectCopy } from "./project/copy"
|
||||
import { ProjectDirectories } from "./project/directories"
|
||||
import { EventV2 } from "./event"
|
||||
import { Credential } from "./credential"
|
||||
import { Npm } from "./npm"
|
||||
import { ModelsDev } from "./models-dev"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { Git } from "./git"
|
||||
import { Global } from "./global"
|
||||
import { Database } from "./database/database"
|
||||
import { PermissionV2 } from "./permission"
|
||||
import { PermissionSaved } from "./permission/saved"
|
||||
import { FileSystem } from "./filesystem"
|
||||
import { Ripgrep } from "./ripgrep"
|
||||
import { Watcher } from "./filesystem/watcher"
|
||||
import { LocationMutation } from "./location-mutation"
|
||||
import { FileMutation } from "./file-mutation"
|
||||
import { Reference } from "./reference"
|
||||
import { ReferenceGuidance } from "./reference/guidance"
|
||||
import { RepositoryCache } from "./repository-cache"
|
||||
import { Pty } from "./pty"
|
||||
import { SkillV2 } from "./skill"
|
||||
import { SkillGuidance } from "./skill/guidance"
|
||||
import { BuiltInTools } from "./tool/builtins"
|
||||
import { Image } from "./image"
|
||||
import { ToolRegistry } from "./tool/registry"
|
||||
import { ApplicationTools } from "./tool/application-tools"
|
||||
import { ToolOutputStore } from "./tool-output-store"
|
||||
import { AppProcess } from "./process"
|
||||
import { SessionStore } from "./session/store"
|
||||
import { SessionTodo } from "./session/todo"
|
||||
import { QuestionV2 } from "./question"
|
||||
import { LLMClient } from "@opencode-ai/llm"
|
||||
import { RequestExecutor } from "@opencode-ai/llm/route"
|
||||
import * as SessionRunnerLLM from "./session/runner/llm"
|
||||
import { SessionRunnerModel } from "./session/runner/model"
|
||||
import { SystemContextBuiltIns } from "./system-context/builtins"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { Snapshot } from "./snapshot"
|
||||
|
||||
export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
|
||||
lookup: (ref: Location.Ref) => {
|
||||
const boot = Layer.effectDiscard(
|
||||
Effect.logInfo("booting location services", { directory: ref.directory, workspaceID: ref.workspaceID }),
|
||||
)
|
||||
const location = Location.layer(ref)
|
||||
const systemContext = SystemContextBuiltIns.locationLayer
|
||||
const base = Layer.mergeAll(
|
||||
location,
|
||||
Policy.locationLayer,
|
||||
Config.locationLayer,
|
||||
Reference.locationLayer,
|
||||
PluginV2.locationLayer,
|
||||
Catalog.locationLayer,
|
||||
Integration.locationLayer,
|
||||
CommandV2.locationLayer,
|
||||
AgentV2.locationLayer,
|
||||
PluginInternal.locationLayer,
|
||||
ProjectCopy.locationLayer,
|
||||
FileSystem.locationLayer,
|
||||
Watcher.locationLayer,
|
||||
Pty.locationLayer,
|
||||
SkillV2.locationLayer,
|
||||
systemContext,
|
||||
LocationMutation.locationLayer.pipe(Layer.orDie),
|
||||
).pipe(Layer.provideMerge(location))
|
||||
const resources = ToolOutputStore.layer.pipe(Layer.provide(base))
|
||||
const permissionsAndTools = ToolRegistry.layer.pipe(
|
||||
Layer.provideMerge(PermissionV2.locationLayer),
|
||||
Layer.provide(resources),
|
||||
Layer.provide(base),
|
||||
)
|
||||
const services = Layer.mergeAll(base, resources, permissionsAndTools)
|
||||
const image = Image.layer.pipe(Layer.provide(services))
|
||||
const mutation = FileMutation.locationLayer.pipe(Layer.provide(services))
|
||||
const skillGuidance = SkillGuidance.locationLayer.pipe(Layer.provide(services))
|
||||
const referenceGuidance = ReferenceGuidance.locationLayer.pipe(Layer.provide(services))
|
||||
const todos = SessionTodo.layer.pipe(Layer.provide(services))
|
||||
const questions = QuestionV2.locationLayer.pipe(Layer.provide(services))
|
||||
const builtInTools = BuiltInTools.locationLayer.pipe(
|
||||
Layer.provide(services),
|
||||
Layer.provide(mutation),
|
||||
Layer.provide(resources),
|
||||
Layer.provide(todos),
|
||||
Layer.provide(questions),
|
||||
Layer.provide(image),
|
||||
)
|
||||
const model = SessionRunnerModel.locationLayer.pipe(Layer.provide(services))
|
||||
const snapshot = Snapshot.locationLayer.pipe(Layer.provide(services))
|
||||
const runner = SessionRunnerLLM.defaultLayer.pipe(
|
||||
Layer.provide(services),
|
||||
Layer.provide(model),
|
||||
Layer.provide(skillGuidance),
|
||||
Layer.provide(referenceGuidance),
|
||||
Layer.provide(snapshot),
|
||||
)
|
||||
|
||||
// Kick off a background project copy refresh to update locations now that we
|
||||
// have a location
|
||||
const projectCopyRefresh = Layer.effectDiscard(ProjectCopy.refreshAfterBoot).pipe(Layer.provide(services))
|
||||
|
||||
return Layer.mergeAll(
|
||||
boot,
|
||||
services,
|
||||
image,
|
||||
mutation,
|
||||
resources,
|
||||
todos,
|
||||
questions,
|
||||
model,
|
||||
snapshot,
|
||||
runner,
|
||||
builtInTools,
|
||||
referenceGuidance,
|
||||
projectCopyRefresh,
|
||||
).pipe(Layer.fresh)
|
||||
},
|
||||
idleTimeToLive: "60 minutes",
|
||||
dependencies: [
|
||||
Project.defaultLayer,
|
||||
EventV2.defaultLayer,
|
||||
Credential.defaultLayer,
|
||||
Npm.defaultLayer,
|
||||
ModelsDev.defaultLayer,
|
||||
FSUtil.defaultLayer,
|
||||
Git.defaultLayer,
|
||||
AppProcess.defaultLayer,
|
||||
Global.defaultLayer,
|
||||
Ripgrep.defaultLayer,
|
||||
Database.defaultLayer,
|
||||
ProjectDirectories.defaultLayer,
|
||||
SessionStore.layer.pipe(Layer.provide(Database.defaultLayer)),
|
||||
PermissionSaved.defaultLayer,
|
||||
RepositoryCache.defaultLayer,
|
||||
LLMClient.layer.pipe(Layer.provide(RequestExecutor.defaultLayer)),
|
||||
FetchHttpClient.layer,
|
||||
ToolOutputStore.defaultCleanupLayer,
|
||||
ApplicationTools.layer,
|
||||
],
|
||||
}) {}
|
||||
@@ -1,5 +1,6 @@
|
||||
export * as LocationMutation from "./location-mutation"
|
||||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { FSUtil } from "./fs-util"
|
||||
@@ -153,3 +154,9 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer: layer.pipe(Layer.orDie),
|
||||
deps: [FSUtil.node, Location.node],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Context, Effect, Layer, LayerMap } from "effect"
|
||||
import { LayerNode } from "./effect/layer-node"
|
||||
import { Node } from "./effect/app-node"
|
||||
import { Location } from "./location"
|
||||
import type { LocationError, LocationServices } from "./location-services"
|
||||
|
||||
export class Service extends Context.Service<
|
||||
Service,
|
||||
LayerMap.LayerMap<Location.Ref, LocationServices, LocationError>
|
||||
>()("@opencode/example/LocationServiceMap") {
|
||||
static get(ref: Location.Ref) {
|
||||
return Layer.unwrap(Effect.map(Service, (locations) => locations.get(ref)))
|
||||
}
|
||||
}
|
||||
|
||||
export const node = LayerNode.unbound(Service, Node.tags.values.global)
|
||||
|
||||
export * as LocationServiceMap from "./location-service-map"
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Effect, Layer, LayerMap } from "effect"
|
||||
import { AgentV2 } from "./agent"
|
||||
import { AISDK } from "./aisdk"
|
||||
import { Catalog } from "./catalog"
|
||||
import { CommandV2 } from "./command"
|
||||
import { Config } from "./config"
|
||||
import { LayerNode } from "./effect/layer-node"
|
||||
import { Node } from "./effect/app-node"
|
||||
import { FileMutation } from "./file-mutation"
|
||||
import { FileSystem } from "./filesystem"
|
||||
import { FileSystemSearch } from "./filesystem/search"
|
||||
import { Watcher } from "./filesystem/watcher"
|
||||
import { Image } from "./image"
|
||||
import { Integration } from "./integration"
|
||||
import { Location } from "./location"
|
||||
import { LocationMutation } from "./location-mutation"
|
||||
import { LocationServiceMap } from "./location-service-map"
|
||||
import { PermissionV2 } from "./permission"
|
||||
import { PluginV2 } from "./plugin"
|
||||
import { PluginInternal } from "./plugin/internal"
|
||||
import { Policy } from "./policy"
|
||||
import { ProjectCopy } from "./project/copy"
|
||||
import { Pty } from "./pty"
|
||||
import { QuestionV2 } from "./question"
|
||||
import { Reference } from "./reference"
|
||||
import { ReferenceGuidance } from "./reference/guidance"
|
||||
import * as SessionRunnerLLM from "./session/runner/llm"
|
||||
import { SessionRunnerModel } from "./session/runner/model"
|
||||
import { SessionRuntime } from "./session/runtime"
|
||||
import { SessionTodo } from "./session/todo"
|
||||
import { SkillV2 } from "./skill"
|
||||
import { SkillGuidance } from "./skill/guidance"
|
||||
import { Snapshot } from "./snapshot"
|
||||
import { SystemContextBuiltIns } from "./system-context/builtins"
|
||||
import { SystemContextRegistry } from "./system-context/registry"
|
||||
import { BuiltInTools } from "./tool/builtins"
|
||||
import { ReadToolFileSystem } from "./tool/read-filesystem"
|
||||
import { ToolRegistry } from "./tool/registry"
|
||||
import { ToolOutputStore } from "./tool-output-store"
|
||||
|
||||
export { LocationServiceMap } from "./location-service-map"
|
||||
|
||||
export const locationServices = LayerNode.group([
|
||||
Location.node,
|
||||
Policy.node,
|
||||
Config.node,
|
||||
AgentV2.node,
|
||||
CommandV2.node,
|
||||
Reference.node,
|
||||
Integration.node,
|
||||
Catalog.node,
|
||||
AISDK.node,
|
||||
PluginV2.node,
|
||||
PluginInternal.node,
|
||||
ProjectCopy.node,
|
||||
ProjectCopy.refreshNode,
|
||||
FileSystemSearch.node,
|
||||
FileSystem.node,
|
||||
Watcher.node,
|
||||
Pty.node,
|
||||
SkillV2.node,
|
||||
SystemContextRegistry.node,
|
||||
SystemContextBuiltIns.node,
|
||||
LocationMutation.node,
|
||||
FileMutation.node,
|
||||
PermissionV2.node,
|
||||
ToolOutputStore.node,
|
||||
ToolRegistry.node,
|
||||
ToolRegistry.toolsNode,
|
||||
Image.node,
|
||||
SkillGuidance.node,
|
||||
ReferenceGuidance.node,
|
||||
SessionTodo.node,
|
||||
QuestionV2.node,
|
||||
ReadToolFileSystem.node,
|
||||
BuiltInTools.node,
|
||||
SessionRunnerModel.node,
|
||||
Snapshot.node,
|
||||
SessionRuntime.node,
|
||||
SessionRunnerLLM.node,
|
||||
])
|
||||
|
||||
export type LocationServices = LayerNode.Output<typeof locationServices>
|
||||
export type LocationError = LayerNode.Error<typeof locationServices>
|
||||
|
||||
export function buildLocationServiceMap(
|
||||
replacements?: ReadonlyMap<Layer.Any, Layer.Any>,
|
||||
): Layer.Layer<LocationServiceMap.Service> {
|
||||
return Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
LayerMap.make(
|
||||
(ref: Location.Ref) => {
|
||||
const location = LayerNode.hoist(
|
||||
LayerNode.bind(locationServices, Location.node, Location.boundNode(ref)),
|
||||
Node.tags.values.global,
|
||||
)
|
||||
return LayerNode.compile(location.node, replacements).pipe(
|
||||
Layer.fresh,
|
||||
Layer.tap(() =>
|
||||
Effect.logInfo("booting location services", {
|
||||
directory: ref.directory,
|
||||
workspaceID: ref.workspaceID,
|
||||
}),
|
||||
),
|
||||
Layer.provide(LayerNode.compile(location.hoisted, replacements)),
|
||||
)
|
||||
},
|
||||
{ idleTimeToLive: "60 minutes" },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// This is temporary for backwards compatibility
|
||||
export const locationServiceMapLayer = buildLocationServiceMap()
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Info, Ref, response } from "@opencode-ai/schema/location"
|
||||
import { Project } from "./project"
|
||||
import { LayerNode } from "./effect/layer-node"
|
||||
import { makeLocationNode, tags } from "./effect/app-node"
|
||||
|
||||
export * as Location from "./location"
|
||||
|
||||
@@ -12,6 +14,8 @@ export interface Interface extends Info {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Location") {}
|
||||
|
||||
export const node = LayerNode.unbound(Service, tags.values.location)
|
||||
|
||||
export const layer = (ref: Ref) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
@@ -26,3 +30,10 @@ export const layer = (ref: Ref) =>
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const boundNode = (ref: Ref) =>
|
||||
makeLocationNode({
|
||||
service: Service,
|
||||
layer: layer(ref),
|
||||
deps: [Project.node],
|
||||
})
|
||||
|
||||
@@ -9,8 +9,8 @@ import { Hash } from "./util/hash"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { InstallationChannel, InstallationVersion } from "./installation/version"
|
||||
import { EventV2 } from "./event"
|
||||
import { LayerNode } from "./effect/layer-node"
|
||||
import { httpClient } from "./effect/layer-node-platform"
|
||||
import { makeGlobalNode } from "./effect/app-node"
|
||||
import { httpClient } from "./effect/app-node-platform"
|
||||
|
||||
export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"])
|
||||
export type CatalogModelStatus = typeof CatalogModelStatus.Type
|
||||
@@ -244,6 +244,6 @@ export const defaultLayer = layer.pipe(
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
)
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [FSUtil.node, EventV2.node, httpClient] })
|
||||
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [FSUtil.node, EventV2.node, httpClient] })
|
||||
|
||||
export * as ModelsDev from "./models-dev"
|
||||
|
||||
@@ -7,8 +7,8 @@ import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { Global } from "./global"
|
||||
import { EffectFlock } from "./util/effect-flock"
|
||||
import { LayerNode } from "./effect/layer-node"
|
||||
import { filesystem } from "./effect/layer-node-platform"
|
||||
import { makeGlobalNode } from "./effect/app-node"
|
||||
import { filesystem } from "./effect/app-node-platform"
|
||||
import { makeRuntime } from "./effect/runtime"
|
||||
import { NpmConfig } from "./npm-config"
|
||||
|
||||
@@ -253,7 +253,7 @@ export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Global.layer),
|
||||
Layer.provide(NodeFileSystem.layer),
|
||||
)
|
||||
export const node = LayerNode.make({
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [FSUtil.node, Global.node, filesystem, EffectFlock.node],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * as PermissionV2 from "./permission"
|
||||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import { Context, Deferred, Effect as EffectRuntime, Layer, Schema } from "effect"
|
||||
import { Permission } from "@opencode-ai/schema/permission"
|
||||
import { EventV2 } from "./event"
|
||||
@@ -300,3 +301,9 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
export const locationLayer = layer.pipe(Layer.provideMerge(AgentV2.locationLayer))
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [EventV2.node, Location.node, AgentV2.node, SessionStore.node, PermissionSaved.node],
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ export * as PermissionSaved from "./saved"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Database } from "../database/database"
|
||||
import { makeGlobalNode } from "../effect/app-node"
|
||||
import { ProjectV2 } from "../project"
|
||||
import { PermissionTable } from "./sql"
|
||||
import { PermissionSaved } from "@opencode-ai/schema/permission-saved"
|
||||
@@ -76,3 +77,5 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node] })
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * as PluginV2 from "./plugin"
|
||||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import { Context, Deferred, Effect, Exit, Layer, Scope } from "effect"
|
||||
import type { Plugin as PluginRuntime } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Plugin } from "@opencode-ai/schema/plugin"
|
||||
@@ -12,7 +13,10 @@ import { Integration } from "./integration"
|
||||
import { KeyedMutex } from "./effect/keyed-mutex"
|
||||
import { PluginHost } from "./plugin/host"
|
||||
import { Reference } from "./reference"
|
||||
import { SessionV2 } from "./session"
|
||||
import { SessionRuntime } from "./session/runtime"
|
||||
import { SkillV2 } from "./skill"
|
||||
import { Location } from "./location"
|
||||
import { State } from "./state"
|
||||
|
||||
export const ID = Plugin.ID
|
||||
@@ -148,5 +152,25 @@ export const locationLayer = layer.pipe(
|
||||
Layer.provideMerge(CommandV2.locationLayer),
|
||||
Layer.provideMerge(Integration.locationLayer),
|
||||
Layer.provideMerge(Reference.locationLayer),
|
||||
Layer.provideMerge(SessionV2.defaultLayer),
|
||||
Layer.provideMerge(SessionRuntime.layer),
|
||||
Layer.provideMerge(SkillV2.locationLayer),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [
|
||||
EventV2.node,
|
||||
AgentV2.node,
|
||||
AISDK.node,
|
||||
Catalog.node,
|
||||
CommandV2.node,
|
||||
Integration.node,
|
||||
Reference.node,
|
||||
SessionV2.node,
|
||||
SessionRuntime.node,
|
||||
SkillV2.node,
|
||||
Location.node,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -13,7 +13,11 @@ import { PluginV2 } from "../plugin"
|
||||
import { ProviderV2 } from "../provider"
|
||||
import { Reference } from "../reference"
|
||||
import type { DeepMutable } from "../schema"
|
||||
import { SessionV2 } from "../session"
|
||||
import { SessionMessage } from "../session/message"
|
||||
import { SessionRuntime } from "../session/runtime"
|
||||
import { SkillV2 } from "../skill"
|
||||
import { Location } from "../location"
|
||||
|
||||
const mutable = <T>(value: T) => value as DeepMutable<T>
|
||||
|
||||
@@ -24,7 +28,10 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
||||
const commands = yield* CommandV2.Service
|
||||
const integration = yield* Integration.Service
|
||||
const reference = yield* Reference.Service
|
||||
const session = yield* SessionV2.Service
|
||||
const sessionRuntime = yield* SessionRuntime.Service
|
||||
const skill = yield* SkillV2.Service
|
||||
const location = yield* Location.Service
|
||||
|
||||
return {
|
||||
options: {},
|
||||
@@ -205,6 +212,60 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
||||
}),
|
||||
),
|
||||
},
|
||||
session: {
|
||||
create: (input) =>
|
||||
session.create(
|
||||
input.parentID
|
||||
? {
|
||||
id: input.id ? SessionV2.ID.make(input.id) : undefined,
|
||||
parentID: SessionV2.ID.make(input.parentID),
|
||||
title: input.title,
|
||||
agent: input.agent ? AgentV2.ID.make(input.agent) : undefined,
|
||||
model: input.model
|
||||
? {
|
||||
id: ModelV2.ID.make(input.model.id),
|
||||
providerID: ProviderV2.ID.make(input.model.providerID),
|
||||
variant: input.model.variant ? ModelV2.VariantID.make(input.model.variant) : undefined,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
: {
|
||||
id: input.id ? SessionV2.ID.make(input.id) : undefined,
|
||||
location: { directory: location.directory, workspaceID: location.workspaceID },
|
||||
title: input.title,
|
||||
agent: input.agent ? AgentV2.ID.make(input.agent) : undefined,
|
||||
model: input.model
|
||||
? {
|
||||
id: ModelV2.ID.make(input.model.id),
|
||||
providerID: ProviderV2.ID.make(input.model.providerID),
|
||||
variant: input.model.variant ? ModelV2.VariantID.make(input.model.variant) : undefined,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
),
|
||||
get: (sessionID) => session.get(SessionV2.ID.make(sessionID)),
|
||||
messages: (input) =>
|
||||
session.messages({
|
||||
sessionID: SessionV2.ID.make(input.sessionID),
|
||||
limit: input.limit,
|
||||
order: input.order,
|
||||
cursor: input.cursor
|
||||
? { id: SessionMessage.ID.make(input.cursor.id), direction: input.cursor.direction }
|
||||
: undefined,
|
||||
}),
|
||||
context: (sessionID) => session.context(SessionV2.ID.make(sessionID)),
|
||||
prompt: (input) =>
|
||||
sessionRuntime.prompt({
|
||||
id: input.id ? SessionMessage.ID.make(input.id) : undefined,
|
||||
sessionID: SessionV2.ID.make(input.sessionID),
|
||||
prompt: input.prompt,
|
||||
delivery: input.delivery,
|
||||
resume: input.resume,
|
||||
}),
|
||||
resume: (sessionID) => sessionRuntime.resume(SessionV2.ID.make(sessionID)),
|
||||
wait: (sessionID) => sessionRuntime.wait(SessionV2.ID.make(sessionID)),
|
||||
interrupt: (sessionID) => sessionRuntime.interrupt(SessionV2.ID.make(sessionID)),
|
||||
},
|
||||
skill: {
|
||||
reload: skill.reload,
|
||||
transform: (callback) =>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export * as PluginInternal from "./internal"
|
||||
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { httpClient } from "../effect/app-node-platform"
|
||||
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Effect, Layer, Scope } from "effect"
|
||||
import { AgentV2 } from "../agent"
|
||||
@@ -58,7 +60,7 @@ export function define<R>(plugin: Plugin<R>) {
|
||||
return plugin
|
||||
}
|
||||
|
||||
export const locationLayer = Layer.effectDiscard(
|
||||
const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const commands = yield* CommandV2.Service
|
||||
@@ -120,9 +122,34 @@ export const locationLayer = Layer.effectDiscard(
|
||||
}),
|
||||
).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true }))
|
||||
}),
|
||||
).pipe(
|
||||
)
|
||||
|
||||
export const locationLayer = layer.pipe(
|
||||
Layer.provideMerge(PluginV2.locationLayer),
|
||||
Layer.provideMerge(Config.locationLayer),
|
||||
Layer.provideMerge(FileSystem.locationLayer),
|
||||
Layer.provideMerge(FetchHttpClient.layer),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
name: "plugin-internal",
|
||||
layer,
|
||||
deps: [
|
||||
Catalog.node,
|
||||
CommandV2.node,
|
||||
PluginV2.node,
|
||||
Integration.node,
|
||||
AgentV2.node,
|
||||
Config.node,
|
||||
Location.node,
|
||||
ModelsDev.node,
|
||||
Npm.node,
|
||||
EventV2.node,
|
||||
FSUtil.node,
|
||||
FileSystem.node,
|
||||
Global.node,
|
||||
httpClient,
|
||||
SkillV2.node,
|
||||
Reference.node,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -81,6 +81,16 @@ export function fromPromise(plugin: Plugin) {
|
||||
transform: transform(host.reference),
|
||||
reload: () => run(host.reference.reload()),
|
||||
},
|
||||
session: {
|
||||
create: (input) => run(host.session.create(input)),
|
||||
get: (sessionID) => run(host.session.get(sessionID)),
|
||||
messages: (input) => run(host.session.messages(input)),
|
||||
context: (sessionID) => run(host.session.context(sessionID)),
|
||||
prompt: (input) => run(host.session.prompt(input)),
|
||||
resume: (sessionID) => run(host.session.resume(sessionID)),
|
||||
wait: (sessionID) => run(host.session.wait(sessionID)),
|
||||
interrupt: (sessionID) => run(host.session.interrupt(sessionID)),
|
||||
},
|
||||
skill: {
|
||||
transform: transform(host.skill),
|
||||
reload: () => run(host.skill.reload()),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * as Policy from "./policy"
|
||||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import { Context, Effect as EffectRuntime, Layer, Schema } from "effect"
|
||||
import { Wildcard } from "./util/wildcard"
|
||||
import { Location } from "./location"
|
||||
@@ -44,3 +45,5 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Location.node] })
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { PlatformError } from "effect/PlatformError"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import { CrossSpawnSpawner } from "./cross-spawn-spawner"
|
||||
import { LayerNode } from "./effect/layer-node"
|
||||
import { makeGlobalNode } from "./effect/app-node"
|
||||
|
||||
export class AppProcessError extends Schema.TaggedErrorClass<AppProcessError>()("AppProcessError", {
|
||||
command: Schema.String,
|
||||
@@ -257,6 +257,6 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(CrossSpawnSpawner.defaultLayer))
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [CrossSpawnSpawner.node] })
|
||||
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [CrossSpawnSpawner.node] })
|
||||
|
||||
export * as AppProcess from "./process"
|
||||
|
||||
@@ -6,7 +6,7 @@ import path from "path"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { Git } from "./git"
|
||||
import { LayerNode } from "./effect/layer-node"
|
||||
import { makeGlobalNode } from "./effect/app-node"
|
||||
import { Hash } from "./util/hash"
|
||||
import { ProjectDirectories } from "./project/directories"
|
||||
import { ProjectSchema } from "./project/schema"
|
||||
@@ -134,7 +134,7 @@ export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provideMerge(ProjectDirectories.defaultLayer),
|
||||
)
|
||||
export const node = LayerNode.make({
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [FSUtil.node, Git.node, ProjectDirectories.node],
|
||||
|
||||
@@ -5,7 +5,7 @@ import path from "path"
|
||||
import { AbsolutePath } from "../schema"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { Git } from "../git"
|
||||
import { LayerNode } from "../effect/layer-node"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { Project } from "../project"
|
||||
import { ProjectDirectories } from "./directories"
|
||||
import { makeGitWorktreeStrategy } from "./copy-strategies"
|
||||
@@ -279,8 +279,14 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
export const node = LayerNode.make({
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [FSUtil.node, Git.node, ProjectDirectories.node, EventV2.node, Database.node],
|
||||
})
|
||||
|
||||
export const refreshNode = makeLocationNode({
|
||||
name: "project-copy-refresh",
|
||||
layer: Layer.effectDiscard(refreshAfterBoot),
|
||||
deps: [node, Location.node],
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ export * as ProjectDirectories from "./directories"
|
||||
import { and, asc, desc, eq, isNotNull, isNull, ne, or } from "drizzle-orm"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Database } from "../database/database"
|
||||
import { LayerNode } from "../effect/layer-node"
|
||||
import { makeGlobalNode } from "../effect/app-node"
|
||||
import { AbsolutePath, optional } from "../schema"
|
||||
import { ProjectSchema } from "./schema"
|
||||
import { ProjectDirectoryTable } from "./sql"
|
||||
@@ -156,4 +156,4 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [Database.node] })
|
||||
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [Database.node] })
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * as Pty from "./pty"
|
||||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import type { Disp, Proc } from "#pty"
|
||||
import { Context, Effect, Layer, Schema, Types } from "effect"
|
||||
import { Pty } from "@opencode-ai/schema/pty"
|
||||
@@ -313,3 +314,5 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer))
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Location.node, Config.node] })
|
||||
|
||||
@@ -4,7 +4,7 @@ import { WorkspaceV2 } from "../workspace"
|
||||
import { PtyTicket } from "@opencode-ai/schema/pty-ticket"
|
||||
import { PtyID } from "./schema"
|
||||
import { Cache, Context, Duration, Effect, Layer } from "effect"
|
||||
import { LayerNode } from "../effect/layer-node"
|
||||
import { makeGlobalNode } from "../effect/app-node"
|
||||
|
||||
const DEFAULT_TTL = Duration.seconds(60)
|
||||
const CAPACITY = 10_000
|
||||
@@ -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({ service: Service, layer: layer, deps: [] })
|
||||
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [] })
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
export * as PublicEventManifest from "./public-event-manifest"
|
||||
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { EventManifest } from "@opencode-ai/schema/event-manifest"
|
||||
|
||||
export const Definitions = EventManifest.ServerDefinitions
|
||||
export const Latest = Event.latest(Definitions)
|
||||
@@ -1,5 +1,6 @@
|
||||
export * as QuestionV2 from "./question"
|
||||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import { Context, Deferred, Effect, Layer, Schema } from "effect"
|
||||
import { Question } from "@opencode-ai/schema/question"
|
||||
import { EventV2 } from "./event"
|
||||
@@ -148,3 +149,5 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node] })
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * as Reference from "./reference"
|
||||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import { Context, Effect, Layer, Scope, Types } from "effect"
|
||||
import { Reference } from "@opencode-ai/schema/reference"
|
||||
import { Global } from "./global"
|
||||
@@ -120,3 +121,9 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Global.node, EventV2.node, RepositoryCache.node],
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * as ReferenceGuidance from "./guidance"
|
||||
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Reference } from "../reference"
|
||||
import { SystemContext } from "../system-context/index"
|
||||
@@ -64,3 +65,5 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Reference.node] })
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Git } from "./git"
|
||||
import { Global } from "./global"
|
||||
import { Repository } from "./repository"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { makeGlobalNode } from "./effect/app-node"
|
||||
import { EffectFlock } from "./util/effect-flock"
|
||||
|
||||
export type Result = {
|
||||
@@ -229,6 +230,12 @@ export const defaultLayer: Layer.Layer<Service> = layer.pipe(
|
||||
Layer.provide(Global.defaultLayer),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [EffectFlock.node, FSUtil.node, Git.node, Global.node],
|
||||
})
|
||||
|
||||
function statusForRepository(input: { reuse: boolean; refresh?: boolean; branchMatches?: boolean }) {
|
||||
if (!input.reuse) return "cloned" as const
|
||||
if (input.branchMatches === false || input.refresh) return "refreshed" as const
|
||||
|
||||
@@ -3,7 +3,7 @@ export * as Ripgrep from "./ripgrep"
|
||||
import { Context, Effect, Fiber, Layer, Schema, Stream } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { Entry, Match } from "@opencode-ai/schema/filesystem"
|
||||
import { LayerNode } from "./effect/layer-node"
|
||||
import { makeGlobalNode } from "./effect/app-node"
|
||||
import { AppProcess, collectStream, waitForAbort } from "./process"
|
||||
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
|
||||
import { RipgrepBinary } from "./ripgrep/binary"
|
||||
@@ -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({ service: Service, layer: layer, deps: [RipgrepBinary.node, AppProcess.node] })
|
||||
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [RipgrepBinary.node, AppProcess.node] })
|
||||
|
||||
@@ -4,8 +4,8 @@ import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import { CrossSpawnSpawner } from "../cross-spawn-spawner"
|
||||
import { LayerNode } from "../effect/layer-node"
|
||||
import { httpClient } from "../effect/layer-node-platform"
|
||||
import { makeGlobalNode } from "../effect/app-node"
|
||||
import { httpClient } from "../effect/app-node-platform"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { Global } from "../global"
|
||||
import { which } from "../util/which"
|
||||
@@ -130,7 +130,7 @@ export namespace RipgrepBinary {
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
)
|
||||
|
||||
export const node = LayerNode.make({
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [FSUtil.node, httpClient, CrossSpawnSpawner.node],
|
||||
|
||||
+311
-284
@@ -25,8 +25,9 @@ import { ProjectTable } from "./project/sql"
|
||||
import path from "path"
|
||||
import { fromRow } from "./session/info"
|
||||
import { SessionRunner } from "./session/runner/index"
|
||||
import { SessionRuntimeCoordinator } from "./session/runtime-coordinator"
|
||||
import { SessionStore } from "./session/store"
|
||||
import { SessionExecution } from "./session/execution"
|
||||
import { makeGlobalNode } from "./effect/app-node"
|
||||
import { MessageDecodeError } from "./session/error"
|
||||
import { SessionEvent } from "./session/event"
|
||||
import { SessionInput } from "./session/input"
|
||||
@@ -34,6 +35,7 @@ import { Snapshot } from "./snapshot"
|
||||
import { SessionRevert } from "./session/revert"
|
||||
import { Revert } from "@opencode-ai/schema/revert"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { SessionDurable } from "@opencode-ai/schema/durable-event-manifest"
|
||||
|
||||
export const RevertState = Revert.State
|
||||
export type RevertState = Revert.State
|
||||
@@ -73,12 +75,14 @@ const ListAllInput = Schema.Struct(ListInputBase)
|
||||
export const ListInput = Schema.Union([ListDirectoryInput, ListProjectInput, ListAllInput])
|
||||
export type ListInput = typeof ListInput.Type
|
||||
|
||||
type CreateInput = {
|
||||
type CreateBaseInput = {
|
||||
id?: SessionSchema.ID
|
||||
title?: string
|
||||
agent?: AgentV2.ID
|
||||
model?: ModelV2.Ref
|
||||
location: Location.Ref
|
||||
}
|
||||
type CreateInput = CreateBaseInput &
|
||||
({ location: Location.Ref; parentID?: never } | { parentID: SessionSchema.ID; location?: never })
|
||||
|
||||
type CompactInput = {
|
||||
sessionID: SessionSchema.ID
|
||||
@@ -92,7 +96,7 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Ses
|
||||
export class OperationUnavailableError extends Schema.TaggedErrorClass<OperationUnavailableError>()(
|
||||
"Session.OperationUnavailableError",
|
||||
{
|
||||
operation: Schema.Literals(["move", "shell", "skill", "switchAgent", "compact", "wait"]),
|
||||
operation: Schema.Literals(["move", "shell", "skill", "switchAgent", "compact"]),
|
||||
},
|
||||
) {}
|
||||
|
||||
@@ -102,6 +106,9 @@ export class PromptConflictError extends Schema.TaggedErrorClass<PromptConflictE
|
||||
sessionID: SessionSchema.ID,
|
||||
messageID: SessionMessage.ID,
|
||||
}) {}
|
||||
export class BusyError extends Schema.TaggedErrorClass<BusyError>()("Session.BusyError", {
|
||||
sessionID: SessionSchema.ID,
|
||||
}) {}
|
||||
export const MessageNotFoundError = SessionRevert.MessageNotFoundError
|
||||
export type MessageNotFoundError = SessionRevert.MessageNotFoundError
|
||||
|
||||
@@ -109,7 +116,7 @@ export type Error = NotFoundError | MessageDecodeError | OperationUnavailableErr
|
||||
|
||||
export interface Interface {
|
||||
readonly list: (input?: ListInput) => Effect.Effect<SessionSchema.Info[]>
|
||||
readonly create: (input: CreateInput) => Effect.Effect<SessionSchema.Info>
|
||||
readonly create: (input: CreateInput) => Effect.Effect<SessionSchema.Info, NotFoundError>
|
||||
readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<SessionSchema.Info, NotFoundError>
|
||||
readonly messages: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
@@ -131,11 +138,17 @@ export interface Interface {
|
||||
sessionID: SessionSchema.ID
|
||||
after?: number
|
||||
}) => Stream.Stream<SessionEvent.DurableEvent, NotFoundError>
|
||||
readonly history: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
after?: number
|
||||
limit: number
|
||||
}) => Effect.Effect<{ events: ReadonlyArray<SessionEvent.DurableEvent>; hasMore: boolean }, NotFoundError>
|
||||
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect<void, NotFoundError>
|
||||
readonly switchModel: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
model: ModelV2.Ref
|
||||
}) => Effect.Effect<void, NotFoundError>
|
||||
readonly rename: (input: { sessionID: SessionSchema.ID; title: string }) => Effect.Effect<void, NotFoundError>
|
||||
readonly prompt: (input: {
|
||||
id?: SessionMessage.ID
|
||||
sessionID: SessionSchema.ID
|
||||
@@ -156,7 +169,7 @@ export interface Interface {
|
||||
resume?: boolean
|
||||
}) => Effect.Effect<void, OperationUnavailableError>
|
||||
readonly compact: (input: CompactInput) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
|
||||
readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
|
||||
readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
|
||||
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
|
||||
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | SessionRunner.RunError>
|
||||
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
@@ -165,300 +178,300 @@ export interface Interface {
|
||||
sessionID: SessionSchema.ID
|
||||
messageID: SessionMessage.ID
|
||||
files?: boolean
|
||||
}) => Effect.Effect<Revert.State, NotFoundError | MessageNotFoundError | Snapshot.Error>
|
||||
readonly clear: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | Snapshot.Error>
|
||||
readonly commit: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
|
||||
}) => Effect.Effect<Revert.State, NotFoundError | MessageNotFoundError | BusyError | Snapshot.Error>
|
||||
readonly clear: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | BusyError | Snapshot.Error>
|
||||
readonly commit: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | BusyError>
|
||||
}
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Session") {}
|
||||
|
||||
export const layer = Layer.unwrap(
|
||||
Effect.promise(() => import("./location-layer")).pipe(
|
||||
Effect.map(({ LocationServiceMap }) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const db = database.db
|
||||
const events = yield* EventV2.Service
|
||||
const projects = yield* ProjectV2.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const locations = yield* LocationServiceMap
|
||||
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
|
||||
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
|
||||
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||
decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
|
||||
Effect.mapError(
|
||||
() =>
|
||||
new MessageDecodeError({
|
||||
sessionID: SessionSchema.ID.make(row.session_id),
|
||||
messageID: SessionMessage.ID.make(row.id),
|
||||
}),
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const db = database.db
|
||||
const events = yield* EventV2.Service
|
||||
const projects = yield* ProjectV2.Service
|
||||
const runtimeCoordinator = yield* SessionRuntimeCoordinator.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
|
||||
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
|
||||
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||
decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
|
||||
Effect.mapError(
|
||||
() =>
|
||||
new MessageDecodeError({
|
||||
sessionID: SessionSchema.ID.make(row.session_id),
|
||||
messageID: SessionMessage.ID.make(row.id),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const result = Service.of({
|
||||
create: Effect.fn("V2Session.create")(function* (input) {
|
||||
const sessionID = input.id ?? SessionSchema.ID.create()
|
||||
const recorded = yield* store.get(sessionID)
|
||||
if (recorded) return recorded
|
||||
const parent = input.parentID ? yield* store.get(input.parentID) : undefined
|
||||
if (input.parentID && parent === undefined) return yield* new NotFoundError({ sessionID: input.parentID })
|
||||
const location = parent?.location ?? input.location
|
||||
if (location === undefined)
|
||||
return yield* Effect.die(new Error("V2Session.create requires either location or an existing parentID"))
|
||||
const project = yield* projects.resolve(location.directory)
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: project.id, worktree: project.directory, vcs: project.vcs?.type, sandboxes: [] })
|
||||
.onConflictDoNothing()
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const now = Date.now()
|
||||
const info = SessionV1.SessionInfo.make({
|
||||
id: sessionID,
|
||||
slug: Slug.create(),
|
||||
version: InstallationVersion,
|
||||
projectID: project.id,
|
||||
parentID: input.parentID,
|
||||
directory: location.directory,
|
||||
path: path.relative(project.directory, location.directory).replaceAll("\\", "/"),
|
||||
workspaceID: location.workspaceID ? WorkspaceV2.ID.make(location.workspaceID) : undefined,
|
||||
title: input.title ?? `New session - ${new Date(now).toISOString()}`,
|
||||
agent: input.agent,
|
||||
model: input.model
|
||||
? {
|
||||
id: ModelV2.ID.make(input.model.id),
|
||||
providerID: input.model.providerID,
|
||||
variant: input.model.variant,
|
||||
}
|
||||
: undefined,
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: now, updated: now },
|
||||
})
|
||||
const projected = yield* events.publish(SessionV1.Event.Created, { sessionID, info }, { location }).pipe(
|
||||
Effect.as({ type: "created" } as const),
|
||||
Effect.catchDefect((defect) => {
|
||||
if (!(defect instanceof SessionProjector.SessionAlreadyProjected)) {
|
||||
return Effect.die(defect)
|
||||
}
|
||||
// Concurrent creation lost the projection race. The existing Session identity wins.
|
||||
return store
|
||||
.get(sessionID)
|
||||
.pipe(
|
||||
Effect.flatMap((session) =>
|
||||
session ? Effect.succeed({ type: "existing", session } as const) : Effect.die(defect),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
if (projected.type === "existing") return projected.session
|
||||
// TODO: Restore recorded sessions onto replacement synchronized workspaces in a future API slice.
|
||||
return yield* result.get(sessionID).pipe(Effect.orDie)
|
||||
}),
|
||||
get: Effect.fn("V2Session.get")(function* (sessionID) {
|
||||
const session = yield* store.get(sessionID)
|
||||
if (!session) return yield* new NotFoundError({ sessionID })
|
||||
return session
|
||||
}),
|
||||
list: Effect.fn("V2Session.list")(function* (input = {}) {
|
||||
const direction = input.anchor?.direction ?? "next"
|
||||
const requestedOrder = input.order ?? "desc"
|
||||
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
|
||||
const sortColumn = SessionTable.time_created
|
||||
const conditions: SQL[] = []
|
||||
if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory))
|
||||
if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
|
||||
if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project))
|
||||
if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
|
||||
if (input.anchor) {
|
||||
conditions.push(
|
||||
order === "asc"
|
||||
? or(
|
||||
gt(sortColumn, input.anchor.time),
|
||||
and(eq(sortColumn, input.anchor.time), gt(SessionTable.id, input.anchor.id)),
|
||||
)!
|
||||
: or(
|
||||
lt(sortColumn, input.anchor.time),
|
||||
and(eq(sortColumn, input.anchor.time), lt(SessionTable.id, input.anchor.id)),
|
||||
)!,
|
||||
)
|
||||
}
|
||||
const query = db
|
||||
.select()
|
||||
.from(SessionTable)
|
||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||
.orderBy(
|
||||
order === "asc" ? asc(sortColumn) : desc(sortColumn),
|
||||
order === "asc" ? asc(SessionTable.id) : desc(SessionTable.id),
|
||||
)
|
||||
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
|
||||
Effect.orDie,
|
||||
)
|
||||
return (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row))
|
||||
}),
|
||||
messages: Effect.fn("V2Session.messages")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
const direction = input.cursor?.direction ?? "next"
|
||||
const requestedOrder = input.order ?? "desc"
|
||||
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
|
||||
const anchor = input.cursor
|
||||
? yield* db
|
||||
.select({ seq: SessionMessageTable.seq })
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(eq(SessionMessageTable.session_id, input.sessionID), eq(SessionMessageTable.id, input.cursor.id)),
|
||||
)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
: undefined
|
||||
if (input.cursor && !anchor) return []
|
||||
const boundary = anchor
|
||||
? order === "asc"
|
||||
? gt(SessionMessageTable.seq, anchor.seq)
|
||||
: lt(SessionMessageTable.seq, anchor.seq)
|
||||
: undefined
|
||||
const where = boundary
|
||||
? and(eq(SessionMessageTable.session_id, input.sessionID), boundary)
|
||||
: eq(SessionMessageTable.session_id, input.sessionID)
|
||||
const query = db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(where)
|
||||
.orderBy(order === "asc" ? asc(SessionMessageTable.seq) : desc(SessionMessageTable.seq))
|
||||
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
|
||||
Effect.orDie,
|
||||
)
|
||||
return yield* Effect.forEach(direction === "previous" ? rows.toReversed() : rows, decode)
|
||||
}),
|
||||
message: Effect.fn("V2Session.message")(function* (input) {
|
||||
const stored = yield* store.message(input.messageID)
|
||||
return stored?.sessionID === input.sessionID ? stored.message : undefined
|
||||
}),
|
||||
context: Effect.fn("V2Session.context")(function* (sessionID) {
|
||||
yield* result.get(sessionID)
|
||||
return yield* store.context(sessionID)
|
||||
}),
|
||||
events: (input) =>
|
||||
Stream.unwrap(
|
||||
result
|
||||
.get(input.sessionID)
|
||||
.pipe(Effect.as(events.durable({ aggregateID: input.sessionID, after: input.after }))),
|
||||
).pipe(Stream.filter((event): event is SessionEvent.DurableEvent => isDurableSessionEvent(event))),
|
||||
history: Effect.fn("V2Session.history")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
return yield* EventV2.readAggregate(db, {
|
||||
...input,
|
||||
aggregateID: input.sessionID,
|
||||
manifest: SessionDurable,
|
||||
})
|
||||
}),
|
||||
prompt: Effect.fn("V2Session.prompt")((input) =>
|
||||
Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
yield* result.get(input.sessionID)
|
||||
const prompt = resolvePrompt(input.prompt)
|
||||
const messageID = input.id ?? SessionMessage.ID.create()
|
||||
const delivery = input.delivery ?? "steer"
|
||||
const expected = { sessionID: input.sessionID, messageID, prompt, delivery }
|
||||
const admitted = yield* SessionInput.admit(db, events, {
|
||||
id: messageID,
|
||||
sessionID: input.sessionID,
|
||||
prompt,
|
||||
delivery,
|
||||
}).pipe(
|
||||
Effect.catchDefect((defect) =>
|
||||
defect instanceof SessionInput.LifecycleConflict
|
||||
? new PromptConflictError({ sessionID: input.sessionID, messageID })
|
||||
: Effect.die(defect),
|
||||
),
|
||||
)
|
||||
|
||||
const result = Service.of({
|
||||
create: Effect.fn("V2Session.create")(function* (input) {
|
||||
const sessionID = input.id ?? SessionSchema.ID.create()
|
||||
const recorded = yield* store.get(sessionID)
|
||||
if (recorded) return recorded
|
||||
const project = yield* projects.resolve(input.location.directory)
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: project.id, worktree: project.directory, vcs: project.vcs?.type, sandboxes: [] })
|
||||
.onConflictDoNothing()
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const now = Date.now()
|
||||
const info = SessionV1.SessionInfo.make({
|
||||
id: sessionID,
|
||||
slug: Slug.create(),
|
||||
version: InstallationVersion,
|
||||
projectID: project.id,
|
||||
directory: input.location.directory,
|
||||
path: path.relative(project.directory, input.location.directory).replaceAll("\\", "/"),
|
||||
workspaceID: input.location.workspaceID ? WorkspaceV2.ID.make(input.location.workspaceID) : undefined,
|
||||
title: `New session - ${new Date(now).toISOString()}`,
|
||||
agent: input.agent,
|
||||
model: input.model
|
||||
? {
|
||||
id: ModelV2.ID.make(input.model.id),
|
||||
providerID: input.model.providerID,
|
||||
variant: input.model.variant,
|
||||
}
|
||||
: undefined,
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: now, updated: now },
|
||||
})
|
||||
const projected = yield* events
|
||||
.publish(SessionV1.Event.Created, { sessionID, info }, { location: input.location })
|
||||
.pipe(
|
||||
Effect.as({ type: "created" } as const),
|
||||
Effect.catchDefect((defect) => {
|
||||
if (!(defect instanceof SessionProjector.SessionAlreadyProjected)) {
|
||||
return Effect.die(defect)
|
||||
}
|
||||
// Concurrent creation lost the projection race. The existing Session identity wins.
|
||||
return store
|
||||
.get(sessionID)
|
||||
.pipe(
|
||||
Effect.flatMap((session) =>
|
||||
session ? Effect.succeed({ type: "existing", session } as const) : Effect.die(defect),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
if (projected.type === "existing") return projected.session
|
||||
// TODO: Restore recorded sessions onto replacement synchronized workspaces in a future API slice.
|
||||
return yield* result.get(sessionID).pipe(Effect.orDie)
|
||||
}),
|
||||
get: Effect.fn("V2Session.get")(function* (sessionID) {
|
||||
const session = yield* store.get(sessionID)
|
||||
if (!session) return yield* new NotFoundError({ sessionID })
|
||||
return session
|
||||
}),
|
||||
list: Effect.fn("V2Session.list")(function* (input = {}) {
|
||||
const direction = input.anchor?.direction ?? "next"
|
||||
const requestedOrder = input.order ?? "desc"
|
||||
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
|
||||
const sortColumn = SessionTable.time_created
|
||||
const conditions: SQL[] = []
|
||||
if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory))
|
||||
if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
|
||||
if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project))
|
||||
if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
|
||||
if (input.anchor) {
|
||||
conditions.push(
|
||||
order === "asc"
|
||||
? or(
|
||||
gt(sortColumn, input.anchor.time),
|
||||
and(eq(sortColumn, input.anchor.time), gt(SessionTable.id, input.anchor.id)),
|
||||
)!
|
||||
: or(
|
||||
lt(sortColumn, input.anchor.time),
|
||||
and(eq(sortColumn, input.anchor.time), lt(SessionTable.id, input.anchor.id)),
|
||||
)!,
|
||||
)
|
||||
}
|
||||
const query = db
|
||||
.select()
|
||||
.from(SessionTable)
|
||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||
.orderBy(
|
||||
order === "asc" ? asc(sortColumn) : desc(sortColumn),
|
||||
order === "asc" ? asc(SessionTable.id) : desc(SessionTable.id),
|
||||
)
|
||||
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
|
||||
Effect.orDie,
|
||||
)
|
||||
return (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row))
|
||||
}),
|
||||
messages: Effect.fn("V2Session.messages")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
const direction = input.cursor?.direction ?? "next"
|
||||
const requestedOrder = input.order ?? "desc"
|
||||
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
|
||||
const anchor = input.cursor
|
||||
? yield* db
|
||||
.select({ seq: SessionMessageTable.seq })
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, input.sessionID),
|
||||
eq(SessionMessageTable.id, input.cursor.id),
|
||||
),
|
||||
)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
: undefined
|
||||
if (input.cursor && !anchor) return []
|
||||
const boundary = anchor
|
||||
? order === "asc"
|
||||
? gt(SessionMessageTable.seq, anchor.seq)
|
||||
: lt(SessionMessageTable.seq, anchor.seq)
|
||||
: undefined
|
||||
const where = boundary
|
||||
? and(eq(SessionMessageTable.session_id, input.sessionID), boundary)
|
||||
: eq(SessionMessageTable.session_id, input.sessionID)
|
||||
const query = db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(where)
|
||||
.orderBy(order === "asc" ? asc(SessionMessageTable.seq) : desc(SessionMessageTable.seq))
|
||||
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
|
||||
Effect.orDie,
|
||||
)
|
||||
return yield* Effect.forEach(direction === "previous" ? rows.toReversed() : rows, decode)
|
||||
}),
|
||||
message: Effect.fn("V2Session.message")(function* (input) {
|
||||
const stored = yield* store.message(input.messageID)
|
||||
return stored?.sessionID === input.sessionID ? stored.message : undefined
|
||||
}),
|
||||
context: Effect.fn("V2Session.context")(function* (sessionID) {
|
||||
yield* result.get(sessionID)
|
||||
return yield* store.context(sessionID)
|
||||
}),
|
||||
events: (input) =>
|
||||
Stream.unwrap(
|
||||
result
|
||||
.get(input.sessionID)
|
||||
.pipe(Effect.as(events.durable({ aggregateID: input.sessionID, after: input.after }))),
|
||||
).pipe(Stream.filter((event): event is SessionEvent.DurableEvent => isDurableSessionEvent(event))),
|
||||
prompt: Effect.fn("V2Session.prompt")((input) =>
|
||||
Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
yield* result.get(input.sessionID)
|
||||
const prompt = resolvePrompt(input.prompt)
|
||||
const messageID = input.id ?? SessionMessage.ID.create()
|
||||
const delivery = input.delivery ?? "steer"
|
||||
const expected = { sessionID: input.sessionID, messageID, prompt, delivery }
|
||||
const admitted = yield* SessionInput.admit(db, events, {
|
||||
id: messageID,
|
||||
sessionID: input.sessionID,
|
||||
prompt,
|
||||
delivery,
|
||||
}).pipe(
|
||||
Effect.catchDefect((defect) =>
|
||||
defect instanceof SessionInput.LifecycleConflict
|
||||
? new PromptConflictError({ sessionID: input.sessionID, messageID })
|
||||
: Effect.die(defect),
|
||||
),
|
||||
)
|
||||
if (!SessionInput.equivalent(admitted, expected))
|
||||
return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
|
||||
if (input.resume !== false) yield* execution.wake(admitted.sessionID)
|
||||
return admitted
|
||||
}),
|
||||
),
|
||||
),
|
||||
shell: Effect.fn("V2Session.shell")(function* () {
|
||||
return yield* new OperationUnavailableError({ operation: "shell" })
|
||||
}),
|
||||
skill: Effect.fn("V2Session.skill")(function* () {
|
||||
return yield* new OperationUnavailableError({ operation: "skill" })
|
||||
}),
|
||||
switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
yield* events.publish(SessionEvent.AgentSwitched, {
|
||||
sessionID: input.sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: yield* DateTime.now,
|
||||
agent: input.agent,
|
||||
})
|
||||
}),
|
||||
switchModel: Effect.fn("V2Session.switchModel")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
if (
|
||||
session.model?.providerID === input.model.providerID &&
|
||||
session.model.id === input.model.id &&
|
||||
(session.model.variant ?? "default") === (input.model.variant ?? "default")
|
||||
)
|
||||
return
|
||||
yield* events.publish(SessionEvent.ModelSwitched, {
|
||||
sessionID: input.sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: yield* DateTime.now,
|
||||
model: input.model,
|
||||
})
|
||||
}),
|
||||
compact: Effect.fn("V2Session.compact")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
return yield* new OperationUnavailableError({ operation: "compact" })
|
||||
}),
|
||||
wait: Effect.fn("V2Session.wait")(function* (sessionID) {
|
||||
yield* result.get(sessionID)
|
||||
return yield* new OperationUnavailableError({ operation: "wait" })
|
||||
}),
|
||||
active: execution.active,
|
||||
resume: Effect.fn("V2Session.resume")(function* (sessionID) {
|
||||
yield* result.get(sessionID)
|
||||
yield* execution.resume(sessionID)
|
||||
}),
|
||||
interrupt: Effect.fn("V2Session.interrupt")((sessionID) =>
|
||||
Effect.uninterruptible(execution.interrupt(sessionID)),
|
||||
),
|
||||
revert: {
|
||||
stage: Effect.fn("V2Session.revert.stage")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
return yield* SessionRevert.stage({ session, messageID: input.messageID, files: input.files }).pipe(
|
||||
Effect.provideService(Database.Service, database),
|
||||
Effect.provideService(EventV2.Service, events),
|
||||
Effect.provide(locations.get(session.location)),
|
||||
)
|
||||
}),
|
||||
clear: Effect.fn("V2Session.revert.clear")(function* (sessionID) {
|
||||
const session = yield* result.get(sessionID)
|
||||
yield* SessionRevert.clear(session).pipe(
|
||||
Effect.provideService(EventV2.Service, events),
|
||||
Effect.provide(locations.get(session.location)),
|
||||
)
|
||||
}),
|
||||
commit: Effect.fn("V2Session.revert.commit")(function* (sessionID) {
|
||||
const session = yield* result.get(sessionID)
|
||||
yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events))
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
return result
|
||||
}),
|
||||
if (!SessionInput.equivalent(admitted, expected))
|
||||
return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
|
||||
if (input.resume !== false)
|
||||
return yield* Effect.die("SessionV2.prompt with resume moved to SessionRuntime.Service")
|
||||
return admitted
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
shell: Effect.fn("V2Session.shell")(function* () {
|
||||
return yield* new OperationUnavailableError({ operation: "shell" })
|
||||
}),
|
||||
skill: Effect.fn("V2Session.skill")(function* () {
|
||||
return yield* new OperationUnavailableError({ operation: "skill" })
|
||||
}),
|
||||
switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
yield* events.publish(SessionEvent.AgentSwitched, {
|
||||
sessionID: input.sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: yield* DateTime.now,
|
||||
agent: input.agent,
|
||||
})
|
||||
}),
|
||||
switchModel: Effect.fn("V2Session.switchModel")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
if (
|
||||
session.model?.providerID === input.model.providerID &&
|
||||
session.model.id === input.model.id &&
|
||||
(session.model.variant ?? "default") === (input.model.variant ?? "default")
|
||||
)
|
||||
return
|
||||
yield* events.publish(SessionEvent.ModelSwitched, {
|
||||
sessionID: input.sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: yield* DateTime.now,
|
||||
model: input.model,
|
||||
})
|
||||
}),
|
||||
rename: Effect.fn("V2Session.rename")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
yield* events.publish(SessionEvent.Renamed, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: yield* DateTime.now,
|
||||
title: input.title,
|
||||
})
|
||||
}),
|
||||
compact: Effect.fn("V2Session.compact")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
return yield* new OperationUnavailableError({ operation: "compact" })
|
||||
}),
|
||||
wait: Effect.fn("V2Session.wait")(function* (sessionID) {
|
||||
yield* result.get(sessionID)
|
||||
return yield* Effect.die("SessionV2.wait moved to SessionRuntime.Service")
|
||||
}),
|
||||
active: runtimeCoordinator.active,
|
||||
resume: Effect.fn("V2Session.resume")(function* (sessionID) {
|
||||
yield* result.get(sessionID)
|
||||
return yield* Effect.die("SessionV2.resume moved to SessionRuntime.Service")
|
||||
}),
|
||||
interrupt: Effect.fn("V2Session.interrupt")(() => Effect.die("SessionV2.interrupt moved to SessionRuntime.Service")),
|
||||
revert: {
|
||||
stage: Effect.fn("V2Session.revert.stage")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
return yield* Effect.die("SessionV2.revert.stage moved to SessionRuntime.Service")
|
||||
}),
|
||||
clear: Effect.fn("V2Session.revert.clear")(function* (sessionID) {
|
||||
yield* result.get(sessionID)
|
||||
return yield* Effect.die("SessionV2.revert.clear moved to SessionRuntime.Service")
|
||||
}),
|
||||
commit: Effect.fn("V2Session.revert.commit")(function* (sessionID) {
|
||||
const session = yield* result.get(sessionID)
|
||||
return yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events))
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
return result
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(
|
||||
Layer.unwrap(Effect.promise(() => import("./location-layer")).pipe(Effect.map((m) => m.LocationServiceMap.layer))),
|
||||
),
|
||||
Layer.provide(SessionStore.defaultLayer),
|
||||
Layer.provide(SessionProjector.defaultLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(ProjectV2.defaultLayer),
|
||||
Layer.provide(SessionRuntimeCoordinator.defaultLayer),
|
||||
Layer.orDie,
|
||||
)
|
||||
|
||||
@@ -466,6 +479,7 @@ const resolvePrompt = (input: PromptInput.Prompt) =>
|
||||
Prompt.make({
|
||||
text: input.text,
|
||||
agents: input.agents,
|
||||
system: input.system,
|
||||
files: input.files?.map((file) => {
|
||||
const dataMime = file.uri.match(/^data:([^;,]+)[;,]/i)?.[1]
|
||||
const target = URL.canParse(file.uri) ? new URL(file.uri).pathname : (file.name ?? file.uri)
|
||||
@@ -475,3 +489,16 @@ const resolvePrompt = (input: PromptInput.Prompt) =>
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer: layer.pipe(Layer.orDie),
|
||||
deps: [
|
||||
Database.node,
|
||||
EventV2.node,
|
||||
ProjectV2.node,
|
||||
SessionRuntimeCoordinator.node,
|
||||
SessionStore.node,
|
||||
SessionProjector.node,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export * as SessionExecution from "./execution"
|
||||
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { LayerNode } from "../effect/layer-node"
|
||||
import { Node } from "../effect/app-node"
|
||||
import { SessionRunner } from "./runner/index"
|
||||
import { SessionSchema } from "./schema"
|
||||
|
||||
@@ -13,11 +15,15 @@ export interface Interface {
|
||||
readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
/** Interrupt active work owned by this process. Idle interruption is a no-op. */
|
||||
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
/** Resolves once this process owns no active execution for the Session. Returns immediately when idle and never starts work. */
|
||||
readonly awaitIdle: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
/** Routes execution from a Session ID to the runner owned by that Session's Location. */
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionExecution") {}
|
||||
|
||||
export const node = LayerNode.unbound(Service, Node.tags.values.global)
|
||||
|
||||
/** Low-level compatibility layer for callers that only need durable Session recording. */
|
||||
export const noopLayer = Layer.succeed(
|
||||
Service,
|
||||
@@ -26,5 +32,6 @@ export const noopLayer = Layer.succeed(
|
||||
resume: () => Effect.void,
|
||||
wake: () => Effect.void,
|
||||
interrupt: () => Effect.void,
|
||||
awaitIdle: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Cause, Effect, Layer } from "effect"
|
||||
import { LocationServiceMap } from "../../location-layer"
|
||||
import { LocationServiceMap } from "../../location-service-map"
|
||||
import { makeGlobalNode } from "../../effect/app-node"
|
||||
import { SessionRunCoordinator } from "../run-coordinator"
|
||||
import { SessionRunner } from "../runner"
|
||||
import { SessionSchema } from "../schema"
|
||||
@@ -11,7 +12,7 @@ export const layer = Layer.effect(
|
||||
SessionExecution.Service,
|
||||
Effect.gen(function* () {
|
||||
const store = yield* SessionStore.Service
|
||||
const locations = yield* LocationServiceMap
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError>({
|
||||
drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) {
|
||||
const session = yield* store.get(sessionID)
|
||||
@@ -32,8 +33,17 @@ export const layer = Layer.effect(
|
||||
interrupt: coordinator.interrupt,
|
||||
resume: coordinator.run,
|
||||
wake: coordinator.wake,
|
||||
awaitIdle: coordinator.awaitIdle,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(SessionStore.defaultLayer))
|
||||
|
||||
export const node = makeGlobalNode({
|
||||
service: SessionExecution.Service,
|
||||
layer,
|
||||
deps: [SessionStore.node, LocationServiceMap.node],
|
||||
})
|
||||
|
||||
export * as SessionExecutionLocal from "./local"
|
||||
|
||||
@@ -123,6 +123,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
)
|
||||
},
|
||||
"session.next.moved": () => Effect.void,
|
||||
"session.next.renamed": () => Effect.void,
|
||||
"session.next.prompted": (event) => {
|
||||
return adapter.appendMessage(
|
||||
SessionMessage.User.make({
|
||||
@@ -132,6 +133,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
text: event.data.prompt.text,
|
||||
files: event.data.prompt.files,
|
||||
agents: event.data.prompt.agents,
|
||||
system: event.data.prompt.system,
|
||||
time: { created: event.data.timestamp },
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { and, desc, eq, gt, or, sql } from "drizzle-orm"
|
||||
import { DateTime, Effect, Layer, Schema } from "effect"
|
||||
import { Database } from "../database/database"
|
||||
import { EventV2 } from "../event"
|
||||
import { LayerNode } from "../effect/layer-node"
|
||||
import { makeGlobalNode } from "../effect/app-node"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionV1 } from "../v1/session"
|
||||
import { WorkspaceTable } from "../control-plane/workspace.sql"
|
||||
@@ -347,6 +347,14 @@ export const layer = Layer.effectDiscard(
|
||||
yield* run(db, event)
|
||||
}),
|
||||
)
|
||||
yield* events.project(SessionEvent.Renamed, (event) =>
|
||||
db
|
||||
.update(SessionTable)
|
||||
.set({ title: event.data.title, time_updated: DateTime.toEpochMillis(event.data.timestamp) })
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie),
|
||||
)
|
||||
yield* events.project(SessionEvent.Prompted, (event) =>
|
||||
Effect.gen(function* () {
|
||||
if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence")
|
||||
@@ -456,4 +464,4 @@ export const layer = Layer.effectDiscard(
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer))
|
||||
export const node = LayerNode.make({ name: "session-projector", layer, deps: [EventV2.node, Database.node] })
|
||||
export const node = makeGlobalNode({ name: "session-projector", layer, deps: [EventV2.node, Database.node] })
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface Coordinator<Key, E> {
|
||||
readonly wake: (key: Key) => Effect.Effect<void>
|
||||
/** Stops active execution and waits for its cleanup. */
|
||||
readonly interrupt: (key: Key) => Effect.Effect<void>
|
||||
/** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */
|
||||
readonly awaitIdle: (key: Key) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
type Entry<E> = {
|
||||
@@ -100,5 +102,15 @@ export const make = <Key, E>(options: {
|
||||
return Fiber.interrupt(entry.owner)
|
||||
})
|
||||
|
||||
return { active: Effect.sync(() => new Set(active.keys())), run, wake, interrupt }
|
||||
// Each successful drain reuses its entry.done across coalesced wakes, so one await
|
||||
// already spans steered and queued continuation. Re-check after it settles to cover a
|
||||
// fresh wake (or a failure/stopping successor) that installs a new entry.
|
||||
const awaitIdle = (key: Key): Effect.Effect<void> =>
|
||||
Effect.suspend(() => {
|
||||
const entry = active.get(key)
|
||||
if (entry === undefined) return Effect.void
|
||||
return Deferred.await(entry.done).pipe(Effect.exit, Effect.andThen(awaitIdle(key)))
|
||||
})
|
||||
|
||||
return { active: Effect.sync(() => new Set(active.keys())), run, wake, interrupt, awaitIdle }
|
||||
})
|
||||
|
||||
@@ -36,6 +36,8 @@ import { createLLMEventPublisher } from "./publish-llm-event"
|
||||
import { toLLMMessages } from "./to-llm-message"
|
||||
import { MAX_STEPS_PROMPT } from "./max-steps"
|
||||
import { Snapshot } from "../../snapshot"
|
||||
import { makeLocationNode } from "../../effect/app-node"
|
||||
import { llmClient } from "../../effect/app-node-platform"
|
||||
|
||||
/**
|
||||
* Runs one durable coding-agent Session until it settles.
|
||||
@@ -192,13 +194,16 @@ export const layer = Layer.effect(
|
||||
const model = yield* models.resolve(session)
|
||||
const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq)
|
||||
const context = entries.map((entry) => entry.message)
|
||||
// Mirror V1 (session/llm/request.ts): append the current turn's per-request system string after the
|
||||
// agent prompt and durable baseline. The current turn's user prompt is the latest user message in context.
|
||||
const turnSystem = context.findLast((message) => message.type === "user")?.system
|
||||
const isLastStep = agent.info?.steps !== undefined && currentStep >= agent.info.steps
|
||||
const toolMaterialization = isLastStep ? undefined : yield* tools.materialize(agent.info?.permissions)
|
||||
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id
|
||||
const request = LLM.request({
|
||||
model,
|
||||
providerOptions: { openai: { promptCacheKey } },
|
||||
system: [agent.info?.system, system.baseline]
|
||||
system: [agent.info?.system, system.baseline, turnSystem]
|
||||
.filter((part): part is string => part !== undefined && part.length > 0)
|
||||
.map(SystemPart.make),
|
||||
messages: [...toLLMMessages(context, model), ...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : [])],
|
||||
@@ -287,19 +292,18 @@ export const layer = Layer.effect(
|
||||
}
|
||||
if (stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)) yield* FiberSet.clear(toolFibers)
|
||||
const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit)
|
||||
const streamInterrupted = stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)
|
||||
const toolsInterrupted = settled._tag === "Failure" && Cause.hasInterrupts(settled.cause)
|
||||
if (settled._tag === "Failure" && isQuestionRejected(settled.cause)) {
|
||||
yield* FiberSet.clear(toolFibers)
|
||||
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
|
||||
yield* withPublication(publisher.failAssistant("Provider turn interrupted"))
|
||||
return yield* Effect.interrupt
|
||||
}
|
||||
if (
|
||||
(stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)) ||
|
||||
(settled._tag === "Failure" && Cause.hasInterrupts(settled.cause))
|
||||
) {
|
||||
if (streamInterrupted || toolsInterrupted) {
|
||||
yield* FiberSet.clear(toolFibers)
|
||||
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
|
||||
if (publisher.hasActiveAssistant())
|
||||
yield* withPublication(publisher.failAssistant("Provider turn interrupted"))
|
||||
yield* withPublication(publisher.failAssistant("Provider turn interrupted"))
|
||||
}
|
||||
if (settled._tag === "Failure" && !Cause.hasInterrupts(settled.cause)) {
|
||||
const failure = Cause.squash(settled.cause)
|
||||
@@ -307,7 +311,7 @@ export const layer = Layer.effect(
|
||||
yield* withPublication(publisher.failUnsettledTools(`Tool execution failed: ${message}`))
|
||||
}
|
||||
const stepSettlement = publisher.stepSettlement()
|
||||
if (stepSettlement && !publisher.hasProviderError()) {
|
||||
if (stepSettlement && !streamInterrupted && !toolsInterrupted && !publisher.hasProviderError()) {
|
||||
const endSnapshot = yield* snapshots.capture()
|
||||
const files =
|
||||
startSnapshot && endSnapshot
|
||||
@@ -405,3 +409,23 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
export const defaultLayer = layer
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [
|
||||
EventV2.node,
|
||||
llmClient,
|
||||
AgentV2.node,
|
||||
ToolRegistry.node,
|
||||
SessionRunnerModel.node,
|
||||
SessionStore.node,
|
||||
Location.node,
|
||||
SystemContextRegistry.node,
|
||||
SkillGuidance.node,
|
||||
ReferenceGuidance.node,
|
||||
Config.node,
|
||||
Snapshot.node,
|
||||
Database.node,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * as SessionRunnerModel from "./model"
|
||||
|
||||
import { makeLocationNode } from "../../effect/app-node"
|
||||
import { type Model } from "@opencode-ai/llm"
|
||||
import * as AnthropicMessages from "@opencode-ai/llm/protocols/anthropic-messages"
|
||||
import * as OpenAICompatibleChat from "@opencode-ai/llm/protocols/openai-compatible-chat"
|
||||
@@ -213,3 +214,5 @@ export const locationLayer = Layer.effect(
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [Catalog.node, Integration.node] })
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user