Compare commits

...

34 Commits

Author SHA1 Message Date
𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 c9e1aedd42 fix(desktop): handle moved projects and deleted paths 2026-06-27 02:33:26 +00:00
opencode-agent[bot] 4b948c5d74 fix(app): hide home session archive action (#34136)
Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local>
2026-06-27 02:23:36 +00:00
opencode-agent[bot] cd56c51e2d fix(app): keep bare slash as plain inline code (#34122)
Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local>
2026-06-26 22:21:01 +00:00
opencode-agent[bot] 43e39d7f68 fix(tui): use generated event union (#34118)
Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local>
2026-06-26 18:04:53 -04:00
Frank 7a17925495 zen: new inference 2026-06-26 16:25:07 -04:00
Brendan Allan 5acb2530b4 fix(app): centralize notification state (#34105) 2026-06-26 19:24:33 +00:00
opencode-agent[bot] 44a6787359 chore: generate 2026-06-26 19:18:18 +00:00
Kit Langton 42e6b7db32 feat(sdk): expose live event stream (#34098) 2026-06-26 21:16:33 +02:00
opencode-agent[bot] 2c02f8bace chore: update nix node_modules hashes 2026-06-26 18:53:34 +00:00
Affan Ali 2ec20e576b fix(app): slow tooltip display for models (#30745)
Co-authored-by: affanali2k3 <affanalikhanxx@gmail.com>
2026-06-27 02:53:08 +08:00
opencode-agent[bot] 20f47fec7a chore: generate 2026-06-26 18:51:42 +00:00
Kit Langton 65210f2d97 feat(api): add finite durable session history pages (#34097) 2026-06-26 14:49:47 -04:00
Brendan Allan af0b7ffae7 fix(app): animate prompt selectors after loading (#34101) 2026-06-27 02:36:01 +08:00
opencode-agent[bot] 0e2dd4ad15 chore: generate 2026-06-26 17:56:55 +00:00
Dax Raad 11d2f3e5f8 fix(llm): end reasoning before responses 2026-06-26 13:55:04 -04:00
Dax Raad 1ac6b4bec4 fix(core): authorize external read paths 2026-06-26 13:55:04 -04:00
opencode-agent[bot] 0befd9b049 chore: generate 2026-06-26 17:53:26 +00:00
Dax Raad 850a0dfe7c fix(core): resolve prompt attachment mime types 2026-06-26 13:51:00 -04:00
opencode-agent[bot] 18a419e634 chore: generate 2026-06-26 17:36:45 +00:00
Adam 82f47cb312 feat(stats): add clickable section headings (#34095) 2026-06-26 12:34:44 -05:00
Adam 7b1fe33ed3 fix(data): clarify unchanged rank 2026-06-26 12:29:29 -05:00
Stefan Avram c72cca8def docs: route enterprise contact links (#34080) 2026-06-26 12:05:24 -05:00
Adam 05ce6bc275 fix(data): canonicalize locale pages 2026-06-26 12:00:32 -05:00
Shoubhit Dash 1de7368580 fix(acp): send partial completed tool updates (#34091) 2026-06-26 22:21:35 +05:30
Brendan Allan 36f901588a fix(app): wait for persisted home state before reading collapsed state (#34088) 2026-06-27 00:25:43 +08:00
Ariane Emory 92f1a17b67 docs(providers): document blacklist and whitelist model filtering (#33792) 2026-06-26 11:09:14 -05:00
Shoubhit Dash ebf4007efd fix(acp): enrich permission prompts (#34079) 2026-06-26 21:25:52 +05:30
Brendan Allan 1aea999d7c fix(app): consolidate add project action (#34086) 2026-06-26 23:54:31 +08:00
Brendan Allan 4a8fee3b2d feat(app): add new session workspace controls (#34085) 2026-06-26 23:43:41 +08:00
Aiden Cline 0d3e0fc8f4 fix(core): enlarge OAuth callback card (#34082) 2026-06-26 10:16:51 -05:00
Aiden Cline f254476043 fix(llm): omit stateless response item ids (#34027) 2026-06-26 10:14:43 -05:00
Aiden Cline 639c8e6475 tweak: update OAuth callback branding (#34081) 2026-06-26 10:08:29 -05:00
Aiden Cline e3bfd4cce6 fix(mcp): refresh credentials on reauthentication (#33717) 2026-06-26 10:03:39 -05:00
Shoubhit Dash c2882268d6 fix(acp): skip resume transcript replay (#34070) 2026-06-26 14:12:05 +00:00
114 changed files with 5394 additions and 5583 deletions
+4
View File
@@ -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
View File
@@ -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="
} }
} }
+1
View File
@@ -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
View File
@@ -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)
+11 -4
View File
@@ -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
+11 -6
View File
@@ -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()
})
})
+2 -1
View File
@@ -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)
} }
+343 -245
View File
@@ -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)
})
})
},
},
}
}
+4
View File
@@ -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 -9
View File
@@ -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;
}
}
} }
+34 -24
View File
@@ -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 -9
View File
@@ -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: () => {
+42 -8
View File
@@ -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>
+15 -6
View File
@@ -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)),
), ),
], ],
+3 -1
View File
@@ -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)
+1
View File
@@ -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"
+43 -19
View File
@@ -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))
+22
View File
@@ -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,
),
},
} }
} }
+473 -13
View File
@@ -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
View File
@@ -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")
+100 -1
View File
@@ -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",
+66 -3
View File
@@ -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",
+68 -1
View File
@@ -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
View File
@@ -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(
+12 -12
View File
@@ -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)" />
+34 -3
View File
@@ -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)),
}
}),
})
+24 -20
View File
@@ -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,
}) })
+64 -1
View File
@@ -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
+174
View File
@@ -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")
}),
)
})
+21
View File
@@ -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
+55 -2
View File
@@ -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="
+28 -17
View File
@@ -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(
+23 -9
View File
@@ -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({
+6 -1
View File
@@ -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(
+13 -5
View File
@@ -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"
@@ -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,
+4 -1
View File
@@ -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" },
+1 -1
View File
@@ -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" },
], ],
+139 -9
View File
@@ -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"
-1
View File
@@ -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, {
+1 -4
View File
@@ -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),
} }
} }
+13 -11
View File
@@ -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)
+62 -9
View File
@@ -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 }
/** /**
+51 -3
View File
@@ -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) {
+130 -3
View File
@@ -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 () => {
+81
View File
@@ -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>",
@@ -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,
@@ -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",
() => () =>
+83 -1
View File
@@ -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,
}, },
+10 -13
View File
@@ -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
+38 -4
View File
@@ -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 },
+10 -2
View File
@@ -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,
+3 -3
View File
@@ -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]>()),
) )
} }
+1
View File
@@ -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"
+26
View File
@@ -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" })
+3 -1
View File
@@ -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"))
+1
View File
@@ -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"
+84 -1
View File
@@ -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
+1
View File
@@ -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"
}, },
+54
View File
@@ -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
+36 -2
View File
@@ -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
* *
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
View File
File diff suppressed because it is too large Load Diff
+14 -8
View File
@@ -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: {
+26
View File
@@ -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
+1
View File
@@ -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",
+1
View File
@@ -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": "مستخدمون",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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": "ユーザー",
+1
View File
@@ -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": "사용자",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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": "пользователи",
+1
View File
@@ -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": "ผู้ใช้",
+1
View File
@@ -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ı",
+1
View File
@@ -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": "користувачі",
+1
View File
@@ -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