Compare commits

..

1 Commits

Author SHA1 Message Date
𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 cac531148a fix(desktop): show interrupt loading state 2026-06-26 09:42:26 +00:00
181 changed files with 3977 additions and 5690 deletions
+2 -4
View File
@@ -6,7 +6,6 @@ on:
branches:
- ci
- dev
- v2
- beta
- fix/npm-native-binary-install
- snapshot-*
@@ -123,7 +122,7 @@ jobs:
- build-cli
- version
runs-on: blacksmith-4vcpu-windows-2025
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
if: github.repository == 'anomalyco/opencode'
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
@@ -222,7 +221,7 @@ jobs:
needs:
- build-cli
- version
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
if: github.repository == 'anomalyco/opencode'
continue-on-error: false
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
@@ -448,7 +447,6 @@ jobs:
path: packages/opencode/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name != 'v2'
with:
name: opencode-cli-signed-windows
path: packages/opencode/dist
+5
View File
@@ -74,6 +74,11 @@ jobs:
working-directory: packages/client
run: bun run check:generated
- name: Run HttpApi exerciser gates
if: runner.os == 'Linux'
working-directory: packages/opencode
run: bun run test:httpapi
e2e:
name: e2e (${{ matrix.settings.name }})
strategy:
+1 -3
View File
@@ -93,7 +93,7 @@
"name": "@opencode-ai/cli",
"version": "1.17.11",
"bin": {
"opencode2": "./bin/opencode2.cjs",
"lildax": "./bin/lildax.cjs",
},
"dependencies": {
"@effect/platform-node": "catalog:",
@@ -316,7 +316,6 @@
"ai-gateway-provider": "3.1.2",
"bun-pty": "0.4.8",
"cross-spawn": "catalog:",
"diff": "catalog:",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
@@ -935,7 +934,6 @@
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
"clipboardy": "4.0.0",
"diff": "catalog:",
"effect": "catalog:",
@@ -1,6 +1,6 @@
import "@pierre/trees/web-components"
import { FileTree } from "@pierre/trees"
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
import { Dialog, DialogFooter } from "@opencode-ai/ui/v2/dialog-v2"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -27,7 +27,6 @@ import {
pickerRoot,
} from "./directory-picker-domain"
import "./dialog-select-directory-v2.css"
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
interface DialogSelectDirectoryV2Props {
title?: string
@@ -267,12 +266,8 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
onCleanup(() => tree?.cleanUp())
return (
<Dialog size="large" class="directory-picker-v2">
<DialogHeader>
<DialogTitle>{props.title ?? language.t("command.project.open")}</DialogTitle>
</DialogHeader>
<DividerV2 />
<DialogBody class="directory-picker-v2-body pt-4!">
<Dialog title={props.title ?? language.t("command.project.open")} size="large" class="directory-picker-v2">
<div class="directory-picker-v2-body">
<div class="directory-picker-v2-path" ref={pathArea}>
<TextInputV2
value={input()}
@@ -354,7 +349,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
</Show>
</div>
<div class="directory-picker-v2-selection">{policy.result(root(), selected(), rootValid())}</div>
</DialogBody>
</div>
<DialogFooter>
<ButtonV2 variant="neutral" onClick={() => dialog.close()}>
{language.t("common.cancel")}
+42 -17
View File
@@ -66,6 +66,7 @@ import { promptPlaceholder } from "./prompt-input/placeholder"
import { createPromptInputTransientState } from "./prompt-input/transient-state"
import { showToast } from "@/utils/toast"
import { ImagePreview } from "@opencode-ai/ui/image-preview"
import { SessionProgressIndicatorV2 } from "@opencode-ai/session-ui/v2/session-progress-indicator-v2"
export type PromptInputState = ReturnType<typeof usePrompt>
@@ -339,6 +340,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
() => prompt.capture(),
Math.floor(Math.random() * EXAMPLES.length),
)
const [submissionState, setSubmissionState] = createStore({ interrupting: false })
const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
const motion = (value: number) => ({
opacity: value,
@@ -1180,6 +1182,22 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onSubmit: props.onSubmit,
})
const interrupt = () => {
if (submissionState.interrupting) return
if (platform.platform !== "desktop" || !props.controls.newLayoutDesigns) return abort()
setSubmissionState("interrupting", true)
return Promise.resolve()
.then(() => abort())
.finally(() => setSubmissionState("interrupting", false))
}
const handleV2Submit = (event: Event) => {
if (!stopping() || platform.platform !== "desktop") return handleSubmit(event)
event.preventDefault()
return interrupt()
}
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "u") {
event.preventDefault()
@@ -1232,7 +1250,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
if (working()) {
void abort()
void interrupt()
event.preventDefault()
event.stopPropagation()
return
@@ -1298,7 +1316,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return
}
if (working()) {
void abort()
void interrupt()
event.preventDefault()
}
return
@@ -1410,7 +1428,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<div class="flex flex-col gap-3">
<DockShellForm
data-component={newSession() ? "session-new-composer" : "session-composer"}
onSubmit={handleSubmit}
onSubmit={handleV2Submit}
classList={{
"group/prompt-input min-h-[96px] w-full rounded-xl bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]": true,
"border-icon-info-active border-dashed": store.draggingType !== null,
@@ -1554,20 +1572,27 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</Show>
</div>
<Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
<IconButton
data-action="prompt-submit"
type="submit"
disabled={!working() && blank()}
tabIndex={store.mode === "normal" ? undefined : -1}
icon={stopping() ? "stop" : store.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
variant="primary"
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted shadow-[var(--v2-elevation-button-contrast)] disabled:opacity-50"
style={{
"background-image":
"linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-contrast) 0%,var(--v2-background-bg-contrast) 100%)",
}}
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/>
<span class="relative flex size-7">
<IconButton
data-action="prompt-submit"
type="submit"
disabled={submissionState.interrupting || (!working() && blank())}
tabIndex={store.mode === "normal" ? undefined : -1}
icon={stopping() ? "stop" : store.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
variant="primary"
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted shadow-[var(--v2-elevation-button-contrast)] disabled:opacity-50"
classList={{ "[&_[data-slot=icon-svg]]:invisible": submissionState.interrupting }}
style={{
"background-image":
"linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-contrast) 0%,var(--v2-background-bg-contrast) 100%)",
}}
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
aria-busy={submissionState.interrupting}
/>
<Show when={submissionState.interrupting}>
<SessionProgressIndicatorV2 class="pointer-events-none absolute inset-[6px] size-4 opacity-50" />
</Show>
</span>
</Tooltip>
</div>
</DockShellForm>
@@ -69,14 +69,12 @@ export function createPromptProjectController(input: {
if (servers().length <= 1) {
return [...projects().map(projectKey), actionKey(servers()[0]?.key)]
}
return [
...servers().flatMap((server) =>
projects()
.filter((project) => project.server?.key === server!.key)
.map(projectKey),
),
actionKey(),
]
return servers().flatMap((server) => [
...projects()
.filter((project) => project.server?.key === server!.key)
.map(projectKey),
actionKey(server!.key),
])
}
const initialActive = () => {
const selectedKey = selected() ? projectKey(selected()!) : undefined
@@ -132,10 +130,7 @@ export function createPromptProjectController(input: {
const first = input
.controls()
.available.find((project) => !search || displayName(project).toLowerCase().includes(search))
setStore({
search: value,
active: first ? projectKey(first) : actionKey(servers().length > 1 ? undefined : servers()[0]?.key),
})
setStore({ search: value, active: first ? projectKey(first) : actionKey(servers()[0]?.key) })
},
clearSearch() {
setStore({ search: "", active: initialActive() })
@@ -161,9 +156,6 @@ export function createPromptProjectController(input: {
? decodeURIComponent(store.active.slice(actionPrefix.length)) || undefined
: undefined
},
activeAction() {
return store.active.startsWith(actionPrefix)
},
setSearchRef(el: HTMLInputElement) {
searchRef = el
},
@@ -175,10 +167,7 @@ export function createPromptProjectController(input: {
export type PromptProjectController = ReturnType<typeof createPromptProjectController>
export function PromptProjectSelector(props: {
controller: PromptProjectController
placement?: "bottom" | "bottom-start"
}) {
export function PromptProjectSelector(props: { controller: PromptProjectController }) {
let contentRef: HTMLDivElement | undefined
let restoreTrigger = true
@@ -212,12 +201,6 @@ export function PromptProjectSelector(props: {
selectProject(project)
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())
}
const moveActive = (delta: number) => {
@@ -246,8 +229,9 @@ export function PromptProjectSelector(props: {
return (
<DropdownMenu
open={props.controller.open()}
placement={props.placement ?? "bottom"}
placement="bottom-start"
gutter={4}
shift={-6}
modal={false}
onOpenChange={(open) => props.controller.setOpen(open)}
>
@@ -334,13 +318,7 @@ export function PromptProjectSelector(props: {
</DropdownMenu.RadioGroup>
}
>
<For
each={props.controller
.servers()
.filter((server) =>
props.controller.projects().some((project) => project.server?.key === server!.key),
)}
>
<For each={props.controller.servers()}>
{(server) => (
<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">
@@ -353,49 +331,22 @@ export function PromptProjectSelector(props: {
)}
</For>
</DropdownMenu.RadioGroup>
<ProjectAction server={server!.key} controller={props.controller} onSelect={selectAction} />
</div>
)}
</For>
</Show>
</div>
<div class="h-px bg-v2-border-border-muted" />
<div class="flex flex-col p-0.5">
<Show
when={props.controller.servers().length > 1}
fallback={
<ProjectAction
server={props.controller.servers()[0]?.key}
controller={props.controller}
onSelect={selectAction}
/>
}
>
<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>
<Show when={props.controller.servers().length <= 1}>
<div class="h-px bg-v2-border-border-muted" />
<div class="flex flex-col p-0.5">
<ProjectAction
server={props.controller.servers()[0]?.key}
controller={props.controller}
onSelect={selectAction}
/>
</div>
</Show>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
@@ -425,12 +376,11 @@ function ProjectTrigger(props: ComponentProps<"button"> & { controller: PromptPr
{...rest}
data-action="prompt-project"
type="button"
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"
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"
classList={{
...local.classList,
"hover:bg-v2-overlay-simple-overlay-hover": !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))}
onKeyDown={(event) => {
@@ -504,9 +454,6 @@ 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: {
server?: string
controller: PromptProjectController
@@ -541,11 +488,3 @@ function ProjectAction(props: {
</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>
)
}
@@ -1,102 +0,0 @@
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>
</>
)
}
@@ -1,6 +1,5 @@
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
import { Dialog, DialogFooter } from "@opencode-ai/ui/v2/dialog-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { type Component, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js"
@@ -53,12 +52,8 @@ export const DialogServerV2: Component<{
}
return (
<Dialog fit class="settings-v2-server-dialog">
<DialogHeader hideClose={true}>
<DialogTitle>{title()}</DialogTitle>
</DialogHeader>
<DividerV2 />
<DialogBody class="flex w-full min-w-0 flex-1 flex-col px-4 pt-4 pb-2">
<Dialog title={title()} fit class="settings-v2-server-dialog">
<div class="flex w-full min-w-0 flex-1 flex-col px-4">
<div class="flex w-full min-w-0 flex-col gap-6">
<div class="flex w-full min-w-0 flex-col gap-2">
<label class="settings-v2-server-dialog-label">{language.t("dialog.server.add.url")}</label>
@@ -120,7 +115,7 @@ export const DialogServerV2: Component<{
</div>
</div>
</div>
</DialogBody>
</div>
<DialogFooter>
<ButtonV2 variant="neutral" disabled={controller.formBusy()} onClick={() => dialog.close()}>
{language.t("common.cancel")}
@@ -633,7 +633,7 @@
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-header"] {
align-items: center;
padding: 24px 24px 16px;
padding: 24px 24px 0;
}
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-body"] {
-4
View File
@@ -656,10 +656,6 @@ export const dict = {
"session.new.worktree.main": "Main branch",
"session.new.worktree.mainWithBranch": "Main branch ({{branch}})",
"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.header.search.placeholder": "Search {{project}}",
+3 -10
View File
@@ -2,7 +2,6 @@ import type { Session } from "@opencode-ai/sdk/v2/client"
import {
createEffect,
createMemo,
createResource,
createRoot,
For,
Match,
@@ -526,16 +525,10 @@ function HomeProjectColumn(props: {
const global = useGlobal()
const dialog = useDialog()
const controller = useServerManagementController({ navigateOnAdd: false })
const [_state, setState, _, ready] = persisted(
const [state, setState] = persisted(
Persist.global("home.servers", ["home.servers.v1"]),
createStore({ collapsed: {} as Record<string, boolean> }),
)
const [state] = createResource(
() => ready.promise ?? Promise.resolve(),
(p) => p.then(() => _state),
{ initialValue: _state },
)
return (
<aside
class="mt-6 flex min-w-0 flex-col gap-4 lg:mt-14 lg:pt-[52px]"
@@ -567,7 +560,7 @@ function HomeProjectColumn(props: {
const key = ServerConnection.key(item)
const healthy = () => !!global.servers.health[key]?.healthy
const serverCtx = global.ensureServerCtx(item)
const collapsed = () => !!state().collapsed[key]
const collapsed = () => !!state.collapsed[key]
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">
<HomeServerRow
@@ -580,7 +573,7 @@ function HomeProjectColumn(props: {
focusServer={props.focusServer}
chooseProject={props.chooseProject}
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}
/>
<Show when={healthy() && !collapsed()}>
+8 -42
View File
@@ -19,9 +19,6 @@ import { createPromptInputController, createPromptProjectControls } from "@/page
import { useSessionKey } from "@/pages/session/session-layout"
import { useComposerCommands } from "@/pages/session/use-composer-commands"
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
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
@@ -54,21 +51,16 @@ export default function NewSessionPage() {
onDone: () => inputRef?.focus(),
})
const [store, setStore] = createStore<{ worktree?: string }>({})
const [store, setStore] = createStore({
worktree: "main",
})
const newSessionWorktree = createMemo(() => {
if (store.worktree) return store.worktree
if (store.worktree === "create") return "create"
const project = sync().project
if (project && sdk().directory !== project.worktree) return sdk().directory
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(() => {
if (!prompt.ready()) return
@@ -105,7 +97,7 @@ export default function NewSessionPage() {
</div>
}
>
<div class="flex flex-col" classList={{ "gap-8": showWorkspaceBar, "gap-3": !showWorkspaceBar }}>
<div class="flex flex-col gap-3">
<PromptInput
controls={inputController()}
variant="new-session"
@@ -113,7 +105,7 @@ export default function NewSessionPage() {
inputRef = el
}}
newSessionWorktree={newSessionWorktree()}
onNewSessionWorktreeReset={() => setStore("worktree", undefined)}
onNewSessionWorktreeReset={() => setStore("worktree", "main")}
onSubmit={() => comments.clear()}
toolbar={
<Show when={!projectController.selected()}>
@@ -122,34 +114,8 @@ export default function NewSessionPage() {
}
/>
<Show when={projectController.selected()}>
<div
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 class="flex h-7 min-w-0 items-center gap-0 px-2">
<PromptProjectSelector controller={projectController} />
</div>
</Show>
</div>
+3 -10
View File
@@ -1,8 +1,7 @@
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Dialog, DialogBody, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
import { Dialog } from "@opencode-ai/ui/v2/dialog-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
@@ -31,14 +30,8 @@ export function AddServerMenu(props: { onAddServer: () => void }) {
const language = useLanguage()
const openAddWsl = () => {
dialog.push(() => (
<Dialog size="large" fit class="settings-v2-wsl-dialog">
<DialogHeader hideClose={true}>
<DialogTitle>{language.t("wsl.server.add")}</DialogTitle>
</DialogHeader>
<DividerV2 />
<DialogBody>
<DialogAddWslServer />
</DialogBody>
<Dialog title={language.t("wsl.server.add")} size="large" fit class="settings-v2-wsl-dialog">
<DialogAddWslServer />
</Dialog>
))
}
-82
View File
@@ -1,82 +0,0 @@
# V2 CLI and TUI development guide
## Migration context
- The TUI is being ported from legacy APIs to the new V2 APIs. New and migrated TUI behavior should use `sdk.client.v2` and the location-scoped data in `packages/tui/src/context/data.tsx` instead of adding dependencies on legacy sync state.
- Preserve established TUI behavior unless the task intentionally changes it. When behavior, copy, keyboard interaction, or layout is unclear, compare the local V2 TUI with the latest released legacy TUI.
- Run both versions in separate Terminal Control sessions and save PNG-only captures at equivalent states:
```bash
# From packages/cli: local V2 TUI
termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev --standalone
# Released legacy TUI behavior reference
termctrl start opencode-legacy --host opentui --cols 112 --rows 34 -- bunx opencode-ai@latest
termctrl save opencode-v2-dev --format png --out /tmp/opencode/v2.png
termctrl save opencode-legacy --format png --out /tmp/opencode/legacy.png
```
- Use the same viewport and send equivalent inputs to both sessions before comparing screenshots. The released CLI is a behavioral reference, not a source of V2 API design; keep the local implementation on V2 endpoints.
- Stop both sessions after comparison: `termctrl stop opencode-v2-dev` and `termctrl stop opencode-legacy`.
## Interactive debugging
- This package is the V2 CLI adapter. Run its `dev` script when testing the TUI; do not use the repository-root `bun dev`, which launches the legacy `packages/opencode` CLI.
- Run commands from `packages/cli`. Use `bun dev --standalone` for most debugging so the TUI starts with a private V2 server instead of depending on the background service.
- Use `termctrl` for interactive checks instead of starting the TUI as a blocking foreground process. It provides a real PTY, handles OpenTUI's host handshake, and can save reviewable screenshots.
- Use a dedicated session name and do not reuse or kill an unrelated session.
```bash
termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev --standalone
termctrl wait opencode-v2-dev "Ask anything" --timeout 20000
termctrl show opencode-v2-dev
```
- Wait for visible text before interacting instead of relying on fixed sleeps. Use the text expected from the screen under test, such as `Ask anything` or `Connect a provider`.
- Drive the running TUI with `termctrl send`. Prefix typed input with `text:` and send control keys separately so the interaction matches real terminal input.
```bash
termctrl send opencode-v2-dev 'text:example prompt' enter
termctrl send opencode-v2-dev ctrl-c
```
- Use `termctrl show` after each meaningful interaction and inspect the full visible screen for rendering errors, stale state, error toasts, and unexpected exits.
- Save PNG evidence for every user-visible bug and fix. Do not save text captures; inspect the rendered PNG. Write temporary captures outside the repository unless the artifact is intended to be committed.
```bash
termctrl save opencode-v2-dev --format png --out /tmp/opencode/v2-tui.png
```
- For resize-sensitive changes, resize the viewport, wait for the expected content, and capture the screen again:
```bash
termctrl resize opencode-v2-dev --cols 100 --rows 30
termctrl show opencode-v2-dev
```
- Source changes may require restarting the process. Use `termctrl restart opencode-v2-dev` rather than assuming the running TUI reloaded the change.
- To exercise background-service behavior, omit `--standalone`. Service lifecycle commands are available through `bun dev service start`, `bun dev service status`, and `bun dev service stop`.
- Always clean up the Terminal Control session when the check is complete:
```bash
termctrl stop opencode-v2-dev
```
## Debugger
- To debug the V2 CLI or TUI with Bun's inspector, launch the CLI entrypoint through Terminal Control with an inspector URL, then attach a debugger to that URL:
```bash
termctrl start opencode-v2-debug --host opentui --cols 112 --rows 34 -- \
bun run --inspect=ws://localhost:6499/ src/index.ts --standalone
```
- Use `--inspect-wait` or `--inspect-brk` when execution must pause until the debugger attaches.
- Use `termctrl logs opencode-v2-debug` for inspector output or startup failures emitted before the TUI renderer starts. Use `termctrl show` for the visible full-screen TUI.
## Verification
- Run `bun typecheck` from `packages/cli` after CLI adapter changes.
- Run `bun typecheck` and `bun test` from `packages/tui` after shared TUI changes. Do not run tests from the repository root.
- Treat automated checks and Terminal Control smoke tests as complementary. For user-visible changes, verify initial render, the changed interaction, Ctrl-C exit behavior, and save a screenshot of the corrected state.
@@ -31,11 +31,11 @@ function run(target) {
const envPath = process.env.OPENCODE_BIN_PATH
const scriptDir = path.dirname(fs.realpathSync(__filename))
const cached = path.join(scriptDir, ".opencode2")
const cached = path.join(scriptDir, ".lildax")
const platform = { darwin: "darwin", linux: "linux", win32: "windows" }[os.platform()] || os.platform()
const arch = { x64: "x64", arm64: "arm64", arm: "arm" }[os.arch()] || os.arch()
const base = "@opencode-ai/cli-" + platform + "-" + arch
const binary = platform === "windows" ? "opencode2.exe" : "opencode2"
const binary = platform === "windows" ? "lildax.exe" : "lildax"
function supportsAvx2() {
if (arch !== "x64") return false
@@ -121,7 +121,7 @@ function findBinary(startDir) {
const resolved = envPath || (fs.existsSync(cached) ? cached : findBinary(scriptDir))
if (!resolved) {
console.error(
"It seems that your package manager failed to install the right opencode2 CLI package. Try manually installing " +
"It seems that your package manager failed to install the right lildax CLI package. Try manually installing " +
names.map((name) => `"${name}"`).join(" or ") +
" package",
)
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module",
"license": "MIT",
"bin": {
"opencode2": "./bin/opencode2.cjs"
"lildax": "./bin/lildax.cjs"
},
"files": [
"bin"
+1 -1
View File
@@ -10,7 +10,7 @@ import pkg from "../package.json"
import { modelsData } from "./generate"
const dir = path.resolve(import.meta.dirname, "..")
const binary = "opencode2"
const binary = "lildax"
process.chdir(dir)
await rm("dist", { recursive: true, force: true })
+6 -7
View File
@@ -25,15 +25,14 @@ for (const filepath of new Bun.Glob("*/package.json").scanSync({ cwd: "./dist" }
}
console.log("binaries", binaries)
const version = Object.values(binaries)[0]
const name = "opencode-ai"
await $`mkdir -p ./dist/${name}/bin`
await $`cp ./bin/opencode2.cjs ./dist/${name}/bin/opencode2`
await Bun.file(`./dist/${name}/package.json`).write(
await $`mkdir -p ./dist/${pkg.name}/bin`
await $`cp ./bin/lildax.cjs ./dist/${pkg.name}/bin/lildax`
await Bun.file(`./dist/${pkg.name}/package.json`).write(
JSON.stringify(
{
name,
bin: { opencode2: "./bin/opencode2" },
name: pkg.name,
bin: { lildax: "./bin/lildax" },
version,
license: pkg.license,
repository: { type: "git", url: "git+https://github.com/anomalyco/opencode.git" },
@@ -51,4 +50,4 @@ await Promise.all(
publish(`./dist/${name.replace("@opencode-ai/", "")}`, name, version),
),
)
await publish(`./dist/${name}`, name, version)
await publish(`./dist/${pkg.name}`, pkg.name, version)
-11
View File
@@ -5,16 +5,6 @@ declare const OPENCODE_CLI_NAME: string | undefined
export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", {
description: "OpenCode 2.0 preview command line interface",
params: {
directory: Argument.string("directory").pipe(
Argument.withDescription("Directory to start OpenCode in"),
Argument.optional,
),
standalone: Flag.boolean("standalone").pipe(
Flag.withDescription("Run with a private server instead of the background service"),
Flag.withDefault(false),
),
},
commands: [
Spec.make("api", {
description: "Make a request to the running server",
@@ -56,7 +46,6 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
hostname: Flag.string("hostname").pipe(Flag.withDefault("127.0.0.1")),
port: Flag.integer("port").pipe(Flag.optional),
register: Flag.boolean("register").pipe(Flag.withDefault(false)),
stdio: Flag.boolean("stdio").pipe(Flag.withDefault(false)),
},
}),
],
@@ -1,15 +1,12 @@
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Effect, Option } from "effect"
import { Effect } from "effect"
import { Daemon } from "../../services/daemon"
import { Standalone } from "../../services/standalone"
export default Runtime.handler(Commands, (input) =>
export default Runtime.handler(Commands, () =>
Effect.gen(function* () {
const directory = Option.getOrUndefined(input.directory)
if (directory !== undefined) process.chdir(directory)
const daemon = yield* Daemon.Service
const transport = yield* (input.standalone ? Standalone.transport() : daemon.transport())
const transport = yield* daemon.transport()
const { runTui } = yield* Effect.promise(() => import("../../tui"))
yield* runTui(transport)
}),
+5 -22
View File
@@ -6,8 +6,6 @@ import * as Effect from "effect/Effect"
import { HttpRouter, HttpServer } from "effect/unstable/http"
import { createServer } from "node:http"
import { createRoutes } from "@opencode-ai/server/routes"
import { ServerAuth } from "@opencode-ai/server/auth"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Daemon } from "../../services/daemon"
@@ -18,22 +16,11 @@ export default Runtime.handler(
return yield* Effect.scoped(
Effect.gen(function* () {
const daemon = yield* Daemon.Service
const standalonePassword = process.env.OPENCODE_SERVER_PASSWORD
if (input.stdio) delete process.env.OPENCODE_SERVER_PASSWORD
const password = input.stdio ? standalonePassword : yield* daemon.password()
if (!password) return yield* Effect.fail(new Error("Missing server password"))
const address = yield* listen(input.hostname, input.port, password)
yield* Effect.tryPromise(() =>
createOpencodeClient({
baseUrl: HttpServer.formatAddress(address),
headers: ServerAuth.headers({ password }),
}).v2.location.get(undefined, { throwOnError: true }),
)
const address = yield* listen(input.hostname, input.port, yield* daemon.password())
if (input.register) yield* daemon.register(address)
const url = HttpServer.formatAddress(address)
console.log(input.stdio ? JSON.stringify({ url }) : `server listening on ${url}`)
console.log(`server listening on ${HttpServer.formatAddress(address)}`)
return yield* Effect.never
}).pipe(Effect.annotateLogs({ role: "server" })),
}),
)
}),
)
@@ -48,15 +35,11 @@ function listen(hostname: string, port: Option.Option<number>, password: string)
}
function bind(hostname: string, port: number, password: string) {
const server = createServer()
return Layer.build(
HttpRouter.serve(createRoutes(password), { disableListenLog: true, disableLogger: true }).pipe(
Layer.provideMerge(NodeHttpServer.layer(() => server, { port, host: hostname })),
Layer.provideMerge(NodeHttpServer.layer(() => createServer(), { port, host: hostname })),
Layer.provide(Credential.defaultLayer),
Layer.provide(PermissionSaved.defaultLayer),
),
).pipe(
Effect.tap(() => Effect.addFinalizer(() => Effect.sync(() => server.closeAllConnections()))),
Effect.map((context) => Context.get(context, HttpServer.HttpServer).address),
)
).pipe(Effect.map((context) => Context.get(context, HttpServer.HttpServer).address))
}
@@ -8,6 +8,6 @@ export default Runtime.handler(
Commands.commands.service.commands.status,
Effect.fn("cli.service.status")(function* () {
const url = yield* (yield* Daemon.Service).status()
process.stdout.write((url ? url : "stopped") + EOL)
process.stdout.write((url ? `running ${url}` : "stopped") + EOL)
}),
)
+3 -4
View File
@@ -2,7 +2,6 @@ import * as Effect from "effect/Effect"
import * as Command from "effect/unstable/cli/Command"
import { Spec } from "./spec"
import { Daemon } from "../services/daemon"
import { Scope } from "effect"
export type Input<Value> =
Value extends Spec.Node<infer _Name, infer Command, infer _Commands>
@@ -11,11 +10,11 @@ export type Input<Value> =
? Input
: never
type RuntimeHandler = (input: unknown) => Effect.Effect<void, unknown, Daemon.Service | Scope.Scope>
type RuntimeHandler = (input: unknown) => Effect.Effect<void, unknown, Daemon.Service>
type Loader<Node extends Spec.Any> = () => Promise<{
default: (input: Input<Node>) => Effect.Effect<void, any, Daemon.Service | Scope.Scope>
default: (input: Input<Node>) => Effect.Effect<void, any, Daemon.Service>
}>
type ProvidedCommand = Command.Command<string, unknown, unknown, unknown, Daemon.Service | Scope.Scope>
type ProvidedCommand = Command.Command<string, unknown, unknown, unknown, Daemon.Service>
export type Handlers<Node extends Spec.Any> = keyof Node["commands"] extends never
? Loader<Node>
-12
View File
@@ -2,19 +2,10 @@
import * as NodeRuntime from "@effect/platform-node/NodeRuntime"
import * as NodeServices from "@effect/platform-node/NodeServices"
import { NodeFileSystem } from "@effect/platform-node"
import * as Effect from "effect/Effect"
import { Layer, Logger, References } from "effect"
import { Commands } from "./commands/commands"
import { Runtime } from "./framework/runtime"
import { Daemon } from "./services/daemon"
import { Logging } from "@opencode-ai/core/observability/logging"
const LoggingLayer = Logger.layer(Logging.loggers(), { mergeWithExisting: false }).pipe(
Layer.provide(NodeFileSystem.layer),
Layer.orDie,
Layer.merge(Layer.succeed(References.MinimumLogLevel, Logging.minimumLogLevel())),
)
const Handlers = Runtime.handlers(Commands, {
$: () => import("./commands/handlers/default"),
@@ -34,11 +25,8 @@ const Handlers = Runtime.handlers(Commands, {
})
Runtime.run(Commands, Handlers, { version: "local" }).pipe(
Effect.annotateLogs({ role: "cli" }),
Effect.provide(Daemon.defaultLayer),
Effect.provide(LoggingLayer),
Effect.provide(NodeServices.layer),
Effect.scoped,
Effect.tap(() => Effect.sync(() => process.exit(0))),
NodeRuntime.runMain,
)
+10 -22
View File
@@ -28,10 +28,6 @@ const Registration = Schema.Struct({
})
type Registration = typeof Registration.Type
const Config = Schema.Struct({
password: Schema.optional(Schema.String),
})
function sameRegistration(left: Registration, right: Registration) {
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
}
@@ -42,29 +38,21 @@ export const layer = Layer.effect(
const fs = yield* FileSystem.FileSystem
const directory = Global.Path.state
const file = path.join(directory, "server.json")
const configFile = path.join(Global.Path.config, "service.json")
const legacyPasswordFile = path.join(directory, "password")
const passwordFile = path.join(directory, "password")
const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Registration))
const decodeConfig = Schema.decodeUnknownEffect(Schema.fromJsonString(Config))
const password = Effect.fn("cli.daemon.password")(function* (value?: string) {
const config = yield* fs
.readFileString(configFile)
.pipe(Effect.flatMap(decodeConfig), Effect.catch(() => Effect.succeed(undefined)))
if (value === undefined && config?.password) return config.password
const legacy = yield* fs
.readFileString(legacyPasswordFile)
.pipe(Effect.catch(() => Effect.succeed(undefined)))
const next = value ?? legacy ?? randomBytes(32).toString("base64url")
const existing = yield* fs.readFileString(passwordFile).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (value === undefined && existing) return existing
// Keep one private credential across server restarts so discovered clients
// can reconnect without exposing a password flag or environment variable.
const temp = configFile + ".tmp"
yield* fs.writeFileString(temp, JSON.stringify({ password: next }, null, 2) + "\n", { mode: 0o600 })
yield* fs.rename(temp, configFile)
if (legacy) yield* fs.remove(legacyPasswordFile).pipe(Effect.ignore)
return next
const generated = value ?? randomBytes(32).toString("base64url")
const temp = passwordFile + ".tmp"
yield* fs.makeDirectory(directory, { recursive: true })
yield* fs.writeFileString(temp, generated, { mode: 0o600 })
yield* fs.rename(temp, passwordFile)
return generated
})
const registration = Effect.fnUntraced(function* () {
@@ -123,7 +111,7 @@ export const layer = Layer.effect(
const existing = yield* healthy().pipe(Effect.option)
const found = Option.getOrUndefined(existing)
const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
if (found?.version === InstallationVersion) return found.url
if (found?.version === InstallationVersion && compiled) return found.url
if (found) yield* stopProcess(found).pipe(Effect.ignore)
const entrypoint = compiled ? undefined : process.argv[1]
-38
View File
@@ -1,38 +0,0 @@
import { ServerAuth } from "@opencode-ai/server/auth"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Effect, Schema, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { randomBytes } from "node:crypto"
import path from "node:path"
const Ready = Schema.Struct({ url: Schema.String })
const decodeReady = Schema.decodeUnknownPromise(Schema.fromJsonString(Ready))
function command(password: string) {
const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
const entrypoint = compiled ? [] : process.argv[1] ? [process.argv[1]] : []
if (!compiled && entrypoint.length === 0) throw new Error("Failed to resolve CLI entrypoint")
return ChildProcess.make(process.execPath, [...entrypoint, "serve", "--stdio", "--port", "0"], {
cwd: process.cwd(),
env: { OPENCODE_SERVER_PASSWORD: password },
extendEnv: true,
stdin: "ignore",
stderr: "ignore",
killSignal: "SIGKILL",
})
}
export const transport = Effect.fn("cli.standalone.transport")(
function* () {
const password = randomBytes(32).toString("base64url")
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const proc = yield* spawner.spawn(command(password))
const output = yield* proc.stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.take(1), Stream.mkString)
if (!output) return yield* Effect.fail(new Error("Standalone server exited before reporting readiness"))
const ready = yield* Effect.tryPromise(() => decodeReady(output))
return { url: ready.url, headers: ServerAuth.headers({ password }) }
},
Effect.provide(CrossSpawnSpawner.defaultLayer),
)
export * as Standalone from "./standalone"
+27 -29
View File
@@ -2,37 +2,35 @@ import { run } from "@opencode-ai/tui"
import { TuiConfig } from "@opencode-ai/tui/config"
import { Effect } from "effect"
import { Global } from "@opencode-ai/core/global"
import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
export function runTui(transport: { url: string; headers: RequestInit["headers"] }) {
const config = TuiConfig.resolve({}, { terminalSuspend: false })
let disposeSlots: (() => void) | undefined
return Effect.gen(function* () {
const options = { baseUrl: transport.url, headers: transport.headers }
const client = createOpencodeClient(options)
const directory = yield* Effect.tryPromise(() =>
client.v2.fs.list({ location: { directory: process.cwd() } }, { throwOnError: true }),
).pipe(
Effect.map((response) => response.data.location.directory),
Effect.catch(() =>
Effect.tryPromise(() => client.v2.location.get(undefined, { throwOnError: true })).pipe(
Effect.map((response) => response.data.directory),
),
),
)
return yield* run({
client: createOpencodeClient({ ...options, directory }),
args: {},
config,
pluginHost: {
async start(input) {
disposeSlots = await loadBuiltinPlugins(input.api, input.runtime)
},
async dispose() {
disposeSlots?.()
},
},
})
return run({
...transport,
args: {},
config,
fetch: gracefulFetch,
pluginHost: {
async start() {},
async dispose() {},
},
}).pipe(Effect.provide(Global.defaultLayer))
}
const legacyDefaults: Record<string, unknown> = {
"/config/providers": { providers: [], default: {} },
"/provider": { all: [], default: {}, connected: [] },
"/agent": [],
"/config": {},
}
const gracefulFetch = Object.assign(
async (input: RequestInfo | URL, init?: RequestInit) => {
const response = await fetch(input, init)
if (response.status !== 404) return response
const fallback = legacyDefaults[new URL(input instanceof Request ? input.url : input).pathname]
if (fallback === undefined) return response
return Response.json(fallback)
},
{ preconnect: fetch.preconnect },
)
+4 -2
View File
@@ -305,6 +305,7 @@ export type SessionsPromptInput = {
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 }
@@ -323,6 +324,7 @@ export type SessionsPromptInput = {
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 }
@@ -341,6 +343,7 @@ export type SessionsPromptInput = {
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 }
@@ -359,6 +362,7 @@ export type SessionsPromptInput = {
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 }
@@ -510,7 +514,6 @@ export type SessionsContextOutput = {
readonly id: string
readonly text: string
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
readonly time?: { readonly created: number; readonly completed?: number }
}
| {
readonly type: "tool"
@@ -1128,7 +1131,6 @@ export type SessionsMessageOutput = {
readonly id: string
readonly text: string
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
readonly time?: { readonly created: number; readonly completed?: number }
}
| {
readonly type: "tool"
+1 -68
View File
@@ -1,15 +1,10 @@
import type { APIEvent } from "@solidjs/start/server"
import { Resource } from "@opencode-ai/console-resource"
import { LOCALE_HEADER, cookie, localeFromRequest, route, tag } from "~/lib/language"
const dataPath = "/data"
export async function statsProxy(evt: APIEvent) {
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)
targetUrl.protocol = "https:"
targetUrl.hostname = Resource.App.stage === "production" ? "stats.opencode.ai" : "stats.dev.opencode.ai"
@@ -23,13 +18,9 @@ export async function statsProxy(evt: APIEvent) {
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, {
method: req.method,
headers: requestHeaders,
headers: req.headers,
body: req.body,
})
@@ -39,7 +30,6 @@ export async function statsProxy(evt: APIEvent) {
headers.delete("content-encoding")
headers.delete("content-length")
headers.delete("etag")
appendVary(headers, "Accept-Language", "Cookie")
return new Response(rewriteStatsHtml(await response.text()), {
status: response.status,
@@ -62,60 +52,3 @@ export function statsRedirect(evt: APIEvent) {
function rewriteStatsHtml(html: string) {
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(", "),
)
}
-1
View File
@@ -102,7 +102,6 @@
"ai-gateway-provider": "3.1.2",
"bun-pty": "0.4.8",
"cross-spawn": "catalog:",
"diff": "catalog:",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
+3 -21
View File
@@ -1,7 +1,6 @@
export * as Integration from "./integration"
import {
Cache,
Cause,
Clock,
Context,
@@ -311,25 +310,6 @@ export const locationLayer = Layer.effect(
const authorize = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
effect.pipe(Effect.mapError((cause) => new AuthorizationError({ cause })))
const refreshes = yield* Cache.make<Credential.ID, Credential.Value | undefined, AuthorizationError>({
capacity: Number.POSITIVE_INFINITY,
timeToLive: Duration.zero,
lookup: Effect.fnUntraced(function* (credentialID) {
const credential = yield* credentials.get(credentialID)
if (!credential || credential.value.type === "key") return credential?.value
const implementation = state
.get()
.integrations.get(credential.integrationID)
?.implementations.get(credential.value.methodID)
if (!implementation?.refresh) return credential.value
const now = yield* Clock.currentTimeMillis
if (credential.value.expires > now + Duration.toMillis(Duration.minutes(5))) return credential.value
const value = yield* authorize(implementation.refresh(credential.value))
yield* credentials.update(credentialID, { value })
return value
}),
})
const close = (attemptScope: Scope.Closeable) =>
Scope.close(attemptScope, Exit.void).pipe(Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
@@ -416,7 +396,9 @@ export const locationLayer = Layer.effect(
if (!implementation?.refresh) return credential.value
const now = yield* Clock.currentTimeMillis
if (credential.value.expires > now + Duration.toMillis(Duration.minutes(5))) return credential.value
return yield* Cache.get(refreshes, credential.id)
const value = yield* authorize(implementation.refresh(credential.value))
yield* credentials.update(credential.id, { value })
return value
}),
key: Effect.fn("Integration.connection.key")(function* (input) {
const method = state
+12 -12
View File
@@ -6,7 +6,7 @@
// offline and drop into any transport (`res.end(...)`, Effect `response.end`,
// 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
// 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.
@@ -25,8 +25,8 @@ export function success(options?: CallbackPageOptions) {
body: renderCard({
status: "success",
headline: "Authorization successful",
message: provider ? `OpenCode is now connected to ${escapeHtml(provider)}.` : "OpenCode is now authorized.",
footnote: "You can close this window.",
message: provider ? `opencode is now connected to ${escapeHtml(provider)}.` : "opencode is now authorized.",
footnote: "You can close this window and return to opencode.",
}),
script: options?.autoClose === false ? undefined : AUTO_CLOSE_SCRIPT,
})
@@ -40,10 +40,10 @@ export function error(detail: string, options?: CallbackPageOptions) {
status: "error",
headline: "Authorization failed",
message: provider
? `OpenCode couldn't finish connecting to ${escapeHtml(provider)}.`
: "OpenCode couldn't complete authorization.",
? `opencode couldn't finish connecting to ${escapeHtml(provider)}.`
: "opencode couldn't complete authorization.",
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 name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<title>${escapeHtml(input.title)} · OpenCode</title>
<title>${escapeHtml(input.title)} · opencode</title>
<style>${STYLES}</style>
</head>
<body>
@@ -116,8 +116,8 @@ function bootstrapScript(options: BootstrapOptions) {
var TOKEN_URL=new URL(${scriptString(options.tokenPath)},window.location.origin).href;
(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");
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.";setTimeout(function(){try{window.close()}catch(e){}},2500)}
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)}
try{
var hash=new URLSearchParams((window.location.hash||"").slice(1));
var search=new URLSearchParams(window.location.search||"");
@@ -205,7 +205,7 @@ const STYLES = `
text-rendering: optimizeLegibility;
}
.card {
width: min(100%, 28rem);
width: min(100%, 25rem);
padding: 2.25rem 2rem 1.75rem;
background: var(--oc-card);
border: 1px solid var(--oc-border-weak);
@@ -249,8 +249,8 @@ const STYLES = `
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }
`
// 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">
// 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">
<path d="M18 30H6V18H18V30Z" fill="var(--oc-icon-weak)" />
<path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" fill="var(--oc-icon-base)" />
<path d="M48 30H36V18H48V30Z" fill="var(--oc-icon-weak)" />
+1 -5
View File
@@ -1,7 +1,3 @@
export * as PublicEventManifest from "./public-event-manifest"
import { Event } from "@opencode-ai/schema/event"
import { EventManifest } from "@opencode-ai/schema/event-manifest"
export const Definitions = EventManifest.ServerDefinitions
export const Latest = Event.latest(Definitions)
export { ServerDefinitions as Definitions } from "@opencode-ai/schema/event-manifest"
+4 -27
View File
@@ -10,7 +10,6 @@ import { ModelV2 } from "./model"
import { Location } from "./location"
import { SessionMessage } from "./session/message"
import { Prompt } from "./session/prompt"
import { PromptInput } from "@opencode-ai/schema/prompt-input"
import { EventV2 } from "./event"
import { Database } from "./database/database"
import { SessionProjector } from "./session/projector"
@@ -33,7 +32,6 @@ import { SessionInput } from "./session/input"
import { Snapshot } from "./snapshot"
import { SessionRevert } from "./session/revert"
import { Revert } from "@opencode-ai/schema/revert"
import { FSUtil } from "./fs-util"
export const RevertState = Revert.State
export type RevertState = Revert.State
@@ -139,7 +137,7 @@ export interface Interface {
readonly prompt: (input: {
id?: SessionMessage.ID
sessionID: SessionSchema.ID
prompt: PromptInput.Prompt
prompt: Prompt
delivery?: SessionInput.Delivery
resume?: boolean
}) => Effect.Effect<SessionInput.Admitted, NotFoundError | PromptConflictError>
@@ -351,14 +349,13 @@ export const layer = Layer.unwrap(
Effect.uninterruptible(
Effect.gen(function* () {
yield* result.get(input.sessionID)
const prompt = resolvePrompt(input.prompt)
const messageID = input.id ?? SessionMessage.ID.create()
const delivery = input.delivery ?? "steer"
const expected = { sessionID: input.sessionID, messageID, prompt, delivery }
const expected = { sessionID: input.sessionID, messageID, prompt: input.prompt, delivery }
const admitted = yield* SessionInput.admit(db, events, {
id: messageID,
sessionID: input.sessionID,
prompt,
prompt: input.prompt,
delivery,
}).pipe(
Effect.catchDefect((defect) =>
@@ -390,13 +387,7 @@ export const layer = Layer.unwrap(
})
}),
switchModel: Effect.fn("V2Session.switchModel")(function* (input) {
const session = yield* result.get(input.sessionID)
if (
session.model?.providerID === input.model.providerID &&
session.model.id === input.model.id &&
(session.model.variant ?? "default") === (input.model.variant ?? "default")
)
return
yield* result.get(input.sessionID)
yield* events.publish(SessionEvent.ModelSwitched, {
sessionID: input.sessionID,
messageID: SessionMessage.ID.create(),
@@ -461,17 +452,3 @@ export const defaultLayer = layer.pipe(
Layer.provide(ProjectV2.defaultLayer),
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)),
}
}),
})
@@ -349,7 +349,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
id: event.data.reasoningID,
text: "",
providerMetadata: event.data.providerMetadata,
time: { created: event.data.timestamp },
}),
),
)
@@ -366,7 +365,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
const match = latestReasoning(draft, event.data.reasoningID)
if (match) {
match.text = event.data.text
match.time = { created: match.time?.created ?? event.data.timestamp, completed: event.data.timestamp }
if (event.data.providerMetadata !== undefined) match.providerMetadata = event.data.providerMetadata
}
})
+1 -1
View File
@@ -132,7 +132,7 @@ export const fromCatalogModel = (
credential?: Credential.Value,
): Effect.Effect<Model, UnsupportedApiError> => {
const resolved =
credential?.type !== "key" || credential.metadata === undefined
credential?.metadata === undefined
? model
: produce(model, (draft) => {
Object.assign(draft.request.body, credential.metadata)
+12 -60
View File
@@ -52,7 +52,6 @@ function isSafeRelativePath(value: string) {
class IndexSkill extends Schema.Class<IndexSkill>("SkillDiscovery.IndexSkill")({
name: Schema.String,
version: Schema.optional(Schema.String),
files: Schema.Array(Schema.String),
}) {}
@@ -81,15 +80,12 @@ export const layer = Layer.effect(
)
const download = Effect.fn("SkillDiscovery.download")(function* (url: string, destination: string) {
if (yield* fs.exists(destination).pipe(Effect.orDie)) return true
return yield* HttpClientRequest.get(url).pipe(
if (yield* fs.exists(destination).pipe(Effect.orDie)) return
yield* HttpClientRequest.get(url).pipe(
http.execute,
Effect.flatMap((response) => response.arrayBuffer),
Effect.flatMap((body) => fs.writeWithDirs(destination, new Uint8Array(body))),
Effect.as(true),
Effect.catch((error) =>
Effect.logError("failed to download skill file", { url, error }).pipe(Effect.as(false)),
),
Effect.catch((error) => Effect.logError("failed to download skill file", { url, error })),
)
})
@@ -124,7 +120,6 @@ export const layer = Layer.effect(
}
const skillUrl = new URL(`${encodeURIComponent(skill.name)}/`, source)
const versionFile = path.join(root, ".opencode-version")
const files = skill.files.map((file) => {
if (!isSafeRelativePath(file)) return undefined
let resource: URL
@@ -140,66 +135,23 @@ export const layer = Layer.effect(
return {
url: resource.href,
destination,
file,
}
})
if (files.some((file) => file === undefined)) {
return []
}
return [{ skill, root, versionFile, files: files as { url: string; destination: string; file: string }[] }]
return [{ skill, root, files: files as { url: string; destination: string }[] }]
}),
({ skill, root, versionFile, files }) =>
({ skill, root, files }) =>
Effect.gen(function* () {
const version = skill.version
const current =
version === undefined
? undefined
: yield* fs.readFileStringSafe(versionFile).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (version === undefined || current === version) {
yield* Effect.forEach(files, (file) => download(file.url, file.destination), {
concurrency: fileConcurrency,
discard: true,
})
} else {
const token = crypto.randomUUID()
const staging = `${root}.tmp-${token}`
const backup = `${root}.old-${token}`
yield* Effect.gen(function* () {
const downloaded = yield* Effect.forEach(
files,
(file) => download(file.url, path.resolve(staging, file.file)),
{ concurrency: fileConcurrency },
)
if (!downloaded.every(Boolean)) return
const exists =
(yield* fs.exists(path.join(staging, "SKILL.md")).pipe(Effect.orDie)) ||
(yield* fs.exists(path.join(staging, `${skill.name}.md`)).pipe(Effect.orDie))
if (!exists) return
yield* fs.writeFileString(path.join(staging, ".opencode-version"), version)
yield* Effect.uninterruptible(
Effect.gen(function* () {
const cached = yield* fs.exists(root).pipe(Effect.orDie)
if (cached) yield* fs.rename(root, backup)
yield* fs.rename(staging, root).pipe(
Effect.catch((error) =>
Effect.gen(function* () {
if (cached) yield* fs.rename(backup, root).pipe(Effect.ignore)
return yield* Effect.fail(error)
}),
),
)
if (cached) yield* fs.remove(backup, { recursive: true, force: true }).pipe(Effect.ignore)
}),
)
}).pipe(
Effect.catch((error) => Effect.logError("failed to refresh skill", { skill: skill.name, error })),
Effect.ensuring(fs.remove(staging, { recursive: true, force: true }).pipe(Effect.ignore)),
)
}
const exists =
(yield* fs.exists(path.join(root, "SKILL.md")).pipe(Effect.orDie)) ||
yield* Effect.forEach(files, (file) => download(file.url, file.destination), {
concurrency: fileConcurrency,
discard: true,
})
return (yield* fs.exists(path.join(root, "SKILL.md")).pipe(Effect.orDie)) ||
(yield* fs.exists(path.join(root, `${skill.name}.md`)).pipe(Effect.orDie))
return exists ? [AbsolutePath.make(root)] : []
? [AbsolutePath.make(root)]
: []
}),
{ concurrency: skillConcurrency },
).pipe(Effect.map((directories) => directories.flat()))
+11 -45
View File
@@ -1,8 +1,6 @@
export * as ApplyPatchTool from "./apply-patch"
import { ToolFailure } from "@opencode-ai/llm"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { createTwoFilesPatch, diffLines } from "diff"
import { Effect, Layer, Schema } from "effect"
import { FileMutation } from "../file-mutation"
import { FSUtil } from "../fs-util"
@@ -26,10 +24,7 @@ export const Applied = Schema.Struct({
target: Schema.String,
})
export const Output = Schema.Struct({
applied: Schema.Array(Applied),
files: Schema.Array(FileDiff.Info),
})
export const Output = Schema.Struct({ applied: Schema.Array(Applied) })
export type Output = typeof Output.Type
export const toModelOutput = (output: Output) =>
@@ -41,17 +36,11 @@ export const toModelOutput = (output: Output) =>
].join("\n")
type Prepared =
| (Extract<Patch.Hunk, { readonly type: "add" | "delete" }> & {
readonly target: LocationMutation.Target
readonly before: string
readonly after: string
})
| (Extract<Patch.Hunk, { readonly type: "add" | "delete" }> & { readonly target: LocationMutation.Target })
| (Extract<Patch.Hunk, { readonly type: "update" }> & {
readonly target: LocationMutation.Target
readonly source: Uint8Array
readonly content: string
readonly before: string
readonly after: string
})
export const layer = Layer.effectDiscard(
@@ -124,36 +113,29 @@ export const layer = Layer.effectDiscard(
for (const { hunk, target } of targets) {
yield* Effect.gen(function* () {
if (hunk.type === "add") {
prepared.push({
...hunk,
target,
before: "",
after:
hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`,
})
prepared.push({ ...hunk, target })
return
}
if ((yield* fs.stat(target.canonical)).type !== "File") yield* fail(hunk.path)
const source = yield* fs.readFile(target.canonical)
const original = new TextDecoder("utf-8", { ignoreBOM: true }).decode(source)
const before = original.replace(/^\uFEFF/, "")
if (hunk.type === "delete") {
prepared.push({ ...hunk, target, before, after: "" })
prepared.push({ ...hunk, target })
return
}
const update = Patch.derive(hunk.path, hunk.chunks, original)
const source = yield* fs.readFile(target.canonical)
const update = Patch.derive(
hunk.path,
hunk.chunks,
new TextDecoder("utf-8", { ignoreBOM: true }).decode(source),
)
prepared.push({
...hunk,
target,
source,
content: Patch.joinBom(update.content, update.bom),
before,
after: update.content,
})
}).pipe(Effect.mapError(() => fail(hunk.path)))
}
const patchFiles = prepared.map(patchFile)
yield* Effect.forEach(
prepared,
(change) =>
@@ -183,7 +165,7 @@ export const layer = Layer.effectDiscard(
}).pipe(Effect.mapError(() => fail(change.path))),
{ discard: true },
)
return { applied, files: patchFiles }
return { applied }
}).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch"))))
},
}),
@@ -193,19 +175,3 @@ export const layer = Layer.effectDiscard(
.pipe(Effect.orDie)
}),
)
function patchFile(change: Prepared): typeof FileDiff.Info.Type {
const counts = diffLines(change.before, change.after).reduce(
(result, item) => ({
additions: result.additions + (item.added ? (item.count ?? 0) : 0),
deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0),
}),
{ additions: 0, deletions: 0 },
)
return {
file: change.target.resource,
patch: createTwoFilesPatch(change.target.resource, change.target.resource, change.before, change.after),
status: change.type === "add" ? "added" : change.type === "delete" ? "deleted" : "modified",
...counts,
}
}
+6 -22
View File
@@ -7,8 +7,6 @@
export * as EditTool from "./edit"
import { ToolFailure } from "@opencode-ai/llm"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { createTwoFilesPatch, diffLines } from "diff"
import { Effect, Layer, Schema } from "effect"
import { FileMutation } from "../file-mutation"
import { FSUtil } from "../fs-util"
@@ -32,7 +30,10 @@ export const Input = Schema.Struct({
})
export const Output = Schema.Struct({
files: Schema.Array(FileDiff.Info),
operation: Schema.Literal("write"),
target: Schema.String,
resource: Schema.String,
existed: Schema.Boolean,
replacements: Schema.Number,
})
export type Output = typeof Output.Type
@@ -70,7 +71,7 @@ const previewLines = (value: string, prefix: "+" | "-") => {
export const toModelOutput = (output: Output, oldString: string, newString: string) =>
[
`Edited file successfully: ${output.files[0]?.file}`,
`Edited file successfully: ${output.resource}`,
`Replacements: ${output.replacements}`,
"```diff",
...previewLines(oldString, "-"),
@@ -178,13 +179,6 @@ export const layer = Layer.effectDiscard(
input.replaceAll === true
? source.text.replaceAll(oldString, newString)
: source.text.replace(oldString, newString)
const counts = diffLines(source.text, replaced).reduce(
(result, item) => ({
additions: result.additions + (item.added ? (item.count ?? 0) : 0),
deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0),
}),
{ additions: 0, deletions: 0 },
)
const next = splitBom(replaced)
const result = yield* unableToEdit(
files.writeIfUnchanged({
@@ -193,17 +187,7 @@ export const layer = Layer.effectDiscard(
content: joinBom(next.text, source.bom || next.bom),
}),
)
return {
files: [
{
file: result.resource,
patch: createTwoFilesPatch(result.resource, result.resource, source.text, replaced),
status: "modified" as const,
...counts,
},
],
replacements,
} satisfies Output
return { ...result, replacements } satisfies Output
})
},
}),
+20 -24
View File
@@ -1,10 +1,12 @@
export * as ReadTool from "./read"
import { ToolFailure } from "@opencode-ai/llm"
import path from "path"
import { Effect, Layer, Schema } from "effect"
import { FileSystem } from "../filesystem"
import { FSUtil } from "../fs-util"
import { Image } from "../image"
import { LocationMutation } from "../location-mutation"
import { Location } from "../location"
import { PermissionV2 } from "../permission"
import { AbsolutePath } from "../schema"
import { ReadToolFileSystem } from "./read-filesystem"
@@ -28,8 +30,9 @@ const Output = Schema.Union([FileSystem.Content, ReadToolFileSystem.TextPage, Re
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const tools = yield* Tools.Service
const fs = yield* FSUtil.Service
const reader = yield* ReadToolFileSystem.Service
const mutation = yield* LocationMutation.Service
const location = yield* Location.Service
const image = yield* Image.Service
const permission = yield* PermissionV2.Service
@@ -37,7 +40,7 @@ export const layer = Layer.effectDiscard(
.register({
[name]: Tool.make({
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 inside it are accepted, while external absolute paths require external_directory approval.",
"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.",
input: Input,
output: Output,
toModelOutput: ({ input, output }) => {
@@ -50,34 +53,27 @@ export const layer = Layer.effectDiscard(
},
execute: (input, context) => {
return Effect.gen(function* () {
const source = {
type: "tool" as const,
messageID: context.assistantMessageID,
callID: context.toolCallID,
}
const target = yield* mutation.resolve({ path: input.path, kind: "directory" })
const external = target.externalDirectory
if (external)
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID,
agent: context.agent,
source,
})
const resource = target.resource
const absolute = AbsolutePath.make(target.canonical)
const type = yield* reader.inspect(absolute)
const absolute = path.resolve(location.directory, input.path)
const selected = path.isAbsolute(input.path) ? path.dirname(absolute) : location.directory
if (!path.isAbsolute(input.path) && !FSUtil.contains(location.directory, absolute))
return yield* Effect.die(new Error("Path escapes the allowed read root"))
const real = yield* fs.realPath(absolute)
const root = yield* fs.realPath(selected)
if (!FSUtil.contains(root, real))
return yield* Effect.die(new Error("Path escapes the allowed read root"))
const resource = path.relative(root, real).replaceAll("\\", "/") || "."
const target = AbsolutePath.make(real)
const type = yield* reader.inspect(target)
yield* permission.assert({
action: name,
resources: [resource],
save: ["*"],
sessionID: context.sessionID,
agent: context.agent,
source,
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
})
if (type === "directory")
return yield* reader.list(absolute, { offset: input.offset, limit: input.limit })
const content = yield* reader.read(absolute, resource, {
if (type === "directory") return yield* reader.list(target, { offset: input.offset, limit: input.limit })
const content = yield* reader.read(target, resource, {
offset: input.offset,
limit: input.limit,
})
@@ -42,15 +42,6 @@ const openai: Lowerer = {
},
request(options) {
const result = snake(options)
if (options.reasoningEffort !== undefined || options.reasoningSummary !== undefined) {
result.reasoning = {
...(isRecord(result.reasoning) ? result.reasoning : {}),
...(options.reasoningEffort !== undefined ? { effort: options.reasoningEffort } : {}),
...(options.reasoningSummary !== undefined ? { summary: options.reasoningSummary } : {}),
}
delete result.reasoning_effort
delete result.reasoning_summary
}
if (options.textVerbosity !== undefined) {
result.text = { ...(isRecord(result.text) ? result.text : {}), verbosity: options.textVerbosity }
delete result.text_verbosity
+2 -2
View File
@@ -601,9 +601,9 @@ describe("Config", () => {
models: {
model: {
request: {
body: { temperature: 0.3, reasoning: { effort: "high" }, service_tier: "priority" },
body: { temperature: 0.3, reasoning_effort: "high", service_tier: "priority" },
},
variants: [{ id: "high", body: { reasoning: { effort: "high", summary: "auto" } } }],
variants: [{ id: "high", body: { reasoning_effort: "high", reasoning_summary: "auto" } }],
},
},
})
@@ -42,14 +42,12 @@ describe("ConfigProviderOptionsV1", () => {
expect(
lowerer.request({
reasoningEffort: "high",
reasoningSummary: "auto",
reasoning: { encryptedContent: true },
textVerbosity: "low",
text: { outputFormat: "plain" },
nestedValue: { camelCase: true },
}),
).toEqual({
reasoning: { encrypted_content: true, effort: "high", summary: "auto" },
reasoning_effort: "high",
text: { output_format: "plain", verbosity: "low" },
nested_value: { camel_case: true },
})
@@ -140,8 +138,8 @@ describe("ConfigProviderOptionsV1", () => {
body: { trace: true },
settings: { resourceName: "resource" },
})
expect(lowerer.request({ reasoningEffort: "high", reasoningSummary: "auto", textVerbosity: "low" })).toEqual({
reasoning: { effort: "high", summary: "auto" },
expect(lowerer.request({ reasoningEffort: "high", textVerbosity: "low" })).toEqual({
reasoning_effort: "high",
text: { verbosity: "low" },
})
})
+1 -113
View File
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { Deferred, Duration, Effect, Exit, Fiber, Layer, Scope, Stream } from "effect"
import { Duration, Effect, Exit, Fiber, Layer, Scope, Stream } from "effect"
import * as TestClock from "effect/testing/TestClock"
import { Integration } from "@opencode-ai/core/integration"
import { Credential } from "@opencode-ai/core/credential"
@@ -346,116 +346,4 @@ describe("Integration", () => {
}),
)
})
it.effect("shares concurrent OAuth credential refreshes", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("openai")
const methodID = Integration.MethodID.make("chatgpt")
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
let refreshes = 0
const value = Credential.OAuth.make({
type: "oauth",
methodID,
access: "refreshed",
refresh: "refresh-2",
expires: Duration.toMillis(Duration.hours(1)),
})
yield* integrations.transform((editor) =>
editor.method.update({
integrationID,
method: { id: methodID, type: "oauth", label: "ChatGPT" },
authorize: () => Effect.die("unexpected authorization"),
refresh: () =>
Effect.sync(() => refreshes++).pipe(
Effect.andThen(Deferred.succeed(started, undefined)),
Effect.andThen(Deferred.await(release)),
Effect.as(value),
),
}),
)
const credential = yield* credentials.create({
integrationID,
value: Credential.OAuth.make({
type: "oauth",
methodID,
access: "expired",
refresh: "refresh-1",
expires: 0,
}),
})
const connection = { type: "credential" as const, id: credential.id, label: credential.label }
const first = yield* integrations.connection.resolve(connection).pipe(Effect.forkChild)
yield* Deferred.await(started)
const second = yield* integrations.connection.resolve(connection).pipe(Effect.forkChild)
yield* Effect.yieldNow
expect(refreshes).toBe(1)
yield* Deferred.succeed(release, undefined)
expect(yield* Effect.all([Fiber.join(first), Fiber.join(second)], { concurrency: "unbounded" })).toEqual([
value,
value,
])
expect(refreshes).toBe(1)
expect((yield* credentials.get(credential.id))?.value).toEqual(value)
}),
)
it.effect("shares concurrent refresh failures and retries later", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("openai")
const methodID = Integration.MethodID.make("chatgpt")
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const failure = new Error("refresh failed")
let refreshes = 0
yield* integrations.transform((editor) =>
editor.method.update({
integrationID,
method: { id: methodID, type: "oauth", label: "ChatGPT" },
authorize: () => Effect.die("unexpected authorization"),
refresh: () =>
Effect.sync(() => refreshes++).pipe(
Effect.andThen(Deferred.succeed(started, undefined)),
Effect.andThen(Deferred.await(release)),
Effect.andThen(Effect.fail(failure)),
),
}),
)
const credential = yield* credentials.create({
integrationID,
value: Credential.OAuth.make({
type: "oauth",
methodID,
access: "expired",
refresh: "refresh",
expires: 0,
}),
})
const connection = { type: "credential" as const, id: credential.id, label: credential.label }
const first = yield* integrations.connection.resolve(connection).pipe(Effect.flip, Effect.forkChild)
yield* Deferred.await(started)
const second = yield* integrations.connection.resolve(connection).pipe(Effect.flip, Effect.forkChild)
yield* Effect.yieldNow
expect(refreshes).toBe(1)
yield* Deferred.succeed(release, undefined)
const results = yield* Effect.all([Fiber.join(first), Fiber.join(second)], { concurrency: "unbounded" })
expect(results).toEqual([
new Integration.AuthorizationError({ cause: failure }),
new Integration.AuthorizationError({ cause: failure }),
])
expect(yield* integrations.connection.resolve(connection).pipe(Effect.flip)).toEqual(
new Integration.AuthorizationError({ cause: failure }),
)
expect(refreshes).toBe(2)
}),
)
})
+2 -20
View File
@@ -377,7 +377,7 @@ describe("SessionV2.create", () => {
}),
)
it.effect("ignores a model switch when the selected model is unchanged", () =>
it.effect("persists repeated switches as distinct durable Session events", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const created = yield* session.create({ location })
@@ -389,29 +389,11 @@ describe("SessionV2.create", () => {
const { db } = yield* Database.Service
expect(
yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, created.id)).all().pipe(Effect.orDie),
).toHaveLength(2)
).toHaveLength(3)
expect(yield* session.get(created.id)).toMatchObject({ model })
}),
)
it.effect("treats an omitted variant as the default variant", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const model = ModelV2.Ref.make({ id: ModelV2.ID.make("sonnet"), providerID: ProviderV2.ID.anthropic })
const created = yield* session.create({ location, model })
yield* session.switchModel({
sessionID: created.id,
model: ModelV2.Ref.make({ ...model, variant: ModelV2.VariantID.make("default") }),
})
const { db } = yield* Database.Service
expect(
yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, created.id)).all().pipe(Effect.orDie),
).toHaveLength(1)
}),
)
it.effect("rejects a model switch for a missing Session", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
-21
View File
@@ -173,27 +173,6 @@ 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", () =>
Effect.gen(function* () {
yield* setup
@@ -4,7 +4,6 @@ import { LLMClient } from "@opencode-ai/llm/route"
import { DateTime, Effect } from "effect"
import { Headers } from "effect/unstable/http"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ProjectV2 } from "@opencode-ai/core/project"
@@ -292,27 +291,6 @@ describe("SessionRunnerModel", () => {
}),
)
it.effect("does not project OAuth account metadata into the request body", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
ModelV2.Info.make({
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
request: { headers: {}, body: {} },
}),
Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("device"),
access: "secret",
refresh: "refresh",
expires: Date.now() + 60_000,
metadata: { server: "https://console.example", orgID: "org_123" },
}),
)
expect(resolved.route.defaults.http?.body).toEqual({})
}),
)
it.effect("rejects catalog APIs without a native route", () =>
Effect.gen(function* () {
const failure = yield* SessionRunnerModel.fromCatalogModel(
+2 -62
View File
@@ -10,8 +10,8 @@ import { tmpdir } from "./fixture/tmpdir"
const base = "https://skills.example.test/catalog/"
async function pull(skills: unknown[], files: Record<string, string> = {}, cache?: Awaited<ReturnType<typeof tmpdir>>) {
const tmp = cache ?? (await tmpdir())
async function pull(skills: unknown[], files: Record<string, string> = {}) {
const tmp = await tmpdir()
const requests: string[] = []
const http = Layer.succeed(
HttpClient.HttpClient,
@@ -101,64 +101,4 @@ describe("SkillDiscovery.pull", () => {
await result.tmp[Symbol.asyncDispose]()
}
})
test("refreshes cached files when the version changes", async () => {
const tmp = await tmpdir()
try {
const first = await pull(
[{ name: "deploy", version: "1", files: ["SKILL.md"] }],
{
[`${base}deploy/SKILL.md`]: "# Old",
},
tmp,
)
const second = await pull(
[{ name: "deploy", version: "2", files: ["SKILL.md"] }],
{
[`${base}deploy/SKILL.md`]: "# New",
},
tmp,
)
expect(await fs.readFile(path.join(first.directories[0], "SKILL.md"), "utf8")).toBe("# New")
expect(second.requests).toContain(`${base}deploy/SKILL.md`)
const third = await pull(
[{ name: "deploy", version: "2", files: ["SKILL.md"] }],
{ [`${base}deploy/SKILL.md`]: "# Ignored" },
tmp,
)
expect(third.requests).toEqual([`${base}index.json`])
} finally {
await tmp[Symbol.asyncDispose]()
}
})
test("publishes complete updates and removes stale files", async () => {
const tmp = await tmpdir()
try {
const first = await pull(
[{ name: "deploy", version: "1", files: ["SKILL.md", "old.md"] }],
{
[`${base}deploy/SKILL.md`]: "# Old",
[`${base}deploy/old.md`]: "old reference",
},
tmp,
)
const root = first.directories[0]
await pull(
[{ name: "deploy", version: "2", files: ["SKILL.md", "missing.md"] }],
{ [`${base}deploy/SKILL.md`]: "# Partial" },
tmp,
)
expect(await fs.readFile(path.join(root, "SKILL.md"), "utf8")).toBe("# Old")
expect(await fs.readFile(path.join(root, "old.md"), "utf8")).toBe("old reference")
await pull([{ name: "deploy", version: "3", files: ["SKILL.md"] }], { [`${base}deploy/SKILL.md`]: "# New" }, tmp)
expect(await fs.readFile(path.join(root, "SKILL.md"), "utf8")).toBe("# New")
expect(await Bun.file(path.join(root, "old.md")).exists()).toBe(false)
} finally {
await tmp[Symbol.asyncDispose]()
}
})
})
@@ -149,29 +149,6 @@ describe("ApplyPatchTool", () => {
{ type: "update", resource: "update.txt" },
{ type: "delete", resource: "remove.txt" },
],
files: [
{
file: "nested/new.txt",
status: "added",
additions: 1,
deletions: 0,
patch: expect.stringContaining("+created"),
},
{
file: "update.txt",
status: "modified",
additions: 1,
deletions: 1,
patch: expect.stringContaining("-before\n+after"),
},
{
file: "remove.txt",
status: "deleted",
additions: 0,
deletions: 1,
patch: expect.stringContaining("-remove"),
},
],
})
expect(assertions).toMatchObject([
{ sessionID, action: "edit", resources: ["nested/new.txt", "update.txt", "remove.txt"], save: ["*"] },
+4 -9
View File
@@ -125,16 +125,11 @@ describe("EditTool", () => {
value: "Edited file successfully: hello.txt\nReplacements: 1\n```diff\n-before\n+after\n```",
})
expect(settled.output?.structured).toEqual({
operation: "write",
target: yield* Effect.promise(() => fs.realpath(target)),
resource: "hello.txt",
existed: true,
replacements: 1,
files: [
{
file: "hello.txt",
status: "modified",
additions: 1,
deletions: 1,
patch: expect.stringContaining("-before\n+after"),
},
],
})
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\nrest\n")
expect(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["hello.txt"], save: ["*"] }])
+2 -55
View File
@@ -11,7 +11,6 @@ import { PermissionV2 } from "@opencode-ai/core/permission"
import { SessionV2 } from "@opencode-ai/core/session"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Global } from "@opencode-ai/core/global"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { location } from "./fixture/location"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ReadTool } from "@opencode-ai/core/tool/read"
@@ -98,32 +97,6 @@ const infrastructure = Layer.mergeAll(
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(process.cwd()) }))),
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(
Image.Service,
Image.Service.of({ normalize: () => Effect.fail(new Image.ResizerUnavailableError()) }),
@@ -134,21 +107,19 @@ const read = ReadTool.layer.pipe(
Layer.provide(permission),
Layer.provide(config),
Layer.provide(image),
Layer.provide(mutation),
Layer.provide(infrastructure),
)
const it = testEffect(Layer.mergeAll(registry, reader, permission, config, image, mutation, infrastructure, read))
const it = testEffect(Layer.mergeAll(registry, reader, permission, config, image, infrastructure, read))
const unavailableRead = ReadTool.layer.pipe(
Layer.provide(registry),
Layer.provide(reader),
Layer.provide(permission),
Layer.provide(config),
Layer.provide(unavailableImage),
Layer.provide(mutation),
Layer.provide(infrastructure),
)
const itWithoutResizer = testEffect(
Layer.mergeAll(registry, reader, permission, config, unavailableImage, mutation, infrastructure, unavailableRead),
Layer.mergeAll(registry, reader, permission, config, unavailableImage, infrastructure, unavailableRead),
)
const sessionID = SessionV2.ID.make("ses_read_tool_test")
@@ -203,30 +174,6 @@ 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", () =>
Effect.gen(function* () {
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
+9 -23
View File
@@ -407,35 +407,21 @@ const step = (state: ParserState, event: GeminiEvent) => {
if ("thoughtSignature" in part && part.thoughtSignature && "thought" in part && part.thought)
reasoningSignature = part.thoughtSignature
if ("text" in part && part.text.length > 0) {
if (part.thought) {
lifecycle = Lifecycle.reasoningDelta(
lifecycle,
events,
"reasoning-0",
part.text,
part.thoughtSignature ? googleMetadata({ thoughtSignature: part.thoughtSignature }) : undefined,
)
continue
}
lifecycle = Lifecycle.reasoningEnd(
lifecycle,
events,
"reasoning-0",
reasoningSignature ? googleMetadata({ thoughtSignature: reasoningSignature }) : undefined,
)
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", part.text)
lifecycle = part.thought
? Lifecycle.reasoningDelta(
lifecycle,
events,
"reasoning-0",
part.text,
part.thoughtSignature ? googleMetadata({ thoughtSignature: part.thoughtSignature }) : undefined,
)
: Lifecycle.textDelta(lifecycle, events, "text-0", part.text)
continue
}
if ("functionCall" in part) {
const input = part.functionCall.args
const id = `tool_${nextToolCallId++}`
lifecycle = Lifecycle.reasoningEnd(
lifecycle,
events,
"reasoning-0",
reasoningSignature ? googleMetadata({ thoughtSignature: reasoningSignature }) : undefined,
)
lifecycle = Lifecycle.stepStart(lifecycle, events)
events.push(
LLMEvent.toolCall({
+1 -6
View File
@@ -411,12 +411,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
if (delta?.reasoning_content)
lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", delta.reasoning_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")
if (delta?.content) lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
for (const tool of toolDeltas) {
const result = ToolStream.appendOrStart(
+5 -13
View File
@@ -53,7 +53,7 @@ const OpenAIResponsesReasoningSummaryText = Schema.Struct({
const OpenAIResponsesReasoningItem = Schema.Struct({
type: Schema.tag("reasoning"),
id: Schema.optionalKey(Schema.String),
id: Schema.String,
summary: Schema.Array(OpenAIResponsesReasoningSummaryText),
encrypted_content: optionalNull(Schema.String),
})
@@ -101,7 +101,6 @@ type OpenAIResponsesReasoningInput = {
summary: Array<{ type: "summary_text"; text: string }>
encrypted_content?: string | null
}
type OpenAIResponsesReasoningReplay = Omit<OpenAIResponsesReasoningInput, "id">
const OpenAIResponsesTool = Schema.Struct({
type: Schema.tag("function"),
@@ -367,7 +366,7 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
if (message.role === "assistant") {
const content: TextPart[] = []
const reasoningItems: Record<string, OpenAIResponsesReasoningReplay> = {}
const reasoningItems: Record<string, OpenAIResponsesReasoningInput> = {}
const reasoningReferences = new Set<string>()
const hostedToolReferences = new Set<string>()
const flushText = () => {
@@ -384,7 +383,7 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
flushText()
const reasoning = lowerReasoning(part)
if (!reasoning) continue
if (store !== false) {
if (store !== false && reasoning.id) {
if (!reasoningReferences.has(reasoning.id)) input.push({ type: "item_reference", id: reasoning.id })
reasoningReferences.add(reasoning.id)
continue
@@ -396,13 +395,8 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
existing.encrypted_content = reasoning.encrypted_content
continue
}
const replay = {
type: reasoning.type,
summary: reasoning.summary,
encrypted_content: reasoning.encrypted_content,
}
reasoningItems[reasoning.id] = replay
input.push(replay)
reasoningItems[reasoning.id] = reasoning
input.push(reasoning)
continue
}
if (part.type === "tool-call") {
@@ -980,7 +974,6 @@ export const route = Route.make({
endpoint,
auth,
transport: httpTransport,
defaults: { providerOptions: { openai: { store: false } } },
})
const decodeWebSocketMessage = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIResponsesWebSocketMessage))
@@ -1008,7 +1001,6 @@ export const webSocketRoute = Route.make({
endpoint,
auth,
transport: webSocketTransport,
defaults: { providerOptions: { openai: { store: false } } },
})
export * as OpenAIResponses from "./openai-responses"
@@ -44,7 +44,7 @@
"headers": {
"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\",\"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\",\"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}"
},
"response": {
"status": 200,
+1 -4
View File
@@ -347,10 +347,10 @@ describe("Gemini route", () => {
{ type: "step-start", index: 0 },
{ type: "reasoning-start", id: "reasoning-0" },
{ type: "reasoning-delta", id: "reasoning-0", text: "thinking" },
{ type: "reasoning-end", id: "reasoning-0" },
{ type: "text-start", id: "text-0" },
{ type: "text-delta", id: "text-0", text: "Hello" },
{ type: "text-delta", id: "text-0", text: "!" },
{ type: "reasoning-end", id: "reasoning-0" },
{ type: "text-end", id: "text-0" },
{ type: "step-finish", index: 0, reason: "stop", usage, providerMetadata: undefined },
{
@@ -399,9 +399,6 @@ describe("Gemini route", () => {
providerMetadata: { google: { thoughtSignature: "thought_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>(
LLM.request({
@@ -542,9 +542,9 @@ describe("OpenAI Chat route", () => {
{ type: "step-start", index: 0 },
{ type: "reasoning-start", id: "reasoning-0" },
{ type: "reasoning-delta", id: "reasoning-0", text: "thinking" },
{ type: "reasoning-end", id: "reasoning-0" },
{ type: "text-start", id: "text-0" },
{ type: "text-delta", id: "text-0", text: "Hello" },
{ type: "reasoning-end", id: "reasoning-0" },
{ type: "text-end", id: "text-0" },
{ type: "step-finish", index: 0, reason: "stop" },
{ type: "finish", reason: "stop" },
@@ -50,7 +50,6 @@ describe("OpenAI Responses route", () => {
{ role: "system", content: "You are concise." },
{ role: "user", content: [{ type: "input_text", text: "Say hello." }] },
],
store: false,
stream: true,
max_output_tokens: 20,
temperature: 0,
@@ -162,16 +161,16 @@ describe("OpenAI Responses route", () => {
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLM.updateRequest(request, {
model: OpenAIResponses.webSocketRoute
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "gpt-4.1-mini" }),
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responsesWebSocket(
"gpt-4.1-mini",
),
}),
)
expect(prepared.route).toBe("openai-responses-websocket")
expect(prepared.protocol).toBe("openai-responses")
expect(prepared.metadata).toEqual({ transport: "websocket-json" })
expect(prepared.body).toMatchObject({ model: "gpt-4.1-mini", store: false, stream: true })
expect(prepared.body).toMatchObject({ model: "gpt-4.1-mini", stream: true })
}),
)
@@ -357,13 +356,7 @@ describe("OpenAI Responses route", () => {
{ type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
{ type: "function_call_output", call_id: "call_1", output: '{"forecast":"sunny"}' },
],
store: false,
stream: true,
max_output_tokens: undefined,
temperature: undefined,
tool_choice: undefined,
tools: undefined,
top_p: undefined,
})
}),
)
@@ -526,6 +519,7 @@ describe("OpenAI Responses route", () => {
},
{
type: "reasoning",
id: "rs_continuation_1",
encrypted_content: "encrypted-continuation-state",
summary: [{ type: "summary_text", text: "I inspected the previous turn." }],
},
@@ -870,9 +864,7 @@ describe("OpenAI Responses route", () => {
it.effect("closes reasoning summary parts when storage is not disabled", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.updateRequest(request, { providerOptions: { openai: { store: true } } }),
).pipe(
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
@@ -933,12 +925,12 @@ describe("OpenAI Responses route", () => {
dynamicResponse((input) =>
Effect.gen(function* () {
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
const body = yield* Effect.promise(() => web.json())
expect(body).toMatchObject({
expect(yield* Effect.promise(() => web.json())).toMatchObject({
input: [
{ role: "user", content: [{ type: "input_text", text: "What changed?" }] },
{
type: "reasoning",
id: "rs_1",
encrypted_content: "encrypted-state",
summary: [{ type: "summary_text", text: "Checked the previous diff." }],
},
@@ -946,7 +938,6 @@ describe("OpenAI Responses route", () => {
{ role: "user", content: [{ type: "input_text", text: "Summarize it." }] },
],
})
expect(body.input[1]).not.toHaveProperty("id")
return input.respond(
sseEvents(
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Parser now round-trips reasoning." },
@@ -993,6 +984,7 @@ describe("OpenAI Responses route", () => {
{ role: "assistant", content: [{ type: "output_text", text: "Before." }] },
{
type: "reasoning",
id: "rs_1",
encrypted_content: "encrypted-state",
summary: [{ type: "summary_text", text: "Checked order." }],
},
@@ -1086,6 +1078,7 @@ describe("OpenAI Responses route", () => {
expect(prepared.body.input).toEqual([
{
type: "reasoning",
id: "rs_1",
encrypted_content: "encrypted-state",
summary: [
{ type: "summary_text", text: "First" },
+1 -1
View File
@@ -598,7 +598,7 @@ describe("LLMClient tools", () => {
include: ["reasoning.encrypted_content"],
input: [
{ role: "user" },
{ type: "reasoning", summary: [], encrypted_content: "encrypted-state" },
{ type: "reasoning", id: "rs_1", summary: [], encrypted_content: "encrypted-state" },
{ type: "function_call", call_id: "call_1", name: "get_weather" },
{ type: "function_call_output", call_id: "call_1" },
],
+1 -8
View File
@@ -46,7 +46,6 @@ export class UnsupportedOperationError extends Schema.TaggedErrorClass<Unsupport
export class ServiceFailureError extends Schema.TaggedErrorClass<ServiceFailureError>()("ACPServiceFailureError", {
safeMessage: Schema.String,
service: Schema.optional(Schema.String),
errorName: Schema.optional(Schema.String),
}) {}
export type Error =
@@ -82,13 +81,7 @@ export function toRequestError(error: Error) {
case "ACPUnsupportedOperationError":
return RequestError.methodNotFound(error.method)
case "ACPServiceFailureError":
return RequestError.internalError(
{
...(error.service ? { service: error.service } : {}),
...(error.errorName ? { errorName: error.errorName } : {}),
},
error.safeMessage,
)
return RequestError.internalError({ service: error.service }, error.safeMessage)
}
}
+9 -139
View File
@@ -1,16 +1,9 @@
import type {
AgentSideConnection,
PermissionOption,
RequestPermissionResponse,
ToolCallContent,
ToolCallLocation,
ToolCallUpdate,
} from "@agentclientprotocol/sdk"
import type { AgentSideConnection, PermissionOption, RequestPermissionResponse } from "@agentclientprotocol/sdk"
import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2"
import { applyPatch } from "diff"
import { exists, readText } from "@/util/filesystem"
import type { ACPSession } from "./session"
import { pendingToolCall, toLocations, type ToolInput } from "./tool"
import { toLocations, toToolKind, type ToolInput } from "./tool"
import { Effect } from "effect"
type PermissionEvent = Extract<Event, { type: "permission.asked" }>
@@ -61,11 +54,14 @@ export class Handler {
const result = await this.input.connection
.requestPermission({
sessionId: permission.sessionID,
toolCall: await permissionToolCall({
toolCall: {
toolCallId: permission.tool?.callID ?? permission.id,
toolName: permission.permission,
input: permission.metadata,
}),
status: "pending",
title: permission.permission,
rawInput: permission.metadata,
kind: toToolKind(permission.permission),
locations: toLocations(permission.permission, permission.metadata),
},
options: permissionOptions,
})
.catch(async () => {
@@ -115,107 +111,6 @@ 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 {
if (result.outcome.outcome !== "selected") return "reject"
if (result.outcome.optionId === "once" || result.outcome.optionId === "always") return result.outcome.optionId
@@ -226,29 +121,4 @@ function stringValue(value: unknown) {
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"
+10 -57
View File
@@ -30,7 +30,7 @@ import {
type SetSessionModeResponse,
} from "@agentclientprotocol/sdk"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import type { AssistantMessage, Message, OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2"
import type { Message, OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2"
import { Context, Effect, Layer, ManagedRuntime } from "effect"
import * as ACPError from "./error"
import { buildConfigOptions, parseModelSelection } from "./config-option"
@@ -314,6 +314,7 @@ export function make(input: {
yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers ?? [])
yield* sendAvailableCommands(input.connection, state.id, snapshot)
yield* replayMessages(events, messages)
return {
configOptions: configOptions(snapshot, {
@@ -520,7 +521,7 @@ export function make(input: {
"session",
)
yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd)
return yield* promptResponse(response.info, params.messageId)
return promptResponse(response.info, params.messageId)
}
const known = snapshot.availableCommands.find((item) => item.name === command.name)
@@ -542,7 +543,7 @@ export function make(input: {
"session",
)
yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd)
return yield* promptResponse(response.info, params.messageId)
return promptResponse(response.info, params.messageId)
}
if (command.name === "compact") {
@@ -562,7 +563,7 @@ export function make(input: {
}
yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd)
return yield* promptResponse(undefined, params.messageId)
return promptResponse(undefined, params.messageId)
}),
cancel,
}
@@ -694,8 +695,7 @@ type MessageInfo = {
readonly agent?: Message["agent"]
}
type AssistantError = NonNullable<AssistantMessage["error"]>
type AssistantInfo = (UsageService.AssistantTokenCost & Pick<AssistantMessage, "error">) | undefined
type AssistantInfo = UsageService.AssistantTokenCost | undefined
function request<T>(fn: () => Promise<T | SdkResponse<T>>, service?: string) {
return Effect.tryPromise({
@@ -811,60 +811,13 @@ function detectSlashCommand(parts: ReturnType<typeof promptContentToParts>) {
return { name, args: rest.join(" ").trim() }
}
const promptResponse = Effect.fn("ACP.promptResponse")(function* (
info: AssistantInfo,
messageId: string | null | undefined,
) {
if (!info?.error) {
return {
stopReason: "end_turn" as const,
...(info ? { usage: UsageService.buildUsage(info) } : {}),
...(messageId ? { userMessageId: messageId } : {}),
_meta: {},
}
}
const base = {
usage: UsageService.buildUsage(info),
function promptResponse(info: AssistantInfo, messageId: string | null | undefined): PromptResponse {
return {
stopReason: "end_turn",
...(info ? { usage: UsageService.buildUsage(info) } : {}),
...(messageId ? { userMessageId: messageId } : {}),
_meta: {},
}
if (info.error.name === "MessageAbortedError") {
return {
stopReason: "cancelled" as const,
...base,
}
}
if (info.error.name === "MessageOutputLengthError") {
return {
stopReason: "max_tokens" as const,
...base,
}
}
if (info.error.name === "ContentFilterError") {
return {
stopReason: "refusal" as const,
...base,
}
}
if (info.error.name === "ProviderAuthError") {
return yield* new ACPError.AuthRequiredError({ providerId: info.error.data.providerID })
}
return yield* new ACPError.ServiceFailureError({
service: "session",
safeMessage: promptErrorMessage(info.error),
errorName: info.error.name,
})
})
function promptErrorMessage(error: AssistantError) {
if ("message" in error.data && typeof error.data.message === "string") return error.data.message
return "OpenCode prompt failed"
}
function sendUsageUpdate(
+4 -1
View File
@@ -192,8 +192,11 @@ export function completedToolUpdate(input: {
return {
toolCallId: input.toolCallId,
status: "completed",
...(input.state.title ? { title: input.state.title } : {}),
kind: toToolKind(input.toolName),
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),
rawInput: rawInput(input.toolName, input.state.input, input.cwd),
rawOutput: completedToolRawOutput(input.state),
}
}
+3 -2
View File
@@ -3,7 +3,6 @@ import { UI } from "@/cli/ui"
import { errorMessage } from "@opencode-ai/tui/util/error"
import { validateSession } from "../tui/validate-session"
import { ServerAuth } from "@/server/auth"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
export const AttachCommand = cmd({
command: "attach <url>",
@@ -133,7 +132,7 @@ export const AttachCommand = cmd({
const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime")
await Effect.runPromise(
run({
client: createOpencodeClient({ baseUrl: args.url, headers, directory }),
url: args.url,
config,
pluginHost: createLegacyTuiPluginHost(),
args: {
@@ -141,6 +140,8 @@ export const AttachCommand = cmd({
sessionID: args.session,
fork: args.fork,
},
directory,
headers,
}),
)
},
@@ -338,6 +338,7 @@ export function turnSummaryWriter(input: { agent: string; model: string; duratio
() => (
<box width="100%" height={1}>
<text wrapMode="none" truncate>
<span style={{ fg: input.theme.block.highlight }}> </span>
<span style={{ fg: input.theme.block.text }}>{input.agent}</span>
<span style={{ fg: input.theme.block.muted }}>
{" "}
@@ -10,7 +10,7 @@ export function turnSummaryCommit(input: {
}): StreamCommit {
return {
kind: "system",
text: `${input.agent} · ${input.model} · ${input.duration}`,
text: `${input.agent} · ${input.model} · ${input.duration}`,
phase: "final",
source: "system",
summary: {
+57 -4
View File
@@ -8,7 +8,8 @@ import { errorMessage } from "@opencode-ai/tui/util/error"
import { withTimeout } from "@/util/timeout"
import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network"
import { Filesystem } from "@/util/filesystem"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import type { EventSource } from "@opencode-ai/tui/context/sdk"
import { writeHeapSnapshot } from "v8"
import { validateSession } from "../tui/validate-session"
import { win32InstallCtrlCGuard } from "@opencode-ai/tui/terminal-win32"
@@ -17,6 +18,36 @@ declare global {
const OPENCODE_WORKER_PATH: string
}
type RpcClient = ReturnType<typeof Rpc.client<typeof rpc>>
function createWorkerFetch(client: RpcClient): typeof fetch {
const fn = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const request = new Request(input, init)
const body = request.body ? await request.text() : undefined
const result = await client.call("fetch", {
url: request.url,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
body,
})
return new Response(result.body, {
status: result.status,
headers: result.headers,
})
}
return fn as typeof fetch
}
function createEventSource(client: RpcClient): EventSource {
return {
subscribe: async (handler) => {
return client.on<GlobalEvent>("global.event", (e) => {
handler(e)
})
},
}
}
async function target() {
if (typeof OPENCODE_WORKER_PATH !== "undefined") return OPENCODE_WORKER_PATH
const dist = new URL("./cli/tui/worker.js", import.meta.url)
@@ -180,13 +211,32 @@ export const TuiThreadCommand = cmd({
const config = await TuiConfig.get()
const network = resolveNetworkOptionsNoConfig(args)
const url = (await client.call("server", network)).url
const external =
process.argv.includes("--port") ||
process.argv.includes("--hostname") ||
process.argv.includes("--mdns") ||
network.mdns ||
network.port !== 0 ||
network.hostname !== "127.0.0.1"
const transport = external
? {
url: (await client.call("server", network)).url,
fetch: undefined,
events: undefined,
}
: {
url: "http://opencode.internal",
fetch: createWorkerFetch(client),
events: createEventSource(client),
}
try {
await validateSession({
url,
url: transport.url,
sessionID: args.session,
directory: cwd,
fetch: transport.fetch,
})
} catch (error) {
UI.error(errorMessage(error))
@@ -204,7 +254,7 @@ export const TuiThreadCommand = cmd({
const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime")
await Effect.runPromise(
run({
client: createOpencodeClient({ baseUrl: url, directory: cwd }),
url: transport.url,
async onSnapshot() {
const tui = writeHeapSnapshot("tui.heapsnapshot")
const server = await client.call("snapshot", undefined)
@@ -212,6 +262,9 @@ export const TuiThreadCommand = cmd({
},
config,
pluginHost: createLegacyTuiPluginHost(),
directory: cwd,
fetch: transport.fetch,
events: transport.events,
args: {
continue: args.continue,
sessionID: args.session,
+26
View File
@@ -3,6 +3,8 @@ import { InstanceRuntime } from "@/project/instance-runtime"
import { Rpc } from "@/util/rpc"
import { upgrade } from "@/cli/upgrade"
import { Config } from "@/config/config"
import { GlobalBus } from "@/bus/global"
import { ServerAuth } from "@/server/auth"
import { writeHeapSnapshot } from "node:v8"
import { Heap } from "@/cli/heap"
import { AppRuntime } from "@/effect/app-runtime"
@@ -18,9 +20,33 @@ const onUncaughtException = (_error: Error) => {}
process.on("unhandledRejection", onUnhandledRejection)
process.on("uncaughtException", onUncaughtException)
// Subscribe to global events and forward them via RPC
GlobalBus.on("event", (event) => {
Rpc.emit("global.event", event)
})
let server: Awaited<ReturnType<typeof Server.listen>> | undefined
export const rpc = {
async fetch(input: { url: string; method: string; headers: Record<string, string>; body?: string }) {
const headers = { ...input.headers }
const auth = ServerAuth.header()
if (auth && !headers["authorization"] && !headers["Authorization"]) {
headers["Authorization"] = auth
}
const request = new Request(input.url, {
method: input.method,
headers,
body: input.body,
})
const response = await Server.Default().app.fetch(request)
const body = await response.text()
return {
status: response.status,
headers: Object.fromEntries(response.headers.entries()),
body,
}
},
snapshot() {
const result = writeHeapSnapshot("server.heapsnapshot")
return result
+1 -9
View File
@@ -1,5 +1,4 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import path from "path"
import { InstanceState } from "@/effect/instance-state"
import { EffectBridge } from "@/effect/bridge"
import type { InstanceContext } from "@/project/instance-context"
@@ -133,19 +132,12 @@ export const layer = Layer.effect(
for (const item of yield* skill.all()) {
if (commands[item.name]) continue
const dir = item.location === "<built-in>" ? undefined : path.dirname(item.location)
commands[item.name] = {
name: item.name,
description: item.description,
source: "skill",
get template() {
if (!dir) return item.content
return [
item.content,
"",
`Base directory for this skill: ${dir}`,
"Relative paths in this skill (e.g., scripts/, references/) are relative to this base directory.",
].join("\n")
return item.content
},
hints: [],
}
+11 -13
View File
@@ -22,7 +22,7 @@ import { NamedError } from "@opencode-ai/core/util/error"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { withTimeout } from "@/util/timeout"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { McpOAuthPendingProvider, McpOAuthProvider, OAUTH_CALLBACK_PATH } from "./oauth-provider"
import { McpOAuthProvider, OAUTH_CALLBACK_PATH } from "./oauth-provider"
import { McpOAuthCallback } from "./oauth-callback"
import { McpAuth } from "./auth"
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
type TransportWithAuth = StreamableHTTPClientTransport | SSEClientTransport
const pendingOAuthTransports = new Map<string, { transport: TransportWithAuth; provider?: McpOAuthPendingProvider }>()
const pendingOAuthTransports = new Map<string, TransportWithAuth>()
// Prompt cache types
type PromptInfo = Awaited<ReturnType<MCPClient["listPrompts"]>>["prompts"][number]
@@ -301,7 +301,7 @@ export const layer = Layer.effect(
})
.pipe(Effect.ignore, Effect.as(undefined))
} else {
pendingOAuthTransports.set(key, { transport })
pendingOAuthTransports.set(key, transport)
lastStatus = { status: "needs_auth" as const }
return events
.publish(TuiEvent.ToastShow, {
@@ -819,7 +819,7 @@ export const layer = Layer.effect(
.join("")
yield* auth.updateOAuthState(mcpName, oauthState)
let capturedUrl: URL | undefined
const authProvider = new McpOAuthPendingProvider(
const authProvider = new McpOAuthProvider(
mcpName,
mcpConfig.url,
{
@@ -845,16 +845,15 @@ export const layer = Layer.effect(
return yield* Effect.tryPromise({
try: () => {
const client = createClient(directory)
return client.connect(transport).then(async () => {
await authProvider.commit()
return { authorizationUrl: "", oauthState, client } satisfies AuthResult
})
return client
.connect(transport)
.then(() => ({ authorizationUrl: "", oauthState, client }) satisfies AuthResult)
},
catch: (error) => error,
}).pipe(
Effect.catch((error) => {
if (error instanceof UnauthorizedError && capturedUrl) {
pendingOAuthTransports.set(mcpName, { transport, provider: authProvider })
pendingOAuthTransports.set(mcpName, transport)
return Effect.succeed({ authorizationUrl: capturedUrl.toString(), oauthState } satisfies AuthResult)
}
return Effect.die(error)
@@ -925,11 +924,11 @@ export const layer = Layer.effect(
const finishAuth = Effect.fn("MCP.finishAuth")(function* (mcpName: string, authorizationCode: string) {
yield* requireMcpConfig(mcpName)
const pending = pendingOAuthTransports.get(mcpName)
if (!pending) throw new Error(`No pending OAuth flow for MCP server: ${mcpName}`)
const transport = pendingOAuthTransports.get(mcpName)
if (!transport) throw new Error(`No pending OAuth flow for MCP server: ${mcpName}`)
const result = yield* Effect.tryPromise({
try: () => pending.transport.finishAuth(authorizationCode).then(() => true as const),
try: () => transport.finishAuth(authorizationCode).then(() => true as const),
catch: (error) => {
return error
},
@@ -939,7 +938,6 @@ export const layer = Layer.effect(
return { status: "failed", error: "OAuth completion failed" } satisfies Status
}
yield* Effect.promise(() => pending.provider?.commit() ?? Promise.resolve())
yield* auth.clearCodeVerifier(mcpName)
pendingOAuthTransports.delete(mcpName)
+9 -62
View File
@@ -25,11 +25,11 @@ export interface McpOAuthCallbacks {
export class McpOAuthProvider implements OAuthClientProvider {
constructor(
protected mcpName: string,
protected serverUrl: string,
protected config: McpOAuthConfig,
private mcpName: string,
private serverUrl: string,
private config: McpOAuthConfig,
private callbacks: McpOAuthCallbacks,
protected auth: McpAuth.Interface,
private auth: McpAuth.Interface,
) {}
get redirectUrl(): string {
@@ -53,6 +53,7 @@ export class McpOAuthProvider implements OAuthClientProvider {
}
async clientInformation(): Promise<OAuthClientInformation | undefined> {
// Check config first (pre-registered client)
if (this.config.clientId) {
return {
client_id: this.config.clientId,
@@ -163,7 +164,10 @@ export class McpOAuthProvider implements OAuthClientProvider {
async invalidateCredentials(type: "all" | "client" | "tokens"): Promise<void> {
const entry = await Effect.runPromise(this.auth.get(this.mcpName))
if (!entry) return
if (!entry) {
return
}
switch (type) {
case "all":
await Effect.runPromise(this.auth.remove(this.mcpName))
@@ -180,63 +184,6 @@ 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 }
/**
+5 -2
View File
@@ -1,7 +1,10 @@
import { createBuiltinPlugins, type BuiltinTuiPlugin } from "@opencode-ai/tui/builtins"
import type { RuntimeFlags } from "@/effect/runtime-flags"
export type InternalTuiPlugin = BuiltinTuiPlugin
export function internalTuiPlugins(): InternalTuiPlugin[] {
return createBuiltinPlugins()
export function internalTuiPlugins(flags: Pick<RuntimeFlags.Info, "experimentalEventSystem">): InternalTuiPlugin[] {
return createBuiltinPlugins({
experimentalEventSystem: flags.experimentalEventSystem,
})
}
+1 -1
View File
@@ -1089,7 +1089,7 @@ async function load(input: {
if (Flag.OPENCODE_PURE && pluginOrigins.length) {
}
for (const item of internalTuiPlugins()) {
for (const item of internalTuiPlugins(flags)) {
const entry = loadInternalPlugin(item)
const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id)
addPluginEntry(next, {
+10 -47
View File
@@ -13,7 +13,6 @@ const fileConcurrency = 8
class IndexSkill extends Schema.Class<IndexSkill>("IndexSkill")({
name: Schema.String,
files: Schema.Array(Schema.String),
version: Schema.optional(Schema.String),
}) {}
class Index extends Schema.Class<Index>("Index")({
@@ -77,53 +76,17 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Path.Path | Htt
(skill) =>
Effect.gen(function* () {
const root = path.join(cache, skill.name)
const versionFile = path.join(root, ".opencode-version")
const version = skill.version
const current =
version === undefined
? undefined
: yield* fs.readFileStringSafe(versionFile).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (version === undefined || current === version) {
yield* Effect.forEach(
skill.files,
(file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(root, file)),
{ concurrency: fileConcurrency, discard: true },
)
} else {
const token = crypto.randomUUID()
const staging = `${root}.tmp-${token}`
const backup = `${root}.old-${token}`
yield* Effect.gen(function* () {
const downloaded = yield* Effect.forEach(
skill.files,
(file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(staging, file)),
{ concurrency: fileConcurrency },
)
if (!downloaded.every(Boolean)) return
if (!(yield* fs.exists(path.join(staging, "SKILL.md")).pipe(Effect.orDie))) return
yield* fs.writeFileString(path.join(staging, ".opencode-version"), version)
yield* Effect.uninterruptible(
Effect.gen(function* () {
const cached = yield* fs.exists(root).pipe(Effect.orDie)
if (cached) yield* fs.rename(root, backup)
yield* fs.rename(staging, root).pipe(
Effect.catch((error) =>
Effect.gen(function* () {
if (cached) yield* fs.rename(backup, root).pipe(Effect.ignore)
return yield* Effect.fail(error)
}),
),
)
if (cached) yield* fs.remove(backup, { recursive: true, force: true }).pipe(Effect.ignore)
}),
)
}).pipe(
Effect.catch((error) => Effect.logError("failed to refresh skill", { skill: skill.name, error })),
Effect.ensuring(fs.remove(staging, { recursive: true, force: true }).pipe(Effect.ignore)),
)
}
return (yield* fs.exists(path.join(root, "SKILL.md")).pipe(Effect.orDie)) ? root : null
yield* Effect.forEach(
skill.files,
(file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(root, file)),
{
concurrency: fileConcurrency,
},
)
const md = path.join(root, "SKILL.md")
return (yield* fs.exists(md).pipe(Effect.orDie)) ? root : null
}),
{ concurrency: skillConcurrency },
)
+2 -2
View File
@@ -1,5 +1,6 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import path from "path"
import { pathToFileURL } from "url"
import { Effect, Layer, Context, Schema } from "effect"
import { NamedError } from "@opencode-ai/core/util/error"
import type { Agent } from "@/agent/agent"
@@ -16,7 +17,6 @@ import { RuntimeFlags } from "@/effect/runtime-flags"
import { Glob } from "@opencode-ai/core/util/glob"
import { Discovery } from "./discovery"
import { isRecord } from "@/util/record"
import { escapeHtml } from "@/util/html"
const CLAUDE_EXTERNAL_DIR = ".claude"
const AGENTS_EXTERNAL_DIR = ".agents"
@@ -339,7 +339,7 @@ export function fmt(list: Info[], opts: { verbose: boolean }) {
" <skill>",
` <name>${skill.name}</name>`,
` <description>${skill.description}</description>`,
` <location>${escapeHtml(skill.location)}</location>`,
` <location>${pathToFileURL(skill.location).href}</location>`,
" </skill>",
]),
"</available_skills>",
+3 -130
View File
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it } from "bun:test"
import { describe, expect, it } from "bun:test"
import type {
AgentSideConnection,
RequestPermissionRequest,
@@ -6,22 +6,13 @@ import type {
SessionUpdate,
} from "@agentclientprotocol/sdk"
import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2"
import { createTwoFilesPatch } from "diff"
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 { ACPSession } from "@/acp/session"
type PermissionEvent = Extract<Event, { type: "permission.asked" }>
type PermissionReplyParams = Parameters<OpencodeClient["permission"]["reply"]>[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 (
check: () => boolean | Promise<boolean>,
@@ -146,14 +137,6 @@ function textFromUpdates(updates: SessionUpdateParams[], sessionId: string) {
.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", () => {
it("sends requestPermission and replies with the selected outcome", async () => {
const harness = createHarness()
@@ -168,7 +151,7 @@ describe("acp permissions", () => {
toolCall: {
toolCallId: "call_1",
status: "pending",
title: "printf hello",
title: "bash",
rawInput: { command: "printf hello" },
kind: "execute",
locations: [],
@@ -182,116 +165,6 @@ describe("acp permissions", () => {
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 () => {
const harness = createHarness()
await createSession(harness.session, "ses_a")
@@ -316,7 +189,7 @@ describe("acp permissions", () => {
toolCall: {
toolCallId: "call_1",
status: "pending",
title: "Create external directory",
title: "external_directory",
rawInput: {
command: "mkdir -p /tmp/outside",
description: "Create external directory",
@@ -10,7 +10,7 @@ import type {
SessionConfigSelectOption,
SetSessionConfigOptionResponse,
} from "@agentclientprotocol/sdk"
import type { AssistantMessage, OpencodeClient } from "@opencode-ai/sdk/v2"
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { Effect } from "effect"
@@ -144,10 +144,7 @@ const provider: Provider.Info = {
describe("ACP service sessions", () => {
const makeService = (
messages: readonly { info: unknown; parts: readonly unknown[] }[] = [],
options?: {
abort?: (input: { sessionID: string }) => Promise<{ data: boolean }>
prompt?: (input: unknown) => Promise<{ data: { info: ReturnType<typeof assistantInfo> } }>
},
options?: { abort?: (input: { sessionID: string }) => Promise<{ data: boolean }> },
) => {
const updates: SessionNotification[] = []
const mcpAdds: string[] = []
@@ -196,21 +193,19 @@ describe("ACP service sessions", () => {
data: input.directory ? sessions.filter((session) => session.directory === input.directory) : sessions,
}),
messages: () => Promise.resolve({ data: messages }),
prompt:
options?.prompt ??
((input: unknown) => {
prompts.push(input)
return Promise.resolve({
data: {
info: assistantInfo({
input: 100,
output: 40,
reasoning: 7,
cache: { read: 11, write: 13 },
}),
},
})
}),
prompt: (input: unknown) => {
prompts.push(input)
return Promise.resolve({
data: {
info: assistantInfo({
input: 100,
output: 40,
reasoning: 7,
cache: { read: 11, write: 13 },
}),
},
})
},
command: (input: unknown) => {
commands.push(input)
return Promise.resolve({
@@ -394,29 +389,15 @@ describe("ACP service sessions", () => {
expect(second.sessions.map((session) => session.sessionId)).toEqual(["ses_2", "ses_1"])
})
it("resumes a session and stores restored state without replaying transcript chunks", async () => {
const { service, updates } = makeService([
it("resumes a session and stores restored state", async () => {
const { service } = makeService([
{
info: {
id: "msg_user",
sessionID: "ses_resume",
role: "user",
model: { providerID: "test", modelID: "test-model", variant: "high" },
agent: "plan",
},
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",
},
],
parts: [],
},
])
const resumed = await Effect.runPromise(
@@ -428,11 +409,6 @@ describe("ACP service sessions", () => {
expect(select(resumed, "effort")?.currentValue).toBe("high")
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 () => {
@@ -1018,52 +994,6 @@ describe("ACP service sessions", () => {
expect(usageUpdates).toEqual([session.sessionId])
})
it("maps assistant prompt errors to request errors instead of end turn", async () => {
const { service } = makeService([], {
prompt: () =>
Promise.resolve({
data: {
info: assistantInfo(
{ input: 8, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
{ name: "APIError", data: { message: "Provider request failed", isRetryable: false } },
),
},
}),
})
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
const error = await Effect.runPromise(
service
.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "hello" }] })
.pipe(Effect.mapError(ACPError.toRequestError), Effect.flip),
)
expect(error.code).toBe(-32603)
expect(error.message).toBe("Internal error: Provider request failed")
expect(error.data).toEqual({ service: "session", errorName: "APIError" })
})
it("maps aborted assistant prompt errors to cancelled", async () => {
const { service } = makeService([], {
prompt: () =>
Promise.resolve({
data: {
info: assistantInfo(
{ input: 8, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
{ name: "MessageAbortedError", data: { message: "Aborted" } },
),
},
}),
})
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
const result = await Effect.runPromise(
service.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "hello" }] }),
)
expect(result.stopReason).toBe("cancelled")
})
it("prompt maps assistant and user audience annotations", async () => {
const { service, prompts } = makeService()
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
@@ -1215,17 +1145,13 @@ describe("ACP service sessions", () => {
})
})
function assistantInfo(
tokens: UsageService.AssistantTokenCost["tokens"],
error?: AssistantMessage["error"],
): UsageService.AssistantMessage & Pick<AssistantMessage, "error"> {
function assistantInfo(tokens: UsageService.AssistantTokenCost["tokens"]): UsageService.AssistantMessage {
return {
role: "assistant",
providerID: "test",
modelID: "test-model",
cost: 0,
tokens,
...(error ? { error } : {}),
}
}
-81
View File
@@ -2,11 +2,9 @@ import { resolve } from "path"
import { describe, expect, test } from "bun:test"
import {
completedToolContent,
completedToolUpdate,
completedToolRawOutput,
extractImageAttachments,
imageContents,
pendingToolCall,
shellOutputSnapshot,
toLocations,
toToolKind,
@@ -113,85 +111,6 @@ 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", () => {
const output = [
"<path>/tmp/file.ts</path>",
@@ -119,7 +119,7 @@ test("turn summary starts at the left edge", async () => {
const commits = claim(out.renderer)
try {
expect(renderRows(commits.at(-1)!)[0]).toBe("Build · Little Frank · 2.2s")
expect(renderRows(commits.at(-1)!)[0]).toBe("Build · Little Frank · 2.2s")
} finally {
destroy(commits)
}
@@ -279,7 +279,7 @@ describe("run session replay", () => {
}),
expect.objectContaining({
kind: "system",
text: "Build · gpt-5 · 2.8s",
text: "Build · gpt-5 · 2.8s",
phase: "final",
source: "system",
messageID: "msg-1",
@@ -314,7 +314,7 @@ describe("run session replay", () => {
expect(out.commits.at(-1)).toEqual(
expect.objectContaining({
kind: "system",
text: "Build · Little Frank · 2.8s",
text: "Build · Little Frank · 2.8s",
summary: {
agent: "Build",
model: "Little Frank",
@@ -346,7 +346,7 @@ describe("run session replay", () => {
expect(out.commits.filter((commit) => commit.summary)).toEqual([
expect.objectContaining({
kind: "system",
text: "Build · gpt-5 · 2.0s",
text: "Build · gpt-5 · 2.0s",
messageID: "msg-step-2",
}),
])
+82
View File
@@ -0,0 +1,82 @@
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import type { EventSource } from "@opencode-ai/tui/context/sdk"
export const worktree = "/tmp/opencode"
export const directory = `${worktree}/packages/opencode`
export function json(data: unknown, init?: ResponseInit) {
return new Response(JSON.stringify(data), {
...init,
headers: { "content-type": "application/json", ...(init?.headers ?? {}) },
})
}
export function eventSource(): EventSource {
return { subscribe: async () => () => {} }
}
export function createEventSource() {
let fn: ((event: GlobalEvent) => void) | undefined
return {
source: {
subscribe: async (handler: (event: GlobalEvent) => void) => {
fn = handler
return () => {
if (fn === handler) fn = undefined
}
},
} satisfies EventSource,
emit(event: GlobalEvent) {
if (!fn) throw new Error("event source not ready")
fn(event)
},
}
}
export type FetchHandler = (url: URL) => Response | Promise<Response> | undefined
export function createFetch(override?: FetchHandler) {
const session = [] as URL[]
const fetch = (async (input: RequestInfo | URL) => {
const url = new URL(input instanceof Request ? input.url : String(input))
if (url.pathname === "/session") session.push(url)
const overridden = await override?.(url)
if (overridden) return overridden
switch (url.pathname) {
case "/agent":
case "/command":
case "/experimental/workspace":
case "/experimental/workspace/status":
case "/formatter":
case "/lsp":
return json([])
case "/config":
case "/experimental/resource":
case "/mcp":
case "/provider/auth":
case "/session/status":
return json({})
case "/config/providers":
return json({ providers: {}, default: {} })
case "/experimental/console":
return json({ consoleManagedProviders: [], switchableOrgCount: 0 })
case "/path":
return json({ home: "", state: "", config: "", worktree, directory })
case "/project/current":
return json({ id: "proj_test" })
case "/provider":
return json({ all: [], default: {}, connected: [] })
case "/session":
return json([])
case "/vcs":
return json({ branch: "main" })
}
throw new Error(`unexpected request: ${url.pathname}`)
}) as typeof globalThis.fetch
return { fetch, session }
}
@@ -33,7 +33,7 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"reasoning\",\"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\",\"id\":\"rs_0812d6cbe7a2b19b016a1214d32f6881998bcd9ff2e739d7f2\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEhTUCQT4XELlBu6r5VHqqtu5Il5WdX4m1upE8li0mPmIwgIykAmUTZWiE0213kmviuAgIrmhhiL4B8DXbWQD2vOEkQMhpZq_UCqc22SOg-4DpQLrebMWkzgAPL618VPu9mXNUIH9BW1sRhPdDSbbtK5_bitzsn-FMJGcO3UN7Ga2RW1Rdvt1M3m7J4MRlTutH8cwY8SthzgvOFEBS-_IrAhiwKVz4Se9Jlu3pVNMqhPF7kdrQOfDYui0v-AT8VrHBVomqekJl_dWESww0eWo6bS1PxZB4cLQHWp9JJi5pEECvU9Ntcz3GxuGJEtTKq5mFcRvCanXHOwZGmbBcWMNdVyikk3fxgIE2g9t8rCKJmhNXznMERtrfG2tey19qWbsVbo2YmBbg_5N02AA4NmEVvdfgHJx58nOfEEc2OZYk0YQ1fHBOkpBnwY61hxtrWFdj48QnTEKuvjAyNpX-KKFmMzL4531yLbEEzpaERlr11fDeoMpKofUoMsg3Jz8aTaZ1CpzI3O7iFzGDEV6gKh8vQYGrKOaOXnfBVDXDo8iJhZywpcQY6xB4NNf4pyyjFkR-vjgvBYV2hejlq2V1j8vQHgy8CsZJ6lW5oaTNMfP76MAHlwUwyMYj-cFmuX0epJdDWv8GDznUpOS-v2X5eNsvyx9qvvcTEMLsKJ--3_odisilj4vPhw16P9fB8eLmvESZmJRYmWM4mO7hPTVXOooOa-zxRHGhRQH9ouUea9UHSuH1A0o54qTEPr-JqYlQggugW449IuYW4HSMNMyeGdUNJfodWRu5cL0VPgk6zwTU3ArBq28FDgG7NZMk3njfCId351GZ8VRlTMA6U522_6FFaZ8-5gxsidOm0WULOwyTTo54tJsJFv2pgYUKs0VFWSwi3rvNMVMOgwOVIdSgZt1hFTxBImZh8HUIXUPvdOVKZzQmWT5M6uOTUsm5xsufhj8m79RuYZh2J0bkVOBzZ1As8zH-4v_r9d7e8464EuWXCln_6LAJdrTYgE2gVfHK0zeUaAMbIKhirOf0AVQZyfVsGvJ_CPqrPE_QSECeSA2D4TSa5Tc_IRY-Fb2_HKNCMEP2uvy\"},{\"type\":\"function_call\",\"call_id\":\"call_Ix5Urx04RtKsUJ75K0vTTgFF\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":{}}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_Ix5Urx04RtKsUJ75K0vTTgFF\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"instructions\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"prompt_cache_key\":\"session-recorded-openai-oauth-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"stream\":true}"
},
"response": {
"status": 200,
@@ -35,7 +35,7 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-5.2-codex\",\"input\":[{\"role\":\"system\",\"content\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"reasoning\",\"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\",\"id\":\"rs_0fdce240b46054ad016a1214d326848196b269feebe1844759\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEhTTGeallj_mC3ciDydiTVJLA6bjJfitoj4ftFfWwlxekFNaf_cDNWP3pE6qsvK9gKJNRfbAbpaEVf1qjAhQx53witrmt6H3KaaNJm3wXHG5sEi9gp3nLWK4T76tcVYHG1x6mbbTjEjCvhIuEkn_7Q7lJ1BErkEURYBBMPmkKya2-YuL8XP14Yrko9BA1t56BkwK5U3TFse4nwHI1qi82hdkX_aYAtz6YgbTpf-dvOCBGfeApxWLFotkt355Qy2b6MmPaH6cQwrvLJXOqEzGkwxFcs3mLEKLV103gd8Z5e_OapjJHTv_LarN-WN9C7nCQ0BBHClk4ND3SDdGb-XV665r23RB40GJ3Q9brJALGaJhij4uceXZNYbakZVOxgqLuDnX6EgABwEzrZb7vhVAKCewVYkLDu0LiS1rIvcFT8HpovxaBU2F2kVG7TRvzYewCW9zXWnAR048p5pUvi6zfMzapk8bnl4uM_uD45gp1sMzeSHryai1U0AUO2cLeQV1pA7KJoJBwWlHxo0YNPbDidI2KfByIoI0A7oiKoZ32vJkiwx3BEGePnzb-JQnv1eDXwlimICVKEVPk1BxpUZ2XBoWdUGYR77u5NGmZ2sKh4OM-qIaB0VaChGsCsJLyQ5_MCkeOm9EMjg1cXbIHDzs9jpF2BXlowY1Vw_L-Ve6nzwK7ZcyHM3ij27wEXYO2On6zbN_AqOvX_CFAjI7ktCYF2guftXuVpFCuiqRyDZ6i2RHXMhR77CoPT97sAvXDejN8feNtidqq4OH5uLa3BHYvW0UKfNlBCOL6A6927l4iTKURZznq_mVjLgTHWv9k-ByxP0hC5sIQHyB5hJaD8_svMr4Aqz_vH9Z8HShgjK47NsMQKxGGgaXdnq3xEdwydM-hTG4Pi35o6Kt0bbJ5KTRQ2ObjmnVTG7J__QTKMTrK2S6Ro4VIMrYzaai7BTLa8MGNotj\"},{\"type\":\"function_call\",\"call_id\":\"call_hwPdXfzZmrdySXU2ZmrL51Ln\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":{}}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_hwPdXfzZmrdySXU2ZmrL51Ln\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"prompt_cache_key\":\"session-recorded-opencode-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":32000,\"stream\":true}"
},
"response": {
"status": 200,
@@ -23,8 +23,6 @@ let simulateAuthFlow = true
let connectSucceedsImmediately = false
let serverCapabilities: { tools?: object; resources?: object } = { tools: {} }
let listToolsCalls = 0
let finishAuthFails = false
let finishAuthStoresCredentials = false
// Mock the transport constructors to simulate OAuth auto-auth on 401
void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
@@ -34,10 +32,6 @@ void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
state?: () => Promise<string>
redirectToAuthorization?: (url: URL) => 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
constructor(url: URL, options?: { authProvider?: unknown }) {
@@ -55,8 +49,6 @@ void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
// It calls auth() which eventually calls provider.state(), then
// provider.redirectToAuthorization(), then throws UnauthorizedError.
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
if (this.authProvider.state) {
await this.authProvider.state()
@@ -73,14 +65,7 @@ void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
}
throw new MockUnauthorizedError()
}
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() {}
async finishAuth(_code: string) {}
},
}))
@@ -140,8 +125,6 @@ beforeEach(() => {
connectSucceedsImmediately = false
serverCapabilities = { tools: {} }
listToolsCalls = 0
finishAuthFails = false
finishAuthStoresCredentials = false
})
// Import modules after mocking
@@ -150,7 +133,6 @@ const { EventV2Bridge } = await import("../../src/event-v2-bridge")
const { Config } = await import("../../src/config/config")
const { McpAuth } = await import("../../src/mcp/auth")
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 { CrossSpawnSpawner } = await import("@opencode-ai/core/cross-spawn-spawner")
@@ -245,59 +227,6 @@ 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(
"auth status only reports credentials stored for the configured server URL",
() =>
@@ -115,9 +115,10 @@ const storedSession = {
const openAIResponses = {
user: (text: string) => ({ role: "user", content: [{ type: "input_text", text }] }),
assistant: (text: string) => ({ role: "assistant", content: [{ type: "output_text", text }] }),
openaiReasoning: (text: string, encryptedContent: string) => ({
openaiReasoning: (text: string, options: { readonly itemId: string; readonly encryptedContent: string }) => ({
type: "reasoning",
encrypted_content: encryptedContent,
id: options.itemId,
encrypted_content: options.encryptedContent,
summary: [{ type: "summary_text", text }],
}),
}
@@ -656,7 +657,10 @@ describe("session.llm-native.request", () => {
expectedBody: {
input: [
openAIResponses.user("What changed?"),
openAIResponses.openaiReasoning("Checked the previous diff.", "encrypted-state"),
openAIResponses.openaiReasoning("Checked the previous diff.", {
itemId: "rs_1",
encryptedContent: "encrypted-state",
}),
openAIResponses.assistant("The parser changed."),
openAIResponses.user("Summarize it."),
],
@@ -679,7 +683,7 @@ describe("session.llm-native.request", () => {
],
providerOptions: { openai: { store: false, include: ["reasoning.encrypted_content"] } },
expectedBody: {
input: [{ type: "reasoning", summary: [], encrypted_content: "encrypted-state" }],
input: [{ type: "reasoning", id: "rs_1", summary: [], encrypted_content: "encrypted-state" }],
include: ["reasoning.encrypted_content"],
store: false,
},
@@ -11,10 +11,6 @@ import { testEffect } from "../lib/effect"
let CLOUDFLARE_SKILLS_URL: string
let server: ReturnType<typeof Bun.serve>
let downloadCount = 0
let mutableVersion = "1"
let mutableContent = "# Old"
let mutableDownloadCount = 0
let mutableFiles = ["SKILL.md"]
const fixturePath = path.join(import.meta.dir, "../fixture/skills")
const cacheDir = path.join(Global.Path.cache, "skills")
@@ -28,15 +24,6 @@ beforeAll(async () => {
async fetch(req) {
const url = new URL(req.url)
if (url.pathname === "/mutable/index.json") {
return Response.json({ skills: [{ name: "mutable", version: mutableVersion, files: mutableFiles }] })
}
if (url.pathname === "/mutable/mutable/SKILL.md") {
mutableDownloadCount++
return new Response(mutableContent)
}
if (url.pathname === "/mutable/mutable/old.md") return new Response("old reference")
// route /.well-known/skills/* to the fixture directory
if (url.pathname.startsWith("/.well-known/skills/")) {
const filePath = url.pathname.replace("/.well-known/skills/", "")
@@ -149,37 +136,4 @@ describe("Discovery.pull", () => {
expect(downloadCount).toBe(firstCount)
}),
)
it.live("refreshes a remote skill when its version changes", () =>
Effect.gen(function* () {
yield* Effect.promise(() => rm(cacheDir, { recursive: true, force: true }))
mutableVersion = "1"
mutableContent = "# Old"
mutableDownloadCount = 0
mutableFiles = ["SKILL.md", "old.md"]
const discovery = yield* Discovery.Service
const url = `http://localhost:${server.port}/mutable/`
const first = yield* discovery.pull(url)
expect(yield* Effect.promise(() => Bun.file(path.join(first[0], "SKILL.md")).text())).toBe("# Old")
mutableVersion = "2"
mutableContent = "# Partial"
mutableFiles = ["SKILL.md", "missing.md"]
const second = yield* discovery.pull(url)
expect(yield* Effect.promise(() => Bun.file(path.join(second[0], "SKILL.md")).text())).toBe("# Old")
expect(yield* Effect.promise(() => Bun.file(path.join(second[0], "old.md")).text())).toBe("old reference")
mutableVersion = "3"
mutableContent = "# New"
mutableFiles = ["SKILL.md"]
yield* discovery.pull(url)
expect(yield* Effect.promise(() => Bun.file(path.join(second[0], "SKILL.md")).text())).toBe("# New")
expect(yield* Effect.promise(() => Bun.file(path.join(second[0], "old.md")).exists())).toBe(false)
expect(mutableDownloadCount).toBe(3)
yield* discovery.pull(url)
expect(mutableDownloadCount).toBe(3)
}),
)
})
@@ -77,33 +77,6 @@ const withHome = <A, E, R>(home: string, self: Effect.Effect<A, E, R>) =>
)
describe("skill", () => {
it.effect("formats verbose locations as XML-safe filesystem paths", () =>
Effect.sync(() => {
const output = Skill.fmt(
[
{
name: "tagged-skill",
description: "A tagged skill.",
location: "/tmp/plugin.git#v1.3.0/SKILL.md",
content: "",
},
{
name: "built-in-skill",
description: "A built-in skill.",
location: "<built-in>",
content: "",
},
],
{ verbose: true },
)
expect(output).toContain("<location>/tmp/plugin.git#v1.3.0/SKILL.md</location>")
expect(output).toContain("<location>&lt;built-in&gt;</location>")
expect(output).not.toContain("file://")
expect(output).not.toContain("%23")
}),
)
it.live("discovers skills from .opencode/skill/ directory", () =>
provideTmpdirInstance(
(dir) =>
+2 -2
View File
@@ -1,6 +1,6 @@
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { SessionInput } from "@opencode-ai/schema/session-input"
import { PromptInput } from "@opencode-ai/schema/prompt-input"
import { Prompt } from "@opencode-ai/schema/prompt"
import { Session } from "@opencode-ai/schema/session"
import { Project } from "@opencode-ai/schema/project"
import { AbsolutePath, NonNegativeInt, PositiveInt, RelativePath, statics } from "@opencode-ai/schema/schema"
@@ -192,7 +192,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
params: { sessionID: Session.ID },
payload: Schema.Struct({
id: SessionMessage.ID.pipe(Schema.optional),
prompt: PromptInput.Prompt,
prompt: Prompt,
delivery: SessionInput.Delivery.pipe(Schema.optional),
resume: Schema.Boolean.pipe(Schema.optional),
}),
-1
View File
@@ -24,5 +24,4 @@ export { PtyTicket } from "./pty-ticket"
export { Question } from "./question"
export { Workspace } from "./workspace"
export { Prompt, Source, FileAttachment, AgentAttachment } from "./prompt"
export { PromptInput } from "./prompt-input"
export * from "./schema"
-26
View File
@@ -1,26 +0,0 @@
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" })
-4
View File
@@ -150,10 +150,6 @@ export const AssistantReasoning = Schema.Struct({
id: Schema.String,
text: Schema.String,
providerMetadata: ProviderMetadata.pipe(optional),
time: Schema.Struct({
created: DateTimeUtcFromMillis,
completed: DateTimeUtcFromMillis.pipe(optional),
}).pipe(optional),
}).annotate({ identifier: "Session.Message.Assistant.Reasoning" })
export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool]).pipe(
+2 -2
View File
@@ -142,7 +142,7 @@ import type {
ProjectListResponses,
ProjectUpdateErrors,
ProjectUpdateResponses,
PromptInput,
Prompt,
ProviderAuthErrors,
ProviderAuthResponses,
ProviderListErrors,
@@ -5621,7 +5621,7 @@ export class Session3 extends HeyApiClient {
parameters: {
sessionID: string
id?: string
prompt?: PromptInput
prompt?: Prompt
delivery?: "steer" | "queue"
resume?: boolean
},
+1 -18
View File
@@ -2700,12 +2700,6 @@ export type SessionNotFoundError = {
message: string
}
export type PromptInput = {
text: string
files?: Array<PromptInputFileAttachment>
agents?: Array<PromptAgentAttachment>
}
export type ConflictError = {
_tag: "ConflictError"
message: string
@@ -3820,13 +3814,6 @@ export type SessionV2Info = {
revert?: RevertState
}
export type PromptInputFileAttachment = {
uri: string
name?: string
description?: string
source?: PromptSource
}
export type SessionInputAdmitted = {
admittedSeq: number
id: string
@@ -3926,10 +3913,6 @@ export type SessionMessageAssistantReasoning = {
id: string
text: string
providerMetadata?: LlmProviderMetadata
time?: {
created: number
completed?: number
}
}
export type SessionMessageToolStatePending = {
@@ -12108,7 +12091,7 @@ export type V2SessionSwitchModelResponse = V2SessionSwitchModelResponses[keyof V
export type V2SessionPromptData = {
body: {
id?: string
prompt: PromptInput
prompt: Prompt
delivery?: "steer" | "queue"
resume?: boolean
}
+1 -42
View File
@@ -10594,7 +10594,7 @@
"pattern": "^msg_"
},
"prompt": {
"$ref": "#/components/schemas/PromptInput"
"$ref": "#/components/schemas/Prompt"
},
"delivery": {
"type": "string",
@@ -23533,28 +23533,6 @@
"required": ["_tag", "sessionID", "message"],
"additionalProperties": false
},
"PromptInput": {
"type": "object",
"properties": {
"text": {
"type": "string"
},
"files": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PromptInputFileAttachment"
}
},
"agents": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PromptAgentAttachment"
}
}
},
"required": ["text"],
"additionalProperties": false
},
"ConflictError": {
"type": "object",
"properties": {
@@ -26979,25 +26957,6 @@
"required": ["id", "projectID", "cost", "tokens", "time", "title", "location"],
"additionalProperties": false
},
"PromptInputFileAttachment": {
"type": "object",
"properties": {
"uri": {
"type": "string"
},
"name": {
"type": "string"
},
"description": {
"type": "string"
},
"source": {
"$ref": "#/components/schemas/PromptSource"
}
},
"required": ["uri"],
"additionalProperties": false
},
"SessionInputAdmitted": {
"type": "object",
"properties": {
+2 -11
View File
@@ -1,25 +1,16 @@
import { EventV2 } from "@opencode-ai/core/event"
import { PublicEventManifest } from "@opencode-ai/core/public-event-manifest"
import { Effect, Schema, Stream } from "effect"
import { Effect, Stream } from "effect"
import { HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import * as Sse from "effect/unstable/encoding/Sse"
import { Api } from "../api"
function eventData(data: unknown): Sse.Event {
const event = data as EventV2.Payload
const definition = PublicEventManifest.Latest.get(event.type)
const encoded = definition
? {
...event,
data: Schema.encodeUnknownSync(definition.data as Schema.Codec<unknown, unknown, never, never>)(event.data),
}
: event
return {
_tag: "Event",
event: "message",
id: undefined,
data: JSON.stringify(encoded),
data: JSON.stringify(data),
}
}
-1
View File
@@ -240,7 +240,6 @@ const en = {
"model.noPeersDescription": "Peer rankings appear after usage lands.",
"model.noUsageLastWeek": "No usage last week",
"model.newThisWeek": "New this week",
"model.sameAsPreviousWeek": "Same as previous week",
"model.vsPreviousWeek": "{{change}} vs previous week",
"model.pdf": "PDF",
"format.users": "users",
-1
View File
@@ -221,7 +221,6 @@ export const dict = {
"model.noPeersDescription": "تظهر ترتيبات النماذج المشابهة بعد وصول الاستخدام.",
"model.noUsageLastWeek": "لا يوجد استخدام الأسبوع الماضي",
"model.newThisWeek": "جديد هذا الأسبوع",
"model.sameAsPreviousWeek": "دون تغيير عن الأسبوع السابق",
"model.vsPreviousWeek": "{{change}} مقارنة بالأسبوع السابق",
"model.pdf": "PDF",
"format.users": "مستخدمون",
-1
View File
@@ -223,7 +223,6 @@ export const dict = {
"model.noPeersDescription": "Os rankings de pares aparecem depois que o uso chega.",
"model.noUsageLastWeek": "Sem uso na semana passada",
"model.newThisWeek": "Novo esta semana",
"model.sameAsPreviousWeek": "Igual à semana anterior",
"model.vsPreviousWeek": "{{change}} vs semana anterior",
"model.pdf": "PDF",
"format.users": "usuários",
-1
View File
@@ -222,7 +222,6 @@ export const dict = {
"model.noPeersDescription": "Ranglister over lignende modeller vises, når brug lander.",
"model.noUsageLastWeek": "Ingen brug sidste uge",
"model.newThisWeek": "Ny denne uge",
"model.sameAsPreviousWeek": "Samme som forrige uge",
"model.vsPreviousWeek": "{{change}} vs forrige uge",
"model.pdf": "PDF",
"format.users": "brugere",
-1
View File
@@ -223,7 +223,6 @@ export const dict = {
"model.noPeersDescription": "Vergleichsrankings erscheinen, nachdem Nutzung eingegangen ist.",
"model.noUsageLastWeek": "Keine Nutzung letzte Woche",
"model.newThisWeek": "Neu diese Woche",
"model.sameAsPreviousWeek": "Unverändert zur vorherigen Woche",
"model.vsPreviousWeek": "{{change}} ggü. vorheriger Woche",
"model.pdf": "PDF",
"format.users": "Nutzer",

Some files were not shown because too many files have changed in this diff Show More