mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 17:08:21 -04:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c9e1aedd42 | |||
| 4b948c5d74 | |||
| cd56c51e2d | |||
| 43e39d7f68 | |||
| 7a17925495 | |||
| 5acb2530b4 | |||
| 44a6787359 | |||
| 42e6b7db32 | |||
| 2c02f8bace | |||
| 2ec20e576b | |||
| 20f47fec7a | |||
| 65210f2d97 | |||
| af0b7ffae7 | |||
| 0e2dd4ad15 | |||
| 11d2f3e5f8 | |||
| 1ac6b4bec4 | |||
| 0befd9b049 | |||
| 850a0dfe7c | |||
| 18a419e634 | |||
| 82f47cb312 | |||
| 7b1fe33ed3 | |||
| c72cca8def | |||
| 05ce6bc275 | |||
| 1de7368580 | |||
| 36f901588a | |||
| 92f1a17b67 | |||
| ebf4007efd | |||
| 1aea999d7c | |||
| 4a8fee3b2d | |||
| 0d3e0fc8f4 | |||
| f254476043 | |||
| 639c8e6475 | |||
| e3bfd4cce6 | |||
| c2882268d6 |
@@ -83,6 +83,7 @@
|
|||||||
"@types/luxon": "catalog:",
|
"@types/luxon": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
|
"tw-animate-css": "1.4.0",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"vite": "catalog:",
|
"vite": "catalog:",
|
||||||
"vite-plugin-icons-spritesheet": "3.0.1",
|
"vite-plugin-icons-spritesheet": "3.0.1",
|
||||||
@@ -990,6 +991,7 @@
|
|||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
"solid-js": "catalog:",
|
"solid-js": "catalog:",
|
||||||
"tailwindcss": "catalog:",
|
"tailwindcss": "catalog:",
|
||||||
|
"tw-animate-css": "1.4.0",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"vite": "catalog:",
|
"vite": "catalog:",
|
||||||
"vite-plugin-icons-spritesheet": "3.0.1",
|
"vite-plugin-icons-spritesheet": "3.0.1",
|
||||||
@@ -5315,6 +5317,8 @@
|
|||||||
|
|
||||||
"turndown": ["turndown@7.2.0", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A=="],
|
"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=="],
|
"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=="],
|
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-OiWvZ57vuyHwiIKNtW1n1KX+MLmOXVG3x4fLKvUoGQw=",
|
"x86_64-linux": "sha256-rB/CcrBUQVZ08nBFSYA8u2w88rQmTpKxKPkIreDEKgI=",
|
||||||
"aarch64-linux": "sha256-RnPLxVEg/UsL5IeIFWmXMSLUOG6rVrajYxhyDYj1vTA=",
|
"aarch64-linux": "sha256-ZRTphtic8Ip96MnILteFgZAUxjK9O4YfJu2O6u/0H8k=",
|
||||||
"aarch64-darwin": "sha256-KPIgcBA0pTFBPrCTSZgIbvEorbtWcMgXvyX9bFAypVs=",
|
"aarch64-darwin": "sha256-VK5XIzraP0HtqnPwPCejiDKer4ewtNtX1vxP5uuyjSk=",
|
||||||
"x86_64-darwin": "sha256-6jVU7/uVId0VD24MVQ8s8Ill5b6PsKdlBgHg+oceKRg="
|
"x86_64-darwin": "sha256-ZLPHqcCZB1EmxQk95cmUpiODTTKOyi7PSF0yr/rDk6Y="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
"@types/luxon": "catalog:",
|
"@types/luxon": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
|
"tw-animate-css": "1.4.0",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"vite": "catalog:",
|
"vite": "catalog:",
|
||||||
"vite-plugin-icons-spritesheet": "3.0.1",
|
"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 { LanguageProvider, type Locale, useLanguage } from "@/context/language"
|
||||||
import { LayoutProvider } from "@/context/layout"
|
import { LayoutProvider } from "@/context/layout"
|
||||||
import { ModelsProvider } from "@/context/models"
|
import { ModelsProvider } from "@/context/models"
|
||||||
import { NotificationProvider } from "@/context/notification"
|
import { NotificationProvider, useNotification } from "@/context/notification"
|
||||||
import { PermissionProvider } from "@/context/permission"
|
import { PermissionProvider } from "@/context/permission"
|
||||||
import { PromptProvider } from "@/context/prompt"
|
import { PromptProvider } from "@/context/prompt"
|
||||||
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
|
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
|
||||||
@@ -316,9 +316,7 @@ function ServerScopedProviders(props: ServerScopedShellProps) {
|
|||||||
return (
|
return (
|
||||||
<PermissionProvider directory={props.directory}>
|
<PermissionProvider directory={props.directory}>
|
||||||
<LayoutProvider>
|
<LayoutProvider>
|
||||||
<NotificationProvider directory={props.directory} sessionID={props.sessionID}>
|
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
|
||||||
</NotificationProvider>
|
|
||||||
</LayoutProvider>
|
</LayoutProvider>
|
||||||
</PermissionProvider>
|
</PermissionProvider>
|
||||||
)
|
)
|
||||||
@@ -345,13 +343,23 @@ function NewAppLayout(props: ParentProps) {
|
|||||||
function TargetServerScopedProviders(props: ServerScopedShellProps) {
|
function TargetServerScopedProviders(props: ServerScopedShellProps) {
|
||||||
return (
|
return (
|
||||||
<PermissionProvider directory={props.directory}>
|
<PermissionProvider directory={props.directory}>
|
||||||
<NotificationProvider directory={props.directory} sessionID={props.sessionID}>
|
<MarkSessionNotificationsViewed sessionID={props.sessionID} />
|
||||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||||
</NotificationProvider>
|
|
||||||
</PermissionProvider>
|
</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) {
|
function SessionProviders(props: ParentProps) {
|
||||||
return (
|
return (
|
||||||
<TerminalProvider>
|
<TerminalProvider>
|
||||||
@@ -560,11 +568,13 @@ export function AppInterface(props: {
|
|||||||
component={props.router ?? Router}
|
component={props.router ?? Router}
|
||||||
root={(routerProps) => (
|
root={(routerProps) => (
|
||||||
<TabsProvider>
|
<TabsProvider>
|
||||||
<ServerShell>
|
<NotificationProvider>
|
||||||
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
|
<ServerShell>
|
||||||
<NewAppLayout>{routerProps.children}</NewAppLayout>
|
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
|
||||||
</Show>
|
<NewAppLayout>{routerProps.children}</NewAppLayout>
|
||||||
</ServerShell>
|
</Show>
|
||||||
|
</ServerShell>
|
||||||
|
</NotificationProvider>
|
||||||
</TabsProvider>
|
</TabsProvider>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ const ModelList: Component<{
|
|||||||
class="w-full"
|
class="w-full"
|
||||||
placement="right-start"
|
placement="right-start"
|
||||||
gutter={12}
|
gutter={12}
|
||||||
|
openDelay={0}
|
||||||
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />}
|
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />}
|
||||||
>
|
>
|
||||||
{node}
|
{node}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ beforeAll(async () => {
|
|||||||
describe("file tree fetch discipline", () => {
|
describe("file tree fetch discipline", () => {
|
||||||
test("root lists on mount unless already loaded or loading", () => {
|
test("root lists on mount unless already loaded or loading", () => {
|
||||||
expect(shouldListRoot({ level: 0 })).toBe(true)
|
expect(shouldListRoot({ level: 0 })).toBe(true)
|
||||||
|
expect(shouldListRoot({ level: 0, filtered: true })).toBe(false)
|
||||||
expect(shouldListRoot({ level: 0, dir: { loaded: true } })).toBe(false)
|
expect(shouldListRoot({ level: 0, dir: { loaded: true } })).toBe(false)
|
||||||
expect(shouldListRoot({ level: 0, dir: { loading: true } })).toBe(false)
|
expect(shouldListRoot({ level: 0, dir: { loading: true } })).toBe(false)
|
||||||
expect(shouldListRoot({ level: 1 })).toBe(false)
|
expect(shouldListRoot({ level: 1 })).toBe(false)
|
||||||
|
|||||||
@@ -32,7 +32,12 @@ type Filter = {
|
|||||||
dirs: Set<string>
|
dirs: Set<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldListRoot(input: { level: number; dir?: { loaded?: boolean; loading?: boolean } }) {
|
export function shouldListRoot(input: {
|
||||||
|
level: number
|
||||||
|
filtered?: boolean
|
||||||
|
dir?: { loaded?: boolean; loading?: boolean }
|
||||||
|
}) {
|
||||||
|
if (input.filtered) return false
|
||||||
if (input.level !== 0) return false
|
if (input.level !== 0) return false
|
||||||
if (input.dir?.loaded) return false
|
if (input.dir?.loaded) return false
|
||||||
if (input.dir?.loading) return false
|
if (input.dir?.loading) return false
|
||||||
@@ -309,7 +314,7 @@ export default function FileTree(props: {
|
|||||||
filter: current,
|
filter: current,
|
||||||
expanded: (dir) => untrack(() => file.tree.state(dir)?.expanded) ?? false,
|
expanded: (dir) => untrack(() => file.tree.state(dir)?.expanded) ?? false,
|
||||||
})
|
})
|
||||||
for (const dir of dirs) file.tree.expand(dir)
|
for (const dir of dirs) file.tree.expand(dir, { load: false })
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
@@ -317,7 +322,7 @@ export default function FileTree(props: {
|
|||||||
() => props.path,
|
() => props.path,
|
||||||
(path) => {
|
(path) => {
|
||||||
const dir = untrack(() => file.tree.state(path))
|
const dir = untrack(() => file.tree.state(path))
|
||||||
if (!shouldListRoot({ level, dir })) return
|
if (!shouldListRoot({ level, filtered: !!filter(), dir })) return
|
||||||
void file.tree.list(path)
|
void file.tree.list(path)
|
||||||
},
|
},
|
||||||
{ defer: false },
|
{ defer: false },
|
||||||
@@ -401,7 +406,9 @@ export default function FileTree(props: {
|
|||||||
data-scope="filetree"
|
data-scope="filetree"
|
||||||
forceMount={false}
|
forceMount={false}
|
||||||
open={expanded()}
|
open={expanded()}
|
||||||
onOpenChange={(open) => (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))}
|
onOpenChange={(open) =>
|
||||||
|
open ? file.tree.expand(node.path, { load: !filter() }) : file.tree.collapse(node.path)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Collapsible.Trigger>
|
<Collapsible.Trigger>
|
||||||
<FileTreeNode
|
<FileTreeNode
|
||||||
|
|||||||
@@ -1343,9 +1343,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const agentsLoading = () => props.controls.agents.loading
|
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 providersLoading = () => props.controls.model.loading
|
||||||
const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading())
|
const providersShouldFadeIn = createMemo<boolean>((prev) => prev ?? providersLoading())
|
||||||
|
|
||||||
const [promptReady] = createResource(
|
const [promptReady] = createResource(
|
||||||
() => prompt.ready.promise,
|
() => prompt.ready.promise,
|
||||||
@@ -1359,6 +1359,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
|
|
||||||
const modelControlState = createMemo<ComposerModelControlState>(() => ({
|
const modelControlState = createMemo<ComposerModelControlState>(() => ({
|
||||||
loading: providersLoading(),
|
loading: providersLoading(),
|
||||||
|
shouldAnimate: providersShouldFadeIn(),
|
||||||
paid: props.controls.model.paid,
|
paid: props.controls.model.paid,
|
||||||
title: language.t("command.model.choose"),
|
title: language.t("command.model.choose"),
|
||||||
keybind: command.keybind("model.choose"),
|
keybind: command.keybind("model.choose"),
|
||||||
@@ -1519,10 +1520,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
</Show>
|
</Show>
|
||||||
{props.toolbar}
|
{props.toolbar}
|
||||||
<ComposerModelControl state={modelControlState()} />
|
<ComposerModelControl state={modelControlState()} />
|
||||||
<Show when={store.mode !== "shell" && showVariantControl()}>
|
<Show when={!providersLoading() && store.mode !== "shell" && showVariantControl()}>
|
||||||
<div
|
<div
|
||||||
data-component="prompt-variant-control"
|
data-component="prompt-variant-control"
|
||||||
classList={{
|
classList={{
|
||||||
|
"animate-in fade-in": providersShouldFadeIn(),
|
||||||
"hidden group-hover/prompt-input:block group-focus-within/prompt-input:block":
|
"hidden group-hover/prompt-input:block group-focus-within/prompt-input:block":
|
||||||
!props.controls.model.selection.variant.current() && !store.variantOpen,
|
!props.controls.model.selection.variant.current() && !store.variantOpen,
|
||||||
}}
|
}}
|
||||||
@@ -1765,7 +1767,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
<Show when={!agentsLoading()}>
|
<Show when={!agentsLoading()}>
|
||||||
<div
|
<div
|
||||||
data-component="prompt-agent-control"
|
data-component="prompt-agent-control"
|
||||||
style={agentsShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
|
classList={{ "animate-in fade-in duration-300": agentsShouldFadeIn() }}
|
||||||
>
|
>
|
||||||
<TooltipKeybind
|
<TooltipKeybind
|
||||||
placement="top"
|
placement="top"
|
||||||
@@ -1794,7 +1796,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
<Show when={store.mode !== "shell"}>
|
<Show when={store.mode !== "shell"}>
|
||||||
<div
|
<div
|
||||||
data-component="prompt-model-control"
|
data-component="prompt-model-control"
|
||||||
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
|
classList={{ "animate-in fade-in duration-300": providersShouldFadeIn() }}
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={props.controls.model.paid}
|
when={props.controls.model.paid}
|
||||||
@@ -1873,7 +1875,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
<Show when={showVariantControl()}>
|
<Show when={showVariantControl()}>
|
||||||
<div
|
<div
|
||||||
data-component="prompt-variant-control"
|
data-component="prompt-variant-control"
|
||||||
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
|
classList={{ "animate-in fade-in duration-300": providersShouldFadeIn() }}
|
||||||
>
|
>
|
||||||
<TooltipKeybind
|
<TooltipKeybind
|
||||||
placement="top"
|
placement="top"
|
||||||
@@ -1923,6 +1925,7 @@ type ComposerAgentControlState = {
|
|||||||
|
|
||||||
type ComposerModelControlState = {
|
type ComposerModelControlState = {
|
||||||
loading: boolean
|
loading: boolean
|
||||||
|
shouldAnimate: boolean
|
||||||
paid: boolean
|
paid: boolean
|
||||||
title: string
|
title: string
|
||||||
keybind: string
|
keybind: string
|
||||||
@@ -1970,6 +1973,7 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="normal"
|
size="normal"
|
||||||
class="min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group"
|
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}
|
style={props.state.style}
|
||||||
onClick={props.state.onUnpaidClick}
|
onClick={props.state.onUnpaidClick}
|
||||||
>
|
>
|
||||||
@@ -2000,6 +2004,7 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
|
|||||||
style: props.state.style,
|
style: props.state.style,
|
||||||
class:
|
class:
|
||||||
"min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group",
|
"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",
|
"data-action": "prompt-model",
|
||||||
}}
|
}}
|
||||||
onClose={props.state.onClose}
|
onClose={props.state.onClose}
|
||||||
|
|||||||
@@ -69,12 +69,14 @@ export function createPromptProjectController(input: {
|
|||||||
if (servers().length <= 1) {
|
if (servers().length <= 1) {
|
||||||
return [...projects().map(projectKey), actionKey(servers()[0]?.key)]
|
return [...projects().map(projectKey), actionKey(servers()[0]?.key)]
|
||||||
}
|
}
|
||||||
return servers().flatMap((server) => [
|
return [
|
||||||
...projects()
|
...servers().flatMap((server) =>
|
||||||
.filter((project) => project.server?.key === server!.key)
|
projects()
|
||||||
.map(projectKey),
|
.filter((project) => project.server?.key === server!.key)
|
||||||
actionKey(server!.key),
|
.map(projectKey),
|
||||||
])
|
),
|
||||||
|
actionKey(),
|
||||||
|
]
|
||||||
}
|
}
|
||||||
const initialActive = () => {
|
const initialActive = () => {
|
||||||
const selectedKey = selected() ? projectKey(selected()!) : undefined
|
const selectedKey = selected() ? projectKey(selected()!) : undefined
|
||||||
@@ -130,7 +132,10 @@ export function createPromptProjectController(input: {
|
|||||||
const first = input
|
const first = input
|
||||||
.controls()
|
.controls()
|
||||||
.available.find((project) => !search || displayName(project).toLowerCase().includes(search))
|
.available.find((project) => !search || displayName(project).toLowerCase().includes(search))
|
||||||
setStore({ search: value, active: first ? projectKey(first) : actionKey(servers()[0]?.key) })
|
setStore({
|
||||||
|
search: value,
|
||||||
|
active: first ? projectKey(first) : actionKey(servers().length > 1 ? undefined : servers()[0]?.key),
|
||||||
|
})
|
||||||
},
|
},
|
||||||
clearSearch() {
|
clearSearch() {
|
||||||
setStore({ search: "", active: initialActive() })
|
setStore({ search: "", active: initialActive() })
|
||||||
@@ -156,6 +161,9 @@ export function createPromptProjectController(input: {
|
|||||||
? decodeURIComponent(store.active.slice(actionPrefix.length)) || undefined
|
? decodeURIComponent(store.active.slice(actionPrefix.length)) || undefined
|
||||||
: undefined
|
: undefined
|
||||||
},
|
},
|
||||||
|
activeAction() {
|
||||||
|
return store.active.startsWith(actionPrefix)
|
||||||
|
},
|
||||||
setSearchRef(el: HTMLInputElement) {
|
setSearchRef(el: HTMLInputElement) {
|
||||||
searchRef = el
|
searchRef = el
|
||||||
},
|
},
|
||||||
@@ -167,7 +175,10 @@ export function createPromptProjectController(input: {
|
|||||||
|
|
||||||
export type PromptProjectController = ReturnType<typeof createPromptProjectController>
|
export type PromptProjectController = ReturnType<typeof createPromptProjectController>
|
||||||
|
|
||||||
export function PromptProjectSelector(props: { controller: PromptProjectController }) {
|
export function PromptProjectSelector(props: {
|
||||||
|
controller: PromptProjectController
|
||||||
|
placement?: "bottom" | "bottom-start"
|
||||||
|
}) {
|
||||||
let contentRef: HTMLDivElement | undefined
|
let contentRef: HTMLDivElement | undefined
|
||||||
let restoreTrigger = true
|
let restoreTrigger = true
|
||||||
|
|
||||||
@@ -201,6 +212,12 @@ export function PromptProjectSelector(props: { controller: PromptProjectControll
|
|||||||
selectProject(project)
|
selectProject(project)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (props.controller.activeAction() && props.controller.servers().length > 1) {
|
||||||
|
const item = activeItem()
|
||||||
|
item?.focus()
|
||||||
|
item?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }))
|
||||||
|
return
|
||||||
|
}
|
||||||
selectAction(props.controller.activeServer())
|
selectAction(props.controller.activeServer())
|
||||||
}
|
}
|
||||||
const moveActive = (delta: number) => {
|
const moveActive = (delta: number) => {
|
||||||
@@ -229,9 +246,8 @@ export function PromptProjectSelector(props: { controller: PromptProjectControll
|
|||||||
return (
|
return (
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
open={props.controller.open()}
|
open={props.controller.open()}
|
||||||
placement="bottom-start"
|
placement={props.placement ?? "bottom"}
|
||||||
gutter={4}
|
gutter={4}
|
||||||
shift={-6}
|
|
||||||
modal={false}
|
modal={false}
|
||||||
onOpenChange={(open) => props.controller.setOpen(open)}
|
onOpenChange={(open) => props.controller.setOpen(open)}
|
||||||
>
|
>
|
||||||
@@ -318,7 +334,13 @@ export function PromptProjectSelector(props: { controller: PromptProjectControll
|
|||||||
</DropdownMenu.RadioGroup>
|
</DropdownMenu.RadioGroup>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<For each={props.controller.servers()}>
|
<For
|
||||||
|
each={props.controller
|
||||||
|
.servers()
|
||||||
|
.filter((server) =>
|
||||||
|
props.controller.projects().some((project) => project.server?.key === server!.key),
|
||||||
|
)}
|
||||||
|
>
|
||||||
{(server) => (
|
{(server) => (
|
||||||
<div>
|
<div>
|
||||||
<div class="flex h-7 select-none items-center pl-1.5 pr-3 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-faint">
|
<div class="flex h-7 select-none items-center pl-1.5 pr-3 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-faint">
|
||||||
@@ -331,22 +353,49 @@ export function PromptProjectSelector(props: { controller: PromptProjectControll
|
|||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</DropdownMenu.RadioGroup>
|
</DropdownMenu.RadioGroup>
|
||||||
<ProjectAction server={server!.key} controller={props.controller} onSelect={selectAction} />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<Show when={props.controller.servers().length <= 1}>
|
<div class="h-px bg-v2-border-border-muted" />
|
||||||
<div class="h-px bg-v2-border-border-muted" />
|
<div class="flex flex-col p-0.5">
|
||||||
<div class="flex flex-col p-0.5">
|
<Show
|
||||||
<ProjectAction
|
when={props.controller.servers().length > 1}
|
||||||
server={props.controller.servers()[0]?.key}
|
fallback={
|
||||||
controller={props.controller}
|
<ProjectAction
|
||||||
onSelect={selectAction}
|
server={props.controller.servers()[0]?.key}
|
||||||
/>
|
controller={props.controller}
|
||||||
</div>
|
onSelect={selectAction}
|
||||||
</Show>
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DropdownMenu.Sub>
|
||||||
|
<DropdownMenu.SubTrigger
|
||||||
|
id={props.controller.actionKey()}
|
||||||
|
data-option-key={props.controller.actionKey()}
|
||||||
|
class={projectActionClass}
|
||||||
|
classList={{
|
||||||
|
"!bg-v2-overlay-simple-overlay-hover": props.controller.active() === props.controller.actionKey(),
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => props.controller.setActive(props.controller.actionKey())}
|
||||||
|
>
|
||||||
|
<Icon name="plus" size="small" />
|
||||||
|
<span data-slot="dropdown-menu-item-label" class="min-w-0 flex-1 truncate leading-5">
|
||||||
|
{props.controller.labels.add()}
|
||||||
|
</span>
|
||||||
|
<Icon name="chevron-right" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||||
|
</DropdownMenu.SubTrigger>
|
||||||
|
<DropdownMenu.Portal>
|
||||||
|
<DropdownMenu.SubContent class="min-w-[180px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 p-0.5 shadow-[var(--v2-elevation-floating)] focus:outline-none">
|
||||||
|
<For each={props.controller.servers()}>
|
||||||
|
{(server) => <ServerAction server={server!} onSelect={selectAction} />}
|
||||||
|
</For>
|
||||||
|
</DropdownMenu.SubContent>
|
||||||
|
</DropdownMenu.Portal>
|
||||||
|
</DropdownMenu.Sub>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Portal>
|
</DropdownMenu.Portal>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
@@ -376,11 +425,12 @@ function ProjectTrigger(props: ComponentProps<"button"> & { controller: PromptPr
|
|||||||
{...rest}
|
{...rest}
|
||||||
data-action="prompt-project"
|
data-action="prompt-project"
|
||||||
type="button"
|
type="button"
|
||||||
class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 transition-colors focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
||||||
classList={{
|
classList={{
|
||||||
...local.classList,
|
...local.classList,
|
||||||
"hover:bg-v2-overlay-simple-overlay-hover": !local.controller.open(),
|
"hover:bg-v2-overlay-simple-overlay-hover": !local.controller.open(),
|
||||||
"bg-v2-overlay-simple-overlay-pressed": local.controller.open(),
|
"bg-v2-overlay-simple-overlay-pressed": local.controller.open(),
|
||||||
|
"text-v2-text-text-muted": local.controller.open(),
|
||||||
}}
|
}}
|
||||||
onClick={local.onClick ?? (() => local.controller.setOpen(true))}
|
onClick={local.onClick ?? (() => local.controller.setOpen(true))}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
@@ -454,6 +504,9 @@ function ProjectItem(props: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const projectActionClass =
|
||||||
|
"h-7 gap-2 rounded-sm px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] data-[highlighted]:!bg-v2-overlay-simple-overlay-hover"
|
||||||
|
|
||||||
function ProjectAction(props: {
|
function ProjectAction(props: {
|
||||||
server?: string
|
server?: string
|
||||||
controller: PromptProjectController
|
controller: PromptProjectController
|
||||||
@@ -488,3 +541,11 @@ function ProjectAction(props: {
|
|||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ServerAction(props: { server: { key: string; name: string }; onSelect: (server: string) => void }) {
|
||||||
|
return (
|
||||||
|
<DropdownMenu.Item class={projectActionClass} onSelect={() => props.onSelect(props.server.key)}>
|
||||||
|
<DropdownMenu.ItemLabel class="min-w-0 flex-1 truncate leading-5">{props.server.name}</DropdownMenu.ItemLabel>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { For, Show } from "solid-js"
|
||||||
|
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||||
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||||
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
|
||||||
|
export function PromptWorkspaceSelector(props: {
|
||||||
|
value: string
|
||||||
|
projectRoot: string
|
||||||
|
workspaces: string[]
|
||||||
|
branch?: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
onDone: () => void
|
||||||
|
}) {
|
||||||
|
const language = useLanguage()
|
||||||
|
let pending: string | undefined
|
||||||
|
const selected = () => (props.value === props.projectRoot ? "main" : props.value)
|
||||||
|
const icon = () => {
|
||||||
|
if (selected() === "main") return "monitor"
|
||||||
|
if (selected() === "create") return "workspace-new"
|
||||||
|
return "workspace"
|
||||||
|
}
|
||||||
|
const select = (value: string) => {
|
||||||
|
pending = value
|
||||||
|
}
|
||||||
|
const onOpenChange = (open: boolean) => {
|
||||||
|
if (open) return
|
||||||
|
const value = pending
|
||||||
|
pending = undefined
|
||||||
|
if (value) props.onChange(value)
|
||||||
|
props.onDone()
|
||||||
|
}
|
||||||
|
const label = () => {
|
||||||
|
if (selected() === "main") return language.t("session.new.workspace.triggerLocal")
|
||||||
|
if (props.value === "create") return language.t("workspace.new")
|
||||||
|
return getFilename(props.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
||||||
|
<MenuV2 placement="bottom" gutter={4} onOpenChange={onOpenChange}>
|
||||||
|
<MenuV2.Trigger class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted">
|
||||||
|
<IconV2 name={icon()} class="shrink-0 text-v2-icon-icon-muted" />
|
||||||
|
<span class="min-w-0 truncate">{label()}</span>
|
||||||
|
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||||
|
</MenuV2.Trigger>
|
||||||
|
<MenuV2.Portal>
|
||||||
|
<MenuV2.Content class="w-[180px]">
|
||||||
|
<MenuV2.Group>
|
||||||
|
<MenuV2.GroupLabel>{language.t("session.new.workspace.runIn")}</MenuV2.GroupLabel>
|
||||||
|
<MenuV2.Item onSelect={() => select("main")}>
|
||||||
|
<IconV2 name="monitor" />
|
||||||
|
<span class="min-w-0 flex-1 truncate">{language.t("session.new.workspace.local")}</span>
|
||||||
|
<Show when={selected() === "main"}>
|
||||||
|
<Icon name="check" size="small" class="shrink-0" />
|
||||||
|
</Show>
|
||||||
|
</MenuV2.Item>
|
||||||
|
<MenuV2.Item onSelect={() => select("create")}>
|
||||||
|
<IconV2 name="workspace-new" />
|
||||||
|
<span class="min-w-0 flex-1 truncate">{language.t("workspace.new")}</span>
|
||||||
|
<Show when={selected() === "create"}>
|
||||||
|
<Icon name="check" size="small" class="shrink-0" />
|
||||||
|
</Show>
|
||||||
|
</MenuV2.Item>
|
||||||
|
</MenuV2.Group>
|
||||||
|
<Show when={props.workspaces.length > 0}>
|
||||||
|
<MenuV2.Separator />
|
||||||
|
<MenuV2.Sub gutter={0} overlap overflowPadding={8}>
|
||||||
|
<MenuV2.SubTrigger>
|
||||||
|
<IconV2 name="workspace" />
|
||||||
|
{language.t("session.new.workspace.existing")}
|
||||||
|
</MenuV2.SubTrigger>
|
||||||
|
<MenuV2.Portal>
|
||||||
|
<MenuV2.SubContent class="max-w-[200px]">
|
||||||
|
<For each={props.workspaces}>
|
||||||
|
{(workspace) => (
|
||||||
|
<MenuV2.Item onSelect={() => select(workspace)}>
|
||||||
|
<IconV2 name="workspace-isolated" />
|
||||||
|
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
|
||||||
|
<Show when={selected() === workspace}>
|
||||||
|
<Icon name="check" size="small" class="shrink-0" />
|
||||||
|
</Show>
|
||||||
|
</MenuV2.Item>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</MenuV2.SubContent>
|
||||||
|
</MenuV2.Portal>
|
||||||
|
</MenuV2.Sub>
|
||||||
|
</Show>
|
||||||
|
</MenuV2.Content>
|
||||||
|
</MenuV2.Portal>
|
||||||
|
</MenuV2>
|
||||||
|
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
||||||
|
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
|
||||||
|
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||||
|
<span class="min-w-0 truncate">{props.branch || "main"}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { createRoot } from "solid-js"
|
||||||
|
import { createFileTreeStore } from "./tree-store"
|
||||||
|
|
||||||
|
describe("file tree store", () => {
|
||||||
|
test("expands synthetic directories without listing them", () => {
|
||||||
|
const listed: string[] = []
|
||||||
|
const value = createRoot((dispose) => ({
|
||||||
|
dispose,
|
||||||
|
tree: createFileTreeStore({
|
||||||
|
scope: () => "/project",
|
||||||
|
normalizeDir: (input) => input,
|
||||||
|
list: (input) => {
|
||||||
|
listed.push(input)
|
||||||
|
return Promise.resolve([])
|
||||||
|
},
|
||||||
|
onError: () => undefined,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
value.tree.expandDir("deleted/parent", { load: false })
|
||||||
|
|
||||||
|
expect(value.tree.dirState("deleted/parent")?.expanded).toBe(true)
|
||||||
|
expect(listed).toEqual([])
|
||||||
|
|
||||||
|
value.dispose()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -127,10 +127,11 @@ export function createFileTreeStore(options: TreeStoreOptions) {
|
|||||||
return promise
|
return promise
|
||||||
}
|
}
|
||||||
|
|
||||||
const expandDir = (input: string) => {
|
const expandDir = (input: string, opts?: { load?: boolean }) => {
|
||||||
const dir = options.normalizeDir(input)
|
const dir = options.normalizeDir(input)
|
||||||
ensureDir(dir)
|
ensureDir(dir)
|
||||||
setTree("dir", dir, "expanded", true)
|
setTree("dir", dir, "expanded", true)
|
||||||
|
if (opts?.load === false) return
|
||||||
void listDir(dir)
|
void listDir(dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { createStore, reconcile } from "solid-js/store"
|
import { createStore, reconcile } from "solid-js/store"
|
||||||
import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js"
|
import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams, useSearchParams } from "@solidjs/router"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { useServerSDK } from "./server-sdk"
|
import type { ServerSDK } from "./server-sdk"
|
||||||
import { useServerSync } from "./server-sync"
|
import type { ServerSync } from "./server-sync"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
@@ -12,6 +12,11 @@ import { decode64 } from "@/utils/base64"
|
|||||||
import { EventSessionError } from "@opencode-ai/sdk/v2"
|
import { EventSessionError } from "@opencode-ai/sdk/v2"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import { playSoundById } from "@/utils/sound"
|
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 = {
|
type NotificationBase = {
|
||||||
directory?: string
|
directory?: string
|
||||||
@@ -107,267 +112,360 @@ function buildNotificationIndex(list: Notification[]) {
|
|||||||
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
|
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
|
||||||
name: "Notification",
|
name: "Notification",
|
||||||
gate: false,
|
gate: false,
|
||||||
init: (props: { directory?: Accessor<string | undefined>; sessionID?: Accessor<string | undefined> }) => {
|
init: () => {
|
||||||
const params = useParams()
|
const params = useParams<{ serverKey?: string; dir?: string; id?: string }>()
|
||||||
const serverSDK = useServerSDK()
|
const [search] = useSearchParams<{ draftId?: string }>()
|
||||||
const serverSync = useServerSync()
|
const global = useGlobal()
|
||||||
|
const server = useServer()
|
||||||
|
const tabs = useTabs()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const owner = getOwner()
|
||||||
|
const states = new Map<ServerScope, { dispose: () => void; state: NotificationState }>()
|
||||||
|
|
||||||
const empty: Notification[] = []
|
const activeServer = createMemo(() => {
|
||||||
|
if (params.serverKey) return requireServerKey(params.serverKey)
|
||||||
const currentDirectory = createMemo(() => {
|
if (search.draftId) {
|
||||||
return props.directory?.() ?? decode64(params.dir)
|
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 ensure = (key: ServerConnection.Key) => {
|
||||||
|
const conn = global.servers.list().find((item) => ServerConnection.key(item) === key)
|
||||||
const [store, setStore, _, ready] = persisted(
|
if (!conn) throw new Error(`Notification server not found: ${key}`)
|
||||||
Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]),
|
const ctx = global.ensureServerCtx(conn)
|
||||||
createStore({
|
const existing = states.get(ctx.sdk.scope)
|
||||||
list: [] as Notification[],
|
if (existing) return existing.state
|
||||||
}),
|
const root = createRoot(
|
||||||
)
|
(dispose) => ({
|
||||||
const [index, setIndex] = createStore<NotificationIndex>(buildNotificationIndex(store.list))
|
dispose,
|
||||||
|
state: createServerNotificationState({
|
||||||
const meta = { pruned: false, disposed: false }
|
sdk: ctx.sdk,
|
||||||
|
sync: ctx.sync,
|
||||||
const updateUnseen = (scope: "session" | "project", key: string, unseen: Notification[]) => {
|
active: () => server.scope(activeServer()) === ctx.sdk.scope,
|
||||||
setIndex(scope, "unseen", key, unseen)
|
directory: activeDirectory,
|
||||||
setIndex(scope, "unseenCount", key, unseen.length)
|
sessionID: activeSession,
|
||||||
setIndex(
|
platform,
|
||||||
scope,
|
settings,
|
||||||
"unseenHasError",
|
language,
|
||||||
key,
|
}),
|
||||||
unseen.some((notification) => notification.type === "error"),
|
}),
|
||||||
|
owner ?? undefined,
|
||||||
)
|
)
|
||||||
}
|
states.set(ctx.sdk.scope, root)
|
||||||
|
return root.state
|
||||||
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(() => {
|
createEffect(() => {
|
||||||
if (!ready()) return
|
global.servers.list().forEach((conn) => ensure(ServerConnection.key(conn)))
|
||||||
if (meta.pruned) return
|
})
|
||||||
meta.pruned = true
|
|
||||||
const list = pruneNotifications(store.list)
|
createEffect(() => {
|
||||||
batch(() => {
|
const scopes = new Set(global.servers.list().map((conn) => server.scope(ServerConnection.key(conn))))
|
||||||
setStore("list", list)
|
states.forEach((value, scope) => {
|
||||||
setIndex(reconcile(buildNotificationIndex(list), { merge: false }))
|
if (scopes.has(scope)) return
|
||||||
|
value.dispose()
|
||||||
|
states.delete(scope)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const append = (notification: Notification) => {
|
onCleanup(() => states.forEach((value) => value.dispose()))
|
||||||
const list = pruneNotifications([...store.list, notification])
|
|
||||||
const keep = new Set(list)
|
|
||||||
const removed = store.list.filter((n) => !keep.has(n))
|
|
||||||
|
|
||||||
batch(() => {
|
const selected = () => ensure(activeServer())
|
||||||
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()
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready,
|
ready: () => selected().ready(),
|
||||||
|
ensureServerState: ensure,
|
||||||
session: {
|
session: {
|
||||||
all(session: string) {
|
all: (session: string) => selected().session.all(session),
|
||||||
return index.session.all[session] ?? empty
|
unseen: (session: string) => selected().session.unseen(session),
|
||||||
},
|
unseenCount: (session: string) => selected().session.unseenCount(session),
|
||||||
unseen(session: string) {
|
unseenHasError: (session: string) => selected().session.unseenHasError(session),
|
||||||
return index.session.unseen[session] ?? empty
|
markViewed: (session: string) => selected().session.markViewed(session),
|
||||||
},
|
|
||||||
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: {
|
project: {
|
||||||
all(directory: string) {
|
all: (directory: string) => selected().project.all(directory),
|
||||||
return index.project.all[directory] ?? empty
|
unseen: (directory: string) => selected().project.unseen(directory),
|
||||||
},
|
unseenCount: (directory: string) => selected().project.unseenCount(directory),
|
||||||
unseen(directory: string) {
|
unseenHasError: (directory: string) => selected().project.unseenHasError(directory),
|
||||||
return index.project.unseen[directory] ?? empty
|
markViewed: (directory: string) => selected().project.markViewed(directory),
|
||||||
},
|
|
||||||
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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -656,6 +656,10 @@ export const dict = {
|
|||||||
"session.new.worktree.main": "Main branch",
|
"session.new.worktree.main": "Main branch",
|
||||||
"session.new.worktree.mainWithBranch": "Main branch ({{branch}})",
|
"session.new.worktree.mainWithBranch": "Main branch ({{branch}})",
|
||||||
"session.new.worktree.create": "Create new worktree",
|
"session.new.worktree.create": "Create new worktree",
|
||||||
|
"session.new.workspace.runIn": "Run session in",
|
||||||
|
"session.new.workspace.triggerLocal": "Local",
|
||||||
|
"session.new.workspace.local": "Local repository",
|
||||||
|
"session.new.workspace.existing": "Workspace…",
|
||||||
"session.new.lastModified": "Last modified",
|
"session.new.lastModified": "Last modified",
|
||||||
|
|
||||||
"session.header.search.placeholder": "Search {{project}}",
|
"session.header.search.placeholder": "Search {{project}}",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
@import "@opencode-ai/ui/styles/tailwind";
|
@import "@opencode-ai/ui/styles/tailwind";
|
||||||
@import "@opencode-ai/session-ui/styles";
|
@import "@opencode-ai/session-ui/styles";
|
||||||
@import "@opencode-ai/ui/v2/styles/tailwind.css";
|
@import "@opencode-ai/ui/v2/styles/tailwind.css";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "JetBrainsMono Nerd Font Mono";
|
font-family: "JetBrainsMono Nerd Font Mono";
|
||||||
@@ -131,13 +132,4 @@
|
|||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fade-in {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Session } from "@opencode-ai/sdk/v2/client"
|
|||||||
import {
|
import {
|
||||||
createEffect,
|
createEffect,
|
||||||
createMemo,
|
createMemo,
|
||||||
|
createResource,
|
||||||
createRoot,
|
createRoot,
|
||||||
For,
|
For,
|
||||||
Match,
|
Match,
|
||||||
@@ -67,6 +68,7 @@ import { archiveHomeSession } from "./home-session-archive"
|
|||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
|
|
||||||
const HOME_SESSION_LIMIT = 64
|
const HOME_SESSION_LIMIT = 64
|
||||||
|
const SHOW_HOME_SESSION_ARCHIVE = false
|
||||||
const HOME_ROW_LAYOUT =
|
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"
|
"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`
|
const HOME_ROW_BASE = `${HOME_ROW_LAYOUT} border-0`
|
||||||
@@ -340,15 +342,15 @@ export function NewHome() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function unseenCount(conn: ServerConnection.Any, project: LocalProject) {
|
function unseenCount(conn: ServerConnection.Any, project: LocalProject) {
|
||||||
if (ServerConnection.key(conn) !== server.key) return 0
|
const state = notification.ensureServerState(ServerConnection.key(conn))
|
||||||
return directories(project).reduce((total, directory) => total + notification.project.unseenCount(directory), 0)
|
return directories(project).reduce((total, directory) => total + state.project.unseenCount(directory), 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearNotifications(conn: ServerConnection.Any, project: LocalProject) {
|
function clearNotifications(conn: ServerConnection.Any, project: LocalProject) {
|
||||||
if (ServerConnection.key(conn) !== server.key) return
|
const state = notification.ensureServerState(ServerConnection.key(conn))
|
||||||
directories(project)
|
directories(project)
|
||||||
.filter((directory) => notification.project.unseenCount(directory) > 0)
|
.filter((directory) => state.project.unseenCount(directory) > 0)
|
||||||
.forEach((directory) => notification.project.markViewed(directory))
|
.forEach((directory) => state.project.markViewed(directory))
|
||||||
}
|
}
|
||||||
|
|
||||||
function openSession(session: Session) {
|
function openSession(session: Session) {
|
||||||
@@ -525,10 +527,16 @@ function HomeProjectColumn(props: {
|
|||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const controller = useServerManagementController({ navigateOnAdd: false })
|
const controller = useServerManagementController({ navigateOnAdd: false })
|
||||||
const [state, setState] = persisted(
|
const [_state, setState, _, ready] = persisted(
|
||||||
Persist.global("home.servers", ["home.servers.v1"]),
|
Persist.global("home.servers", ["home.servers.v1"]),
|
||||||
createStore({ collapsed: {} as Record<string, boolean> }),
|
createStore({ collapsed: {} as Record<string, boolean> }),
|
||||||
)
|
)
|
||||||
|
const [state] = createResource(
|
||||||
|
() => ready.promise ?? Promise.resolve(),
|
||||||
|
(p) => p.then(() => _state),
|
||||||
|
{ initialValue: _state },
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
class="mt-6 flex min-w-0 flex-col gap-4 lg:mt-14 lg:pt-[52px]"
|
class="mt-6 flex min-w-0 flex-col gap-4 lg:mt-14 lg:pt-[52px]"
|
||||||
@@ -560,7 +568,7 @@ function HomeProjectColumn(props: {
|
|||||||
const key = ServerConnection.key(item)
|
const key = ServerConnection.key(item)
|
||||||
const healthy = () => !!global.servers.health[key]?.healthy
|
const healthy = () => !!global.servers.health[key]?.healthy
|
||||||
const serverCtx = global.ensureServerCtx(item)
|
const serverCtx = global.ensureServerCtx(item)
|
||||||
const collapsed = () => !!state.collapsed[key]
|
const collapsed = () => !!state().collapsed[key]
|
||||||
return (
|
return (
|
||||||
<div class="flex max-h-[min(572px,calc(100vh_-_300px))] min-w-0 flex-col gap-1 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
<div class="flex max-h-[min(572px,calc(100vh_-_300px))] min-w-0 flex-col gap-1 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||||
<HomeServerRow
|
<HomeServerRow
|
||||||
@@ -573,7 +581,7 @@ function HomeProjectColumn(props: {
|
|||||||
focusServer={props.focusServer}
|
focusServer={props.focusServer}
|
||||||
chooseProject={props.chooseProject}
|
chooseProject={props.chooseProject}
|
||||||
openEdit={(server) => dialog.show(() => <DialogServerV2 mode="edit" server={server} />)}
|
openEdit={(server) => dialog.show(() => <DialogServerV2 mode="edit" server={server} />)}
|
||||||
toggleCollapsed={() => setState("collapsed", key, !state.collapsed[key])}
|
toggleCollapsed={() => setState("collapsed", key, !state().collapsed[key])}
|
||||||
language={props.language}
|
language={props.language}
|
||||||
/>
|
/>
|
||||||
<Show when={healthy() && !collapsed()}>
|
<Show when={healthy() && !collapsed()}>
|
||||||
@@ -1182,22 +1190,24 @@ function HomeSessionRow(props: {
|
|||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
</button>
|
</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">
|
<Show when={SHOW_HOME_SESSION_ARCHIVE}>
|
||||||
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={language.t("common.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">
|
||||||
<IconButtonV2
|
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={language.t("common.archive")}>
|
||||||
data-action="home-session-archive"
|
<IconButtonV2
|
||||||
variant="ghost-muted"
|
data-action="home-session-archive"
|
||||||
size="large"
|
variant="ghost-muted"
|
||||||
icon={<IconV2 name="archive" />}
|
size="large"
|
||||||
aria-label={language.t("common.archive")}
|
icon={<IconV2 name="archive" />}
|
||||||
onClick={(event) => {
|
aria-label={language.t("common.archive")}
|
||||||
event.preventDefault()
|
onClick={(event) => {
|
||||||
event.stopPropagation()
|
event.preventDefault()
|
||||||
void props.archiveSession(props.record.session)
|
event.stopPropagation()
|
||||||
}}
|
void props.archiveSession(props.record.session)
|
||||||
/>
|
}}
|
||||||
</TooltipV2>
|
/>
|
||||||
</div>
|
</TooltipV2>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,18 @@
|
|||||||
import { createEffect, Suspense, type ParentProps } from "solid-js"
|
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 { DebugBar } from "@/components/debug-bar"
|
||||||
import { HelpButton } from "@/components/help-button"
|
import { HelpButton } from "@/components/help-button"
|
||||||
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
|
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
|
||||||
import { useNotification } from "@/context/notification"
|
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { setNavigate } from "@/utils/notification-click"
|
import { setNavigate } from "@/utils/notification-click"
|
||||||
import { setV2Toast, ToastRegion } from "@/utils/toast"
|
import { setV2Toast, ToastRegion } from "@/utils/toast"
|
||||||
|
|
||||||
export default function NewLayout(props: ParentProps) {
|
export default function NewLayout(props: ParentProps) {
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const notification = useNotification()
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const params = useParams<{ id?: string }>()
|
|
||||||
setNavigate(navigate)
|
setNavigate(navigate)
|
||||||
|
|
||||||
createEffect(() => setV2Toast(true))
|
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 = {
|
const update: TitlebarUpdate = {
|
||||||
version: () => {
|
version: () => {
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ import { createPromptInputController, createPromptProjectControls } from "@/page
|
|||||||
import { useSessionKey } from "@/pages/session/session-layout"
|
import { useSessionKey } from "@/pages/session/session-layout"
|
||||||
import { useComposerCommands } from "@/pages/session/use-composer-commands"
|
import { useComposerCommands } from "@/pages/session/use-composer-commands"
|
||||||
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
|
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
|
||||||
|
import { PromptWorkspaceSelector } from "@/components/prompt-workspace-selector"
|
||||||
|
|
||||||
|
const showWorkspaceBar = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `/new-session` draft page. Unlike `session.tsx`, this only renders the prompt
|
* The `/new-session` draft page. Unlike `session.tsx`, this only renders the prompt
|
||||||
@@ -51,16 +54,21 @@ export default function NewSessionPage() {
|
|||||||
onDone: () => inputRef?.focus(),
|
onDone: () => inputRef?.focus(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore<{ worktree?: string }>({})
|
||||||
worktree: "main",
|
|
||||||
})
|
|
||||||
|
|
||||||
const newSessionWorktree = createMemo(() => {
|
const newSessionWorktree = createMemo(() => {
|
||||||
if (store.worktree === "create") return "create"
|
if (store.worktree) return store.worktree
|
||||||
const project = sync().project
|
const project = sync().project
|
||||||
if (project && sdk().directory !== project.worktree) return sdk().directory
|
if (project && sdk().directory !== project.worktree) return sdk().directory
|
||||||
return "main"
|
return "main"
|
||||||
})
|
})
|
||||||
|
const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory)
|
||||||
|
const localBranch = createMemo(() => serverSync().child(projectRoot())[0].vcs?.branch)
|
||||||
|
const selectedBranch = createMemo(() => {
|
||||||
|
const worktree = newSessionWorktree()
|
||||||
|
if (worktree === "main" || worktree === "create") return localBranch()
|
||||||
|
return serverSync().child(worktree)[0].vcs?.branch ?? localBranch()
|
||||||
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!prompt.ready()) return
|
if (!prompt.ready()) return
|
||||||
@@ -97,7 +105,7 @@ export default function NewSessionPage() {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col" classList={{ "gap-8": showWorkspaceBar, "gap-3": !showWorkspaceBar }}>
|
||||||
<PromptInput
|
<PromptInput
|
||||||
controls={inputController()}
|
controls={inputController()}
|
||||||
variant="new-session"
|
variant="new-session"
|
||||||
@@ -105,7 +113,7 @@ export default function NewSessionPage() {
|
|||||||
inputRef = el
|
inputRef = el
|
||||||
}}
|
}}
|
||||||
newSessionWorktree={newSessionWorktree()}
|
newSessionWorktree={newSessionWorktree()}
|
||||||
onNewSessionWorktreeReset={() => setStore("worktree", "main")}
|
onNewSessionWorktreeReset={() => setStore("worktree", undefined)}
|
||||||
onSubmit={() => comments.clear()}
|
onSubmit={() => comments.clear()}
|
||||||
toolbar={
|
toolbar={
|
||||||
<Show when={!projectController.selected()}>
|
<Show when={!projectController.selected()}>
|
||||||
@@ -114,8 +122,34 @@ export default function NewSessionPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Show when={projectController.selected()}>
|
<Show when={projectController.selected()}>
|
||||||
<div class="flex h-7 min-w-0 items-center gap-0 px-2">
|
<div
|
||||||
<PromptProjectSelector controller={projectController} />
|
class="flex min-h-7 min-w-0 items-center gap-0 text-v2-text-text-faint"
|
||||||
|
classList={{
|
||||||
|
"flex-col justify-center sm:flex-row": showWorkspaceBar,
|
||||||
|
"justify-start": !showWorkspaceBar,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PromptProjectSelector
|
||||||
|
controller={projectController}
|
||||||
|
placement={showWorkspaceBar ? "bottom" : "bottom-start"}
|
||||||
|
/>
|
||||||
|
<Show when={showWorkspaceBar}>
|
||||||
|
<PromptWorkspaceSelector
|
||||||
|
value={newSessionWorktree()}
|
||||||
|
projectRoot={projectRoot()}
|
||||||
|
workspaces={sync().project?.sandboxes ?? []}
|
||||||
|
branch={selectedBranch()}
|
||||||
|
onChange={(value) =>
|
||||||
|
setStore(
|
||||||
|
"worktree",
|
||||||
|
value === "main" && sync().project?.worktree !== sdk().directory
|
||||||
|
? sync().project?.worktree
|
||||||
|
: value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onDone={() => inputRef?.focus()}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,20 +1,29 @@
|
|||||||
import { NodeFileSystem } from "@effect/platform-node"
|
import { NodeFileSystem } from "@effect/platform-node"
|
||||||
import { compile, emitEffectImported, emitPromise, write } from "@opencode-ai/httpapi-codegen"
|
import { compile, emitEffectImported, emitPromise, write } from "@opencode-ai/httpapi-codegen"
|
||||||
import { Api } from "@opencode-ai/server/api"
|
import { ClientApi } from "../src/contract"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { HttpApi } from "effect/unstable/httpapi"
|
|
||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
|
|
||||||
const contract = compile(HttpApi.make("opencode-client").add(Api.groups["server.session"]), {
|
const contract = compile(ClientApi, {
|
||||||
groupNames: { "server.session": "sessions" },
|
groupNames: { "server.session": "sessions", "server.event": "events" },
|
||||||
})
|
})
|
||||||
|
|
||||||
await Effect.runPromise(
|
await Effect.runPromise(
|
||||||
Effect.all(
|
Effect.all(
|
||||||
[
|
[
|
||||||
write(emitPromise(contract), fileURLToPath(new URL("../src/generated", import.meta.url))),
|
|
||||||
write(
|
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)),
|
fileURLToPath(new URL("../src/generated-effect", import.meta.url)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { makeDefaultApi } from "@opencode-ai/protocol/api"
|
import { makeDefaultApi } from "@opencode-ai/protocol/api"
|
||||||
import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
|
import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
|
||||||
import { HttpApiMiddleware } from "effect/unstable/httpapi"
|
import { HttpApi, HttpApiMiddleware } from "effect/unstable/httpapi"
|
||||||
|
|
||||||
class LocationMiddleware extends HttpApiMiddleware.Service<LocationMiddleware>()(
|
class LocationMiddleware extends HttpApiMiddleware.Service<LocationMiddleware>()(
|
||||||
"@opencode-ai/client/LocationMiddleware",
|
"@opencode-ai/client/LocationMiddleware",
|
||||||
@@ -17,3 +17,5 @@ const Api = makeDefaultApi({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const SessionGroup = Api.groups["server.session"]
|
export const SessionGroup = Api.groups["server.session"]
|
||||||
|
export const EventGroup = Api.groups["server.event"]
|
||||||
|
export const ClientApi = HttpApi.make("opencode-client").add(SessionGroup).add(EventGroup)
|
||||||
|
|||||||
@@ -10,3 +10,4 @@ export { Session } from "@opencode-ai/schema/session"
|
|||||||
export { SessionInput } from "@opencode-ai/schema/session-input"
|
export { SessionInput } from "@opencode-ai/schema/session-input"
|
||||||
export { SessionMessage } from "@opencode-ai/schema/session-message"
|
export { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||||
export { Prompt } from "@opencode-ai/schema/prompt"
|
export { Prompt } from "@opencode-ai/schema/prompt"
|
||||||
|
export type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
|
||||||
|
|||||||
@@ -2,13 +2,11 @@
|
|||||||
import { Effect, Stream, Schema } from "effect"
|
import { Effect, Stream, Schema } from "effect"
|
||||||
import { Sse } from "effect/unstable/encoding"
|
import { Sse } from "effect/unstable/encoding"
|
||||||
import { HttpClientError } from "effect/unstable/http"
|
import { HttpClientError } from "effect/unstable/http"
|
||||||
import { HttpApi, HttpApiClient } from "effect/unstable/httpapi"
|
import { HttpApiClient } from "effect/unstable/httpapi"
|
||||||
import { SessionGroup } from "../contract"
|
import { ClientApi } from "../contract"
|
||||||
import { ClientError } from "./client-error"
|
import { ClientError } from "./client-error"
|
||||||
|
|
||||||
const Api = HttpApi.make("generated").add(SessionGroup)
|
type RawClient = HttpApiClient.ForApi<typeof ClientApi>
|
||||||
|
|
||||||
type RawClient = HttpApiClient.ForApi<typeof Api>
|
|
||||||
|
|
||||||
const mapClientError = <E>(error: E) =>
|
const mapClientError = <E>(error: E) =>
|
||||||
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
|
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
|
||||||
@@ -149,12 +147,24 @@ const Endpoint0_12 = (raw: RawClient["server.session"]) => (input: Endpoint0_12I
|
|||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
)
|
)
|
||||||
|
|
||||||
type Endpoint0_13Request = Parameters<RawClient["server.session"]["session.events"]>[0]
|
type Endpoint0_13Request = Parameters<RawClient["server.session"]["session.history"]>[0]
|
||||||
type Endpoint0_13Input = {
|
type Endpoint0_13Input = {
|
||||||
readonly sessionID: Endpoint0_13Request["params"]["sessionID"]
|
readonly sessionID: Endpoint0_13Request["params"]["sessionID"]
|
||||||
|
readonly limit?: Endpoint0_13Request["query"]["limit"]
|
||||||
readonly after?: Endpoint0_13Request["query"]["after"]
|
readonly after?: Endpoint0_13Request["query"]["after"]
|
||||||
}
|
}
|
||||||
const Endpoint0_13 = (raw: RawClient["server.session"]) => (input: Endpoint0_13Input) =>
|
const Endpoint0_13 = (raw: RawClient["server.session"]) => (input: Endpoint0_13Input) =>
|
||||||
|
raw["session.history"]({
|
||||||
|
params: { sessionID: input.sessionID },
|
||||||
|
query: { limit: input.limit, after: input.after },
|
||||||
|
}).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
|
type Endpoint0_14Request = Parameters<RawClient["server.session"]["session.events"]>[0]
|
||||||
|
type Endpoint0_14Input = {
|
||||||
|
readonly sessionID: Endpoint0_14Request["params"]["sessionID"]
|
||||||
|
readonly after?: Endpoint0_14Request["query"]["after"]
|
||||||
|
}
|
||||||
|
const Endpoint0_14 = (raw: RawClient["server.session"]) => (input: Endpoint0_14Input) =>
|
||||||
Stream.unwrap(
|
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.mapError(mapClientError),
|
||||||
@@ -162,17 +172,17 @@ const Endpoint0_13 = (raw: RawClient["server.session"]) => (input: Endpoint0_13I
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
type Endpoint0_14Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
type Endpoint0_15Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
||||||
type Endpoint0_14Input = { readonly sessionID: Endpoint0_14Request["params"]["sessionID"] }
|
type Endpoint0_15Input = { readonly sessionID: Endpoint0_15Request["params"]["sessionID"] }
|
||||||
const Endpoint0_14 = (raw: RawClient["server.session"]) => (input: Endpoint0_14Input) =>
|
const Endpoint0_15 = (raw: RawClient["server.session"]) => (input: Endpoint0_15Input) =>
|
||||||
raw["session.interrupt"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
|
raw["session.interrupt"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
type Endpoint0_15Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
type Endpoint0_16Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
||||||
type Endpoint0_15Input = {
|
type Endpoint0_16Input = {
|
||||||
readonly sessionID: Endpoint0_15Request["params"]["sessionID"]
|
readonly sessionID: Endpoint0_16Request["params"]["sessionID"]
|
||||||
readonly messageID: Endpoint0_15Request["params"]["messageID"]
|
readonly messageID: Endpoint0_16Request["params"]["messageID"]
|
||||||
}
|
}
|
||||||
const Endpoint0_15 = (raw: RawClient["server.session"]) => (input: Endpoint0_15Input) =>
|
const Endpoint0_16 = (raw: RawClient["server.session"]) => (input: Endpoint0_16Input) =>
|
||||||
raw["session.message"]({ params: { sessionID: input.sessionID, messageID: input.messageID } }).pipe(
|
raw["session.message"]({ params: { sessionID: input.sessionID, messageID: input.messageID } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
@@ -192,12 +202,26 @@ const adaptGroup0 = (raw: RawClient["server.session"]) => ({
|
|||||||
clear: Endpoint0_10(raw),
|
clear: Endpoint0_10(raw),
|
||||||
commit: Endpoint0_11(raw),
|
commit: Endpoint0_11(raw),
|
||||||
context: Endpoint0_12(raw),
|
context: Endpoint0_12(raw),
|
||||||
events: Endpoint0_13(raw),
|
history: Endpoint0_13(raw),
|
||||||
interrupt: Endpoint0_14(raw),
|
events: Endpoint0_14(raw),
|
||||||
message: Endpoint0_15(raw),
|
interrupt: Endpoint0_15(raw),
|
||||||
|
message: Endpoint0_16(raw),
|
||||||
})
|
})
|
||||||
|
|
||||||
const adaptClient = (raw: RawClient) => ({ sessions: adaptGroup0(raw["server.session"]) })
|
const Endpoint1_0 = (raw: RawClient["server.event"]) => () =>
|
||||||
|
Stream.unwrap(
|
||||||
|
raw["event.subscribe"]({}).pipe(
|
||||||
|
Effect.mapError(mapClientError),
|
||||||
|
Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const adaptGroup1 = (raw: RawClient["server.event"]) => ({ subscribe: Endpoint1_0(raw) })
|
||||||
|
|
||||||
|
const adaptClient = (raw: RawClient) => ({
|
||||||
|
sessions: adaptGroup0(raw["server.session"]),
|
||||||
|
events: adaptGroup1(raw["server.event"]),
|
||||||
|
})
|
||||||
|
|
||||||
export const make = (options?: { readonly baseUrl?: URL | string }) =>
|
export const make = (options?: { readonly baseUrl?: URL | string }) =>
|
||||||
HttpApiClient.make(Api, options).pipe(Effect.map(adaptClient))
|
HttpApiClient.make(ClientApi, options).pipe(Effect.map(adaptClient))
|
||||||
|
|||||||
@@ -24,12 +24,15 @@ import type {
|
|||||||
SessionsCommitOutput,
|
SessionsCommitOutput,
|
||||||
SessionsContextInput,
|
SessionsContextInput,
|
||||||
SessionsContextOutput,
|
SessionsContextOutput,
|
||||||
|
SessionsHistoryInput,
|
||||||
|
SessionsHistoryOutput,
|
||||||
SessionsEventsInput,
|
SessionsEventsInput,
|
||||||
SessionsEventsOutput,
|
SessionsEventsOutput,
|
||||||
SessionsInterruptInput,
|
SessionsInterruptInput,
|
||||||
SessionsInterruptOutput,
|
SessionsInterruptOutput,
|
||||||
SessionsMessageInput,
|
SessionsMessageInput,
|
||||||
SessionsMessageOutput,
|
SessionsMessageOutput,
|
||||||
|
EventsSubscribeOutput,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
import { ClientError } from "./client-error"
|
import { ClientError } from "./client-error"
|
||||||
|
|
||||||
@@ -324,6 +327,18 @@ export function make(options: ClientOptions) {
|
|||||||
},
|
},
|
||||||
requestOptions,
|
requestOptions,
|
||||||
).then((value) => value.data),
|
).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> =>
|
events: (input: SessionsEventsInput, requestOptions?: RequestOptions): AsyncIterable<SessionsEventsOutput> =>
|
||||||
sse<SessionsEventsOutput>(
|
sse<SessionsEventsOutput>(
|
||||||
{
|
{
|
||||||
@@ -359,6 +374,13 @@ export function make(options: ClientOptions) {
|
|||||||
requestOptions,
|
requestOptions,
|
||||||
).then((value) => value.data),
|
).then((value) => value.data),
|
||||||
},
|
},
|
||||||
|
events: {
|
||||||
|
subscribe: (requestOptions?: RequestOptions): AsyncIterable<EventsSubscribeOutput> =>
|
||||||
|
sse<EventsSubscribeOutput>(
|
||||||
|
{ method: "GET", path: `/api/event`, successStatus: 200, declaredStatuses: [401, 400], empty: false },
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { OpenCodeEventEncoded } from "@opencode-ai/protocol/groups/event"
|
||||||
|
|
||||||
export type JsonValue =
|
export type JsonValue =
|
||||||
| null
|
| null
|
||||||
| boolean
|
| boolean
|
||||||
@@ -67,7 +69,7 @@ export const isUnknownError = (value: unknown): value is UnknownError =>
|
|||||||
export type SessionsListInput = {
|
export type SessionsListInput = {
|
||||||
readonly workspace?: {
|
readonly workspace?: {
|
||||||
readonly workspace?: string | undefined
|
readonly workspace?: string | undefined
|
||||||
readonly limit?: string | undefined
|
readonly limit?: number | undefined
|
||||||
readonly order?: "asc" | "desc" | undefined
|
readonly order?: "asc" | "desc" | undefined
|
||||||
readonly search?: string | undefined
|
readonly search?: string | undefined
|
||||||
readonly directory?: string | undefined
|
readonly directory?: string | undefined
|
||||||
@@ -77,7 +79,7 @@ export type SessionsListInput = {
|
|||||||
}["workspace"]
|
}["workspace"]
|
||||||
readonly limit?: {
|
readonly limit?: {
|
||||||
readonly workspace?: string | undefined
|
readonly workspace?: string | undefined
|
||||||
readonly limit?: string | undefined
|
readonly limit?: number | undefined
|
||||||
readonly order?: "asc" | "desc" | undefined
|
readonly order?: "asc" | "desc" | undefined
|
||||||
readonly search?: string | undefined
|
readonly search?: string | undefined
|
||||||
readonly directory?: string | undefined
|
readonly directory?: string | undefined
|
||||||
@@ -87,7 +89,7 @@ export type SessionsListInput = {
|
|||||||
}["limit"]
|
}["limit"]
|
||||||
readonly order?: {
|
readonly order?: {
|
||||||
readonly workspace?: string | undefined
|
readonly workspace?: string | undefined
|
||||||
readonly limit?: string | undefined
|
readonly limit?: number | undefined
|
||||||
readonly order?: "asc" | "desc" | undefined
|
readonly order?: "asc" | "desc" | undefined
|
||||||
readonly search?: string | undefined
|
readonly search?: string | undefined
|
||||||
readonly directory?: string | undefined
|
readonly directory?: string | undefined
|
||||||
@@ -97,7 +99,7 @@ export type SessionsListInput = {
|
|||||||
}["order"]
|
}["order"]
|
||||||
readonly search?: {
|
readonly search?: {
|
||||||
readonly workspace?: string | undefined
|
readonly workspace?: string | undefined
|
||||||
readonly limit?: string | undefined
|
readonly limit?: number | undefined
|
||||||
readonly order?: "asc" | "desc" | undefined
|
readonly order?: "asc" | "desc" | undefined
|
||||||
readonly search?: string | undefined
|
readonly search?: string | undefined
|
||||||
readonly directory?: string | undefined
|
readonly directory?: string | undefined
|
||||||
@@ -107,7 +109,7 @@ export type SessionsListInput = {
|
|||||||
}["search"]
|
}["search"]
|
||||||
readonly directory?: {
|
readonly directory?: {
|
||||||
readonly workspace?: string | undefined
|
readonly workspace?: string | undefined
|
||||||
readonly limit?: string | undefined
|
readonly limit?: number | undefined
|
||||||
readonly order?: "asc" | "desc" | undefined
|
readonly order?: "asc" | "desc" | undefined
|
||||||
readonly search?: string | undefined
|
readonly search?: string | undefined
|
||||||
readonly directory?: string | undefined
|
readonly directory?: string | undefined
|
||||||
@@ -117,7 +119,7 @@ export type SessionsListInput = {
|
|||||||
}["directory"]
|
}["directory"]
|
||||||
readonly project?: {
|
readonly project?: {
|
||||||
readonly workspace?: string | undefined
|
readonly workspace?: string | undefined
|
||||||
readonly limit?: string | undefined
|
readonly limit?: number | undefined
|
||||||
readonly order?: "asc" | "desc" | undefined
|
readonly order?: "asc" | "desc" | undefined
|
||||||
readonly search?: string | undefined
|
readonly search?: string | undefined
|
||||||
readonly directory?: string | undefined
|
readonly directory?: string | undefined
|
||||||
@@ -127,7 +129,7 @@ export type SessionsListInput = {
|
|||||||
}["project"]
|
}["project"]
|
||||||
readonly subpath?: {
|
readonly subpath?: {
|
||||||
readonly workspace?: string | undefined
|
readonly workspace?: string | undefined
|
||||||
readonly limit?: string | undefined
|
readonly limit?: number | undefined
|
||||||
readonly order?: "asc" | "desc" | undefined
|
readonly order?: "asc" | "desc" | undefined
|
||||||
readonly search?: string | undefined
|
readonly search?: string | undefined
|
||||||
readonly directory?: string | undefined
|
readonly directory?: string | undefined
|
||||||
@@ -137,7 +139,7 @@ export type SessionsListInput = {
|
|||||||
}["subpath"]
|
}["subpath"]
|
||||||
readonly cursor?: {
|
readonly cursor?: {
|
||||||
readonly workspace?: string | undefined
|
readonly workspace?: string | undefined
|
||||||
readonly limit?: string | undefined
|
readonly limit?: number | undefined
|
||||||
readonly order?: "asc" | "desc" | undefined
|
readonly order?: "asc" | "desc" | undefined
|
||||||
readonly search?: string | undefined
|
readonly search?: string | undefined
|
||||||
readonly directory?: string | undefined
|
readonly directory?: string | undefined
|
||||||
@@ -305,7 +307,6 @@ export type SessionsPromptInput = {
|
|||||||
readonly text: string
|
readonly text: string
|
||||||
readonly files?: ReadonlyArray<{
|
readonly files?: ReadonlyArray<{
|
||||||
readonly uri: string
|
readonly uri: string
|
||||||
readonly mime: string
|
|
||||||
readonly name?: string
|
readonly name?: string
|
||||||
readonly description?: string
|
readonly description?: string
|
||||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||||
@@ -324,7 +325,6 @@ export type SessionsPromptInput = {
|
|||||||
readonly text: string
|
readonly text: string
|
||||||
readonly files?: ReadonlyArray<{
|
readonly files?: ReadonlyArray<{
|
||||||
readonly uri: string
|
readonly uri: string
|
||||||
readonly mime: string
|
|
||||||
readonly name?: string
|
readonly name?: string
|
||||||
readonly description?: string
|
readonly description?: string
|
||||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||||
@@ -343,7 +343,6 @@ export type SessionsPromptInput = {
|
|||||||
readonly text: string
|
readonly text: string
|
||||||
readonly files?: ReadonlyArray<{
|
readonly files?: ReadonlyArray<{
|
||||||
readonly uri: string
|
readonly uri: string
|
||||||
readonly mime: string
|
|
||||||
readonly name?: string
|
readonly name?: string
|
||||||
readonly description?: string
|
readonly description?: string
|
||||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||||
@@ -362,7 +361,6 @@ export type SessionsPromptInput = {
|
|||||||
readonly text: string
|
readonly text: string
|
||||||
readonly files?: ReadonlyArray<{
|
readonly files?: ReadonlyArray<{
|
||||||
readonly uri: string
|
readonly uri: string
|
||||||
readonly mime: string
|
|
||||||
readonly name?: string
|
readonly name?: string
|
||||||
readonly description?: string
|
readonly description?: string
|
||||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||||
@@ -595,9 +593,469 @@ export type SessionsContextOutput = {
|
|||||||
>
|
>
|
||||||
}["data"]
|
}["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.prompted"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly timestamp: number
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly messageID: string
|
||||||
|
readonly prompt: {
|
||||||
|
readonly text: string
|
||||||
|
readonly files?: ReadonlyArray<{
|
||||||
|
readonly uri: string
|
||||||
|
readonly mime: string
|
||||||
|
readonly name?: string
|
||||||
|
readonly description?: string
|
||||||
|
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||||
|
}>
|
||||||
|
readonly agents?: ReadonlyArray<{
|
||||||
|
readonly name: string
|
||||||
|
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
readonly delivery: "steer" | "queue"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: 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 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 = {
|
export type SessionsEventsInput = {
|
||||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||||
readonly after?: { readonly after?: string | undefined }["after"]
|
readonly after?: { readonly after?: number | undefined }["after"]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionsEventsOutput =
|
export type SessionsEventsOutput =
|
||||||
@@ -1210,3 +1668,5 @@ export type SessionsMessageOutput = {
|
|||||||
readonly time: { readonly created: number }
|
readonly time: { readonly created: number }
|
||||||
}
|
}
|
||||||
}["data"]
|
}["data"]
|
||||||
|
|
||||||
|
export type EventsSubscribeOutput = OpenCodeEventEncoded
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export * from "./generated/index"
|
export * from "./generated/index"
|
||||||
|
export type { EventsSubscribeOutput as OpenCodeEvent } from "./generated/types"
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { Workspace } from "@opencode-ai/schema/workspace"
|
|||||||
import { Api } from "@opencode-ai/server/api"
|
import { Api } from "@opencode-ai/server/api"
|
||||||
import { compile, emitPromise } from "@opencode-ai/httpapi-codegen"
|
import { compile, emitPromise } from "@opencode-ai/httpapi-codegen"
|
||||||
import { HttpApi } from "effect/unstable/httpapi"
|
import { HttpApi } from "effect/unstable/httpapi"
|
||||||
import { SessionGroup } from "../src/contract"
|
import { EventGroup, SessionGroup } from "../src/contract"
|
||||||
|
|
||||||
test("Core and Server reuse the authoritative Schema and Protocol values", () => {
|
test("Core and Server reuse the authoritative Schema and Protocol values", () => {
|
||||||
expect(AgentV2.ID).toBe(Agent.ID)
|
expect(AgentV2.ID).toBe(Agent.ID)
|
||||||
@@ -32,6 +32,7 @@ test("Core and Server reuse the authoritative Schema and Protocol values", () =>
|
|||||||
expect(CorePrompt).toBe(Prompt)
|
expect(CorePrompt).toBe(Prompt)
|
||||||
expect(Api.groups["server.session"].identifier).toBe("server.session")
|
expect(Api.groups["server.session"].identifier).toBe("server.session")
|
||||||
expect(SessionGroup.identifier).toBe(Api.groups["server.session"].identifier)
|
expect(SessionGroup.identifier).toBe(Api.groups["server.session"].identifier)
|
||||||
|
expect(EventGroup.identifier).toBe(Api.groups["server.event"].identifier)
|
||||||
expect(Session.ID.create()).toStartWith("ses_")
|
expect(Session.ID.create()).toStartWith("ses_")
|
||||||
expect(Project.ID.global).toBe("global")
|
expect(Project.ID.global).toBe("global")
|
||||||
expect(Provider.ID.anthropic).toBe("anthropic")
|
expect(Provider.ID.anthropic).toBe("anthropic")
|
||||||
|
|||||||
@@ -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)
|
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 () => {
|
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 httpClient = HttpClient.make((request) => {
|
||||||
const url = request.url
|
const url = request.url
|
||||||
if (url.includes("/event")) {
|
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")) {
|
if (url.includes("/prompt")) {
|
||||||
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(admission)))
|
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.compact({ sessionID: Session.ID.make("ses_test") })
|
||||||
yield* client.sessions.wait({ 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 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
|
const events = yield* client.sessions
|
||||||
.events({ sessionID: Session.ID.make("ses_test"), after: 0 })
|
.events({ sessionID: Session.ID.make("ses_test"), after: 0 })
|
||||||
.pipe(Stream.runCollect)
|
.pipe(Stream.runCollect)
|
||||||
@@ -80,7 +150,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
|||||||
sessionID: Session.ID.make("ses_test"),
|
sessionID: Session.ID.make("ses_test"),
|
||||||
messageID: SessionMessage.ID.make("msg_model"),
|
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)
|
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||||
|
|
||||||
expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000)
|
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(Object.getPrototypeOf(result.admitted.prompt)).toBe(Object.prototype)
|
||||||
expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000)
|
expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000)
|
||||||
expect(result.context).toEqual([])
|
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(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" }))
|
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 = {
|
const session = {
|
||||||
data: {
|
data: {
|
||||||
id: "ses_test",
|
id: "ses_test",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import { isUnauthorizedError, OpenCode } from "../src"
|
import { isSessionNotFoundError, isUnauthorizedError, OpenCode } from "../src"
|
||||||
|
|
||||||
test("sessions.get returns the wire projection", async () => {
|
test("sessions.get returns the wire projection", async () => {
|
||||||
const client = OpenCode.make({
|
const client = OpenCode.make({
|
||||||
@@ -17,8 +17,38 @@ test("sessions.get returns the wire projection", async () => {
|
|||||||
expect(result.time.created).toBe(1_717_171_717_000)
|
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 () => {
|
test("session methods use the public HTTP contract", async () => {
|
||||||
const requests: Array<{ url: string; init?: RequestInit }> = []
|
const requests: Array<{ url: string; init?: RequestInit }> = []
|
||||||
|
let historyPage = 0
|
||||||
const client = OpenCode.make({
|
const client = OpenCode.make({
|
||||||
baseUrl: "http://localhost:3000",
|
baseUrl: "http://localhost:3000",
|
||||||
fetch: async (input, init) => {
|
fetch: async (input, init) => {
|
||||||
@@ -29,6 +59,12 @@ test("session methods use the public HTTP contract", async () => {
|
|||||||
headers: { "content-type": "text/event-stream" },
|
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("/prompt")) return Response.json(admission)
|
||||||
if (url.includes("/context")) return Response.json({ data: [] })
|
if (url.includes("/context")) return Response.json({ data: [] })
|
||||||
if (url.includes("/message/")) return Response.json({ data: modelSwitchedMessage })
|
if (url.includes("/message/")) return Response.json({ data: modelSwitchedMessage })
|
||||||
@@ -39,7 +75,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 active = await client.sessions.active()
|
||||||
const created = await client.sessions.create({ location: { directory: "/tmp/project" } })
|
const created = await client.sessions.create({ location: { directory: "/tmp/project" } })
|
||||||
await client.sessions.switchAgent({ sessionID: "ses_test", agent: "build" })
|
await client.sessions.switchAgent({ sessionID: "ses_test", agent: "build" })
|
||||||
@@ -55,8 +91,13 @@ test("session methods use the public HTTP contract", async () => {
|
|||||||
await client.sessions.compact({ sessionID: "ses_test" })
|
await client.sessions.compact({ sessionID: "ses_test" })
|
||||||
await client.sessions.wait({ sessionID: "ses_test" })
|
await client.sessions.wait({ sessionID: "ses_test" })
|
||||||
const context = await client.sessions.context({ 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 = []
|
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" })
|
await client.sessions.interrupt({ sessionID: "ses_test" })
|
||||||
const message = await client.sessions.message({ sessionID: "ses_test", messageID: "msg_model" })
|
const message = await client.sessions.message({ sessionID: "ses_test", messageID: "msg_model" })
|
||||||
|
|
||||||
@@ -65,6 +106,8 @@ test("session methods use the public HTTP contract", async () => {
|
|||||||
expect(created.id).toBe("ses_test")
|
expect(created.id).toBe("ses_test")
|
||||||
expect(admitted.id).toBe("msg_test")
|
expect(admitted.id).toBe("msg_test")
|
||||||
expect(context).toEqual([])
|
expect(context).toEqual([])
|
||||||
|
expect(history).toEqual({ data: [modelSwitchedEvent], hasMore: true })
|
||||||
|
expect(historyNext).toEqual({ data: [], hasMore: false })
|
||||||
expect(events).toEqual([modelSwitchedEvent])
|
expect(events).toEqual([modelSwitchedEvent])
|
||||||
expect(message).toEqual(modelSwitchedMessage)
|
expect(message).toEqual(modelSwitchedMessage)
|
||||||
expect(requests.map((request) => [request.init?.method, request.url])).toEqual([
|
expect(requests.map((request) => [request.init?.method, request.url])).toEqual([
|
||||||
@@ -77,6 +120,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/compact"],
|
||||||
["POST", "http://localhost:3000/api/session/ses_test/wait"],
|
["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/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"],
|
["GET", "http://localhost:3000/api/session/ses_test/event?after=0"],
|
||||||
["POST", "http://localhost:3000/api/session/ses_test/interrupt"],
|
["POST", "http://localhost:3000/api/session/ses_test/interrupt"],
|
||||||
["GET", "http://localhost:3000/api/session/ses_test/message/msg_model"],
|
["GET", "http://localhost:3000/api/session/ses_test/message/msg_model"],
|
||||||
@@ -104,6 +149,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 = {
|
const session = {
|
||||||
data: {
|
data: {
|
||||||
id: "ses_test",
|
id: "ses_test",
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import type { APIEvent } from "@solidjs/start/server"
|
import type { APIEvent } from "@solidjs/start/server"
|
||||||
import { Resource } from "@opencode-ai/console-resource"
|
import { Resource } from "@opencode-ai/console-resource"
|
||||||
|
import { LOCALE_HEADER, cookie, localeFromRequest, route, tag } from "~/lib/language"
|
||||||
|
|
||||||
const dataPath = "/data"
|
const dataPath = "/data"
|
||||||
|
|
||||||
export async function statsProxy(evt: APIEvent) {
|
export async function statsProxy(evt: APIEvent) {
|
||||||
const req = evt.request.clone()
|
const req = evt.request.clone()
|
||||||
|
const locale = localeFromRequest(req)
|
||||||
|
const redirect = redirectToLocalizedData(req, new URL(req.url), locale)
|
||||||
|
if (redirect) return redirect
|
||||||
|
|
||||||
const targetUrl = new URL(req.url)
|
const targetUrl = new URL(req.url)
|
||||||
targetUrl.protocol = "https:"
|
targetUrl.protocol = "https:"
|
||||||
targetUrl.hostname = Resource.App.stage === "production" ? "stats.opencode.ai" : "stats.dev.opencode.ai"
|
targetUrl.hostname = Resource.App.stage === "production" ? "stats.opencode.ai" : "stats.dev.opencode.ai"
|
||||||
@@ -18,9 +23,13 @@ export async function statsProxy(evt: APIEvent) {
|
|||||||
targetUrl.pathname = targetUrl.pathname.slice(dataPath.length)
|
targetUrl.pathname = targetUrl.pathname.slice(dataPath.length)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const requestHeaders = new Headers(req.headers)
|
||||||
|
requestHeaders.set(LOCALE_HEADER, locale)
|
||||||
|
requestHeaders.set("accept-language", tag(locale))
|
||||||
|
|
||||||
const response = await fetch(targetUrl, {
|
const response = await fetch(targetUrl, {
|
||||||
method: req.method,
|
method: req.method,
|
||||||
headers: req.headers,
|
headers: requestHeaders,
|
||||||
body: req.body,
|
body: req.body,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -30,6 +39,7 @@ export async function statsProxy(evt: APIEvent) {
|
|||||||
headers.delete("content-encoding")
|
headers.delete("content-encoding")
|
||||||
headers.delete("content-length")
|
headers.delete("content-length")
|
||||||
headers.delete("etag")
|
headers.delete("etag")
|
||||||
|
appendVary(headers, "Accept-Language", "Cookie")
|
||||||
|
|
||||||
return new Response(rewriteStatsHtml(await response.text()), {
|
return new Response(rewriteStatsHtml(await response.text()), {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
@@ -52,3 +62,60 @@ export function statsRedirect(evt: APIEvent) {
|
|||||||
function rewriteStatsHtml(html: string) {
|
function rewriteStatsHtml(html: string) {
|
||||||
return html.replaceAll('"/_build/', `"${dataPath}/_build/`).replaceAll("'/_build/", `'${dataPath}/_build/`)
|
return html.replaceAll('"/_build/', `"${dataPath}/_build/`).replaceAll("'/_build/", `'${dataPath}/_build/`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function redirectToLocalizedData(request: Request, url: URL, locale: ReturnType<typeof localeFromRequest>) {
|
||||||
|
if (locale === "en") return null
|
||||||
|
if (request.headers.get(LOCALE_HEADER)) return null
|
||||||
|
if (request.method !== "GET" && request.method !== "HEAD") return null
|
||||||
|
if (!acceptsHtml(request)) return null
|
||||||
|
if (!url.pathname.startsWith(`${dataPath}/`) && url.pathname !== dataPath) return null
|
||||||
|
if (isDataBypassPath(url.pathname)) return null
|
||||||
|
|
||||||
|
const next = new URL(url)
|
||||||
|
next.pathname = route(locale, url.pathname)
|
||||||
|
|
||||||
|
const headers = new Headers({
|
||||||
|
Location: next.toString(),
|
||||||
|
})
|
||||||
|
headers.append("set-cookie", cookie(locale))
|
||||||
|
appendVary(headers, "Accept-Language", "Cookie")
|
||||||
|
|
||||||
|
return new Response(null, {
|
||||||
|
status: 308,
|
||||||
|
headers,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function acceptsHtml(request: Request) {
|
||||||
|
const accept = request.headers.get("accept")
|
||||||
|
return !accept || accept.includes("text/html") || accept.includes("*/*")
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDataBypassPath(pathname: string) {
|
||||||
|
return (
|
||||||
|
pathname.startsWith(`${dataPath}/_build/`) ||
|
||||||
|
pathname.startsWith(`${dataPath}/api/`) ||
|
||||||
|
pathname.startsWith(`${dataPath}/_server`) ||
|
||||||
|
pathname === `${dataPath}/banner.jpg` ||
|
||||||
|
pathname === `${dataPath}/banner.png`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendVary(headers: Headers, ...values: string[]) {
|
||||||
|
const existing = headers
|
||||||
|
.get("vary")
|
||||||
|
?.split(",")
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
headers.set(
|
||||||
|
"vary",
|
||||||
|
values
|
||||||
|
.reduce(
|
||||||
|
(result, value) =>
|
||||||
|
result.some((item) => item.toLowerCase() === value.toLowerCase()) ? result : [...result, value],
|
||||||
|
existing ?? [],
|
||||||
|
)
|
||||||
|
.join(", "),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -215,6 +215,16 @@ export async function handler(
|
|||||||
body: reqBody,
|
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) {
|
if (res.status !== 200) {
|
||||||
logger.metric({
|
logger.metric({
|
||||||
"llm.error.code": res.status,
|
"llm.error.code": res.status,
|
||||||
|
|||||||
+81
-15
@@ -1,9 +1,9 @@
|
|||||||
export * as EventV2 from "./event"
|
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 { Event } from "@opencode-ai/schema/event"
|
||||||
import type { Data, Definition, Payload } 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 { Database } from "./database/database"
|
||||||
import { EventSequenceTable, EventTable } from "./event/sql"
|
import { EventSequenceTable, EventTable } from "./event/sql"
|
||||||
import { Location } from "./location"
|
import { Location } from "./location"
|
||||||
@@ -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 define = Event.define
|
||||||
export const versionedType = Event.versionedType
|
export const versionedType = Event.versionedType
|
||||||
|
|
||||||
@@ -84,6 +149,20 @@ export interface Interface {
|
|||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Event") {}
|
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 {
|
export interface LayerOptions {
|
||||||
readonly beforeAggregateRead?: (aggregateID: string) => Effect.Effect<void>
|
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 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) =>
|
const readAfter = (aggregateID: string, after: number) =>
|
||||||
(options?.beforeAggregateRead?.(aggregateID) ?? Effect.void).pipe(
|
(options?.beforeAggregateRead?.(aggregateID) ?? Effect.void).pipe(
|
||||||
Effect.andThen(
|
Effect.andThen(
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
// offline and drop into any transport (`res.end(...)`, Effect `response.end`,
|
// offline and drop into any transport (`res.end(...)`, Effect `response.end`,
|
||||||
// etc.).
|
// etc.).
|
||||||
//
|
//
|
||||||
// The visual language mirrors the opencode app: the design tokens are a curated
|
// The visual language mirrors the OpenCode app: the design tokens are a curated
|
||||||
// subset of the OC-2 semantic tokens in `packages/ui/src/styles/theme.css`, and
|
// subset of the OC-2 semantic tokens in `packages/ui/src/styles/theme.css`, and
|
||||||
// the wordmark is the same geometry as `packages/ui/src/components/logo.tsx`.
|
// the wordmark is the same geometry as `packages/ui/src/components/logo.tsx`.
|
||||||
// Keep this file in sync with those sources when the brand changes.
|
// Keep this file in sync with those sources when the brand changes.
|
||||||
@@ -25,8 +25,8 @@ export function success(options?: CallbackPageOptions) {
|
|||||||
body: renderCard({
|
body: renderCard({
|
||||||
status: "success",
|
status: "success",
|
||||||
headline: "Authorization successful",
|
headline: "Authorization successful",
|
||||||
message: provider ? `opencode is now connected to ${escapeHtml(provider)}.` : "opencode is now authorized.",
|
message: provider ? `OpenCode is now connected to ${escapeHtml(provider)}.` : "OpenCode is now authorized.",
|
||||||
footnote: "You can close this window and return to opencode.",
|
footnote: "You can close this window.",
|
||||||
}),
|
}),
|
||||||
script: options?.autoClose === false ? undefined : AUTO_CLOSE_SCRIPT,
|
script: options?.autoClose === false ? undefined : AUTO_CLOSE_SCRIPT,
|
||||||
})
|
})
|
||||||
@@ -40,10 +40,10 @@ export function error(detail: string, options?: CallbackPageOptions) {
|
|||||||
status: "error",
|
status: "error",
|
||||||
headline: "Authorization failed",
|
headline: "Authorization failed",
|
||||||
message: provider
|
message: provider
|
||||||
? `opencode couldn't finish connecting to ${escapeHtml(provider)}.`
|
? `OpenCode couldn't finish connecting to ${escapeHtml(provider)}.`
|
||||||
: "opencode couldn't complete authorization.",
|
: "OpenCode couldn't complete authorization.",
|
||||||
detail,
|
detail,
|
||||||
footnote: "Close this window and try again from opencode.",
|
footnote: "Close this window and try again from OpenCode.",
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -100,7 +100,7 @@ function renderDocument(input: { title: string; body: string; script?: string })
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="robots" content="noindex" />
|
<meta name="robots" content="noindex" />
|
||||||
<title>${escapeHtml(input.title)} · opencode</title>
|
<title>${escapeHtml(input.title)} · OpenCode</title>
|
||||||
<style>${STYLES}</style>
|
<style>${STYLES}</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -116,8 +116,8 @@ function bootstrapScript(options: BootstrapOptions) {
|
|||||||
var TOKEN_URL=new URL(${scriptString(options.tokenPath)},window.location.origin).href;
|
var TOKEN_URL=new URL(${scriptString(options.tokenPath)},window.location.origin).href;
|
||||||
(function(){
|
(function(){
|
||||||
var card=document.getElementById("oc-card"),headline=document.getElementById("oc-headline"),message=document.getElementById("oc-message"),detail=document.getElementById("oc-detail"),footnote=document.getElementById("oc-footnote");
|
var card=document.getElementById("oc-card"),headline=document.getElementById("oc-headline"),message=document.getElementById("oc-message"),detail=document.getElementById("oc-detail"),footnote=document.getElementById("oc-footnote");
|
||||||
function fail(text){card.dataset.status="error";headline.textContent="Authorization failed";message.textContent=PROVIDER?("opencode couldn't finish connecting to "+PROVIDER+"."):"opencode couldn't complete authorization.";if(text){detail.textContent=text;detail.hidden=false}footnote.textContent="Close this window and try again from opencode."}
|
function fail(text){card.dataset.status="error";headline.textContent="Authorization failed";message.textContent=PROVIDER?("OpenCode couldn't finish connecting to "+PROVIDER+"."):"OpenCode couldn't complete authorization.";if(text){detail.textContent=text;detail.hidden=false}footnote.textContent="Close this window and try again from OpenCode."}
|
||||||
function ok(){card.dataset.status="success";headline.textContent="Authorization successful";message.textContent=PROVIDER?("opencode is now connected to "+PROVIDER+"."):"opencode is now authorized.";detail.hidden=true;footnote.textContent="You can close this window and return to opencode.";setTimeout(function(){try{window.close()}catch(e){}},2500)}
|
function ok(){card.dataset.status="success";headline.textContent="Authorization successful";message.textContent=PROVIDER?("OpenCode is now connected to "+PROVIDER+"."):"OpenCode is now authorized.";detail.hidden=true;footnote.textContent="You can close this window.";setTimeout(function(){try{window.close()}catch(e){}},2500)}
|
||||||
try{
|
try{
|
||||||
var hash=new URLSearchParams((window.location.hash||"").slice(1));
|
var hash=new URLSearchParams((window.location.hash||"").slice(1));
|
||||||
var search=new URLSearchParams(window.location.search||"");
|
var search=new URLSearchParams(window.location.search||"");
|
||||||
@@ -205,7 +205,7 @@ const STYLES = `
|
|||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
}
|
}
|
||||||
.card {
|
.card {
|
||||||
width: min(100%, 25rem);
|
width: min(100%, 28rem);
|
||||||
padding: 2.25rem 2rem 1.75rem;
|
padding: 2.25rem 2rem 1.75rem;
|
||||||
background: var(--oc-card);
|
background: var(--oc-card);
|
||||||
border: 1px solid var(--oc-border-weak);
|
border: 1px solid var(--oc-border-weak);
|
||||||
@@ -249,8 +249,8 @@ const STYLES = `
|
|||||||
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }
|
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }
|
||||||
`
|
`
|
||||||
|
|
||||||
// opencode wordmark — same path geometry as packages/ui/src/components/logo.tsx (Logo).
|
// OpenCode wordmark — same path geometry as packages/ui/src/components/logo.tsx (Logo).
|
||||||
const WORDMARK = `<svg class="wordmark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 234 42" fill="none" aria-label="opencode" role="img">
|
const WORDMARK = `<svg class="wordmark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 234 42" fill="none" aria-label="OpenCode" role="img">
|
||||||
<path d="M18 30H6V18H18V30Z" fill="var(--oc-icon-weak)" />
|
<path d="M18 30H6V18H18V30Z" fill="var(--oc-icon-weak)" />
|
||||||
<path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" fill="var(--oc-icon-base)" />
|
<path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" fill="var(--oc-icon-base)" />
|
||||||
<path d="M48 30H36V18H48V30Z" fill="var(--oc-icon-weak)" />
|
<path d="M48 30H36V18H48V30Z" fill="var(--oc-icon-weak)" />
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { ModelV2 } from "./model"
|
|||||||
import { Location } from "./location"
|
import { Location } from "./location"
|
||||||
import { SessionMessage } from "./session/message"
|
import { SessionMessage } from "./session/message"
|
||||||
import { Prompt } from "./session/prompt"
|
import { Prompt } from "./session/prompt"
|
||||||
|
import { PromptInput } from "@opencode-ai/schema/prompt-input"
|
||||||
import { EventV2 } from "./event"
|
import { EventV2 } from "./event"
|
||||||
import { Database } from "./database/database"
|
import { Database } from "./database/database"
|
||||||
import { SessionProjector } from "./session/projector"
|
import { SessionProjector } from "./session/projector"
|
||||||
@@ -32,6 +33,8 @@ import { SessionInput } from "./session/input"
|
|||||||
import { Snapshot } from "./snapshot"
|
import { Snapshot } from "./snapshot"
|
||||||
import { SessionRevert } from "./session/revert"
|
import { SessionRevert } from "./session/revert"
|
||||||
import { Revert } from "@opencode-ai/schema/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 const RevertState = Revert.State
|
||||||
export type RevertState = Revert.State
|
export type RevertState = Revert.State
|
||||||
@@ -129,6 +132,11 @@ export interface Interface {
|
|||||||
sessionID: SessionSchema.ID
|
sessionID: SessionSchema.ID
|
||||||
after?: number
|
after?: number
|
||||||
}) => Stream.Stream<SessionEvent.DurableEvent, NotFoundError>
|
}) => 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 switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect<void, NotFoundError>
|
||||||
readonly switchModel: (input: {
|
readonly switchModel: (input: {
|
||||||
sessionID: SessionSchema.ID
|
sessionID: SessionSchema.ID
|
||||||
@@ -137,7 +145,7 @@ export interface Interface {
|
|||||||
readonly prompt: (input: {
|
readonly prompt: (input: {
|
||||||
id?: SessionMessage.ID
|
id?: SessionMessage.ID
|
||||||
sessionID: SessionSchema.ID
|
sessionID: SessionSchema.ID
|
||||||
prompt: Prompt
|
prompt: PromptInput.Prompt
|
||||||
delivery?: SessionInput.Delivery
|
delivery?: SessionInput.Delivery
|
||||||
resume?: boolean
|
resume?: boolean
|
||||||
}) => Effect.Effect<SessionInput.Admitted, NotFoundError | PromptConflictError>
|
}) => Effect.Effect<SessionInput.Admitted, NotFoundError | PromptConflictError>
|
||||||
@@ -345,17 +353,26 @@ export const layer = Layer.unwrap(
|
|||||||
.get(input.sessionID)
|
.get(input.sessionID)
|
||||||
.pipe(Effect.as(events.durable({ aggregateID: input.sessionID, after: input.after }))),
|
.pipe(Effect.as(events.durable({ aggregateID: input.sessionID, after: input.after }))),
|
||||||
).pipe(Stream.filter((event): event is SessionEvent.DurableEvent => isDurableSessionEvent(event))),
|
).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) =>
|
prompt: Effect.fn("V2Session.prompt")((input) =>
|
||||||
Effect.uninterruptible(
|
Effect.uninterruptible(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* result.get(input.sessionID)
|
yield* result.get(input.sessionID)
|
||||||
|
const prompt = resolvePrompt(input.prompt)
|
||||||
const messageID = input.id ?? SessionMessage.ID.create()
|
const messageID = input.id ?? SessionMessage.ID.create()
|
||||||
const delivery = input.delivery ?? "steer"
|
const delivery = input.delivery ?? "steer"
|
||||||
const expected = { sessionID: input.sessionID, messageID, prompt: input.prompt, delivery }
|
const expected = { sessionID: input.sessionID, messageID, prompt, delivery }
|
||||||
const admitted = yield* SessionInput.admit(db, events, {
|
const admitted = yield* SessionInput.admit(db, events, {
|
||||||
id: messageID,
|
id: messageID,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
prompt: input.prompt,
|
prompt,
|
||||||
delivery,
|
delivery,
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.catchDefect((defect) =>
|
Effect.catchDefect((defect) =>
|
||||||
@@ -452,3 +469,17 @@ export const defaultLayer = layer.pipe(
|
|||||||
Layer.provide(ProjectV2.defaultLayer),
|
Layer.provide(ProjectV2.defaultLayer),
|
||||||
Layer.orDie,
|
Layer.orDie,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const resolvePrompt = (input: PromptInput.Prompt) =>
|
||||||
|
Prompt.make({
|
||||||
|
text: input.text,
|
||||||
|
agents: input.agents,
|
||||||
|
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)
|
||||||
|
return {
|
||||||
|
...file,
|
||||||
|
mime: dataMime ?? (target.endsWith("/") ? "application/x-directory" : FSUtil.mimeType(target)),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
export * as ReadTool from "./read"
|
export * as ReadTool from "./read"
|
||||||
|
|
||||||
import { ToolFailure } from "@opencode-ai/llm"
|
import { ToolFailure } from "@opencode-ai/llm"
|
||||||
import path from "path"
|
|
||||||
import { Effect, Layer, Schema } from "effect"
|
import { Effect, Layer, Schema } from "effect"
|
||||||
import { FileSystem } from "../filesystem"
|
import { FileSystem } from "../filesystem"
|
||||||
import { FSUtil } from "../fs-util"
|
|
||||||
import { Image } from "../image"
|
import { Image } from "../image"
|
||||||
import { Location } from "../location"
|
import { LocationMutation } from "../location-mutation"
|
||||||
import { PermissionV2 } from "../permission"
|
import { PermissionV2 } from "../permission"
|
||||||
import { AbsolutePath } from "../schema"
|
import { AbsolutePath } from "../schema"
|
||||||
import { ReadToolFileSystem } from "./read-filesystem"
|
import { ReadToolFileSystem } from "./read-filesystem"
|
||||||
@@ -30,9 +28,8 @@ const Output = Schema.Union([FileSystem.Content, ReadToolFileSystem.TextPage, Re
|
|||||||
export const layer = Layer.effectDiscard(
|
export const layer = Layer.effectDiscard(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const tools = yield* Tools.Service
|
const tools = yield* Tools.Service
|
||||||
const fs = yield* FSUtil.Service
|
|
||||||
const reader = yield* ReadToolFileSystem.Service
|
const reader = yield* ReadToolFileSystem.Service
|
||||||
const location = yield* Location.Service
|
const mutation = yield* LocationMutation.Service
|
||||||
const image = yield* Image.Service
|
const image = yield* Image.Service
|
||||||
const permission = yield* PermissionV2.Service
|
const permission = yield* PermissionV2.Service
|
||||||
|
|
||||||
@@ -40,7 +37,7 @@ export const layer = Layer.effectDiscard(
|
|||||||
.register({
|
.register({
|
||||||
[name]: Tool.make({
|
[name]: Tool.make({
|
||||||
description:
|
description:
|
||||||
"Read a text file or supported image, page through a large UTF-8 text file by line offset, or list a directory page. Relative paths resolve from the current location; absolute paths are read directly.",
|
"Read a text file or supported image, page through a large UTF-8 text file by line offset, or list a directory page. Relative paths resolve from the current location; absolute paths inside it are accepted, while external absolute paths require external_directory approval.",
|
||||||
input: Input,
|
input: Input,
|
||||||
output: Output,
|
output: Output,
|
||||||
toModelOutput: ({ input, output }) => {
|
toModelOutput: ({ input, output }) => {
|
||||||
@@ -53,27 +50,34 @@ export const layer = Layer.effectDiscard(
|
|||||||
},
|
},
|
||||||
execute: (input, context) => {
|
execute: (input, context) => {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const absolute = path.resolve(location.directory, input.path)
|
const source = {
|
||||||
const selected = path.isAbsolute(input.path) ? path.dirname(absolute) : location.directory
|
type: "tool" as const,
|
||||||
if (!path.isAbsolute(input.path) && !FSUtil.contains(location.directory, absolute))
|
messageID: context.assistantMessageID,
|
||||||
return yield* Effect.die(new Error("Path escapes the allowed read root"))
|
callID: context.toolCallID,
|
||||||
const real = yield* fs.realPath(absolute)
|
}
|
||||||
const root = yield* fs.realPath(selected)
|
const target = yield* mutation.resolve({ path: input.path, kind: "directory" })
|
||||||
if (!FSUtil.contains(root, real))
|
const external = target.externalDirectory
|
||||||
return yield* Effect.die(new Error("Path escapes the allowed read root"))
|
if (external)
|
||||||
const resource = path.relative(root, real).replaceAll("\\", "/") || "."
|
yield* permission.assert({
|
||||||
const target = AbsolutePath.make(real)
|
...LocationMutation.externalDirectoryPermission(external),
|
||||||
const type = yield* reader.inspect(target)
|
sessionID: context.sessionID,
|
||||||
|
agent: context.agent,
|
||||||
|
source,
|
||||||
|
})
|
||||||
|
const resource = target.resource
|
||||||
|
const absolute = AbsolutePath.make(target.canonical)
|
||||||
|
const type = yield* reader.inspect(absolute)
|
||||||
yield* permission.assert({
|
yield* permission.assert({
|
||||||
action: name,
|
action: name,
|
||||||
resources: [resource],
|
resources: [resource],
|
||||||
save: ["*"],
|
save: ["*"],
|
||||||
sessionID: context.sessionID,
|
sessionID: context.sessionID,
|
||||||
agent: context.agent,
|
agent: context.agent,
|
||||||
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
|
source,
|
||||||
})
|
})
|
||||||
if (type === "directory") return yield* reader.list(target, { offset: input.offset, limit: input.limit })
|
if (type === "directory")
|
||||||
const content = yield* reader.read(target, resource, {
|
return yield* reader.list(absolute, { offset: input.offset, limit: input.limit })
|
||||||
|
const content = yield* reader.read(absolute, resource, {
|
||||||
offset: input.offset,
|
offset: input.offset,
|
||||||
limit: input.limit,
|
limit: input.limit,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } from "effect"
|
import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, Stream } from "effect"
|
||||||
import { EventV2 } from "@opencode-ai/core/event"
|
import { EventV2 } from "@opencode-ai/core/event"
|
||||||
import { Event } from "@opencode-ai/schema/event"
|
import { Event } from "@opencode-ai/schema/event"
|
||||||
import { Session } from "@opencode-ai/schema/session"
|
import { Session } from "@opencode-ai/schema/session"
|
||||||
@@ -285,6 +285,69 @@ describe("EventV2", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("notifies global listeners only after a durable event is committed", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const events = yield* EventV2.Service
|
||||||
|
const { db } = yield* Database.Service
|
||||||
|
const aggregateID = EventV2.ID.create()
|
||||||
|
const observed = new Array<{ id: string; seq: number }>()
|
||||||
|
yield* events.listen((event) =>
|
||||||
|
event.type !== SyncMessage.type
|
||||||
|
? Effect.void
|
||||||
|
: db
|
||||||
|
.select({ id: EventTable.id, seq: EventTable.seq })
|
||||||
|
.from(EventTable)
|
||||||
|
.where(eq(EventTable.id, event.id))
|
||||||
|
.get()
|
||||||
|
.pipe(
|
||||||
|
Effect.orDie,
|
||||||
|
Effect.tap((row) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
if (row) observed.push(row)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Effect.asVoid,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const event = yield* events.publish(SyncMessage, { id: aggregateID, text: "committed" })
|
||||||
|
if (!event.durable) throw new Error("Expected durable event metadata")
|
||||||
|
|
||||||
|
expect(observed).toEqual([{ id: event.id, seq: event.durable.seq }])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("ends only an overflowing bounded subscriber without blocking other listeners", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const events = yield* EventV2.Service
|
||||||
|
const consuming = yield* Deferred.make<void>()
|
||||||
|
const release = yield* Deferred.make<void>()
|
||||||
|
const slowStream = yield* EventV2.allBounded(events, 1)
|
||||||
|
const fastStream = yield* EventV2.allBounded(events, 8)
|
||||||
|
const slow = yield* slowStream.pipe(
|
||||||
|
Stream.runForEach(() => Deferred.succeed(consuming, undefined).pipe(Effect.andThen(Deferred.await(release)))),
|
||||||
|
Effect.forkScoped,
|
||||||
|
)
|
||||||
|
const fast = yield* fastStream.pipe(Stream.take(4), Stream.runCollect, Effect.forkScoped)
|
||||||
|
|
||||||
|
yield* events.publish(Message, { text: "one" })
|
||||||
|
yield* Deferred.await(consuming)
|
||||||
|
yield* events.publish(Message, { text: "two" })
|
||||||
|
yield* events.publish(Message, { text: "overflow" })
|
||||||
|
const last = yield* events.publish(Message, { text: "still delivered" })
|
||||||
|
yield* Deferred.succeed(release, undefined)
|
||||||
|
|
||||||
|
const slowExit = yield* Fiber.await(slow)
|
||||||
|
expect(Exit.findErrorOption(slowExit).pipe(Option.getOrUndefined)).toBeInstanceOf(EventV2.SubscriberOverflowError)
|
||||||
|
expect(Array.from(yield* Fiber.join(fast))).toEqual([
|
||||||
|
expect.objectContaining({ data: { text: "one" } }),
|
||||||
|
expect.objectContaining({ data: { text: "two" } }),
|
||||||
|
expect.objectContaining({ data: { text: "overflow" } }),
|
||||||
|
last,
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("preserves observer interruption", () =>
|
it.effect("preserves observer interruption", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import { Effect, Layer, Schema } from "effect"
|
||||||
|
import { Database } from "@opencode-ai/core/database/database"
|
||||||
|
import { EventV2 } from "@opencode-ai/core/event"
|
||||||
|
import { Location } from "@opencode-ai/core/location"
|
||||||
|
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||||
|
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||||
|
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||||
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
|
import { SessionV2 } from "@opencode-ai/core/session"
|
||||||
|
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||||
|
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||||
|
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||||
|
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||||
|
import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
|
const projects = Layer.succeed(
|
||||||
|
ProjectV2.Service,
|
||||||
|
ProjectV2.Service.of({
|
||||||
|
resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }),
|
||||||
|
directories: () => Effect.succeed([]),
|
||||||
|
commit: () => Effect.void,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const sessions = SessionV2.layer.pipe(
|
||||||
|
Layer.provide(LocationServiceMap.layer),
|
||||||
|
Layer.provide(EventV2.defaultLayer),
|
||||||
|
Layer.provide(Database.defaultLayer),
|
||||||
|
Layer.provide(SessionStore.defaultLayer),
|
||||||
|
Layer.provide(projects),
|
||||||
|
Layer.provide(SessionExecution.noopLayer),
|
||||||
|
)
|
||||||
|
const it = testEffect(
|
||||||
|
Layer.mergeAll(
|
||||||
|
Database.defaultLayer,
|
||||||
|
EventV2.defaultLayer,
|
||||||
|
projects,
|
||||||
|
SessionProjector.defaultLayer,
|
||||||
|
SessionStore.defaultLayer,
|
||||||
|
SessionExecution.noopLayer,
|
||||||
|
sessions,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
|
||||||
|
|
||||||
|
const GapEvent = EventV2.define({
|
||||||
|
type: "test.session.history.gap",
|
||||||
|
durable: { aggregate: "sessionID", version: 1 },
|
||||||
|
schema: { sessionID: SessionV2.ID, value: Schema.String },
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("SessionV2.history", () => {
|
||||||
|
it.effect("returns an exhausted page for a migrated Session with no event sequence", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const db = (yield* Database.Service).db
|
||||||
|
const session = yield* SessionV2.Service
|
||||||
|
const sessionID = SessionV2.ID.make("ses_empty_history")
|
||||||
|
yield* db
|
||||||
|
.insert(ProjectTable)
|
||||||
|
.values({ id: ProjectV2.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||||
|
.onConflictDoNothing()
|
||||||
|
.run()
|
||||||
|
yield* db
|
||||||
|
.insert(SessionTable)
|
||||||
|
.values({
|
||||||
|
id: sessionID,
|
||||||
|
project_id: ProjectV2.ID.global,
|
||||||
|
slug: "empty-history",
|
||||||
|
directory: "/project",
|
||||||
|
title: "Empty history",
|
||||||
|
version: "test",
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
|
||||||
|
const first = yield* session.history({ sessionID, limit: 10 })
|
||||||
|
|
||||||
|
expect(first).toEqual({ events: [], hasMore: false })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("treats after as an exclusive aggregate sequence", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const session = yield* SessionV2.Service
|
||||||
|
const created = yield* session.create({ location })
|
||||||
|
yield* session.switchAgent({ sessionID: created.id, agent: "one" })
|
||||||
|
yield* session.switchAgent({ sessionID: created.id, agent: "two" })
|
||||||
|
|
||||||
|
const page = yield* session.history({ sessionID: created.id, after: 1, limit: 10 })
|
||||||
|
|
||||||
|
expect(page.events.map((event) => event.durable?.seq)).toEqual([2])
|
||||||
|
expect(page.hasMore).toBe(false)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("paginates public events in aggregate order across filtered gaps without duplicates", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const session = yield* SessionV2.Service
|
||||||
|
const events = yield* EventV2.Service
|
||||||
|
const created = yield* session.create({ location })
|
||||||
|
yield* session.switchAgent({ sessionID: created.id, agent: "one" })
|
||||||
|
yield* events.publish(GapEvent, { sessionID: created.id, value: "filtered" })
|
||||||
|
yield* session.switchAgent({ sessionID: created.id, agent: "two" })
|
||||||
|
yield* session.switchAgent({ sessionID: created.id, agent: "three" })
|
||||||
|
|
||||||
|
const first = yield* session.history({ sessionID: created.id, limit: 2 })
|
||||||
|
const after = first.events.at(-1)?.durable?.seq
|
||||||
|
const second = yield* session.history({
|
||||||
|
sessionID: created.id,
|
||||||
|
after,
|
||||||
|
limit: 2,
|
||||||
|
})
|
||||||
|
const sequence = [...first.events, ...second.events].map((event) => event.durable?.seq)
|
||||||
|
|
||||||
|
expect(first.hasMore).toBe(true)
|
||||||
|
expect(second.hasMore).toBe(false)
|
||||||
|
expect(sequence).toEqual([1, 3, 4])
|
||||||
|
expect(new Set(sequence).size).toBe(sequence.length)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("includes events committed between pages", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const session = yield* SessionV2.Service
|
||||||
|
const created = yield* session.create({ location })
|
||||||
|
yield* session.switchAgent({ sessionID: created.id, agent: "one" })
|
||||||
|
yield* session.switchAgent({ sessionID: created.id, agent: "two" })
|
||||||
|
|
||||||
|
const first = yield* session.history({ sessionID: created.id, limit: 1 })
|
||||||
|
yield* session.switchAgent({ sessionID: created.id, agent: "later" })
|
||||||
|
const second = yield* session.history({
|
||||||
|
sessionID: created.id,
|
||||||
|
after: first.events.at(-1)?.durable?.seq,
|
||||||
|
limit: 10,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(first.hasMore).toBe(true)
|
||||||
|
expect([...first.events, ...second.events].map((event) => event.durable?.seq)).toEqual([1, 2, 3])
|
||||||
|
expect(second.hasMore).toBe(false)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("reports exhaustion for exact-limit and limit-plus-one pages", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const session = yield* SessionV2.Service
|
||||||
|
const created = yield* session.create({ location })
|
||||||
|
yield* session.switchAgent({ sessionID: created.id, agent: "one" })
|
||||||
|
yield* session.switchAgent({ sessionID: created.id, agent: "two" })
|
||||||
|
|
||||||
|
const exact = yield* session.history({ sessionID: created.id, limit: 2 })
|
||||||
|
const oneMore = yield* session.history({ sessionID: created.id, limit: 1 })
|
||||||
|
const exhausted = yield* session.history({
|
||||||
|
sessionID: created.id,
|
||||||
|
after: oneMore.events.at(-1)?.durable?.seq,
|
||||||
|
limit: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(exact.events).toHaveLength(2)
|
||||||
|
expect(exact.hasMore).toBe(false)
|
||||||
|
expect(oneMore.events).toHaveLength(1)
|
||||||
|
expect(oneMore.hasMore).toBe(true)
|
||||||
|
expect(exhausted.events).toHaveLength(1)
|
||||||
|
expect(exhausted.hasMore).toBe(false)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("fails with NotFoundError for a missing Session", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const session = yield* SessionV2.Service
|
||||||
|
const error = yield* session.history({ sessionID: SessionV2.ID.make("ses_missing"), limit: 10 }).pipe(Effect.flip)
|
||||||
|
|
||||||
|
expect(error._tag).toBe("Session.NotFoundError")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -173,6 +173,27 @@ describe("SessionV2.prompt", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("resolves attachment MIME before admission", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* setup
|
||||||
|
const session = yield* SessionV2.Service
|
||||||
|
|
||||||
|
const message = yield* session.prompt({
|
||||||
|
sessionID,
|
||||||
|
prompt: {
|
||||||
|
text: "Inspect this image",
|
||||||
|
files: [{ uri: "data:image/png;base64,aGVsbG8=", name: "image.png" }],
|
||||||
|
},
|
||||||
|
resume: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(message.prompt.files).toEqual([
|
||||||
|
{ uri: "data:image/png;base64,aGVsbG8=", name: "image.png", mime: "image/png" },
|
||||||
|
])
|
||||||
|
expect((yield* admitted(message.id))?.prompt.files).toEqual(message.prompt.files)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("streams durable Session events after an aggregate sequence", () =>
|
it.effect("streams durable Session events after an aggregate sequence", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* setup
|
yield* setup
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { PermissionV2 } from "@opencode-ai/core/permission"
|
|||||||
import { SessionV2 } from "@opencode-ai/core/session"
|
import { SessionV2 } from "@opencode-ai/core/session"
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
|
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||||
import { location } from "./fixture/location"
|
import { location } from "./fixture/location"
|
||||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||||
import { ReadTool } from "@opencode-ai/core/tool/read"
|
import { ReadTool } from "@opencode-ai/core/tool/read"
|
||||||
@@ -97,6 +98,32 @@ const infrastructure = Layer.mergeAll(
|
|||||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(process.cwd()) }))),
|
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(process.cwd()) }))),
|
||||||
Global.layerWith({ data: Global.Path.data }),
|
Global.layerWith({ data: Global.Path.data }),
|
||||||
)
|
)
|
||||||
|
const mutation = Layer.succeed(
|
||||||
|
LocationMutation.Service,
|
||||||
|
LocationMutation.Service.of({
|
||||||
|
resolve: (input) => {
|
||||||
|
if (input.path === missingPath)
|
||||||
|
return Effect.fail(new LocationMutation.PathError({ path: input.path, reason: "non_directory_ancestor" }))
|
||||||
|
const canonical = path.resolve(process.cwd(), input.path)
|
||||||
|
const external = path.isAbsolute(input.path) && !FSUtil.contains(process.cwd(), canonical)
|
||||||
|
const resource = external ? canonical.replaceAll("\\", "/") : path.relative(process.cwd(), canonical) || "."
|
||||||
|
const directory = path.dirname(canonical)
|
||||||
|
const externalResource = path.join(directory, "*").replaceAll("\\", "/")
|
||||||
|
return Effect.succeed({
|
||||||
|
canonical,
|
||||||
|
resource,
|
||||||
|
externalDirectory: external
|
||||||
|
? {
|
||||||
|
action: "external_directory" as const,
|
||||||
|
directory,
|
||||||
|
resource: externalResource,
|
||||||
|
save: externalResource,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
const unavailableImage = Layer.succeed(
|
const unavailableImage = Layer.succeed(
|
||||||
Image.Service,
|
Image.Service,
|
||||||
Image.Service.of({ normalize: () => Effect.fail(new Image.ResizerUnavailableError()) }),
|
Image.Service.of({ normalize: () => Effect.fail(new Image.ResizerUnavailableError()) }),
|
||||||
@@ -107,19 +134,21 @@ const read = ReadTool.layer.pipe(
|
|||||||
Layer.provide(permission),
|
Layer.provide(permission),
|
||||||
Layer.provide(config),
|
Layer.provide(config),
|
||||||
Layer.provide(image),
|
Layer.provide(image),
|
||||||
|
Layer.provide(mutation),
|
||||||
Layer.provide(infrastructure),
|
Layer.provide(infrastructure),
|
||||||
)
|
)
|
||||||
const it = testEffect(Layer.mergeAll(registry, reader, permission, config, image, infrastructure, read))
|
const it = testEffect(Layer.mergeAll(registry, reader, permission, config, image, mutation, infrastructure, read))
|
||||||
const unavailableRead = ReadTool.layer.pipe(
|
const unavailableRead = ReadTool.layer.pipe(
|
||||||
Layer.provide(registry),
|
Layer.provide(registry),
|
||||||
Layer.provide(reader),
|
Layer.provide(reader),
|
||||||
Layer.provide(permission),
|
Layer.provide(permission),
|
||||||
Layer.provide(config),
|
Layer.provide(config),
|
||||||
Layer.provide(unavailableImage),
|
Layer.provide(unavailableImage),
|
||||||
|
Layer.provide(mutation),
|
||||||
Layer.provide(infrastructure),
|
Layer.provide(infrastructure),
|
||||||
)
|
)
|
||||||
const itWithoutResizer = testEffect(
|
const itWithoutResizer = testEffect(
|
||||||
Layer.mergeAll(registry, reader, permission, config, unavailableImage, infrastructure, unavailableRead),
|
Layer.mergeAll(registry, reader, permission, config, unavailableImage, mutation, infrastructure, unavailableRead),
|
||||||
)
|
)
|
||||||
const sessionID = SessionV2.ID.make("ses_read_tool_test")
|
const sessionID = SessionV2.ID.make("ses_read_tool_test")
|
||||||
|
|
||||||
@@ -174,6 +203,30 @@ describe("ReadTool", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("asks for external_directory approval before reading an external absolute path", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const registry = yield* ToolRegistry.Service
|
||||||
|
const external = path.join(path.parse(process.cwd()).root, "external-read", "notes.txt")
|
||||||
|
|
||||||
|
expect(
|
||||||
|
yield* executeTool(registry, {
|
||||||
|
sessionID,
|
||||||
|
...toolIdentity,
|
||||||
|
call: { type: "tool-call", id: "call-external-read", name: "read", input: { path: external } },
|
||||||
|
}),
|
||||||
|
).toMatchObject({ type: "json" })
|
||||||
|
expect(assertions).toMatchObject([
|
||||||
|
{
|
||||||
|
sessionID,
|
||||||
|
action: "external_directory",
|
||||||
|
resources: [path.join(path.dirname(external), "*").replaceAll("\\", "/")],
|
||||||
|
},
|
||||||
|
{ sessionID, action: "read", resources: [external.replaceAll("\\", "/")], save: ["*"] },
|
||||||
|
])
|
||||||
|
expect(readCalls).toEqual([{ input: AbsolutePath.make(external), page: { offset: undefined, limit: undefined } }])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("returns a small PNG as native media instead of durable base64 text", () =>
|
it.effect("returns a small PNG as native media instead of durable base64 text", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
|
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
|
||||||
|
|||||||
@@ -230,7 +230,12 @@ export function emitEffectImported(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function emitPromise(contract: Contract): Output {
|
export function emitPromise(
|
||||||
|
contract: Contract,
|
||||||
|
options?: {
|
||||||
|
readonly outputTypes?: Readonly<Record<string, { readonly name: string; readonly import: string }>>
|
||||||
|
},
|
||||||
|
): Output {
|
||||||
const groups = contract.groups
|
const groups = contract.groups
|
||||||
for (const group of groups) {
|
for (const group of groups) {
|
||||||
for (const endpoint of group.endpoints) assertPromiseEndpoint(endpoint)
|
for (const endpoint of group.endpoints) assertPromiseEndpoint(endpoint)
|
||||||
@@ -238,7 +243,7 @@ export function emitPromise(contract: Contract): Output {
|
|||||||
return {
|
return {
|
||||||
operations: operations(groups),
|
operations: operations(groups),
|
||||||
files: [
|
files: [
|
||||||
{ path: "types.ts", content: renderPromiseTypes(groups) },
|
{ path: "types.ts", content: renderPromiseTypes(groups, options?.outputTypes) },
|
||||||
{
|
{
|
||||||
path: "client-error.ts",
|
path: "client-error.ts",
|
||||||
content: `export type ClientErrorReason = "Transport" | "UnexpectedStatus" | "UnsupportedContentType" | "MalformedResponse"\n\nexport class ClientError extends Error {\n override readonly name = "ClientError"\n constructor(readonly reason: ClientErrorReason, options?: ErrorOptions) {\n super(reason, options)\n }\n}\n`,
|
content: `export type ClientErrorReason = "Transport" | "UnexpectedStatus" | "UnsupportedContentType" | "MalformedResponse"\n\nexport class ClientError extends Error {\n override readonly name = "ClientError"\n constructor(readonly reason: ClientErrorReason, options?: ErrorOptions) {\n super(reason, options)\n }\n}\n`,
|
||||||
@@ -408,14 +413,17 @@ function renderImportedProjection(groups: ReadonlyArray<Group>, endpoints: Reado
|
|||||||
return { imports: [...new Set(imports)], source }
|
return { imports: [...new Set(imports)], source }
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPromiseTypes(groups: ReadonlyArray<Group>) {
|
function renderPromiseTypes(
|
||||||
|
groups: ReadonlyArray<Group>,
|
||||||
|
outputTypes?: Readonly<Record<string, { readonly name: string; readonly import: string }>>,
|
||||||
|
) {
|
||||||
const types = new Map<SchemaAST.AST, string>()
|
const types = new Map<SchemaAST.AST, string>()
|
||||||
const typeOf = (schema: Schema.Top) => {
|
const typeOf = (schema: Schema.Top, decoded = false) => {
|
||||||
const encoded = Schema.toEncoded(schema)
|
const projected = decoded ? Schema.toType(schema) : Schema.toEncoded(schema)
|
||||||
const cached = types.get(encoded.ast)
|
const cached = types.get(projected.ast)
|
||||||
if (cached !== undefined) return cached
|
if (cached !== undefined) return cached
|
||||||
const type = structuralType(encoded)
|
const type = structuralType(projected)
|
||||||
types.set(encoded.ast, type)
|
types.set(projected.ast, type)
|
||||||
return type
|
return type
|
||||||
}
|
}
|
||||||
const errors = new Map(
|
const errors = new Map(
|
||||||
@@ -449,17 +457,19 @@ function renderPromiseTypes(groups: ReadonlyArray<Group>) {
|
|||||||
const schema = schemas[field.source]
|
const schema = schemas[field.source]
|
||||||
if (schema === undefined)
|
if (schema === undefined)
|
||||||
throw new GenerationError({ reason: `Missing input schema: ${prefix}.${field.name}` })
|
throw new GenerationError({ reason: `Missing input schema: ${prefix}.${field.name}` })
|
||||||
return `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: (${typeOf(schema)})[${JSON.stringify(field.name)}]`
|
return `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: (${typeOf(schema, field.source === "query")})[${JSON.stringify(field.name)}]`
|
||||||
})
|
})
|
||||||
.join("; ")
|
.join("; ")
|
||||||
const successSchema = endpoint.successes[0]
|
const successSchema = endpoint.successes[0]
|
||||||
const success = typeOf(
|
const success =
|
||||||
isStreamSchema(successSchema) && successSchema._tag === "StreamSse"
|
outputTypes?.[`${group.identifier}.${endpoint.operation.name}`]?.name ??
|
||||||
? successSchema.sseMode === "data"
|
typeOf(
|
||||||
? streamEncodedDataSchema(successSchema)
|
isStreamSchema(successSchema) && successSchema._tag === "StreamSse"
|
||||||
: successSchema.events
|
? successSchema.sseMode === "data"
|
||||||
: successSchema,
|
? streamEncodedDataSchema(successSchema)
|
||||||
)
|
: successSchema.events
|
||||||
|
: successSchema,
|
||||||
|
)
|
||||||
return [
|
return [
|
||||||
...(endpoint.operation.inputMode === "none" ? [] : [`export type ${prefix}Input = { ${input} }`]),
|
...(endpoint.operation.inputMode === "none" ? [] : [`export type ${prefix}Input = { ${input} }`]),
|
||||||
`export type ${prefix}Output = ${endpoint.unwrapData ? `(${success})["data"]` : success}`,
|
`export type ${prefix}Output = ${endpoint.unwrapData ? `(${success})["data"]` : success}`,
|
||||||
@@ -470,7 +480,8 @@ function renderPromiseTypes(groups: ReadonlyArray<Group>) {
|
|||||||
const json = operations.includes("JsonValue")
|
const json = operations.includes("JsonValue")
|
||||||
? "export type JsonValue = null | boolean | number | string | ReadonlyArray<JsonValue> | { readonly [key: string]: JsonValue }"
|
? "export type JsonValue = null | boolean | number | string | ReadonlyArray<JsonValue> | { readonly [key: string]: JsonValue }"
|
||||||
: ""
|
: ""
|
||||||
return [json, ...errorTypes, operations].filter(Boolean).join("\n\n")
|
const imports = [...new Set(Object.values(outputTypes ?? {}).map((override) => override.import))]
|
||||||
|
return [...imports, json, ...errorTypes, operations].filter(Boolean).join("\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPromiseClient(groups: ReadonlyArray<Group>) {
|
function renderPromiseClient(groups: ReadonlyArray<Group>) {
|
||||||
|
|||||||
@@ -48,6 +48,24 @@ describe("HttpApiCodegen.generate", () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("allows Promise outputs to use an authoritative imported wire type", () => {
|
||||||
|
const contract = compileContract(
|
||||||
|
api(HttpApiEndpoint.get("events", "/event", { success: HttpApiSchema.StreamSse({ data: Schema.Unknown }) })),
|
||||||
|
)
|
||||||
|
const output = emitPromise(contract, {
|
||||||
|
outputTypes: {
|
||||||
|
"session.events": {
|
||||||
|
name: "EventWire",
|
||||||
|
import: 'import type { EventWire } from "./event-wire"',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const types = output.files.find((file) => file.path === "types.ts")?.content
|
||||||
|
|
||||||
|
expect(types).toContain('import type { EventWire } from "./event-wire"')
|
||||||
|
expect(types).toContain("export type SessionEventsOutput = EventWire")
|
||||||
|
})
|
||||||
|
|
||||||
test("emits an Effect client against an imported authoritative API", () => {
|
test("emits an Effect client against an imported authoritative API", () => {
|
||||||
const output = emitEffectImported(
|
const output = emitEffectImported(
|
||||||
compileContract(
|
compileContract(
|
||||||
|
|||||||
@@ -407,21 +407,35 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
|||||||
if ("thoughtSignature" in part && part.thoughtSignature && "thought" in part && part.thought)
|
if ("thoughtSignature" in part && part.thoughtSignature && "thought" in part && part.thought)
|
||||||
reasoningSignature = part.thoughtSignature
|
reasoningSignature = part.thoughtSignature
|
||||||
if ("text" in part && part.text.length > 0) {
|
if ("text" in part && part.text.length > 0) {
|
||||||
lifecycle = part.thought
|
if (part.thought) {
|
||||||
? Lifecycle.reasoningDelta(
|
lifecycle = Lifecycle.reasoningDelta(
|
||||||
lifecycle,
|
lifecycle,
|
||||||
events,
|
events,
|
||||||
"reasoning-0",
|
"reasoning-0",
|
||||||
part.text,
|
part.text,
|
||||||
part.thoughtSignature ? googleMetadata({ thoughtSignature: part.thoughtSignature }) : undefined,
|
part.thoughtSignature ? googleMetadata({ thoughtSignature: part.thoughtSignature }) : undefined,
|
||||||
)
|
)
|
||||||
: Lifecycle.textDelta(lifecycle, events, "text-0", part.text)
|
continue
|
||||||
|
}
|
||||||
|
lifecycle = Lifecycle.reasoningEnd(
|
||||||
|
lifecycle,
|
||||||
|
events,
|
||||||
|
"reasoning-0",
|
||||||
|
reasoningSignature ? googleMetadata({ thoughtSignature: reasoningSignature }) : undefined,
|
||||||
|
)
|
||||||
|
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", part.text)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if ("functionCall" in part) {
|
if ("functionCall" in part) {
|
||||||
const input = part.functionCall.args
|
const input = part.functionCall.args
|
||||||
const id = `tool_${nextToolCallId++}`
|
const id = `tool_${nextToolCallId++}`
|
||||||
|
lifecycle = Lifecycle.reasoningEnd(
|
||||||
|
lifecycle,
|
||||||
|
events,
|
||||||
|
"reasoning-0",
|
||||||
|
reasoningSignature ? googleMetadata({ thoughtSignature: reasoningSignature }) : undefined,
|
||||||
|
)
|
||||||
lifecycle = Lifecycle.stepStart(lifecycle, events)
|
lifecycle = Lifecycle.stepStart(lifecycle, events)
|
||||||
events.push(
|
events.push(
|
||||||
LLMEvent.toolCall({
|
LLMEvent.toolCall({
|
||||||
|
|||||||
@@ -411,7 +411,12 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
|||||||
if (delta?.reasoning_content)
|
if (delta?.reasoning_content)
|
||||||
lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", delta.reasoning_content)
|
lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", delta.reasoning_content)
|
||||||
|
|
||||||
if (delta?.content) lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
|
if (delta?.content) {
|
||||||
|
lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0")
|
||||||
|
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolDeltas.length) lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0")
|
||||||
|
|
||||||
for (const tool of toolDeltas) {
|
for (const tool of toolDeltas) {
|
||||||
const result = ToolStream.appendOrStart(
|
const result = ToolStream.appendOrStart(
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ const OpenAIResponsesReasoningSummaryText = Schema.Struct({
|
|||||||
|
|
||||||
const OpenAIResponsesReasoningItem = Schema.Struct({
|
const OpenAIResponsesReasoningItem = Schema.Struct({
|
||||||
type: Schema.tag("reasoning"),
|
type: Schema.tag("reasoning"),
|
||||||
id: Schema.String,
|
id: Schema.optionalKey(Schema.String),
|
||||||
summary: Schema.Array(OpenAIResponsesReasoningSummaryText),
|
summary: Schema.Array(OpenAIResponsesReasoningSummaryText),
|
||||||
encrypted_content: optionalNull(Schema.String),
|
encrypted_content: optionalNull(Schema.String),
|
||||||
})
|
})
|
||||||
@@ -101,6 +101,7 @@ type OpenAIResponsesReasoningInput = {
|
|||||||
summary: Array<{ type: "summary_text"; text: string }>
|
summary: Array<{ type: "summary_text"; text: string }>
|
||||||
encrypted_content?: string | null
|
encrypted_content?: string | null
|
||||||
}
|
}
|
||||||
|
type OpenAIResponsesReasoningReplay = Omit<OpenAIResponsesReasoningInput, "id">
|
||||||
|
|
||||||
const OpenAIResponsesTool = Schema.Struct({
|
const OpenAIResponsesTool = Schema.Struct({
|
||||||
type: Schema.tag("function"),
|
type: Schema.tag("function"),
|
||||||
@@ -366,7 +367,7 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
|
|||||||
|
|
||||||
if (message.role === "assistant") {
|
if (message.role === "assistant") {
|
||||||
const content: TextPart[] = []
|
const content: TextPart[] = []
|
||||||
const reasoningItems: Record<string, OpenAIResponsesReasoningInput> = {}
|
const reasoningItems: Record<string, OpenAIResponsesReasoningReplay> = {}
|
||||||
const reasoningReferences = new Set<string>()
|
const reasoningReferences = new Set<string>()
|
||||||
const hostedToolReferences = new Set<string>()
|
const hostedToolReferences = new Set<string>()
|
||||||
const flushText = () => {
|
const flushText = () => {
|
||||||
@@ -383,7 +384,7 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
|
|||||||
flushText()
|
flushText()
|
||||||
const reasoning = lowerReasoning(part)
|
const reasoning = lowerReasoning(part)
|
||||||
if (!reasoning) continue
|
if (!reasoning) continue
|
||||||
if (store !== false && reasoning.id) {
|
if (store !== false) {
|
||||||
if (!reasoningReferences.has(reasoning.id)) input.push({ type: "item_reference", id: reasoning.id })
|
if (!reasoningReferences.has(reasoning.id)) input.push({ type: "item_reference", id: reasoning.id })
|
||||||
reasoningReferences.add(reasoning.id)
|
reasoningReferences.add(reasoning.id)
|
||||||
continue
|
continue
|
||||||
@@ -395,8 +396,13 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
|
|||||||
existing.encrypted_content = reasoning.encrypted_content
|
existing.encrypted_content = reasoning.encrypted_content
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
reasoningItems[reasoning.id] = reasoning
|
const replay = {
|
||||||
input.push(reasoning)
|
type: reasoning.type,
|
||||||
|
summary: reasoning.summary,
|
||||||
|
encrypted_content: reasoning.encrypted_content,
|
||||||
|
}
|
||||||
|
reasoningItems[reasoning.id] = replay
|
||||||
|
input.push(replay)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (part.type === "tool-call") {
|
if (part.type === "tool-call") {
|
||||||
@@ -974,6 +980,7 @@ export const route = Route.make({
|
|||||||
endpoint,
|
endpoint,
|
||||||
auth,
|
auth,
|
||||||
transport: httpTransport,
|
transport: httpTransport,
|
||||||
|
defaults: { providerOptions: { openai: { store: false } } },
|
||||||
})
|
})
|
||||||
|
|
||||||
const decodeWebSocketMessage = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIResponsesWebSocketMessage))
|
const decodeWebSocketMessage = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIResponsesWebSocketMessage))
|
||||||
@@ -1001,6 +1008,7 @@ export const webSocketRoute = Route.make({
|
|||||||
endpoint,
|
endpoint,
|
||||||
auth,
|
auth,
|
||||||
transport: webSocketTransport,
|
transport: webSocketTransport,
|
||||||
|
defaults: { providerOptions: { openai: { store: false } } },
|
||||||
})
|
})
|
||||||
|
|
||||||
export * as OpenAIResponses from "./openai-responses"
|
export * as OpenAIResponses from "./openai-responses"
|
||||||
|
|||||||
+1
-1
@@ -44,7 +44,7 @@
|
|||||||
"headers": {
|
"headers": {
|
||||||
"content-type": "application/json"
|
"content-type": "application/json"
|
||||||
},
|
},
|
||||||
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]},{\"type\":\"reasoning\",\"id\":\"rs_0a0794dab3b8ec7d016a1235e7ce3881958a5eca32a36a14c5\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEjXoGMCw3WDXpoD9151PEr2Lt8raW7KBKefQhZJGWx5f8jy152bApO6oE-Mr1BhUtfZNq3OPBVfSL4ioQ9bHREfujIBXgk9LUDBAz2Sle7KjOr9HaUV16A4HBiaFIRFjsHPS9G8yEySp1m6F1CD_WR6apyUGgugRh_y39EcOJmxPOzmiac5DVM6fraA1VpcGbqrZ1x2ANHFDOfnYTycPtPNTgzE7LjkYjDDWbT03uN1YxfP4pqjDVRzY14pA8bSZ8ys-pDv5kUFCAsw-OlU4jYKUXp-M8_6KTaRQP71LPwppt__zG_NJPfy-qUil4pOU8_NoxtxerHgLLXbfExZdzfpoGinoEjn7nj7BJDEtl-LNeNEb5c-1ZymNfVMp-Cs3fLEPkAV8rtHFtZ0MhE_07GKbGo7hTrOmkM4DydxmHsdWGNbXAG35cprslEA5P7p3GHFKnRs5hGs2eq-XcZ3yki64ZBOU_Tv6UR7nUH09gF1rdrJo3dpre6M00COwwdZ02zUP5KxCuI8FKu2jsZu9zgMVXDALsdtM5orTCVLXsn4rddWd111zE-vMjNmMMmktW2cHMjH7j1ooA-9P083koNVYiLi4UhMA64gTqgyl8MxkZekl7eFSMa7qk295NaHOKtFxzYYcZ9jdioCwSPSZ0ZZWLoNgrK7SWfRh0uaTHNcMZ3wq8ae6CguktIeVTCPTQAqJLQqd7AU0oOCKCJ7BWnC-L8UC6m7Pm9ZS958uUVeWBhgKHzMAGq9UeQB7IEeAcbMn3EDgOSfd8qCb8iwU9iG9dcu9axQwWU7pd7kd-T-He61W7z5wWgpx1KehWCxrN6kuKSo6p-uUfwVnJukreOn8BJNAzADQgz68bhmN9VGih7YcKVnLgwDwKditrjSd6-tfE0Baarj3jWENvT6ohY17R9FDrKS-2v8IIX6tGjoKJw8SRhaWLNv4vWlmxRgR0gdac3qumd0GKqsWSveNz01naA==\"},{\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hello!\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Now reply exactly with: Done.\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":40,\"stream\":true}"
|
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]},{\"type\":\"reasoning\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEjXoGMCw3WDXpoD9151PEr2Lt8raW7KBKefQhZJGWx5f8jy152bApO6oE-Mr1BhUtfZNq3OPBVfSL4ioQ9bHREfujIBXgk9LUDBAz2Sle7KjOr9HaUV16A4HBiaFIRFjsHPS9G8yEySp1m6F1CD_WR6apyUGgugRh_y39EcOJmxPOzmiac5DVM6fraA1VpcGbqrZ1x2ANHFDOfnYTycPtPNTgzE7LjkYjDDWbT03uN1YxfP4pqjDVRzY14pA8bSZ8ys-pDv5kUFCAsw-OlU4jYKUXp-M8_6KTaRQP71LPwppt__zG_NJPfy-qUil4pOU8_NoxtxerHgLLXbfExZdzfpoGinoEjn7nj7BJDEtl-LNeNEb5c-1ZymNfVMp-Cs3fLEPkAV8rtHFtZ0MhE_07GKbGo7hTrOmkM4DydxmHsdWGNbXAG35cprslEA5P7p3GHFKnRs5hGs2eq-XcZ3yki64ZBOU_Tv6UR7nUH09gF1rdrJo3dpre6M00COwwdZ02zUP5KxCuI8FKu2jsZu9zgMVXDALsdtM5orTCVLXsn4rddWd111zE-vMjNmMMmktW2cHMjH7j1ooA-9P083koNVYiLi4UhMA64gTqgyl8MxkZekl7eFSMa7qk295NaHOKtFxzYYcZ9jdioCwSPSZ0ZZWLoNgrK7SWfRh0uaTHNcMZ3wq8ae6CguktIeVTCPTQAqJLQqd7AU0oOCKCJ7BWnC-L8UC6m7Pm9ZS958uUVeWBhgKHzMAGq9UeQB7IEeAcbMn3EDgOSfd8qCb8iwU9iG9dcu9axQwWU7pd7kd-T-He61W7z5wWgpx1KehWCxrN6kuKSo6p-uUfwVnJukreOn8BJNAzADQgz68bhmN9VGih7YcKVnLgwDwKditrjSd6-tfE0Baarj3jWENvT6ohY17R9FDrKS-2v8IIX6tGjoKJw8SRhaWLNv4vWlmxRgR0gdac3qumd0GKqsWSveNz01naA==\"},{\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hello!\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Now reply exactly with: Done.\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":40,\"stream\":true}"
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": 200,
|
"status": 200,
|
||||||
|
|||||||
@@ -347,10 +347,10 @@ describe("Gemini route", () => {
|
|||||||
{ type: "step-start", index: 0 },
|
{ type: "step-start", index: 0 },
|
||||||
{ type: "reasoning-start", id: "reasoning-0" },
|
{ type: "reasoning-start", id: "reasoning-0" },
|
||||||
{ type: "reasoning-delta", id: "reasoning-0", text: "thinking" },
|
{ type: "reasoning-delta", id: "reasoning-0", text: "thinking" },
|
||||||
|
{ type: "reasoning-end", id: "reasoning-0" },
|
||||||
{ type: "text-start", id: "text-0" },
|
{ type: "text-start", id: "text-0" },
|
||||||
{ type: "text-delta", id: "text-0", text: "Hello" },
|
{ type: "text-delta", id: "text-0", text: "Hello" },
|
||||||
{ type: "text-delta", id: "text-0", text: "!" },
|
{ type: "text-delta", id: "text-0", text: "!" },
|
||||||
{ type: "reasoning-end", id: "reasoning-0" },
|
|
||||||
{ type: "text-end", id: "text-0" },
|
{ type: "text-end", id: "text-0" },
|
||||||
{ type: "step-finish", index: 0, reason: "stop", usage, providerMetadata: undefined },
|
{ type: "step-finish", index: 0, reason: "stop", usage, providerMetadata: undefined },
|
||||||
{
|
{
|
||||||
@@ -399,6 +399,9 @@ describe("Gemini route", () => {
|
|||||||
providerMetadata: { google: { thoughtSignature: "thought_sig" } },
|
providerMetadata: { google: { thoughtSignature: "thought_sig" } },
|
||||||
})
|
})
|
||||||
expect(toolCall).toMatchObject({ providerMetadata: { google: { thoughtSignature: "tool_sig" } } })
|
expect(toolCall).toMatchObject({ providerMetadata: { google: { thoughtSignature: "tool_sig" } } })
|
||||||
|
expect(response.events.findIndex((event) => event.type === "reasoning-end")).toBeLessThan(
|
||||||
|
response.events.findIndex((event) => event.type === "tool-call"),
|
||||||
|
)
|
||||||
|
|
||||||
const prepared = yield* LLMClient.prepare<Gemini.GeminiBody>(
|
const prepared = yield* LLMClient.prepare<Gemini.GeminiBody>(
|
||||||
LLM.request({
|
LLM.request({
|
||||||
|
|||||||
@@ -542,9 +542,9 @@ describe("OpenAI Chat route", () => {
|
|||||||
{ type: "step-start", index: 0 },
|
{ type: "step-start", index: 0 },
|
||||||
{ type: "reasoning-start", id: "reasoning-0" },
|
{ type: "reasoning-start", id: "reasoning-0" },
|
||||||
{ type: "reasoning-delta", id: "reasoning-0", text: "thinking" },
|
{ type: "reasoning-delta", id: "reasoning-0", text: "thinking" },
|
||||||
|
{ type: "reasoning-end", id: "reasoning-0" },
|
||||||
{ type: "text-start", id: "text-0" },
|
{ type: "text-start", id: "text-0" },
|
||||||
{ type: "text-delta", id: "text-0", text: "Hello" },
|
{ type: "text-delta", id: "text-0", text: "Hello" },
|
||||||
{ type: "reasoning-end", id: "reasoning-0" },
|
|
||||||
{ type: "text-end", id: "text-0" },
|
{ type: "text-end", id: "text-0" },
|
||||||
{ type: "step-finish", index: 0, reason: "stop" },
|
{ type: "step-finish", index: 0, reason: "stop" },
|
||||||
{ type: "finish", reason: "stop" },
|
{ type: "finish", reason: "stop" },
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ describe("OpenAI Responses route", () => {
|
|||||||
{ role: "system", content: "You are concise." },
|
{ role: "system", content: "You are concise." },
|
||||||
{ role: "user", content: [{ type: "input_text", text: "Say hello." }] },
|
{ role: "user", content: [{ type: "input_text", text: "Say hello." }] },
|
||||||
],
|
],
|
||||||
|
store: false,
|
||||||
stream: true,
|
stream: true,
|
||||||
max_output_tokens: 20,
|
max_output_tokens: 20,
|
||||||
temperature: 0,
|
temperature: 0,
|
||||||
@@ -161,16 +162,16 @@ describe("OpenAI Responses route", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const prepared = yield* LLMClient.prepare(
|
const prepared = yield* LLMClient.prepare(
|
||||||
LLM.updateRequest(request, {
|
LLM.updateRequest(request, {
|
||||||
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responsesWebSocket(
|
model: OpenAIResponses.webSocketRoute
|
||||||
"gpt-4.1-mini",
|
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
|
||||||
),
|
.model({ id: "gpt-4.1-mini" }),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(prepared.route).toBe("openai-responses-websocket")
|
expect(prepared.route).toBe("openai-responses-websocket")
|
||||||
expect(prepared.protocol).toBe("openai-responses")
|
expect(prepared.protocol).toBe("openai-responses")
|
||||||
expect(prepared.metadata).toEqual({ transport: "websocket-json" })
|
expect(prepared.metadata).toEqual({ transport: "websocket-json" })
|
||||||
expect(prepared.body).toMatchObject({ model: "gpt-4.1-mini", stream: true })
|
expect(prepared.body).toMatchObject({ model: "gpt-4.1-mini", store: false, stream: true })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -356,7 +357,13 @@ describe("OpenAI Responses route", () => {
|
|||||||
{ type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
|
{ type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
|
||||||
{ type: "function_call_output", call_id: "call_1", output: '{"forecast":"sunny"}' },
|
{ type: "function_call_output", call_id: "call_1", output: '{"forecast":"sunny"}' },
|
||||||
],
|
],
|
||||||
|
store: false,
|
||||||
stream: true,
|
stream: true,
|
||||||
|
max_output_tokens: undefined,
|
||||||
|
temperature: undefined,
|
||||||
|
tool_choice: undefined,
|
||||||
|
tools: undefined,
|
||||||
|
top_p: undefined,
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -519,7 +526,6 @@ describe("OpenAI Responses route", () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "reasoning",
|
type: "reasoning",
|
||||||
id: "rs_continuation_1",
|
|
||||||
encrypted_content: "encrypted-continuation-state",
|
encrypted_content: "encrypted-continuation-state",
|
||||||
summary: [{ type: "summary_text", text: "I inspected the previous turn." }],
|
summary: [{ type: "summary_text", text: "I inspected the previous turn." }],
|
||||||
},
|
},
|
||||||
@@ -864,7 +870,9 @@ describe("OpenAI Responses route", () => {
|
|||||||
|
|
||||||
it.effect("closes reasoning summary parts when storage is not disabled", () =>
|
it.effect("closes reasoning summary parts when storage is not disabled", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const response = yield* LLMClient.generate(request).pipe(
|
const response = yield* LLMClient.generate(
|
||||||
|
LLM.updateRequest(request, { providerOptions: { openai: { store: true } } }),
|
||||||
|
).pipe(
|
||||||
Effect.provide(
|
Effect.provide(
|
||||||
fixedResponse(
|
fixedResponse(
|
||||||
sseEvents(
|
sseEvents(
|
||||||
@@ -925,12 +933,12 @@ describe("OpenAI Responses route", () => {
|
|||||||
dynamicResponse((input) =>
|
dynamicResponse((input) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
||||||
expect(yield* Effect.promise(() => web.json())).toMatchObject({
|
const body = yield* Effect.promise(() => web.json())
|
||||||
|
expect(body).toMatchObject({
|
||||||
input: [
|
input: [
|
||||||
{ role: "user", content: [{ type: "input_text", text: "What changed?" }] },
|
{ role: "user", content: [{ type: "input_text", text: "What changed?" }] },
|
||||||
{
|
{
|
||||||
type: "reasoning",
|
type: "reasoning",
|
||||||
id: "rs_1",
|
|
||||||
encrypted_content: "encrypted-state",
|
encrypted_content: "encrypted-state",
|
||||||
summary: [{ type: "summary_text", text: "Checked the previous diff." }],
|
summary: [{ type: "summary_text", text: "Checked the previous diff." }],
|
||||||
},
|
},
|
||||||
@@ -938,6 +946,7 @@ describe("OpenAI Responses route", () => {
|
|||||||
{ role: "user", content: [{ type: "input_text", text: "Summarize it." }] },
|
{ role: "user", content: [{ type: "input_text", text: "Summarize it." }] },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
expect(body.input[1]).not.toHaveProperty("id")
|
||||||
return input.respond(
|
return input.respond(
|
||||||
sseEvents(
|
sseEvents(
|
||||||
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Parser now round-trips reasoning." },
|
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Parser now round-trips reasoning." },
|
||||||
@@ -984,7 +993,6 @@ describe("OpenAI Responses route", () => {
|
|||||||
{ role: "assistant", content: [{ type: "output_text", text: "Before." }] },
|
{ role: "assistant", content: [{ type: "output_text", text: "Before." }] },
|
||||||
{
|
{
|
||||||
type: "reasoning",
|
type: "reasoning",
|
||||||
id: "rs_1",
|
|
||||||
encrypted_content: "encrypted-state",
|
encrypted_content: "encrypted-state",
|
||||||
summary: [{ type: "summary_text", text: "Checked order." }],
|
summary: [{ type: "summary_text", text: "Checked order." }],
|
||||||
},
|
},
|
||||||
@@ -1078,7 +1086,6 @@ describe("OpenAI Responses route", () => {
|
|||||||
expect(prepared.body.input).toEqual([
|
expect(prepared.body.input).toEqual([
|
||||||
{
|
{
|
||||||
type: "reasoning",
|
type: "reasoning",
|
||||||
id: "rs_1",
|
|
||||||
encrypted_content: "encrypted-state",
|
encrypted_content: "encrypted-state",
|
||||||
summary: [
|
summary: [
|
||||||
{ type: "summary_text", text: "First" },
|
{ type: "summary_text", text: "First" },
|
||||||
|
|||||||
@@ -598,7 +598,7 @@ describe("LLMClient tools", () => {
|
|||||||
include: ["reasoning.encrypted_content"],
|
include: ["reasoning.encrypted_content"],
|
||||||
input: [
|
input: [
|
||||||
{ role: "user" },
|
{ role: "user" },
|
||||||
{ type: "reasoning", id: "rs_1", summary: [], encrypted_content: "encrypted-state" },
|
{ type: "reasoning", summary: [], encrypted_content: "encrypted-state" },
|
||||||
{ type: "function_call", call_id: "call_1", name: "get_weather" },
|
{ type: "function_call", call_id: "call_1", name: "get_weather" },
|
||||||
{ type: "function_call_output", call_id: "call_1" },
|
{ type: "function_call_output", call_id: "call_1" },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
import type { AgentSideConnection, PermissionOption, RequestPermissionResponse } from "@agentclientprotocol/sdk"
|
import type {
|
||||||
|
AgentSideConnection,
|
||||||
|
PermissionOption,
|
||||||
|
RequestPermissionResponse,
|
||||||
|
ToolCallContent,
|
||||||
|
ToolCallLocation,
|
||||||
|
ToolCallUpdate,
|
||||||
|
} from "@agentclientprotocol/sdk"
|
||||||
import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2"
|
import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||||
import { applyPatch } from "diff"
|
import { applyPatch } from "diff"
|
||||||
import { exists, readText } from "@/util/filesystem"
|
import { exists, readText } from "@/util/filesystem"
|
||||||
import type { ACPSession } from "./session"
|
import type { ACPSession } from "./session"
|
||||||
import { toLocations, toToolKind, type ToolInput } from "./tool"
|
import { pendingToolCall, toLocations, type ToolInput } from "./tool"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
|
|
||||||
type PermissionEvent = Extract<Event, { type: "permission.asked" }>
|
type PermissionEvent = Extract<Event, { type: "permission.asked" }>
|
||||||
@@ -54,14 +61,11 @@ export class Handler {
|
|||||||
const result = await this.input.connection
|
const result = await this.input.connection
|
||||||
.requestPermission({
|
.requestPermission({
|
||||||
sessionId: permission.sessionID,
|
sessionId: permission.sessionID,
|
||||||
toolCall: {
|
toolCall: await permissionToolCall({
|
||||||
toolCallId: permission.tool?.callID ?? permission.id,
|
toolCallId: permission.tool?.callID ?? permission.id,
|
||||||
status: "pending",
|
toolName: permission.permission,
|
||||||
title: permission.permission,
|
input: permission.metadata,
|
||||||
rawInput: permission.metadata,
|
}),
|
||||||
kind: toToolKind(permission.permission),
|
|
||||||
locations: toLocations(permission.permission, permission.metadata),
|
|
||||||
},
|
|
||||||
options: permissionOptions,
|
options: permissionOptions,
|
||||||
})
|
})
|
||||||
.catch(async () => {
|
.catch(async () => {
|
||||||
@@ -111,6 +115,107 @@ export class Handler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function permissionToolCall(input: {
|
||||||
|
readonly toolCallId: string
|
||||||
|
readonly toolName: string
|
||||||
|
readonly input: ToolInput
|
||||||
|
}): Promise<ToolCallUpdate> {
|
||||||
|
const toolCall = pendingToolCall({
|
||||||
|
toolCallId: input.toolCallId,
|
||||||
|
toolName: input.toolName,
|
||||||
|
state: {
|
||||||
|
input: input.input,
|
||||||
|
title: permissionTitle(input.toolName, input.input),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const content = await permissionContent(input.toolName, input.input)
|
||||||
|
return {
|
||||||
|
...toolCall,
|
||||||
|
locations: permissionLocations(input.toolName, input.input),
|
||||||
|
...(content.length ? { content } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function permissionTitle(toolName: string, input: ToolInput) {
|
||||||
|
const tool = toolName.toLocaleLowerCase()
|
||||||
|
switch (tool) {
|
||||||
|
case "external_directory":
|
||||||
|
return stringValue(input.description) ?? stringValue(input.command) ?? stringValue(input.parentDir)
|
||||||
|
|
||||||
|
case "webfetch":
|
||||||
|
return stringValue(input.url)
|
||||||
|
|
||||||
|
case "websearch":
|
||||||
|
return stringValue(input.query)
|
||||||
|
|
||||||
|
case "grep":
|
||||||
|
case "glob":
|
||||||
|
return stringValue(input.pattern)
|
||||||
|
|
||||||
|
case "read":
|
||||||
|
case "edit":
|
||||||
|
case "write":
|
||||||
|
return editTitle(input)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function editTitle(input: ToolInput) {
|
||||||
|
const files = fileMetadata(input)
|
||||||
|
if (files.length === 1) return files[0]?.relativePath ?? files[0]?.filePath
|
||||||
|
if (files.length > 1) return `${files.length} files`
|
||||||
|
return stringValue(input.filePath) ?? stringValue(input.filepath) ?? stringValue(input.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
function permissionLocations(toolName: string, input: ToolInput): ToolCallLocation[] {
|
||||||
|
const files = fileMetadata(input)
|
||||||
|
if (files.length) {
|
||||||
|
return Array.from(
|
||||||
|
new Set(files.flatMap((file) => [file.filePath, file.movePath].filter((path): path is string => !!path))),
|
||||||
|
(path) => ({ path }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return toLocations(toolName, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function permissionContent(toolName: string, input: ToolInput): Promise<ToolCallContent[]> {
|
||||||
|
if (toolName.toLocaleLowerCase() !== "edit") return []
|
||||||
|
|
||||||
|
const files = fileMetadata(input)
|
||||||
|
if (files.length) return diffContentForFiles(files)
|
||||||
|
|
||||||
|
const filepath = stringValue(input.filepath) ?? stringValue(input.filePath)
|
||||||
|
const diff = stringValue(input.diff)
|
||||||
|
if (!filepath || !diff) return []
|
||||||
|
const content = await diffContentForPatch(filepath, diff)
|
||||||
|
return content ? [content] : []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function diffContentForFiles(files: PermissionFileMetadata[]) {
|
||||||
|
const content = await Promise.all(
|
||||||
|
files.map(async (file) => {
|
||||||
|
if (!file.patch) return []
|
||||||
|
const content = await diffContentForPatch(file.filePath, file.patch, file.movePath)
|
||||||
|
return content ? [content] : []
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return content.flat()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function diffContentForPatch(filepath: string, diff: string, displayPath = filepath) {
|
||||||
|
const content = (await exists(filepath)) ? await readText(filepath) : ""
|
||||||
|
const next = applyPatch(content, diff)
|
||||||
|
if (next === false) return undefined
|
||||||
|
return {
|
||||||
|
type: "diff" as const,
|
||||||
|
path: displayPath,
|
||||||
|
oldText: content,
|
||||||
|
newText: next,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function selectedReply(result: RequestPermissionResponse): Reply {
|
function selectedReply(result: RequestPermissionResponse): Reply {
|
||||||
if (result.outcome.outcome !== "selected") return "reject"
|
if (result.outcome.outcome !== "selected") return "reject"
|
||||||
if (result.outcome.optionId === "once" || result.outcome.optionId === "always") return result.outcome.optionId
|
if (result.outcome.optionId === "once" || result.outcome.optionId === "always") return result.outcome.optionId
|
||||||
@@ -121,4 +226,29 @@ function stringValue(value: unknown) {
|
|||||||
return typeof value === "string" ? value : undefined
|
return typeof value === "string" ? value : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PermissionFileMetadata = {
|
||||||
|
readonly filePath: string
|
||||||
|
readonly relativePath?: string
|
||||||
|
readonly movePath?: string
|
||||||
|
readonly patch?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileMetadata(input: ToolInput): PermissionFileMetadata[] {
|
||||||
|
if (!Array.isArray(input.files)) return []
|
||||||
|
return input.files.flatMap((file): PermissionFileMetadata[] => {
|
||||||
|
if (!file || typeof file !== "object") return []
|
||||||
|
const info = file as Record<string, unknown>
|
||||||
|
const filePath = stringValue(info.filePath)
|
||||||
|
if (!filePath) return []
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
filePath,
|
||||||
|
relativePath: stringValue(info.relativePath),
|
||||||
|
movePath: stringValue(info.movePath),
|
||||||
|
patch: stringValue(info.patch),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export * as ACPPermission from "./permission"
|
export * as ACPPermission from "./permission"
|
||||||
|
|||||||
@@ -314,7 +314,6 @@ export function make(input: {
|
|||||||
|
|
||||||
yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers ?? [])
|
yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers ?? [])
|
||||||
yield* sendAvailableCommands(input.connection, state.id, snapshot)
|
yield* sendAvailableCommands(input.connection, state.id, snapshot)
|
||||||
yield* replayMessages(events, messages)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
configOptions: configOptions(snapshot, {
|
configOptions: configOptions(snapshot, {
|
||||||
|
|||||||
@@ -192,11 +192,8 @@ export function completedToolUpdate(input: {
|
|||||||
return {
|
return {
|
||||||
toolCallId: input.toolCallId,
|
toolCallId: input.toolCallId,
|
||||||
status: "completed",
|
status: "completed",
|
||||||
kind: toToolKind(input.toolName),
|
...(input.state.title ? { title: input.state.title } : {}),
|
||||||
title: toolTitle(input.toolName, input.state.input, input.state.title),
|
|
||||||
locations: toLocations(input.toolName, input.state.input, input.cwd),
|
|
||||||
content: completedToolContent(input.toolName, input.state),
|
content: completedToolContent(input.toolName, input.state),
|
||||||
rawInput: rawInput(input.toolName, input.state.input, input.cwd),
|
|
||||||
rawOutput: completedToolRawOutput(input.state),
|
rawOutput: completedToolRawOutput(input.state),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import { NamedError } from "@opencode-ai/core/util/error"
|
|||||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||||
import { withTimeout } from "@/util/timeout"
|
import { withTimeout } from "@/util/timeout"
|
||||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
import { McpOAuthProvider, OAUTH_CALLBACK_PATH } from "./oauth-provider"
|
import { McpOAuthPendingProvider, McpOAuthProvider, OAUTH_CALLBACK_PATH } from "./oauth-provider"
|
||||||
import { McpOAuthCallback } from "./oauth-callback"
|
import { McpOAuthCallback } from "./oauth-callback"
|
||||||
import { McpAuth } from "./auth"
|
import { McpAuth } from "./auth"
|
||||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||||
@@ -109,7 +109,7 @@ export type Status = Schema.Schema.Type<typeof Status>
|
|||||||
|
|
||||||
// Store transports for OAuth servers to allow finishing auth
|
// Store transports for OAuth servers to allow finishing auth
|
||||||
type TransportWithAuth = StreamableHTTPClientTransport | SSEClientTransport
|
type TransportWithAuth = StreamableHTTPClientTransport | SSEClientTransport
|
||||||
const pendingOAuthTransports = new Map<string, TransportWithAuth>()
|
const pendingOAuthTransports = new Map<string, { transport: TransportWithAuth; provider?: McpOAuthPendingProvider }>()
|
||||||
|
|
||||||
// Prompt cache types
|
// Prompt cache types
|
||||||
type PromptInfo = Awaited<ReturnType<MCPClient["listPrompts"]>>["prompts"][number]
|
type PromptInfo = Awaited<ReturnType<MCPClient["listPrompts"]>>["prompts"][number]
|
||||||
@@ -301,7 +301,7 @@ export const layer = Layer.effect(
|
|||||||
})
|
})
|
||||||
.pipe(Effect.ignore, Effect.as(undefined))
|
.pipe(Effect.ignore, Effect.as(undefined))
|
||||||
} else {
|
} else {
|
||||||
pendingOAuthTransports.set(key, transport)
|
pendingOAuthTransports.set(key, { transport })
|
||||||
lastStatus = { status: "needs_auth" as const }
|
lastStatus = { status: "needs_auth" as const }
|
||||||
return events
|
return events
|
||||||
.publish(TuiEvent.ToastShow, {
|
.publish(TuiEvent.ToastShow, {
|
||||||
@@ -819,7 +819,7 @@ export const layer = Layer.effect(
|
|||||||
.join("")
|
.join("")
|
||||||
yield* auth.updateOAuthState(mcpName, oauthState)
|
yield* auth.updateOAuthState(mcpName, oauthState)
|
||||||
let capturedUrl: URL | undefined
|
let capturedUrl: URL | undefined
|
||||||
const authProvider = new McpOAuthProvider(
|
const authProvider = new McpOAuthPendingProvider(
|
||||||
mcpName,
|
mcpName,
|
||||||
mcpConfig.url,
|
mcpConfig.url,
|
||||||
{
|
{
|
||||||
@@ -845,15 +845,16 @@ export const layer = Layer.effect(
|
|||||||
return yield* Effect.tryPromise({
|
return yield* Effect.tryPromise({
|
||||||
try: () => {
|
try: () => {
|
||||||
const client = createClient(directory)
|
const client = createClient(directory)
|
||||||
return client
|
return client.connect(transport).then(async () => {
|
||||||
.connect(transport)
|
await authProvider.commit()
|
||||||
.then(() => ({ authorizationUrl: "", oauthState, client }) satisfies AuthResult)
|
return { authorizationUrl: "", oauthState, client } satisfies AuthResult
|
||||||
|
})
|
||||||
},
|
},
|
||||||
catch: (error) => error,
|
catch: (error) => error,
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.catch((error) => {
|
Effect.catch((error) => {
|
||||||
if (error instanceof UnauthorizedError && capturedUrl) {
|
if (error instanceof UnauthorizedError && capturedUrl) {
|
||||||
pendingOAuthTransports.set(mcpName, transport)
|
pendingOAuthTransports.set(mcpName, { transport, provider: authProvider })
|
||||||
return Effect.succeed({ authorizationUrl: capturedUrl.toString(), oauthState } satisfies AuthResult)
|
return Effect.succeed({ authorizationUrl: capturedUrl.toString(), oauthState } satisfies AuthResult)
|
||||||
}
|
}
|
||||||
return Effect.die(error)
|
return Effect.die(error)
|
||||||
@@ -924,11 +925,11 @@ export const layer = Layer.effect(
|
|||||||
|
|
||||||
const finishAuth = Effect.fn("MCP.finishAuth")(function* (mcpName: string, authorizationCode: string) {
|
const finishAuth = Effect.fn("MCP.finishAuth")(function* (mcpName: string, authorizationCode: string) {
|
||||||
yield* requireMcpConfig(mcpName)
|
yield* requireMcpConfig(mcpName)
|
||||||
const transport = pendingOAuthTransports.get(mcpName)
|
const pending = pendingOAuthTransports.get(mcpName)
|
||||||
if (!transport) throw new Error(`No pending OAuth flow for MCP server: ${mcpName}`)
|
if (!pending) throw new Error(`No pending OAuth flow for MCP server: ${mcpName}`)
|
||||||
|
|
||||||
const result = yield* Effect.tryPromise({
|
const result = yield* Effect.tryPromise({
|
||||||
try: () => transport.finishAuth(authorizationCode).then(() => true as const),
|
try: () => pending.transport.finishAuth(authorizationCode).then(() => true as const),
|
||||||
catch: (error) => {
|
catch: (error) => {
|
||||||
return error
|
return error
|
||||||
},
|
},
|
||||||
@@ -938,6 +939,7 @@ export const layer = Layer.effect(
|
|||||||
return { status: "failed", error: "OAuth completion failed" } satisfies Status
|
return { status: "failed", error: "OAuth completion failed" } satisfies Status
|
||||||
}
|
}
|
||||||
|
|
||||||
|
yield* Effect.promise(() => pending.provider?.commit() ?? Promise.resolve())
|
||||||
yield* auth.clearCodeVerifier(mcpName)
|
yield* auth.clearCodeVerifier(mcpName)
|
||||||
pendingOAuthTransports.delete(mcpName)
|
pendingOAuthTransports.delete(mcpName)
|
||||||
|
|
||||||
|
|||||||
@@ -25,11 +25,11 @@ export interface McpOAuthCallbacks {
|
|||||||
|
|
||||||
export class McpOAuthProvider implements OAuthClientProvider {
|
export class McpOAuthProvider implements OAuthClientProvider {
|
||||||
constructor(
|
constructor(
|
||||||
private mcpName: string,
|
protected mcpName: string,
|
||||||
private serverUrl: string,
|
protected serverUrl: string,
|
||||||
private config: McpOAuthConfig,
|
protected config: McpOAuthConfig,
|
||||||
private callbacks: McpOAuthCallbacks,
|
private callbacks: McpOAuthCallbacks,
|
||||||
private auth: McpAuth.Interface,
|
protected auth: McpAuth.Interface,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
get redirectUrl(): string {
|
get redirectUrl(): string {
|
||||||
@@ -53,7 +53,6 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async clientInformation(): Promise<OAuthClientInformation | undefined> {
|
async clientInformation(): Promise<OAuthClientInformation | undefined> {
|
||||||
// Check config first (pre-registered client)
|
|
||||||
if (this.config.clientId) {
|
if (this.config.clientId) {
|
||||||
return {
|
return {
|
||||||
client_id: this.config.clientId,
|
client_id: this.config.clientId,
|
||||||
@@ -164,10 +163,7 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
|||||||
|
|
||||||
async invalidateCredentials(type: "all" | "client" | "tokens"): Promise<void> {
|
async invalidateCredentials(type: "all" | "client" | "tokens"): Promise<void> {
|
||||||
const entry = await Effect.runPromise(this.auth.get(this.mcpName))
|
const entry = await Effect.runPromise(this.auth.get(this.mcpName))
|
||||||
if (!entry) {
|
if (!entry) return
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "all":
|
case "all":
|
||||||
await Effect.runPromise(this.auth.remove(this.mcpName))
|
await Effect.runPromise(this.auth.remove(this.mcpName))
|
||||||
@@ -184,6 +180,63 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class McpOAuthPendingProvider extends McpOAuthProvider {
|
||||||
|
private pendingClientInfo?: OAuthClientInformationFull
|
||||||
|
private pendingTokens?: OAuthTokens
|
||||||
|
|
||||||
|
override async clientInformation(): Promise<OAuthClientInformation | undefined> {
|
||||||
|
if (!this.config.clientId) return this.pendingClientInfo
|
||||||
|
return {
|
||||||
|
client_id: this.config.clientId,
|
||||||
|
client_secret: this.config.clientSecret,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override async saveClientInformation(info: OAuthClientInformationFull): Promise<void> {
|
||||||
|
this.pendingClientInfo = info
|
||||||
|
}
|
||||||
|
|
||||||
|
override async tokens(): Promise<OAuthTokens | undefined> {
|
||||||
|
return this.pendingTokens
|
||||||
|
}
|
||||||
|
|
||||||
|
override async saveTokens(tokens: OAuthTokens): Promise<void> {
|
||||||
|
this.pendingTokens = tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
override async invalidateCredentials(type: "all" | "client" | "tokens"): Promise<void> {
|
||||||
|
if (type === "all" || type === "client") this.pendingClientInfo = undefined
|
||||||
|
if (type === "all" || type === "tokens") this.pendingTokens = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
async commit(): Promise<void> {
|
||||||
|
if (!this.pendingTokens) return
|
||||||
|
await Effect.runPromise(
|
||||||
|
this.auth.set(
|
||||||
|
this.mcpName,
|
||||||
|
{
|
||||||
|
tokens: {
|
||||||
|
accessToken: this.pendingTokens.access_token,
|
||||||
|
refreshToken: this.pendingTokens.refresh_token,
|
||||||
|
expiresAt: this.pendingTokens.expires_in ? Date.now() / 1000 + this.pendingTokens.expires_in : undefined,
|
||||||
|
scope: this.pendingTokens.scope,
|
||||||
|
},
|
||||||
|
clientInfo:
|
||||||
|
this.pendingClientInfo && !this.config.clientId
|
||||||
|
? {
|
||||||
|
clientId: this.pendingClientInfo.client_id,
|
||||||
|
clientSecret: this.pendingClientInfo.client_secret,
|
||||||
|
clientIdIssuedAt: this.pendingClientInfo.client_id_issued_at,
|
||||||
|
clientSecretExpiresAt: this.pendingClientInfo.client_secret_expires_at,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
this.serverUrl,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH }
|
export { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import { RuntimeFlags } from "@/effect/runtime-flags"
|
|||||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||||
import { EventV2 } from "@opencode-ai/core/event"
|
import { EventV2 } from "@opencode-ai/core/event"
|
||||||
import { Project } from "@opencode-ai/schema/project"
|
import { Project } from "@opencode-ai/schema/project"
|
||||||
|
import { SessionContextEpoch } from "@opencode-ai/core/session/context-epoch"
|
||||||
|
import path from "path"
|
||||||
|
|
||||||
export const Info = Project.Info
|
export const Info = Project.Info
|
||||||
export type Info = Types.DeepMutable<Schema.Schema.Type<typeof Info>>
|
export type Info = Types.DeepMutable<Schema.Schema.Type<typeof Info>>
|
||||||
@@ -229,12 +231,19 @@ export const layer = Layer.effect(
|
|||||||
sandboxes: [] as string[],
|
sandboxes: [] as string[],
|
||||||
time: { created: Date.now(), updated: Date.now() },
|
time: { created: Date.now(), updated: Date.now() },
|
||||||
}
|
}
|
||||||
|
const previousWorktree =
|
||||||
|
row &&
|
||||||
|
projectID !== ProjectV2.ID.global &&
|
||||||
|
existing.worktree !== worktree &&
|
||||||
|
!(yield* fs.isDir(existing.worktree))
|
||||||
|
? existing.worktree
|
||||||
|
: undefined
|
||||||
|
|
||||||
if (flags.experimentalIconDiscovery) yield* discover(existing).pipe(Effect.ignore, Effect.forkIn(scope))
|
if (flags.experimentalIconDiscovery) yield* discover(existing).pipe(Effect.ignore, Effect.forkIn(scope))
|
||||||
|
|
||||||
const result: Info = {
|
const result: Info = {
|
||||||
...existing,
|
...existing,
|
||||||
worktree: projectID === ProjectV2.ID.global ? worktree : existing.worktree,
|
worktree: projectID === ProjectV2.ID.global || previousWorktree ? worktree : existing.worktree,
|
||||||
vcs: data.vcs?.type ?? fakeVcs,
|
vcs: data.vcs?.type ?? fakeVcs,
|
||||||
time: { ...existing.time, updated: Date.now() },
|
time: { ...existing.time, updated: Date.now() },
|
||||||
}
|
}
|
||||||
@@ -252,7 +261,11 @@ export const layer = Layer.effect(
|
|||||||
Effect.map((exists) => (exists ? s : undefined)),
|
Effect.map((exists) => (exists ? s : undefined)),
|
||||||
),
|
),
|
||||||
{ concurrency: "unbounded" },
|
{ concurrency: "unbounded" },
|
||||||
).pipe(Effect.map((arr) => arr.filter((x): x is string => x !== undefined)))
|
).pipe(
|
||||||
|
Effect.map((arr) =>
|
||||||
|
arr.filter((x): x is string => x !== undefined && x !== result.worktree && x !== previousWorktree),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
yield* db
|
yield* db
|
||||||
.insert(ProjectTable)
|
.insert(ProjectTable)
|
||||||
@@ -288,6 +301,29 @@ export const layer = Layer.effect(
|
|||||||
.run()
|
.run()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
|
|
||||||
|
if (previousWorktree) {
|
||||||
|
const sessions = yield* db
|
||||||
|
.select({ id: SessionTable.id, directory: SessionTable.directory })
|
||||||
|
.from(SessionTable)
|
||||||
|
.where(eq(SessionTable.project_id, projectID))
|
||||||
|
.all()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
yield* Effect.forEach(
|
||||||
|
sessions.filter((session) => FSUtil.contains(previousWorktree, session.directory)),
|
||||||
|
(session) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* db
|
||||||
|
.update(SessionTable)
|
||||||
|
.set({ directory: path.join(result.worktree, path.relative(previousWorktree, session.directory)) })
|
||||||
|
.where(eq(SessionTable.id, session.id))
|
||||||
|
.run()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
yield* SessionContextEpoch.reset(db, session.id)
|
||||||
|
}),
|
||||||
|
{ concurrency: 1, discard: true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (projectID !== ProjectV2.ID.global) {
|
if (projectID !== ProjectV2.ID.global) {
|
||||||
yield* db
|
yield* db
|
||||||
.update(SessionTable)
|
.update(SessionTable)
|
||||||
@@ -301,6 +337,9 @@ export const layer = Layer.effect(
|
|||||||
projectID,
|
projectID,
|
||||||
directory: data.directory,
|
directory: data.directory,
|
||||||
})
|
})
|
||||||
|
if (previousWorktree) {
|
||||||
|
yield* projectDirectories.remove({ projectID, directory: AbsolutePath.make(previousWorktree) })
|
||||||
|
}
|
||||||
|
|
||||||
yield* emitUpdated(result)
|
yield* emitUpdated(result)
|
||||||
if (projectID !== ProjectV2.ID.global && data.vcs?.type === "git") {
|
if (projectID !== ProjectV2.ID.global && data.vcs?.type === "git") {
|
||||||
@@ -334,7 +373,16 @@ export const layer = Layer.effect(
|
|||||||
})
|
})
|
||||||
|
|
||||||
const list = Effect.fn("Project.list")(function* () {
|
const list = Effect.fn("Project.list")(function* () {
|
||||||
return (yield* db.select().from(ProjectTable).all().pipe(Effect.orDie)).map(fromRow)
|
const projects = (yield* db.select().from(ProjectTable).all().pipe(Effect.orDie)).map(fromRow)
|
||||||
|
return (yield* Effect.forEach(
|
||||||
|
projects,
|
||||||
|
Effect.fnUntraced(function* (project) {
|
||||||
|
if (project.id === ProjectV2.ID.global) return project
|
||||||
|
if (yield* fs.isDir(project.worktree)) return project
|
||||||
|
return undefined
|
||||||
|
}),
|
||||||
|
{ concurrency: "unbounded" },
|
||||||
|
)).filter((project): project is Info => project !== undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
const get = Effect.fn("Project.get")(function* (id: ProjectV2.ID) {
|
const get = Effect.fn("Project.get")(function* (id: ProjectV2.ID) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, it } from "bun:test"
|
import { afterEach, describe, expect, it } from "bun:test"
|
||||||
import type {
|
import type {
|
||||||
AgentSideConnection,
|
AgentSideConnection,
|
||||||
RequestPermissionRequest,
|
RequestPermissionRequest,
|
||||||
@@ -6,13 +6,22 @@ import type {
|
|||||||
SessionUpdate,
|
SessionUpdate,
|
||||||
} from "@agentclientprotocol/sdk"
|
} from "@agentclientprotocol/sdk"
|
||||||
import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2"
|
import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||||
|
import { createTwoFilesPatch } from "diff"
|
||||||
import { Effect, ManagedRuntime } from "effect"
|
import { Effect, ManagedRuntime } from "effect"
|
||||||
|
import { mkdtemp, rm } from "node:fs/promises"
|
||||||
|
import { tmpdir } from "node:os"
|
||||||
|
import path from "node:path"
|
||||||
import { ACPEvent } from "@/acp/event"
|
import { ACPEvent } from "@/acp/event"
|
||||||
import { ACPSession } from "@/acp/session"
|
import { ACPSession } from "@/acp/session"
|
||||||
|
|
||||||
type PermissionEvent = Extract<Event, { type: "permission.asked" }>
|
type PermissionEvent = Extract<Event, { type: "permission.asked" }>
|
||||||
type PermissionReplyParams = Parameters<OpencodeClient["permission"]["reply"]>[0]
|
type PermissionReplyParams = Parameters<OpencodeClient["permission"]["reply"]>[0]
|
||||||
type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
|
type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
|
||||||
|
const cleanupDirs: string[] = []
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(cleanupDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })))
|
||||||
|
})
|
||||||
|
|
||||||
const pollUntil = async (
|
const pollUntil = async (
|
||||||
check: () => boolean | Promise<boolean>,
|
check: () => boolean | Promise<boolean>,
|
||||||
@@ -137,6 +146,14 @@ function textFromUpdates(updates: SessionUpdateParams[], sessionId: string) {
|
|||||||
.join("")
|
.join("")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function tempFile(name: string, content: string) {
|
||||||
|
const dir = await mkdtemp(path.join(tmpdir(), "opencode-acp-permission-"))
|
||||||
|
cleanupDirs.push(dir)
|
||||||
|
const file = path.join(dir, name)
|
||||||
|
await Bun.write(file, content)
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
|
||||||
describe("acp permissions", () => {
|
describe("acp permissions", () => {
|
||||||
it("sends requestPermission and replies with the selected outcome", async () => {
|
it("sends requestPermission and replies with the selected outcome", async () => {
|
||||||
const harness = createHarness()
|
const harness = createHarness()
|
||||||
@@ -151,7 +168,7 @@ describe("acp permissions", () => {
|
|||||||
toolCall: {
|
toolCall: {
|
||||||
toolCallId: "call_1",
|
toolCallId: "call_1",
|
||||||
status: "pending",
|
status: "pending",
|
||||||
title: "bash",
|
title: "printf hello",
|
||||||
rawInput: { command: "printf hello" },
|
rawInput: { command: "printf hello" },
|
||||||
kind: "execute",
|
kind: "execute",
|
||||||
locations: [],
|
locations: [],
|
||||||
@@ -165,6 +182,116 @@ describe("acp permissions", () => {
|
|||||||
expect(harness.replies).toEqual([{ requestID: "perm_1", reply: "once", directory: "/workspace" }])
|
expect(harness.replies).toEqual([{ requestID: "perm_1", reply: "once", directory: "/workspace" }])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("uses permission metadata for non-shell titles", async () => {
|
||||||
|
const harness = createHarness()
|
||||||
|
await createSession(harness.session, "ses_a")
|
||||||
|
|
||||||
|
harness.subscription.handle(
|
||||||
|
permissionAsked("ses_a", "perm_fetch", {
|
||||||
|
permission: "webfetch",
|
||||||
|
metadata: {
|
||||||
|
url: "https://example.com/docs",
|
||||||
|
format: "markdown",
|
||||||
|
},
|
||||||
|
tool: { messageID: "msg_1", callID: "call_1" },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await pollUntil(() => harness.replies.length === 1, "webfetch permission was never replied")
|
||||||
|
|
||||||
|
expect(harness.requests[0]?.toolCall).toMatchObject({
|
||||||
|
toolCallId: "call_1",
|
||||||
|
title: "https://example.com/docs",
|
||||||
|
kind: "fetch",
|
||||||
|
rawInput: { url: "https://example.com/docs", format: "markdown" },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("includes a diff content block for edit permission metadata", async () => {
|
||||||
|
const filepath = await tempFile("file.ts", "before\n")
|
||||||
|
const harness = createHarness()
|
||||||
|
await createSession(harness.session, "ses_a")
|
||||||
|
|
||||||
|
harness.subscription.handle(
|
||||||
|
permissionAsked("ses_a", "perm_edit", {
|
||||||
|
permission: "edit",
|
||||||
|
metadata: {
|
||||||
|
filepath,
|
||||||
|
diff: createTwoFilesPatch(filepath, filepath, "before\n", "after\n"),
|
||||||
|
},
|
||||||
|
tool: { messageID: "msg_1", callID: "call_1" },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await pollUntil(() => harness.replies.length === 1, "edit permission was never replied")
|
||||||
|
|
||||||
|
expect(harness.requests[0]?.toolCall).toMatchObject({
|
||||||
|
toolCallId: "call_1",
|
||||||
|
title: filepath,
|
||||||
|
kind: "edit",
|
||||||
|
locations: [{ path: filepath }],
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "diff",
|
||||||
|
path: filepath,
|
||||||
|
oldText: "before\n",
|
||||||
|
newText: "after\n",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("includes per-file diff blocks and locations for apply_patch permission metadata", async () => {
|
||||||
|
const first = await tempFile("first.ts", "one\n")
|
||||||
|
const second = await tempFile("second.ts", "alpha\n")
|
||||||
|
const harness = createHarness()
|
||||||
|
await createSession(harness.session, "ses_a")
|
||||||
|
|
||||||
|
harness.subscription.handle(
|
||||||
|
permissionAsked("ses_a", "perm_patch", {
|
||||||
|
permission: "edit",
|
||||||
|
metadata: {
|
||||||
|
filepath: "first.ts, second.ts",
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
filePath: first,
|
||||||
|
relativePath: "first.ts",
|
||||||
|
patch: createTwoFilesPatch(first, first, "one\n", "two\n"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
filePath: second,
|
||||||
|
relativePath: "second.ts",
|
||||||
|
patch: createTwoFilesPatch(second, second, "alpha\n", "beta\n"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
tool: { messageID: "msg_1", callID: "call_1" },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await pollUntil(() => harness.replies.length === 1, "apply_patch permission was never replied")
|
||||||
|
|
||||||
|
expect(harness.requests[0]?.toolCall).toMatchObject({
|
||||||
|
toolCallId: "call_1",
|
||||||
|
title: "2 files",
|
||||||
|
locations: [{ path: first }, { path: second }],
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "diff",
|
||||||
|
path: first,
|
||||||
|
oldText: "one\n",
|
||||||
|
newText: "two\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "diff",
|
||||||
|
path: second,
|
||||||
|
oldText: "alpha\n",
|
||||||
|
newText: "beta\n",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it("forwards external_directory metadata and locations to requestPermission", async () => {
|
it("forwards external_directory metadata and locations to requestPermission", async () => {
|
||||||
const harness = createHarness()
|
const harness = createHarness()
|
||||||
await createSession(harness.session, "ses_a")
|
await createSession(harness.session, "ses_a")
|
||||||
@@ -189,7 +316,7 @@ describe("acp permissions", () => {
|
|||||||
toolCall: {
|
toolCall: {
|
||||||
toolCallId: "call_1",
|
toolCallId: "call_1",
|
||||||
status: "pending",
|
status: "pending",
|
||||||
title: "external_directory",
|
title: "Create external directory",
|
||||||
rawInput: {
|
rawInput: {
|
||||||
command: "mkdir -p /tmp/outside",
|
command: "mkdir -p /tmp/outside",
|
||||||
description: "Create external directory",
|
description: "Create external directory",
|
||||||
|
|||||||
@@ -394,15 +394,29 @@ describe("ACP service sessions", () => {
|
|||||||
expect(second.sessions.map((session) => session.sessionId)).toEqual(["ses_2", "ses_1"])
|
expect(second.sessions.map((session) => session.sessionId)).toEqual(["ses_2", "ses_1"])
|
||||||
})
|
})
|
||||||
|
|
||||||
it("resumes a session and stores restored state", async () => {
|
it("resumes a session and stores restored state without replaying transcript chunks", async () => {
|
||||||
const { service } = makeService([
|
const { service, updates } = makeService([
|
||||||
{
|
{
|
||||||
info: {
|
info: {
|
||||||
|
id: "msg_user",
|
||||||
|
sessionID: "ses_resume",
|
||||||
role: "user",
|
role: "user",
|
||||||
model: { providerID: "test", modelID: "test-model", variant: "high" },
|
model: { providerID: "test", modelID: "test-model", variant: "high" },
|
||||||
agent: "plan",
|
agent: "plan",
|
||||||
},
|
},
|
||||||
parts: [],
|
parts: [{ id: "part_user", sessionID: "ses_resume", messageID: "msg_user", type: "text", text: "hello" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
info: { id: "msg_assistant", sessionID: "ses_resume", role: "assistant" },
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
id: "part_assistant",
|
||||||
|
sessionID: "ses_resume",
|
||||||
|
messageID: "msg_assistant",
|
||||||
|
type: "text",
|
||||||
|
text: "hi there",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
const resumed = await Effect.runPromise(
|
const resumed = await Effect.runPromise(
|
||||||
@@ -414,6 +428,11 @@ describe("ACP service sessions", () => {
|
|||||||
|
|
||||||
expect(select(resumed, "effort")?.currentValue).toBe("high")
|
expect(select(resumed, "effort")?.currentValue).toBe("high")
|
||||||
expect(select(updated, "effort")?.currentValue).toBe("default")
|
expect(select(updated, "effort")?.currentValue).toBe("default")
|
||||||
|
expect(
|
||||||
|
updates
|
||||||
|
.map((item) => item.update)
|
||||||
|
.filter((item) => item.sessionUpdate === "user_message_chunk" || item.sessionUpdate === "agent_message_chunk"),
|
||||||
|
).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
it("closes local ACP state and aborts the backing session best-effort", async () => {
|
it("closes local ACP state and aborts the backing session best-effort", async () => {
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import { resolve } from "path"
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import {
|
import {
|
||||||
completedToolContent,
|
completedToolContent,
|
||||||
|
completedToolUpdate,
|
||||||
completedToolRawOutput,
|
completedToolRawOutput,
|
||||||
extractImageAttachments,
|
extractImageAttachments,
|
||||||
imageContents,
|
imageContents,
|
||||||
|
pendingToolCall,
|
||||||
shellOutputSnapshot,
|
shellOutputSnapshot,
|
||||||
toLocations,
|
toLocations,
|
||||||
toToolKind,
|
toToolKind,
|
||||||
@@ -111,6 +113,85 @@ describe("acp tool conversion", () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("sends completed tool calls as partial updates", () => {
|
||||||
|
expect(
|
||||||
|
pendingToolCall({
|
||||||
|
toolCallId: "tool-1",
|
||||||
|
toolName: "edit",
|
||||||
|
state: {
|
||||||
|
input: {
|
||||||
|
filePath: "/tmp/file.ts",
|
||||||
|
oldString: "before",
|
||||||
|
newString: "after",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
kind: "edit",
|
||||||
|
locations: [{ path: "/tmp/file.ts" }],
|
||||||
|
rawInput: {
|
||||||
|
filePath: "/tmp/file.ts",
|
||||||
|
oldString: "before",
|
||||||
|
newString: "after",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(
|
||||||
|
completedToolUpdate({
|
||||||
|
toolCallId: "tool-1",
|
||||||
|
toolName: "edit",
|
||||||
|
state: {
|
||||||
|
status: "completed",
|
||||||
|
input: {
|
||||||
|
filePath: "/tmp/file.ts",
|
||||||
|
oldString: "before",
|
||||||
|
newString: "after",
|
||||||
|
},
|
||||||
|
output: "Edit applied successfully.",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
toolCallId: "tool-1",
|
||||||
|
status: "completed",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "content",
|
||||||
|
content: { type: "text", text: "Edit applied successfully." },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "diff",
|
||||||
|
path: "/tmp/file.ts",
|
||||||
|
oldText: "before",
|
||||||
|
newText: "after",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
rawOutput: {
|
||||||
|
output: "Edit applied successfully.",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(
|
||||||
|
completedToolUpdate({
|
||||||
|
toolCallId: "tool-1",
|
||||||
|
toolName: "edit",
|
||||||
|
state: {
|
||||||
|
status: "completed",
|
||||||
|
input: {
|
||||||
|
filePath: "/tmp/file.ts",
|
||||||
|
oldString: "before",
|
||||||
|
newString: "after",
|
||||||
|
},
|
||||||
|
title: "file.ts",
|
||||||
|
output: "Edit applied successfully.",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
toolCallId: "tool-1",
|
||||||
|
status: "completed",
|
||||||
|
title: "file.ts",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test("uses clean read display text for completed content", () => {
|
test("uses clean read display text for completed content", () => {
|
||||||
const output = [
|
const output = [
|
||||||
"<path>/tmp/file.ts</path>",
|
"<path>/tmp/file.ts</path>",
|
||||||
|
|||||||
+1
-1
@@ -33,7 +33,7 @@
|
|||||||
"headers": {
|
"headers": {
|
||||||
"content-type": "application/json"
|
"content-type": "application/json"
|
||||||
},
|
},
|
||||||
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"reasoning\",\"id\":\"rs_0812d6cbe7a2b19b016a1214d32f6881998bcd9ff2e739d7f2\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEhTUCQT4XELlBu6r5VHqqtu5Il5WdX4m1upE8li0mPmIwgIykAmUTZWiE0213kmviuAgIrmhhiL4B8DXbWQD2vOEkQMhpZq_UCqc22SOg-4DpQLrebMWkzgAPL618VPu9mXNUIH9BW1sRhPdDSbbtK5_bitzsn-FMJGcO3UN7Ga2RW1Rdvt1M3m7J4MRlTutH8cwY8SthzgvOFEBS-_IrAhiwKVz4Se9Jlu3pVNMqhPF7kdrQOfDYui0v-AT8VrHBVomqekJl_dWESww0eWo6bS1PxZB4cLQHWp9JJi5pEECvU9Ntcz3GxuGJEtTKq5mFcRvCanXHOwZGmbBcWMNdVyikk3fxgIE2g9t8rCKJmhNXznMERtrfG2tey19qWbsVbo2YmBbg_5N02AA4NmEVvdfgHJx58nOfEEc2OZYk0YQ1fHBOkpBnwY61hxtrWFdj48QnTEKuvjAyNpX-KKFmMzL4531yLbEEzpaERlr11fDeoMpKofUoMsg3Jz8aTaZ1CpzI3O7iFzGDEV6gKh8vQYGrKOaOXnfBVDXDo8iJhZywpcQY6xB4NNf4pyyjFkR-vjgvBYV2hejlq2V1j8vQHgy8CsZJ6lW5oaTNMfP76MAHlwUwyMYj-cFmuX0epJdDWv8GDznUpOS-v2X5eNsvyx9qvvcTEMLsKJ--3_odisilj4vPhw16P9fB8eLmvESZmJRYmWM4mO7hPTVXOooOa-zxRHGhRQH9ouUea9UHSuH1A0o54qTEPr-JqYlQggugW449IuYW4HSMNMyeGdUNJfodWRu5cL0VPgk6zwTU3ArBq28FDgG7NZMk3njfCId351GZ8VRlTMA6U522_6FFaZ8-5gxsidOm0WULOwyTTo54tJsJFv2pgYUKs0VFWSwi3rvNMVMOgwOVIdSgZt1hFTxBImZh8HUIXUPvdOVKZzQmWT5M6uOTUsm5xsufhj8m79RuYZh2J0bkVOBzZ1As8zH-4v_r9d7e8464EuWXCln_6LAJdrTYgE2gVfHK0zeUaAMbIKhirOf0AVQZyfVsGvJ_CPqrPE_QSECeSA2D4TSa5Tc_IRY-Fb2_HKNCMEP2uvy\"},{\"type\":\"function_call\",\"call_id\":\"call_Ix5Urx04RtKsUJ75K0vTTgFF\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":{}}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_Ix5Urx04RtKsUJ75K0vTTgFF\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"instructions\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"prompt_cache_key\":\"session-recorded-openai-oauth-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"stream\":true}"
|
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"reasoning\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEhTUCQT4XELlBu6r5VHqqtu5Il5WdX4m1upE8li0mPmIwgIykAmUTZWiE0213kmviuAgIrmhhiL4B8DXbWQD2vOEkQMhpZq_UCqc22SOg-4DpQLrebMWkzgAPL618VPu9mXNUIH9BW1sRhPdDSbbtK5_bitzsn-FMJGcO3UN7Ga2RW1Rdvt1M3m7J4MRlTutH8cwY8SthzgvOFEBS-_IrAhiwKVz4Se9Jlu3pVNMqhPF7kdrQOfDYui0v-AT8VrHBVomqekJl_dWESww0eWo6bS1PxZB4cLQHWp9JJi5pEECvU9Ntcz3GxuGJEtTKq5mFcRvCanXHOwZGmbBcWMNdVyikk3fxgIE2g9t8rCKJmhNXznMERtrfG2tey19qWbsVbo2YmBbg_5N02AA4NmEVvdfgHJx58nOfEEc2OZYk0YQ1fHBOkpBnwY61hxtrWFdj48QnTEKuvjAyNpX-KKFmMzL4531yLbEEzpaERlr11fDeoMpKofUoMsg3Jz8aTaZ1CpzI3O7iFzGDEV6gKh8vQYGrKOaOXnfBVDXDo8iJhZywpcQY6xB4NNf4pyyjFkR-vjgvBYV2hejlq2V1j8vQHgy8CsZJ6lW5oaTNMfP76MAHlwUwyMYj-cFmuX0epJdDWv8GDznUpOS-v2X5eNsvyx9qvvcTEMLsKJ--3_odisilj4vPhw16P9fB8eLmvESZmJRYmWM4mO7hPTVXOooOa-zxRHGhRQH9ouUea9UHSuH1A0o54qTEPr-JqYlQggugW449IuYW4HSMNMyeGdUNJfodWRu5cL0VPgk6zwTU3ArBq28FDgG7NZMk3njfCId351GZ8VRlTMA6U522_6FFaZ8-5gxsidOm0WULOwyTTo54tJsJFv2pgYUKs0VFWSwi3rvNMVMOgwOVIdSgZt1hFTxBImZh8HUIXUPvdOVKZzQmWT5M6uOTUsm5xsufhj8m79RuYZh2J0bkVOBzZ1As8zH-4v_r9d7e8464EuWXCln_6LAJdrTYgE2gVfHK0zeUaAMbIKhirOf0AVQZyfVsGvJ_CPqrPE_QSECeSA2D4TSa5Tc_IRY-Fb2_HKNCMEP2uvy\"},{\"type\":\"function_call\",\"call_id\":\"call_Ix5Urx04RtKsUJ75K0vTTgFF\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":{}}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_Ix5Urx04RtKsUJ75K0vTTgFF\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"instructions\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"prompt_cache_key\":\"session-recorded-openai-oauth-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"stream\":true}"
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": 200,
|
"status": 200,
|
||||||
|
|||||||
+1
-1
@@ -35,7 +35,7 @@
|
|||||||
"headers": {
|
"headers": {
|
||||||
"content-type": "application/json"
|
"content-type": "application/json"
|
||||||
},
|
},
|
||||||
"body": "{\"model\":\"gpt-5.2-codex\",\"input\":[{\"role\":\"system\",\"content\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"reasoning\",\"id\":\"rs_0fdce240b46054ad016a1214d326848196b269feebe1844759\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEhTTGeallj_mC3ciDydiTVJLA6bjJfitoj4ftFfWwlxekFNaf_cDNWP3pE6qsvK9gKJNRfbAbpaEVf1qjAhQx53witrmt6H3KaaNJm3wXHG5sEi9gp3nLWK4T76tcVYHG1x6mbbTjEjCvhIuEkn_7Q7lJ1BErkEURYBBMPmkKya2-YuL8XP14Yrko9BA1t56BkwK5U3TFse4nwHI1qi82hdkX_aYAtz6YgbTpf-dvOCBGfeApxWLFotkt355Qy2b6MmPaH6cQwrvLJXOqEzGkwxFcs3mLEKLV103gd8Z5e_OapjJHTv_LarN-WN9C7nCQ0BBHClk4ND3SDdGb-XV665r23RB40GJ3Q9brJALGaJhij4uceXZNYbakZVOxgqLuDnX6EgABwEzrZb7vhVAKCewVYkLDu0LiS1rIvcFT8HpovxaBU2F2kVG7TRvzYewCW9zXWnAR048p5pUvi6zfMzapk8bnl4uM_uD45gp1sMzeSHryai1U0AUO2cLeQV1pA7KJoJBwWlHxo0YNPbDidI2KfByIoI0A7oiKoZ32vJkiwx3BEGePnzb-JQnv1eDXwlimICVKEVPk1BxpUZ2XBoWdUGYR77u5NGmZ2sKh4OM-qIaB0VaChGsCsJLyQ5_MCkeOm9EMjg1cXbIHDzs9jpF2BXlowY1Vw_L-Ve6nzwK7ZcyHM3ij27wEXYO2On6zbN_AqOvX_CFAjI7ktCYF2guftXuVpFCuiqRyDZ6i2RHXMhR77CoPT97sAvXDejN8feNtidqq4OH5uLa3BHYvW0UKfNlBCOL6A6927l4iTKURZznq_mVjLgTHWv9k-ByxP0hC5sIQHyB5hJaD8_svMr4Aqz_vH9Z8HShgjK47NsMQKxGGgaXdnq3xEdwydM-hTG4Pi35o6Kt0bbJ5KTRQ2ObjmnVTG7J__QTKMTrK2S6Ro4VIMrYzaai7BTLa8MGNotj\"},{\"type\":\"function_call\",\"call_id\":\"call_hwPdXfzZmrdySXU2ZmrL51Ln\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":{}}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_hwPdXfzZmrdySXU2ZmrL51Ln\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"prompt_cache_key\":\"session-recorded-opencode-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":32000,\"stream\":true}"
|
"body": "{\"model\":\"gpt-5.2-codex\",\"input\":[{\"role\":\"system\",\"content\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"reasoning\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEhTTGeallj_mC3ciDydiTVJLA6bjJfitoj4ftFfWwlxekFNaf_cDNWP3pE6qsvK9gKJNRfbAbpaEVf1qjAhQx53witrmt6H3KaaNJm3wXHG5sEi9gp3nLWK4T76tcVYHG1x6mbbTjEjCvhIuEkn_7Q7lJ1BErkEURYBBMPmkKya2-YuL8XP14Yrko9BA1t56BkwK5U3TFse4nwHI1qi82hdkX_aYAtz6YgbTpf-dvOCBGfeApxWLFotkt355Qy2b6MmPaH6cQwrvLJXOqEzGkwxFcs3mLEKLV103gd8Z5e_OapjJHTv_LarN-WN9C7nCQ0BBHClk4ND3SDdGb-XV665r23RB40GJ3Q9brJALGaJhij4uceXZNYbakZVOxgqLuDnX6EgABwEzrZb7vhVAKCewVYkLDu0LiS1rIvcFT8HpovxaBU2F2kVG7TRvzYewCW9zXWnAR048p5pUvi6zfMzapk8bnl4uM_uD45gp1sMzeSHryai1U0AUO2cLeQV1pA7KJoJBwWlHxo0YNPbDidI2KfByIoI0A7oiKoZ32vJkiwx3BEGePnzb-JQnv1eDXwlimICVKEVPk1BxpUZ2XBoWdUGYR77u5NGmZ2sKh4OM-qIaB0VaChGsCsJLyQ5_MCkeOm9EMjg1cXbIHDzs9jpF2BXlowY1Vw_L-Ve6nzwK7ZcyHM3ij27wEXYO2On6zbN_AqOvX_CFAjI7ktCYF2guftXuVpFCuiqRyDZ6i2RHXMhR77CoPT97sAvXDejN8feNtidqq4OH5uLa3BHYvW0UKfNlBCOL6A6927l4iTKURZznq_mVjLgTHWv9k-ByxP0hC5sIQHyB5hJaD8_svMr4Aqz_vH9Z8HShgjK47NsMQKxGGgaXdnq3xEdwydM-hTG4Pi35o6Kt0bbJ5KTRQ2ObjmnVTG7J__QTKMTrK2S6Ro4VIMrYzaai7BTLa8MGNotj\"},{\"type\":\"function_call\",\"call_id\":\"call_hwPdXfzZmrdySXU2ZmrL51Ln\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":{}}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_hwPdXfzZmrdySXU2ZmrL51Ln\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"prompt_cache_key\":\"session-recorded-opencode-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":32000,\"stream\":true}"
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status": 200,
|
"status": 200,
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ let simulateAuthFlow = true
|
|||||||
let connectSucceedsImmediately = false
|
let connectSucceedsImmediately = false
|
||||||
let serverCapabilities: { tools?: object; resources?: object } = { tools: {} }
|
let serverCapabilities: { tools?: object; resources?: object } = { tools: {} }
|
||||||
let listToolsCalls = 0
|
let listToolsCalls = 0
|
||||||
|
let finishAuthFails = false
|
||||||
|
let finishAuthStoresCredentials = false
|
||||||
|
|
||||||
// Mock the transport constructors to simulate OAuth auto-auth on 401
|
// Mock the transport constructors to simulate OAuth auto-auth on 401
|
||||||
void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
|
void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
|
||||||
@@ -32,6 +34,10 @@ void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
|
|||||||
state?: () => Promise<string>
|
state?: () => Promise<string>
|
||||||
redirectToAuthorization?: (url: URL) => Promise<void>
|
redirectToAuthorization?: (url: URL) => Promise<void>
|
||||||
saveCodeVerifier?: (v: string) => Promise<void>
|
saveCodeVerifier?: (v: string) => Promise<void>
|
||||||
|
tokens?: () => Promise<{ access_token: string } | undefined>
|
||||||
|
clientInformation?: () => Promise<{ client_id: string } | undefined>
|
||||||
|
saveClientInformation?: (info: { client_id: string; client_secret?: string }) => Promise<void>
|
||||||
|
saveTokens?: (tokens: { access_token: string; token_type: string }) => Promise<void>
|
||||||
}
|
}
|
||||||
| undefined
|
| undefined
|
||||||
constructor(url: URL, options?: { authProvider?: unknown }) {
|
constructor(url: URL, options?: { authProvider?: unknown }) {
|
||||||
@@ -49,6 +55,8 @@ void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
|
|||||||
// It calls auth() which eventually calls provider.state(), then
|
// It calls auth() which eventually calls provider.state(), then
|
||||||
// provider.redirectToAuthorization(), then throws UnauthorizedError.
|
// provider.redirectToAuthorization(), then throws UnauthorizedError.
|
||||||
if (simulateAuthFlow && this.authProvider) {
|
if (simulateAuthFlow && this.authProvider) {
|
||||||
|
if (await this.authProvider.tokens?.()) throw new MockUnauthorizedError()
|
||||||
|
if (await this.authProvider.clientInformation?.()) throw new MockUnauthorizedError()
|
||||||
// The SDK calls provider.state() to get the OAuth state parameter
|
// The SDK calls provider.state() to get the OAuth state parameter
|
||||||
if (this.authProvider.state) {
|
if (this.authProvider.state) {
|
||||||
await this.authProvider.state()
|
await this.authProvider.state()
|
||||||
@@ -65,7 +73,14 @@ void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
|
|||||||
}
|
}
|
||||||
throw new MockUnauthorizedError()
|
throw new MockUnauthorizedError()
|
||||||
}
|
}
|
||||||
async finishAuth(_code: string) {}
|
async finishAuth(_code: string) {
|
||||||
|
if (finishAuthFails) throw new Error("Token exchange failed")
|
||||||
|
if (finishAuthStoresCredentials) {
|
||||||
|
await this.authProvider?.saveClientInformation?.({ client_id: "replacement-client" })
|
||||||
|
await this.authProvider?.saveTokens?.({ access_token: "replacement-token", token_type: "Bearer" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async close() {}
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -125,6 +140,8 @@ beforeEach(() => {
|
|||||||
connectSucceedsImmediately = false
|
connectSucceedsImmediately = false
|
||||||
serverCapabilities = { tools: {} }
|
serverCapabilities = { tools: {} }
|
||||||
listToolsCalls = 0
|
listToolsCalls = 0
|
||||||
|
finishAuthFails = false
|
||||||
|
finishAuthStoresCredentials = false
|
||||||
})
|
})
|
||||||
|
|
||||||
// Import modules after mocking
|
// Import modules after mocking
|
||||||
@@ -133,6 +150,7 @@ const { EventV2Bridge } = await import("../../src/event-v2-bridge")
|
|||||||
const { Config } = await import("../../src/config/config")
|
const { Config } = await import("../../src/config/config")
|
||||||
const { McpAuth } = await import("../../src/mcp/auth")
|
const { McpAuth } = await import("../../src/mcp/auth")
|
||||||
const { McpOAuthProvider } = await import("../../src/mcp/oauth-provider")
|
const { McpOAuthProvider } = await import("../../src/mcp/oauth-provider")
|
||||||
|
const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback")
|
||||||
const { FSUtil } = await import("@opencode-ai/core/fs-util")
|
const { FSUtil } = await import("@opencode-ai/core/fs-util")
|
||||||
const { CrossSpawnSpawner } = await import("@opencode-ai/core/cross-spawn-spawner")
|
const { CrossSpawnSpawner } = await import("@opencode-ai/core/cross-spawn-spawner")
|
||||||
|
|
||||||
@@ -227,6 +245,59 @@ mcpTest.instance("state() returns existing state when one is saved", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
mcpTest.instance(
|
||||||
|
"failed reauthentication preserves existing credentials",
|
||||||
|
() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* Effect.addFinalizer(() => Effect.promise(() => McpOAuthCallback.stop()).pipe(Effect.ignore))
|
||||||
|
const mcp = yield* MCP.Service
|
||||||
|
const auth = yield* McpAuth.Service
|
||||||
|
const name = "test-reauth-failure"
|
||||||
|
const url = "https://example.com/mcp"
|
||||||
|
const clientInfo = { clientId: "dynamic-client", clientSecret: "dynamic-secret" }
|
||||||
|
|
||||||
|
yield* auth.updateClientInfo(name, clientInfo, url)
|
||||||
|
yield* auth.updateTokens(name, { accessToken: "working-token" }, url)
|
||||||
|
expect((yield* mcp.startAuth(name)).authorizationUrl).toContain("https://auth.example.com/authorize")
|
||||||
|
finishAuthFails = true
|
||||||
|
|
||||||
|
expect(yield* mcp.finishAuth(name, "invalid-code")).toEqual({
|
||||||
|
status: "failed",
|
||||||
|
error: "OAuth completion failed",
|
||||||
|
})
|
||||||
|
const entry = yield* auth.get(name)
|
||||||
|
expect(entry?.tokens?.accessToken).toBe("working-token")
|
||||||
|
expect(entry?.clientInfo).toEqual(clientInfo)
|
||||||
|
}),
|
||||||
|
{ config: config("test-reauth-failure") },
|
||||||
|
)
|
||||||
|
|
||||||
|
mcpTest.instance(
|
||||||
|
"successful reauthentication commits replacement credentials",
|
||||||
|
() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* Effect.addFinalizer(() => Effect.promise(() => McpOAuthCallback.stop()).pipe(Effect.ignore))
|
||||||
|
const mcp = yield* MCP.Service
|
||||||
|
const auth = yield* McpAuth.Service
|
||||||
|
const name = "test-reauth-success"
|
||||||
|
const url = "https://example.com/mcp"
|
||||||
|
|
||||||
|
yield* auth.updateClientInfo(name, { clientId: "old-client" }, url)
|
||||||
|
yield* auth.updateTokens(name, { accessToken: "old-token" }, url)
|
||||||
|
expect((yield* mcp.startAuth(name)).authorizationUrl).toContain("https://auth.example.com/authorize")
|
||||||
|
expect((yield* auth.get(name))?.tokens?.accessToken).toBe("old-token")
|
||||||
|
finishAuthStoresCredentials = true
|
||||||
|
connectSucceedsImmediately = true
|
||||||
|
|
||||||
|
expect((yield* mcp.finishAuth(name, "valid-code")).status).toBe("connected")
|
||||||
|
const entry = yield* auth.get(name)
|
||||||
|
expect(entry?.tokens?.accessToken).toBe("replacement-token")
|
||||||
|
expect(entry?.clientInfo?.clientId).toBe("replacement-client")
|
||||||
|
expect(entry?.serverUrl).toBe(url)
|
||||||
|
}),
|
||||||
|
{ config: config("test-reauth-success") },
|
||||||
|
)
|
||||||
|
|
||||||
mcpTest.instance(
|
mcpTest.instance(
|
||||||
"auth status only reports credentials stored for the configured server URL",
|
"auth status only reports credentials stored for the configured server URL",
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import path from "path"
|
|||||||
import { tmpdirScoped } from "../fixture/fixture"
|
import { tmpdirScoped } from "../fixture/fixture"
|
||||||
import { GlobalBus } from "../../src/bus/global"
|
import { GlobalBus } from "../../src/bus/global"
|
||||||
import { Database } from "@opencode-ai/core/database/database"
|
import { Database } from "@opencode-ai/core/database/database"
|
||||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
import { ProjectDirectoryTable, ProjectTable } from "@opencode-ai/core/project/sql"
|
||||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||||
import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
|
import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
|
||||||
import { eq } from "drizzle-orm"
|
import { eq } from "drizzle-orm"
|
||||||
@@ -23,6 +23,7 @@ import { ProjectDirectories } from "@opencode-ai/core/project/directories"
|
|||||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||||
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
|
|
||||||
const encoder = new TextEncoder()
|
const encoder = new TextEncoder()
|
||||||
|
|
||||||
@@ -403,6 +404,87 @@ describe("Project.fromDirectory with worktrees", () => {
|
|||||||
expect(result.project.sandboxes).not.toContain(tmp)
|
expect(result.project.sandboxes).not.toContain(tmp)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.live("relocates a project and its sessions when the primary checkout moved", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const { db } = yield* Database.Service
|
||||||
|
const project = yield* Project.Service
|
||||||
|
const tmp = yield* tmpdirScoped({ git: true })
|
||||||
|
const original = yield* project.fromDirectory(tmp)
|
||||||
|
const moved = `${tmp}-moved`
|
||||||
|
const rootSession = SessionID.make(`ses_${crypto.randomUUID()}`)
|
||||||
|
const nestedSession = SessionID.make(`ses_${crypto.randomUUID()}`)
|
||||||
|
yield* Effect.addFinalizer(() => Effect.promise(() => $`rm -rf ${moved}`.quiet().nothrow()).pipe(Effect.ignore))
|
||||||
|
yield* db
|
||||||
|
.insert(SessionTable)
|
||||||
|
.values([
|
||||||
|
{
|
||||||
|
id: rootSession,
|
||||||
|
project_id: original.project.id,
|
||||||
|
slug: rootSession,
|
||||||
|
directory: tmp,
|
||||||
|
title: "root",
|
||||||
|
version: "test",
|
||||||
|
time_created: 1,
|
||||||
|
time_updated: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: nestedSession,
|
||||||
|
project_id: original.project.id,
|
||||||
|
slug: nestedSession,
|
||||||
|
directory: path.join(tmp, "packages", "app"),
|
||||||
|
path: "packages/app",
|
||||||
|
title: "nested",
|
||||||
|
version: "test",
|
||||||
|
time_created: 2,
|
||||||
|
time_updated: 2,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.run()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
yield* Effect.promise(() => $`mv ${tmp} ${moved}`.quiet())
|
||||||
|
|
||||||
|
const result = yield* project.fromDirectory(moved)
|
||||||
|
const sessions = yield* db
|
||||||
|
.select({ id: SessionTable.id, directory: SessionTable.directory, path: SessionTable.path })
|
||||||
|
.from(SessionTable)
|
||||||
|
.where(eq(SessionTable.project_id, original.project.id))
|
||||||
|
.all()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
const directories = yield* db
|
||||||
|
.select({ directory: ProjectDirectoryTable.directory })
|
||||||
|
.from(ProjectDirectoryTable)
|
||||||
|
.where(eq(ProjectDirectoryTable.project_id, original.project.id))
|
||||||
|
.all()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
|
||||||
|
expect(result.project.worktree).toBe(moved)
|
||||||
|
expect(result.project.sandboxes).not.toContain(tmp)
|
||||||
|
expect(result.project.sandboxes).not.toContain(moved)
|
||||||
|
expect(directories.map((item) => item.directory)).toEqual([AbsolutePath.make(moved)])
|
||||||
|
expect(sessions).toContainEqual({ id: rootSession, directory: moved, path: null })
|
||||||
|
expect(sessions).toContainEqual({
|
||||||
|
id: nestedSession,
|
||||||
|
directory: path.join(moved, "packages", "app"),
|
||||||
|
path: "packages/app",
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("omits projects whose primary checkout no longer exists", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const project = yield* Project.Service
|
||||||
|
const tmp = yield* tmpdirScoped({ git: true })
|
||||||
|
const original = yield* project.fromDirectory(tmp)
|
||||||
|
const moved = `${tmp}-moved`
|
||||||
|
yield* Effect.addFinalizer(() => Effect.promise(() => $`rm -rf ${moved}`.quiet().nothrow()).pipe(Effect.ignore))
|
||||||
|
yield* Effect.promise(() => $`mv ${tmp} ${moved}`.quiet())
|
||||||
|
|
||||||
|
const result = (yield* project.list()).find((item) => item.id === original.project.id)
|
||||||
|
|
||||||
|
expect(result).toBeUndefined()
|
||||||
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("Project.discover", () => {
|
describe("Project.discover", () => {
|
||||||
|
|||||||
@@ -1067,6 +1067,40 @@ const scenarios: Scenario[] = [
|
|||||||
headers: ctx.headers(),
|
headers: ctx.headers(),
|
||||||
}))
|
}))
|
||||||
.status(400, undefined, "none"),
|
.status(400, undefined, "none"),
|
||||||
|
http.protected
|
||||||
|
.get("/api/session/{sessionID}/history", "v2.session.history")
|
||||||
|
.seeded((ctx) => ctx.session({ title: "Session history" }))
|
||||||
|
.at((ctx) => ({
|
||||||
|
path: `${route("/api/session/{sessionID}/history", { sessionID: ctx.state.id })}?${new URLSearchParams({
|
||||||
|
after: "0",
|
||||||
|
limit: "2",
|
||||||
|
})}`,
|
||||||
|
headers: ctx.headers(),
|
||||||
|
}))
|
||||||
|
.json(
|
||||||
|
200,
|
||||||
|
(body) => {
|
||||||
|
object(body)
|
||||||
|
array(body.data)
|
||||||
|
check(typeof body.hasMore === "boolean", "Expected a history exhaustion signal")
|
||||||
|
},
|
||||||
|
"none",
|
||||||
|
),
|
||||||
|
http.protected
|
||||||
|
.get("/api/session/{sessionID}/history", "v2.session.history.missing")
|
||||||
|
.at((ctx) => ({
|
||||||
|
path: route("/api/session/{sessionID}/history", { sessionID: "ses_httpapi_missing" }),
|
||||||
|
headers: ctx.headers(),
|
||||||
|
}))
|
||||||
|
.json(404, object, "status"),
|
||||||
|
http.protected
|
||||||
|
.get("/api/session/{sessionID}/history", "v2.session.history.invalid")
|
||||||
|
.seeded((ctx) => ctx.session({ title: "Invalid history sequence" }))
|
||||||
|
.at((ctx) => ({
|
||||||
|
path: `${route("/api/session/{sessionID}/history", { sessionID: ctx.state.id })}?after=-1`,
|
||||||
|
headers: ctx.headers(),
|
||||||
|
}))
|
||||||
|
.json(400, object, "status"),
|
||||||
http.protected
|
http.protected
|
||||||
.get("/api/session/{sessionID}/event", "v2.session.events.missing")
|
.get("/api/session/{sessionID}/event", "v2.session.events.missing")
|
||||||
.at((ctx) => ({
|
.at((ctx) => ({
|
||||||
|
|||||||
@@ -27,13 +27,44 @@ const Event = Schema.Struct({
|
|||||||
data: Schema.Unknown,
|
data: Schema.Unknown,
|
||||||
})
|
})
|
||||||
|
|
||||||
async function readEvent(reader: ReadableStreamDefaultReader<Uint8Array>) {
|
async function* eventStream(body: ReadableStream<Uint8Array>) {
|
||||||
const value = await reader.read()
|
const reader = body.getReader()
|
||||||
if (value.done) throw new Error("event stream closed")
|
const decoder = new TextDecoder()
|
||||||
return Schema.decodeUnknownSync(Event)(JSON.parse(new TextDecoder().decode(value.value).replace(/^data: /, "")))
|
let buffer = ""
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const boundary = buffer.match(/(?:\r\n|\r|\n){2}/)
|
||||||
|
if (!boundary || boundary.index === undefined) {
|
||||||
|
const value = await reader.read()
|
||||||
|
if (value.done) return
|
||||||
|
buffer += decoder.decode(value.value, { stream: true })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = buffer.slice(0, boundary.index)
|
||||||
|
buffer = buffer.slice(boundary.index + boundary[0].length)
|
||||||
|
const data = record
|
||||||
|
.split(/\r\n|\r|\n/)
|
||||||
|
.filter((line) => line.startsWith("data:"))
|
||||||
|
.map((line) => line.slice(5).replace(/^ /, ""))
|
||||||
|
if (data.length) yield Schema.decodeUnknownSync(Event)(JSON.parse(data.join("\n")))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await reader.cancel()
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readEventType(reader: ReadableStreamDefaultReader<Uint8Array>, type: string) {
|
async function readEvent(reader: AsyncIterator<typeof Event.Type>) {
|
||||||
|
const value = await reader.next()
|
||||||
|
if (value.done) throw new Error("event stream closed")
|
||||||
|
return value.value
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readEventType(reader: AsyncIterator<typeof Event.Type>, type: string) {
|
||||||
for (let index = 0; index < 20; index++) {
|
for (let index = 0; index < 20; index++) {
|
||||||
const event = await readEvent(reader)
|
const event = await readEvent(reader)
|
||||||
if (event.type === type) return event
|
if (event.type === type) return event
|
||||||
@@ -78,7 +109,7 @@ describe("v2 location HttpApi", () => {
|
|||||||
await using subscriber = await tmpdir({ git: true })
|
await using subscriber = await tmpdir({ git: true })
|
||||||
await using publisher = await tmpdir({ git: true })
|
await using publisher = await tmpdir({ git: true })
|
||||||
const response = await request("/api/event", subscriber.path)
|
const response = await request("/api/event", subscriber.path)
|
||||||
const reader = response.body!.getReader()
|
const reader = eventStream(response.body!)
|
||||||
const connected = await readEvent(reader)
|
const connected = await readEvent(reader)
|
||||||
expect(connected.type).toBe("server.connected")
|
expect(connected.type).toBe("server.connected")
|
||||||
expect(connected.location).toBeUndefined()
|
expect(connected.location).toBeUndefined()
|
||||||
@@ -90,6 +121,6 @@ describe("v2 location HttpApi", () => {
|
|||||||
location: { directory: publisher.path },
|
location: { directory: publisher.path },
|
||||||
data: { sessionID: expect.any(String) },
|
data: { sessionID: expect.any(String) },
|
||||||
})
|
})
|
||||||
await reader.cancel()
|
await reader.return(undefined)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -115,10 +115,9 @@ const storedSession = {
|
|||||||
const openAIResponses = {
|
const openAIResponses = {
|
||||||
user: (text: string) => ({ role: "user", content: [{ type: "input_text", text }] }),
|
user: (text: string) => ({ role: "user", content: [{ type: "input_text", text }] }),
|
||||||
assistant: (text: string) => ({ role: "assistant", content: [{ type: "output_text", text }] }),
|
assistant: (text: string) => ({ role: "assistant", content: [{ type: "output_text", text }] }),
|
||||||
openaiReasoning: (text: string, options: { readonly itemId: string; readonly encryptedContent: string }) => ({
|
openaiReasoning: (text: string, encryptedContent: string) => ({
|
||||||
type: "reasoning",
|
type: "reasoning",
|
||||||
id: options.itemId,
|
encrypted_content: encryptedContent,
|
||||||
encrypted_content: options.encryptedContent,
|
|
||||||
summary: [{ type: "summary_text", text }],
|
summary: [{ type: "summary_text", text }],
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
@@ -657,10 +656,7 @@ describe("session.llm-native.request", () => {
|
|||||||
expectedBody: {
|
expectedBody: {
|
||||||
input: [
|
input: [
|
||||||
openAIResponses.user("What changed?"),
|
openAIResponses.user("What changed?"),
|
||||||
openAIResponses.openaiReasoning("Checked the previous diff.", {
|
openAIResponses.openaiReasoning("Checked the previous diff.", "encrypted-state"),
|
||||||
itemId: "rs_1",
|
|
||||||
encryptedContent: "encrypted-state",
|
|
||||||
}),
|
|
||||||
openAIResponses.assistant("The parser changed."),
|
openAIResponses.assistant("The parser changed."),
|
||||||
openAIResponses.user("Summarize it."),
|
openAIResponses.user("Summarize it."),
|
||||||
],
|
],
|
||||||
@@ -683,7 +679,7 @@ describe("session.llm-native.request", () => {
|
|||||||
],
|
],
|
||||||
providerOptions: { openai: { store: false, include: ["reasoning.encrypted_content"] } },
|
providerOptions: { openai: { store: false, include: ["reasoning.encrypted_content"] } },
|
||||||
expectedBody: {
|
expectedBody: {
|
||||||
input: [{ type: "reasoning", id: "rs_1", summary: [], encrypted_content: "encrypted-state" }],
|
input: [{ type: "reasoning", summary: [], encrypted_content: "encrypted-state" }],
|
||||||
include: ["reasoning.encrypted_content"],
|
include: ["reasoning.encrypted_content"],
|
||||||
store: false,
|
store: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { EventManifest } from "@opencode-ai/schema/event-manifest"
|
|||||||
import { Location } from "@opencode-ai/schema/location"
|
import { Location } from "@opencode-ai/schema/location"
|
||||||
import type { Definition } from "@opencode-ai/schema/event"
|
import type { Definition } from "@opencode-ai/schema/event"
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||||
|
|
||||||
const fields = {
|
const fields = {
|
||||||
id: Event.ID,
|
id: Event.ID,
|
||||||
@@ -12,15 +12,9 @@ const fields = {
|
|||||||
location: Schema.optional(Location.Ref),
|
location: Schema.optional(Location.Ref),
|
||||||
}
|
}
|
||||||
|
|
||||||
const schema = (definitions: ReadonlyArray<Definition>) =>
|
const schema = <const Definitions extends ReadonlyArray<Definition>>(definitions: Definitions) =>
|
||||||
Schema.Union([
|
Schema.Union([
|
||||||
...definitions.map((definition) =>
|
...definitions,
|
||||||
Schema.Struct({
|
|
||||||
...fields,
|
|
||||||
type: Schema.Literal(definition.type),
|
|
||||||
data: definition.data,
|
|
||||||
}).annotate({ identifier: `V2Event.${definition.type}` }),
|
|
||||||
),
|
|
||||||
...(definitions.some((definition) => definition.type === "server.connected")
|
...(definitions.some((definition) => definition.type === "server.connected")
|
||||||
? []
|
? []
|
||||||
: [
|
: [
|
||||||
@@ -32,14 +26,14 @@ const schema = (definitions: ReadonlyArray<Definition>) =>
|
|||||||
]),
|
]),
|
||||||
]).annotate({ identifier: "V2Event" })
|
]).annotate({ identifier: "V2Event" })
|
||||||
|
|
||||||
const make = (definitions: ReadonlyArray<Definition>) => {
|
const make = <const Definitions extends ReadonlyArray<Definition>>(definitions: Definitions) => {
|
||||||
const EventSchema = schema(definitions)
|
const EventSchema = schema(definitions)
|
||||||
return {
|
return {
|
||||||
schema: EventSchema,
|
schema: EventSchema,
|
||||||
group: HttpApiGroup.make("server.event")
|
group: HttpApiGroup.make("server.event")
|
||||||
.add(
|
.add(
|
||||||
HttpApiEndpoint.get("event.subscribe", "/api/event", {
|
HttpApiEndpoint.get("event.subscribe", "/api/event", {
|
||||||
success: EventSchema,
|
success: HttpApiSchema.StreamSse({ data: EventSchema }),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "v2.event.subscribe",
|
identifier: "v2.event.subscribe",
|
||||||
@@ -52,8 +46,11 @@ const make = (definitions: ReadonlyArray<Definition>) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const makeEventGroup = (definitions: ReadonlyArray<Definition>) => make(definitions).group
|
export const makeEventGroup = <const Definitions extends ReadonlyArray<Definition>>(definitions: Definitions) =>
|
||||||
|
make(definitions).group
|
||||||
|
|
||||||
const event = make(EventManifest.ServerDefinitions)
|
const event = make(EventManifest.ServerDefinitions)
|
||||||
export const EventGroup = event.group
|
export const EventGroup = event.group
|
||||||
export type Event = typeof event.schema.Type
|
export const OpenCodeEvent = event.schema
|
||||||
|
export type OpenCodeEvent = typeof OpenCodeEvent.Type
|
||||||
|
export type OpenCodeEventEncoded = typeof OpenCodeEvent.Encoded
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||||
import { SessionInput } from "@opencode-ai/schema/session-input"
|
import { SessionInput } from "@opencode-ai/schema/session-input"
|
||||||
import { Prompt } from "@opencode-ai/schema/prompt"
|
import { PromptInput } from "@opencode-ai/schema/prompt-input"
|
||||||
import { Session } from "@opencode-ai/schema/session"
|
import { Session } from "@opencode-ai/schema/session"
|
||||||
import { Project } from "@opencode-ai/schema/project"
|
import { Project } from "@opencode-ai/schema/project"
|
||||||
import { AbsolutePath, NonNegativeInt, PositiveInt, RelativePath, statics } from "@opencode-ai/schema/schema"
|
import { AbsolutePath, NonNegativeInt, PositiveInt, RelativePath, statics } from "@opencode-ai/schema/schema"
|
||||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||||
import { Context, Encoding, Result, Schema, Struct } from "effect"
|
import { Context, Effect, Encoding, Result, Schema, Struct } from "effect"
|
||||||
import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||||
import {
|
import {
|
||||||
ConflictError,
|
ConflictError,
|
||||||
@@ -60,6 +60,7 @@ const SessionsCursorInput = Schema.Union([
|
|||||||
const SessionsCursorJson = Schema.fromJsonString(SessionsCursorInput)
|
const SessionsCursorJson = Schema.fromJsonString(SessionsCursorInput)
|
||||||
const encodeSessionsCursor = Schema.encodeSync(SessionsCursorJson)
|
const encodeSessionsCursor = Schema.encodeSync(SessionsCursorJson)
|
||||||
const decodeSessionsCursor = Schema.decodeUnknownEffect(SessionsCursorJson)
|
const decodeSessionsCursor = Schema.decodeUnknownEffect(SessionsCursorJson)
|
||||||
|
const invalidCursor = "Invalid cursor" as const
|
||||||
|
|
||||||
export const SessionsCursor = Schema.String.pipe(
|
export const SessionsCursor = Schema.String.pipe(
|
||||||
Schema.brand("SessionsCursor"),
|
Schema.brand("SessionsCursor"),
|
||||||
@@ -67,7 +68,13 @@ export const SessionsCursor = Schema.String.pipe(
|
|||||||
const make = schema.make.bind(schema)
|
const make = schema.make.bind(schema)
|
||||||
return {
|
return {
|
||||||
make: (input: typeof SessionsCursorInput.Type) => make(Encoding.encodeBase64Url(encodeSessionsCursor(input))),
|
make: (input: typeof SessionsCursorInput.Type) => make(Encoding.encodeBase64Url(encodeSessionsCursor(input))),
|
||||||
parse: (input: string) => decodeSessionsCursor(Result.getOrThrow(Encoding.decodeBase64UrlString(input))),
|
parse: (input: string) =>
|
||||||
|
Effect.suspend(() => {
|
||||||
|
const result = Encoding.decodeBase64UrlString(input)
|
||||||
|
return Result.isFailure(result)
|
||||||
|
? Effect.fail(invalidCursor)
|
||||||
|
: decodeSessionsCursor(result.success).pipe(Effect.mapError(() => invalidCursor))
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -77,6 +84,13 @@ const SessionActive = Schema.Struct({
|
|||||||
type: Schema.Literal("running"),
|
type: Schema.Literal("running"),
|
||||||
}).annotate({ identifier: "SessionActive" })
|
}).annotate({ identifier: "SessionActive" })
|
||||||
|
|
||||||
|
const SessionHistoryLimit = PositiveInt.check(Schema.isLessThanOrEqualTo(100))
|
||||||
|
|
||||||
|
export const SessionHistoryQuery = Schema.Struct({
|
||||||
|
limit: Schema.NumberFromString.pipe(Schema.decodeTo(SessionHistoryLimit), Schema.optional),
|
||||||
|
after: Schema.NumberFromString.pipe(Schema.decodeTo(NonNegativeInt), Schema.optional),
|
||||||
|
})
|
||||||
|
|
||||||
const SessionsQueryCursor = SessionsCursor.annotate({
|
const SessionsQueryCursor = SessionsCursor.annotate({
|
||||||
description: "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response.",
|
description: "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response.",
|
||||||
})
|
})
|
||||||
@@ -192,7 +206,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
|||||||
params: { sessionID: Session.ID },
|
params: { sessionID: Session.ID },
|
||||||
payload: Schema.Struct({
|
payload: Schema.Struct({
|
||||||
id: SessionMessage.ID.pipe(Schema.optional),
|
id: SessionMessage.ID.pipe(Schema.optional),
|
||||||
prompt: Prompt,
|
prompt: PromptInput.Prompt,
|
||||||
delivery: SessionInput.Delivery.pipe(Schema.optional),
|
delivery: SessionInput.Delivery.pipe(Schema.optional),
|
||||||
resume: Schema.Boolean.pipe(Schema.optional),
|
resume: Schema.Boolean.pipe(Schema.optional),
|
||||||
}),
|
}),
|
||||||
@@ -289,6 +303,26 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
.add(
|
||||||
|
HttpApiEndpoint.get("session.history", "/api/session/:sessionID/history", {
|
||||||
|
params: { sessionID: Session.ID },
|
||||||
|
query: SessionHistoryQuery,
|
||||||
|
success: Schema.Struct({
|
||||||
|
data: Schema.Array(SessionEvent.Durable),
|
||||||
|
hasMore: Schema.Boolean,
|
||||||
|
}).annotate({ identifier: "SessionHistory" }),
|
||||||
|
error: SessionNotFoundError,
|
||||||
|
})
|
||||||
|
.middleware(sessionLocationMiddleware)
|
||||||
|
.annotateMerge(
|
||||||
|
OpenApi.annotations({
|
||||||
|
identifier: "v2.session.history",
|
||||||
|
summary: "Get session history",
|
||||||
|
description:
|
||||||
|
"Read one finite page of public durable Session events after an exclusive aggregate sequence. Newly committed events may appear on later pages.",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
.add(
|
.add(
|
||||||
HttpApiEndpoint.get("session.events", "/api/session/:sessionID/event", {
|
HttpApiEndpoint.get("session.events", "/api/session/:sessionID/event", {
|
||||||
params: { sessionID: Session.ID },
|
params: { sessionID: Session.ID },
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import { SessionsCursor } from "../src/groups/session"
|
import { SessionHistoryQuery, SessionsCursor } from "../src/groups/session"
|
||||||
import { Session } from "@opencode-ai/schema/session"
|
import { Session } from "@opencode-ai/schema/session"
|
||||||
|
|
||||||
describe("SessionsCursor", () => {
|
describe("SessionsCursor", () => {
|
||||||
@@ -16,3 +16,11 @@ describe("SessionsCursor", () => {
|
|||||||
expect(await Effect.runPromise(SessionsCursor.parse(cursor))).toEqual(input)
|
expect(await Effect.runPromise(SessionsCursor.parse(cursor))).toEqual(input)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("SessionHistoryQuery", () => {
|
||||||
|
test("decodes numeric paging inputs", async () => {
|
||||||
|
const query = await Effect.runPromise(Schema.decodeUnknownEffect(SessionHistoryQuery)({ after: "3", limit: "10" }))
|
||||||
|
|
||||||
|
expect(query).toEqual({ after: 3, limit: 10 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import { Event } from "./event"
|
|||||||
import { SessionEvent } from "./session-event"
|
import { SessionEvent } from "./session-event"
|
||||||
import { SessionV1 } from "./session-v1"
|
import { SessionV1 } from "./session-v1"
|
||||||
|
|
||||||
|
export const SessionDurable = {
|
||||||
|
definitions: Event.durable(SessionEvent.DurableDefinitions),
|
||||||
|
schema: SessionEvent.Durable,
|
||||||
|
} as const
|
||||||
|
|
||||||
export const Durable = Event.durable([
|
export const Durable = Event.durable([
|
||||||
...SessionV1.Event.Definitions.filter((definition) => definition.durable !== undefined),
|
...SessionV1.Event.Definitions.filter((definition) => definition.durable !== undefined),
|
||||||
...SessionEvent.DurableDefinitions,
|
...SessionEvent.DurableDefinitions,
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export function define<
|
|||||||
id: ID,
|
id: ID,
|
||||||
metadata: optional(Schema.Record(Schema.String, Schema.Unknown)),
|
metadata: optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||||
type: Schema.Literal(input.type),
|
type: Schema.Literal(input.type),
|
||||||
durable: optional(Schema.Struct({ aggregateID: Schema.String, seq: Schema.Number, version: Schema.Number })),
|
durable: optional(Schema.Struct({ aggregateID: Schema.String, seq: Schema.Int, version: Schema.Int })),
|
||||||
location: optional(Location.Ref),
|
location: optional(Location.Ref),
|
||||||
data,
|
data,
|
||||||
})
|
})
|
||||||
@@ -95,7 +95,7 @@ export function versionedType(type: string, version: number) {
|
|||||||
return `${type}.${version}`
|
return `${type}.${version}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function durable(definitions: ReadonlyArray<Definition>) {
|
export function durable<const Definitions extends ReadonlyArray<Definition>>(definitions: Definitions) {
|
||||||
return readonlyMap(
|
return readonlyMap(
|
||||||
definitions.reduce((result, definition) => {
|
definitions.reduce((result, definition) => {
|
||||||
if (!definition.durable) return result
|
if (!definition.durable) return result
|
||||||
@@ -103,7 +103,7 @@ export function durable(definitions: ReadonlyArray<Definition>) {
|
|||||||
if (result.has(key)) throw new Error(`Duplicate durable event definition for ${key}`)
|
if (result.has(key)) throw new Error(`Duplicate durable event definition for ${key}`)
|
||||||
result.set(key, definition)
|
result.set(key, definition)
|
||||||
return result
|
return result
|
||||||
}, new Map<string, Definition>()),
|
}, new Map<string, Definitions[number]>()),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,4 +24,5 @@ export { PtyTicket } from "./pty-ticket"
|
|||||||
export { Question } from "./question"
|
export { Question } from "./question"
|
||||||
export { Workspace } from "./workspace"
|
export { Workspace } from "./workspace"
|
||||||
export { Prompt, Source, FileAttachment, AgentAttachment } from "./prompt"
|
export { Prompt, Source, FileAttachment, AgentAttachment } from "./prompt"
|
||||||
|
export { PromptInput } from "./prompt-input"
|
||||||
export * from "./schema"
|
export * from "./schema"
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
export * as PromptInput from "./prompt-input"
|
||||||
|
|
||||||
|
import { Schema } from "effect"
|
||||||
|
import { AgentAttachment, Source } from "./prompt"
|
||||||
|
import { optional, statics } from "./schema"
|
||||||
|
|
||||||
|
export interface FileAttachment extends Schema.Schema.Type<typeof FileAttachment> {}
|
||||||
|
export const FileAttachment = Schema.Struct({
|
||||||
|
uri: Schema.String,
|
||||||
|
name: Schema.String.pipe(optional),
|
||||||
|
description: Schema.String.pipe(optional),
|
||||||
|
source: Source.pipe(optional),
|
||||||
|
})
|
||||||
|
.annotate({ identifier: "PromptInput.FileAttachment" })
|
||||||
|
.pipe(
|
||||||
|
statics((schema) => ({
|
||||||
|
create: (input: FileAttachment) => schema.make(input),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface Prompt extends Schema.Schema.Type<typeof Prompt> {}
|
||||||
|
export const Prompt = Schema.Struct({
|
||||||
|
text: Schema.String,
|
||||||
|
files: Schema.Array(FileAttachment).pipe(optional),
|
||||||
|
agents: Schema.Array(AgentAttachment).pipe(optional),
|
||||||
|
}).annotate({ identifier: "PromptInput" })
|
||||||
@@ -511,7 +511,9 @@ export const Definitions = Event.inventory(
|
|||||||
RevertEvent.Committed,
|
RevertEvent.Committed,
|
||||||
)
|
)
|
||||||
|
|
||||||
export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" }).pipe(Schema.toTaggedUnion("type"))
|
export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" })
|
||||||
|
.pipe(Schema.toTaggedUnion("type"))
|
||||||
|
.annotate({ identifier: "SessionDurableEvent" })
|
||||||
export type DurableEvent = typeof Durable.Type
|
export type DurableEvent = typeof Durable.Type
|
||||||
|
|
||||||
export const All = Schema.Union(Definitions, { mode: "oneOf" }).pipe(Schema.toTaggedUnion("type"))
|
export const All = Schema.Union(Definitions, { mode: "oneOf" }).pipe(Schema.toTaggedUnion("type"))
|
||||||
|
|||||||
@@ -14,3 +14,4 @@ export {
|
|||||||
SessionInput,
|
SessionInput,
|
||||||
SessionMessage,
|
SessionMessage,
|
||||||
} from "@opencode-ai/client/effect"
|
} from "@opencode-ai/client/effect"
|
||||||
|
export type { OpenCodeEvent } from "@opencode-ai/client/effect"
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { mkdtemp, rm } from "node:fs/promises"
|
|||||||
import { tmpdir } from "node:os"
|
import { tmpdir } from "node:os"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
import { Effect, Option, Schema, Stream } from "effect"
|
import { Deferred, Effect, Latch, Option, Schema, Stream } from "effect"
|
||||||
|
import type { OpenCodeEvent } from "../src"
|
||||||
|
|
||||||
test("embedded client uses the real router and handlers", async () => {
|
test("embedded client uses the real router and handlers", async () => {
|
||||||
const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-"))
|
const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-"))
|
||||||
@@ -103,6 +104,88 @@ test("embedded client uses the real router and handlers", async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("Location-owned runner events reach the ready global client", async () => {
|
||||||
|
const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-events-"))
|
||||||
|
const database = Flag.OPENCODE_DB
|
||||||
|
Flag.OPENCODE_DB = join(directory, "opencode.sqlite")
|
||||||
|
const { AbsolutePath, Location, OpenCode, Prompt, Session } = await import("../src")
|
||||||
|
const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const program = Effect.gen(function* () {
|
||||||
|
const opencode = yield* OpenCode.create()
|
||||||
|
const connected = yield* Latch.make(false)
|
||||||
|
const prompted = yield* Deferred.make<OpenCodeEvent>()
|
||||||
|
yield* opencode.events.subscribe().pipe(
|
||||||
|
Stream.runForEach((event) =>
|
||||||
|
event.type === "server.connected"
|
||||||
|
? connected.open
|
||||||
|
: event.type === "session.next.prompted" && event.data.sessionID === sessionID
|
||||||
|
? Deferred.succeed(prompted, event).pipe(Effect.asVoid)
|
||||||
|
: Effect.void,
|
||||||
|
),
|
||||||
|
Effect.forkScoped,
|
||||||
|
)
|
||||||
|
yield* connected.await
|
||||||
|
yield* opencode.sessions.create({
|
||||||
|
id: sessionID,
|
||||||
|
location: Location.Ref.make({ directory: AbsolutePath.make(directory) }),
|
||||||
|
})
|
||||||
|
yield* opencode.sessions.prompt({ sessionID, prompt: Prompt.make({ text: "Observe this input" }) })
|
||||||
|
|
||||||
|
const event = yield* Deferred.await(prompted).pipe(Effect.timeout("4 seconds"))
|
||||||
|
expect(event.durable).toEqual(expect.objectContaining({ aggregateID: sessionID, seq: expect.any(Number) }))
|
||||||
|
})
|
||||||
|
await Effect.runPromise(Effect.scoped(program))
|
||||||
|
} finally {
|
||||||
|
Flag.OPENCODE_DB = database
|
||||||
|
await rm(directory, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
}, 10_000)
|
||||||
|
|
||||||
|
test("independent embedded hosts do not share live notifications", async () => {
|
||||||
|
const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-hosts-"))
|
||||||
|
const database = Flag.OPENCODE_DB
|
||||||
|
Flag.OPENCODE_DB = join(directory, "opencode.sqlite")
|
||||||
|
const { AbsolutePath, Agent, Location, OpenCode, Session } = await import("../src")
|
||||||
|
const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const program = Effect.gen(function* () {
|
||||||
|
const first = yield* OpenCode.create()
|
||||||
|
const second = yield* OpenCode.create()
|
||||||
|
const firstReady = yield* Latch.make(false)
|
||||||
|
const secondReady = yield* Latch.make(false)
|
||||||
|
const firstEvent = yield* Latch.make(false)
|
||||||
|
const secondEvent = yield* Latch.make(false)
|
||||||
|
const observe = (ready: Latch.Latch, event: Latch.Latch) =>
|
||||||
|
Stream.runForEach((notification: OpenCodeEvent) =>
|
||||||
|
notification.type === "server.connected"
|
||||||
|
? ready.open
|
||||||
|
: notification.type === "session.next.agent.switched" && notification.data.sessionID === sessionID
|
||||||
|
? event.open
|
||||||
|
: Effect.void,
|
||||||
|
)
|
||||||
|
|
||||||
|
yield* first.events.subscribe().pipe(observe(firstReady, firstEvent), Effect.forkScoped)
|
||||||
|
yield* second.events.subscribe().pipe(observe(secondReady, secondEvent), Effect.forkScoped)
|
||||||
|
yield* Effect.all([firstReady.await, secondReady.await], { discard: true })
|
||||||
|
yield* first.sessions.create({
|
||||||
|
id: sessionID,
|
||||||
|
location: Location.Ref.make({ directory: AbsolutePath.make(directory) }),
|
||||||
|
})
|
||||||
|
yield* first.sessions.switchAgent({ sessionID, agent: Agent.ID.make("plan") })
|
||||||
|
|
||||||
|
yield* firstEvent.await.pipe(Effect.timeout("2 seconds"))
|
||||||
|
expect(Option.isNone(yield* secondEvent.await.pipe(Effect.timeoutOption("100 millis")))).toBe(true)
|
||||||
|
})
|
||||||
|
await Effect.runPromise(Effect.scoped(program))
|
||||||
|
} finally {
|
||||||
|
Flag.OPENCODE_DB = database
|
||||||
|
await rm(directory, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
}, 10_000)
|
||||||
|
|
||||||
test("embedded client is available as a Layer service", async () => {
|
test("embedded client is available as a Layer service", async () => {
|
||||||
const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-layer-"))
|
const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-layer-"))
|
||||||
const database = Flag.OPENCODE_DB
|
const database = Flag.OPENCODE_DB
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"test": "bun test",
|
||||||
"typecheck": "tsgo --noEmit",
|
"typecheck": "tsgo --noEmit",
|
||||||
"build": "bun ./script/build.ts"
|
"build": "bun ./script/build.ts"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,6 +13,37 @@ const opencode = path.resolve(dir, "../../opencode")
|
|||||||
|
|
||||||
await $`bun dev generate > ${dir}/openapi.json`.cwd(opencode)
|
await $`bun dev generate > ${dir}/openapi.json`.cwd(opencode)
|
||||||
|
|
||||||
|
const document = (await Bun.file("./openapi.json").json()) as {
|
||||||
|
components?: { schemas?: Record<string, unknown> }
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
const schemas = document.components?.schemas
|
||||||
|
if (schemas) {
|
||||||
|
const reachable = new Set<string>()
|
||||||
|
const visit = (value: unknown) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value.forEach(visit)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (typeof value !== "object" || value === null) return
|
||||||
|
for (const [key, child] of Object.entries(value)) {
|
||||||
|
if (key === "$ref" && typeof child === "string" && child.startsWith("#/components/schemas/")) {
|
||||||
|
const name = child.slice("#/components/schemas/".length)
|
||||||
|
if (reachable.has(name)) continue
|
||||||
|
reachable.add(name)
|
||||||
|
visit(schemas[name])
|
||||||
|
} else {
|
||||||
|
visit(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visit({ ...document, components: { ...document.components, schemas: undefined } })
|
||||||
|
for (const name of Object.keys(schemas)) {
|
||||||
|
if (/^SessionNext\w+1$/.test(name) && !reachable.has(name)) delete schemas[name]
|
||||||
|
}
|
||||||
|
await Bun.write("./openapi.json", JSON.stringify(document))
|
||||||
|
}
|
||||||
|
|
||||||
await createClient({
|
await createClient({
|
||||||
input: "./openapi.json",
|
input: "./openapi.json",
|
||||||
output: {
|
output: {
|
||||||
@@ -40,6 +71,29 @@ await createClient({
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const generatedTypes = await Bun.file("./src/v2/gen/types.gen.ts").text()
|
||||||
|
if (/export type SessionNext\w+1 =/.test(generatedTypes)) {
|
||||||
|
throw new Error("Session history generated duplicate Session event variants")
|
||||||
|
}
|
||||||
|
const historyTypesPatched = generatedTypes.replace(
|
||||||
|
/(export type V2SessionHistoryData = \{[\s\S]*?query\?: \{\s*limit\?: )string([;,]\s*after\?: )string/,
|
||||||
|
"$1number$2number",
|
||||||
|
)
|
||||||
|
if (historyTypesPatched === generatedTypes) {
|
||||||
|
throw new Error("Session history numeric query patch did not apply")
|
||||||
|
}
|
||||||
|
await Bun.write("./src/v2/gen/types.gen.ts", historyTypesPatched)
|
||||||
|
|
||||||
|
const generatedSdk = await Bun.file("./src/v2/gen/sdk.gen.ts").text()
|
||||||
|
const historySdkPatched = generatedSdk.replace(
|
||||||
|
/(Get session history[\s\S]*?parameters: \{\s*sessionID: string[;,]\s*limit\?: )string([;,]\s*after\?: )string/,
|
||||||
|
"$1number$2number",
|
||||||
|
)
|
||||||
|
if (historySdkPatched === generatedSdk) {
|
||||||
|
throw new Error("Session history numeric SDK patch did not apply")
|
||||||
|
}
|
||||||
|
await Bun.write("./src/v2/gen/sdk.gen.ts", historySdkPatched)
|
||||||
|
|
||||||
// Patch a @hey-api/openapi-ts codegen bug: SseFn incorrectly passes the
|
// Patch a @hey-api/openapi-ts codegen bug: SseFn incorrectly passes the
|
||||||
// endpoint's TError into the second generic of ServerSentEventsResult, which
|
// endpoint's TError into the second generic of ServerSentEventsResult, which
|
||||||
// is the AsyncGenerator's TReturn slot. Iterator return values have nothing
|
// is the AsyncGenerator's TReturn slot. Iterator return values have nothing
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ import type {
|
|||||||
ProjectListResponses,
|
ProjectListResponses,
|
||||||
ProjectUpdateErrors,
|
ProjectUpdateErrors,
|
||||||
ProjectUpdateResponses,
|
ProjectUpdateResponses,
|
||||||
Prompt,
|
PromptInput,
|
||||||
ProviderAuthErrors,
|
ProviderAuthErrors,
|
||||||
ProviderAuthResponses,
|
ProviderAuthResponses,
|
||||||
ProviderListErrors,
|
ProviderListErrors,
|
||||||
@@ -345,6 +345,8 @@ import type {
|
|||||||
V2SessionEventsResponses,
|
V2SessionEventsResponses,
|
||||||
V2SessionGetErrors,
|
V2SessionGetErrors,
|
||||||
V2SessionGetResponses,
|
V2SessionGetResponses,
|
||||||
|
V2SessionHistoryErrors,
|
||||||
|
V2SessionHistoryResponses,
|
||||||
V2SessionInterruptErrors,
|
V2SessionInterruptErrors,
|
||||||
V2SessionInterruptResponses,
|
V2SessionInterruptResponses,
|
||||||
V2SessionListErrors,
|
V2SessionListErrors,
|
||||||
@@ -5621,7 +5623,7 @@ export class Session3 extends HeyApiClient {
|
|||||||
parameters: {
|
parameters: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
id?: string
|
id?: string
|
||||||
prompt?: Prompt
|
prompt?: PromptInput
|
||||||
delivery?: "steer" | "queue"
|
delivery?: "steer" | "queue"
|
||||||
resume?: boolean
|
resume?: boolean
|
||||||
},
|
},
|
||||||
@@ -5710,6 +5712,38 @@ export class Session3 extends HeyApiClient {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get session history
|
||||||
|
*
|
||||||
|
* Read one finite page of public durable Session events after an exclusive aggregate sequence. Newly committed events may appear on later pages.
|
||||||
|
*/
|
||||||
|
public history<ThrowOnError extends boolean = false>(
|
||||||
|
parameters: {
|
||||||
|
sessionID: string
|
||||||
|
limit?: number
|
||||||
|
after?: number
|
||||||
|
},
|
||||||
|
options?: Options<never, ThrowOnError>,
|
||||||
|
) {
|
||||||
|
const params = buildClientParams(
|
||||||
|
[parameters],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
args: [
|
||||||
|
{ in: "path", key: "sessionID" },
|
||||||
|
{ in: "query", key: "limit" },
|
||||||
|
{ in: "query", key: "after" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return (options?.client ?? this.client).get<V2SessionHistoryResponses, V2SessionHistoryErrors, ThrowOnError>({
|
||||||
|
url: "/api/session/{sessionID}/history",
|
||||||
|
...options,
|
||||||
|
...params,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscribe to session events
|
* Subscribe to session events
|
||||||
*
|
*
|
||||||
|
|||||||
+570
-1079
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
|||||||
|
import { expect, test } from "bun:test"
|
||||||
|
import type { V2SessionHistoryData } from "../src/v2/gen/types.gen"
|
||||||
|
|
||||||
|
test("uses numeric Session history positions", () => {
|
||||||
|
const input = {
|
||||||
|
path: { sessionID: "ses_test" },
|
||||||
|
query: { after: 1, limit: 50 },
|
||||||
|
url: "/api/session/{sessionID}/history",
|
||||||
|
} satisfies V2SessionHistoryData
|
||||||
|
|
||||||
|
expect(input.query.after).toBe(1)
|
||||||
|
})
|
||||||
+1386
-3853
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,19 @@
|
|||||||
import { EventV2 } from "@opencode-ai/core/event"
|
import { EventV2 } from "@opencode-ai/core/event"
|
||||||
import { Effect, Stream } from "effect"
|
import { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
|
||||||
|
import { Effect, Schema, Stream } from "effect"
|
||||||
import { HttpServerResponse } from "effect/unstable/http"
|
import { HttpServerResponse } from "effect/unstable/http"
|
||||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||||
import * as Sse from "effect/unstable/encoding/Sse"
|
import * as Sse from "effect/unstable/encoding/Sse"
|
||||||
import { Api } from "../api"
|
import { Api } from "../api"
|
||||||
|
|
||||||
|
const subscriberCapacity = 256
|
||||||
|
|
||||||
function eventData(data: unknown): Sse.Event {
|
function eventData(data: unknown): Sse.Event {
|
||||||
return {
|
return {
|
||||||
_tag: "Event",
|
_tag: "Event",
|
||||||
event: "message",
|
event: "message",
|
||||||
id: undefined,
|
id: undefined,
|
||||||
data: JSON.stringify(data),
|
data: JSON.stringify(Schema.encodeUnknownSync(OpenCodeEvent)(data)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,13 +27,16 @@ export const EventHandler = HttpApiBuilder.group(Api, "server.event", (handlers)
|
|||||||
type: "server.connected",
|
type: "server.connected",
|
||||||
data: {},
|
data: {},
|
||||||
}
|
}
|
||||||
|
const output = Stream.unwrap(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
// Acquiring the bounded stream installs its listener before readiness is observable.
|
||||||
|
const live = yield* EventV2.allBounded(events, subscriberCapacity)
|
||||||
|
return Stream.make(connected).pipe(Stream.concat(live))
|
||||||
|
}),
|
||||||
|
).pipe(Stream.map(eventData), Stream.pipeThroughChannel(Sse.encode()))
|
||||||
|
const heartbeat = Stream.tick("15 seconds").pipe(Stream.map(() => ": heartbeat\n\n"))
|
||||||
return HttpServerResponse.stream(
|
return HttpServerResponse.stream(
|
||||||
Stream.make(connected).pipe(
|
output.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }), Stream.encodeText),
|
||||||
Stream.concat(events.all()),
|
|
||||||
Stream.map(eventData),
|
|
||||||
Stream.pipeThroughChannel(Sse.encode()),
|
|
||||||
Stream.encodeText,
|
|
||||||
),
|
|
||||||
{
|
{
|
||||||
contentType: "text/event-stream",
|
contentType: "text/event-stream",
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
|
|
||||||
const DefaultSessionsLimit = 50
|
const DefaultSessionsLimit = 50
|
||||||
|
const DefaultSessionHistoryLimit = 50
|
||||||
|
|
||||||
export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) =>
|
export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -328,6 +329,31 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
.handle(
|
||||||
|
"session.history",
|
||||||
|
Effect.fn(function* (ctx) {
|
||||||
|
return yield* session
|
||||||
|
.history({
|
||||||
|
sessionID: ctx.params.sessionID,
|
||||||
|
after: ctx.query.after,
|
||||||
|
limit: ctx.query.limit ?? DefaultSessionHistoryLimit,
|
||||||
|
})
|
||||||
|
.pipe(
|
||||||
|
Effect.map((page) => ({
|
||||||
|
data: page.events,
|
||||||
|
hasMore: page.hasMore,
|
||||||
|
})),
|
||||||
|
Effect.catchTag(
|
||||||
|
"Session.NotFoundError",
|
||||||
|
(error) =>
|
||||||
|
new SessionNotFoundError({
|
||||||
|
sessionID: error.sessionID,
|
||||||
|
message: `Session not found: ${error.sessionID}`,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
.handle(
|
.handle(
|
||||||
"session.events",
|
"session.events",
|
||||||
Effect.fn((ctx) =>
|
Effect.fn((ctx) =>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export function inlineCodeKind(text: string): "path" | "url" | undefined {
|
export function inlineCodeKind(text: string): "path" | "url" | undefined {
|
||||||
if (/^https?:\/\//i.test(text)) return "url"
|
if (/^https?:\/\//i.test(text)) return "url"
|
||||||
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(text)) return
|
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(text)) return
|
||||||
|
if (text === "/") return
|
||||||
if (/^\/[a-z][a-z0-9-]*$/i.test(text)) return
|
if (/^\/[a-z][a-z0-9-]*$/i.test(text)) return
|
||||||
if (/\s/.test(text)) return
|
if (/\s/.test(text)) return
|
||||||
if (/[()\[\]{}*+=<>|&^"';]/.test(text)) return
|
if (/[()\[\]{}*+=<>|&^"';]/.test(text)) return
|
||||||
|
|||||||
@@ -240,6 +240,7 @@ const en = {
|
|||||||
"model.noPeersDescription": "Peer rankings appear after usage lands.",
|
"model.noPeersDescription": "Peer rankings appear after usage lands.",
|
||||||
"model.noUsageLastWeek": "No usage last week",
|
"model.noUsageLastWeek": "No usage last week",
|
||||||
"model.newThisWeek": "New this week",
|
"model.newThisWeek": "New this week",
|
||||||
|
"model.sameAsPreviousWeek": "Same as previous week",
|
||||||
"model.vsPreviousWeek": "{{change}} vs previous week",
|
"model.vsPreviousWeek": "{{change}} vs previous week",
|
||||||
"model.pdf": "PDF",
|
"model.pdf": "PDF",
|
||||||
"format.users": "users",
|
"format.users": "users",
|
||||||
|
|||||||
@@ -221,6 +221,7 @@ export const dict = {
|
|||||||
"model.noPeersDescription": "تظهر ترتيبات النماذج المشابهة بعد وصول الاستخدام.",
|
"model.noPeersDescription": "تظهر ترتيبات النماذج المشابهة بعد وصول الاستخدام.",
|
||||||
"model.noUsageLastWeek": "لا يوجد استخدام الأسبوع الماضي",
|
"model.noUsageLastWeek": "لا يوجد استخدام الأسبوع الماضي",
|
||||||
"model.newThisWeek": "جديد هذا الأسبوع",
|
"model.newThisWeek": "جديد هذا الأسبوع",
|
||||||
|
"model.sameAsPreviousWeek": "دون تغيير عن الأسبوع السابق",
|
||||||
"model.vsPreviousWeek": "{{change}} مقارنة بالأسبوع السابق",
|
"model.vsPreviousWeek": "{{change}} مقارنة بالأسبوع السابق",
|
||||||
"model.pdf": "PDF",
|
"model.pdf": "PDF",
|
||||||
"format.users": "مستخدمون",
|
"format.users": "مستخدمون",
|
||||||
|
|||||||
@@ -223,6 +223,7 @@ export const dict = {
|
|||||||
"model.noPeersDescription": "Os rankings de pares aparecem depois que o uso chega.",
|
"model.noPeersDescription": "Os rankings de pares aparecem depois que o uso chega.",
|
||||||
"model.noUsageLastWeek": "Sem uso na semana passada",
|
"model.noUsageLastWeek": "Sem uso na semana passada",
|
||||||
"model.newThisWeek": "Novo esta semana",
|
"model.newThisWeek": "Novo esta semana",
|
||||||
|
"model.sameAsPreviousWeek": "Igual à semana anterior",
|
||||||
"model.vsPreviousWeek": "{{change}} vs semana anterior",
|
"model.vsPreviousWeek": "{{change}} vs semana anterior",
|
||||||
"model.pdf": "PDF",
|
"model.pdf": "PDF",
|
||||||
"format.users": "usuários",
|
"format.users": "usuários",
|
||||||
|
|||||||
@@ -222,6 +222,7 @@ export const dict = {
|
|||||||
"model.noPeersDescription": "Ranglister over lignende modeller vises, når brug lander.",
|
"model.noPeersDescription": "Ranglister over lignende modeller vises, når brug lander.",
|
||||||
"model.noUsageLastWeek": "Ingen brug sidste uge",
|
"model.noUsageLastWeek": "Ingen brug sidste uge",
|
||||||
"model.newThisWeek": "Ny denne uge",
|
"model.newThisWeek": "Ny denne uge",
|
||||||
|
"model.sameAsPreviousWeek": "Samme som forrige uge",
|
||||||
"model.vsPreviousWeek": "{{change}} vs forrige uge",
|
"model.vsPreviousWeek": "{{change}} vs forrige uge",
|
||||||
"model.pdf": "PDF",
|
"model.pdf": "PDF",
|
||||||
"format.users": "brugere",
|
"format.users": "brugere",
|
||||||
|
|||||||
@@ -223,6 +223,7 @@ export const dict = {
|
|||||||
"model.noPeersDescription": "Vergleichsrankings erscheinen, nachdem Nutzung eingegangen ist.",
|
"model.noPeersDescription": "Vergleichsrankings erscheinen, nachdem Nutzung eingegangen ist.",
|
||||||
"model.noUsageLastWeek": "Keine Nutzung letzte Woche",
|
"model.noUsageLastWeek": "Keine Nutzung letzte Woche",
|
||||||
"model.newThisWeek": "Neu diese Woche",
|
"model.newThisWeek": "Neu diese Woche",
|
||||||
|
"model.sameAsPreviousWeek": "Unverändert zur vorherigen Woche",
|
||||||
"model.vsPreviousWeek": "{{change}} ggü. vorheriger Woche",
|
"model.vsPreviousWeek": "{{change}} ggü. vorheriger Woche",
|
||||||
"model.pdf": "PDF",
|
"model.pdf": "PDF",
|
||||||
"format.users": "Nutzer",
|
"format.users": "Nutzer",
|
||||||
|
|||||||
@@ -222,6 +222,7 @@ export const dict = {
|
|||||||
"model.noPeersDescription": "Las clasificaciones de modelos similares aparecen después de que llegue uso.",
|
"model.noPeersDescription": "Las clasificaciones de modelos similares aparecen después de que llegue uso.",
|
||||||
"model.noUsageLastWeek": "Sin uso la semana pasada",
|
"model.noUsageLastWeek": "Sin uso la semana pasada",
|
||||||
"model.newThisWeek": "Nuevo esta semana",
|
"model.newThisWeek": "Nuevo esta semana",
|
||||||
|
"model.sameAsPreviousWeek": "Igual que la semana anterior",
|
||||||
"model.vsPreviousWeek": "{{change}} vs semana anterior",
|
"model.vsPreviousWeek": "{{change}} vs semana anterior",
|
||||||
"model.pdf": "PDF",
|
"model.pdf": "PDF",
|
||||||
"format.users": "usuarios",
|
"format.users": "usuarios",
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ export const dict = {
|
|||||||
"model.noPeersDescription": "Les classements de modèles proches apparaissent après l'arrivée de l'utilisation.",
|
"model.noPeersDescription": "Les classements de modèles proches apparaissent après l'arrivée de l'utilisation.",
|
||||||
"model.noUsageLastWeek": "Aucune utilisation la semaine dernière",
|
"model.noUsageLastWeek": "Aucune utilisation la semaine dernière",
|
||||||
"model.newThisWeek": "Nouveau cette semaine",
|
"model.newThisWeek": "Nouveau cette semaine",
|
||||||
|
"model.sameAsPreviousWeek": "Identique à la semaine précédente",
|
||||||
"model.vsPreviousWeek": "{{change}} vs semaine précédente",
|
"model.vsPreviousWeek": "{{change}} vs semaine précédente",
|
||||||
"model.pdf": "PDF",
|
"model.pdf": "PDF",
|
||||||
"format.users": "utilisateurs",
|
"format.users": "utilisateurs",
|
||||||
|
|||||||
@@ -223,6 +223,7 @@ export const dict = {
|
|||||||
"model.noPeersDescription": "Le classifiche dei modelli simili appaiono dopo l'arrivo dell'utilizzo.",
|
"model.noPeersDescription": "Le classifiche dei modelli simili appaiono dopo l'arrivo dell'utilizzo.",
|
||||||
"model.noUsageLastWeek": "Nessun utilizzo la scorsa settimana",
|
"model.noUsageLastWeek": "Nessun utilizzo la scorsa settimana",
|
||||||
"model.newThisWeek": "Nuovo questa settimana",
|
"model.newThisWeek": "Nuovo questa settimana",
|
||||||
|
"model.sameAsPreviousWeek": "Uguale alla settimana precedente",
|
||||||
"model.vsPreviousWeek": "{{change}} vs settimana precedente",
|
"model.vsPreviousWeek": "{{change}} vs settimana precedente",
|
||||||
"model.pdf": "PDF",
|
"model.pdf": "PDF",
|
||||||
"format.users": "utenti",
|
"format.users": "utenti",
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ export const dict = {
|
|||||||
"model.noPeersDescription": "使用量が届くと類似モデルのランキングが表示されます。",
|
"model.noPeersDescription": "使用量が届くと類似モデルのランキングが表示されます。",
|
||||||
"model.noUsageLastWeek": "先週の使用量なし",
|
"model.noUsageLastWeek": "先週の使用量なし",
|
||||||
"model.newThisWeek": "今週新規",
|
"model.newThisWeek": "今週新規",
|
||||||
|
"model.sameAsPreviousWeek": "前週と同じ",
|
||||||
"model.vsPreviousWeek": "前週比 {{change}}",
|
"model.vsPreviousWeek": "前週比 {{change}}",
|
||||||
"model.pdf": "PDF",
|
"model.pdf": "PDF",
|
||||||
"format.users": "ユーザー",
|
"format.users": "ユーザー",
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ export const dict = {
|
|||||||
"model.noPeersDescription": "사용량이 들어오면 비슷한 모델 순위가 표시됩니다.",
|
"model.noPeersDescription": "사용량이 들어오면 비슷한 모델 순위가 표시됩니다.",
|
||||||
"model.noUsageLastWeek": "지난주 사용량 없음",
|
"model.noUsageLastWeek": "지난주 사용량 없음",
|
||||||
"model.newThisWeek": "이번 주 신규",
|
"model.newThisWeek": "이번 주 신규",
|
||||||
|
"model.sameAsPreviousWeek": "지난주와 동일",
|
||||||
"model.vsPreviousWeek": "지난주 대비 {{change}}",
|
"model.vsPreviousWeek": "지난주 대비 {{change}}",
|
||||||
"model.pdf": "PDF",
|
"model.pdf": "PDF",
|
||||||
"format.users": "사용자",
|
"format.users": "사용자",
|
||||||
|
|||||||
@@ -222,6 +222,7 @@ export const dict = {
|
|||||||
"model.noPeersDescription": "Rangeringer for lignende modeller vises etter at bruk lander.",
|
"model.noPeersDescription": "Rangeringer for lignende modeller vises etter at bruk lander.",
|
||||||
"model.noUsageLastWeek": "Ingen bruk forrige uke",
|
"model.noUsageLastWeek": "Ingen bruk forrige uke",
|
||||||
"model.newThisWeek": "Ny denne uken",
|
"model.newThisWeek": "Ny denne uken",
|
||||||
|
"model.sameAsPreviousWeek": "Samme som forrige uke",
|
||||||
"model.vsPreviousWeek": "{{change}} mot forrige uke",
|
"model.vsPreviousWeek": "{{change}} mot forrige uke",
|
||||||
"model.pdf": "PDF",
|
"model.pdf": "PDF",
|
||||||
"format.users": "brukere",
|
"format.users": "brukere",
|
||||||
|
|||||||
@@ -221,6 +221,7 @@ export const dict = {
|
|||||||
"model.noPeersDescription": "Rankingi podobnych modeli pojawią się po nadejściu użycia.",
|
"model.noPeersDescription": "Rankingi podobnych modeli pojawią się po nadejściu użycia.",
|
||||||
"model.noUsageLastWeek": "Brak użycia w zeszłym tygodniu",
|
"model.noUsageLastWeek": "Brak użycia w zeszłym tygodniu",
|
||||||
"model.newThisWeek": "Nowy w tym tygodniu",
|
"model.newThisWeek": "Nowy w tym tygodniu",
|
||||||
|
"model.sameAsPreviousWeek": "Tak samo jak w poprzednim tygodniu",
|
||||||
"model.vsPreviousWeek": "{{change}} vs poprzedni tydzień",
|
"model.vsPreviousWeek": "{{change}} vs poprzedni tydzień",
|
||||||
"model.pdf": "PDF",
|
"model.pdf": "PDF",
|
||||||
"format.users": "użytkownicy",
|
"format.users": "użytkownicy",
|
||||||
|
|||||||
@@ -223,6 +223,7 @@ export const dict = {
|
|||||||
"model.noPeersDescription": "Рейтинги похожих моделей появятся после использования.",
|
"model.noPeersDescription": "Рейтинги похожих моделей появятся после использования.",
|
||||||
"model.noUsageLastWeek": "Нет использования на прошлой неделе",
|
"model.noUsageLastWeek": "Нет использования на прошлой неделе",
|
||||||
"model.newThisWeek": "Новая на этой неделе",
|
"model.newThisWeek": "Новая на этой неделе",
|
||||||
|
"model.sameAsPreviousWeek": "Без изменений к предыдущей неделе",
|
||||||
"model.vsPreviousWeek": "{{change}} к предыдущей неделе",
|
"model.vsPreviousWeek": "{{change}} к предыдущей неделе",
|
||||||
"model.pdf": "PDF",
|
"model.pdf": "PDF",
|
||||||
"format.users": "пользователи",
|
"format.users": "пользователи",
|
||||||
|
|||||||
@@ -223,6 +223,7 @@ export const dict = {
|
|||||||
"model.noPeersDescription": "อันดับโมเดลใกล้เคียงจะแสดงหลังจากมีการใช้งานเข้ามา",
|
"model.noPeersDescription": "อันดับโมเดลใกล้เคียงจะแสดงหลังจากมีการใช้งานเข้ามา",
|
||||||
"model.noUsageLastWeek": "ไม่มีการใช้งานเมื่อสัปดาห์ที่แล้ว",
|
"model.noUsageLastWeek": "ไม่มีการใช้งานเมื่อสัปดาห์ที่แล้ว",
|
||||||
"model.newThisWeek": "ใหม่ในสัปดาห์นี้",
|
"model.newThisWeek": "ใหม่ในสัปดาห์นี้",
|
||||||
|
"model.sameAsPreviousWeek": "เท่าเดิมจากสัปดาห์ก่อน",
|
||||||
"model.vsPreviousWeek": "{{change}} เทียบกับสัปดาห์ก่อน",
|
"model.vsPreviousWeek": "{{change}} เทียบกับสัปดาห์ก่อน",
|
||||||
"model.pdf": "PDF",
|
"model.pdf": "PDF",
|
||||||
"format.users": "ผู้ใช้",
|
"format.users": "ผู้ใช้",
|
||||||
|
|||||||
@@ -223,6 +223,7 @@ export const dict = {
|
|||||||
"model.noPeersDescription": "Benzer model sıralamaları kullanım geldikten sonra görünür.",
|
"model.noPeersDescription": "Benzer model sıralamaları kullanım geldikten sonra görünür.",
|
||||||
"model.noUsageLastWeek": "Geçen hafta kullanım yok",
|
"model.noUsageLastWeek": "Geçen hafta kullanım yok",
|
||||||
"model.newThisWeek": "Bu hafta yeni",
|
"model.newThisWeek": "Bu hafta yeni",
|
||||||
|
"model.sameAsPreviousWeek": "Önceki haftayla aynı",
|
||||||
"model.vsPreviousWeek": "önceki haftaya göre {{change}}",
|
"model.vsPreviousWeek": "önceki haftaya göre {{change}}",
|
||||||
"model.pdf": "PDF",
|
"model.pdf": "PDF",
|
||||||
"format.users": "kullanıcı",
|
"format.users": "kullanıcı",
|
||||||
|
|||||||
@@ -223,6 +223,7 @@ export const dict = {
|
|||||||
"model.noPeersDescription": "Рейтинги схожих моделей з'являться після використання.",
|
"model.noPeersDescription": "Рейтинги схожих моделей з'являться після використання.",
|
||||||
"model.noUsageLastWeek": "Немає використання минулого тижня",
|
"model.noUsageLastWeek": "Немає використання минулого тижня",
|
||||||
"model.newThisWeek": "Нова цього тижня",
|
"model.newThisWeek": "Нова цього тижня",
|
||||||
|
"model.sameAsPreviousWeek": "Без змін відносно попереднього тижня",
|
||||||
"model.vsPreviousWeek": "{{change}} до попереднього тижня",
|
"model.vsPreviousWeek": "{{change}} до попереднього тижня",
|
||||||
"model.pdf": "PDF",
|
"model.pdf": "PDF",
|
||||||
"format.users": "користувачі",
|
"format.users": "користувачі",
|
||||||
|
|||||||
@@ -222,6 +222,7 @@ export const dict = {
|
|||||||
"model.noPeersDescription": "使用量到达后会显示同类模型排名。",
|
"model.noPeersDescription": "使用量到达后会显示同类模型排名。",
|
||||||
"model.noUsageLastWeek": "上周无使用量",
|
"model.noUsageLastWeek": "上周无使用量",
|
||||||
"model.newThisWeek": "本周新增",
|
"model.newThisWeek": "本周新增",
|
||||||
|
"model.sameAsPreviousWeek": "与上周相同",
|
||||||
"model.vsPreviousWeek": "较上周 {{change}}",
|
"model.vsPreviousWeek": "较上周 {{change}}",
|
||||||
"model.pdf": "PDF",
|
"model.pdf": "PDF",
|
||||||
"format.users": "用户",
|
"format.users": "用户",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user