mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-13 04:59:58 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4742b5aca2 |
@@ -331,6 +331,7 @@
|
||||
"minimatch": "10.2.5",
|
||||
"npm-package-arg": "13.0.2",
|
||||
"semver": "^7.6.3",
|
||||
"turndown": "7.2.0",
|
||||
"venice-ai-sdk-provider": "2.0.2",
|
||||
"which": "6.0.1",
|
||||
"xdg-basedir": "5.1.0",
|
||||
@@ -353,6 +354,7 @@
|
||||
"@types/npm-package-arg": "6.1.4",
|
||||
"@types/npmcli__arborist": "6.3.3",
|
||||
"@types/semver": "catalog:",
|
||||
"@types/turndown": "5.0.5",
|
||||
"@types/which": "3.0.4",
|
||||
"drizzle-kit": "catalog:",
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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()}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(", "),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"@types/npm-package-arg": "6.1.4",
|
||||
"@types/npmcli__arborist": "6.3.3",
|
||||
"@types/semver": "catalog:",
|
||||
"@types/turndown": "5.0.5",
|
||||
"@types/which": "3.0.4",
|
||||
"@parcel/watcher-darwin-arm64": "2.5.1",
|
||||
"@parcel/watcher-darwin-x64": "2.5.1",
|
||||
@@ -116,6 +117,7 @@
|
||||
"minimatch": "10.2.5",
|
||||
"npm-package-arg": "13.0.2",
|
||||
"semver": "^7.6.3",
|
||||
"turndown": "7.2.0",
|
||||
"venice-ai-sdk-provider": "2.0.2",
|
||||
"which": "6.0.1",
|
||||
"xdg-basedir": "5.1.0",
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
# HTML to Markdown renderer
|
||||
|
||||
## Goal
|
||||
|
||||
Replace Turndown and Domino in V2 Core only when an htmlparser2 event renderer preserves model-readable semantics and improves resource use and shipped size.
|
||||
|
||||
## Commands
|
||||
|
||||
- `bun run test tool-webfetch.test.ts` from `packages/core`
|
||||
- `bun build --entrypoints src/tool/html-markdown.ts --outdir <dir> --target node --format esm --minify`
|
||||
- `bun run build --single --skip-install` from `packages/cli`
|
||||
|
||||
## Metrics
|
||||
|
||||
- Primary: median conversion throughput after one warmup and nine measured runs.
|
||||
- Secondary: min/max spread, minified/gzip bundle size, CLI artifact size, and peak RSS where practical.
|
||||
|
||||
## Experiment Log
|
||||
|
||||
| Experiment | Hypothesis | Before | After | Decision |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Event renderer | Avoiding Domino's DOM lowers conversion cost while retaining semantics. | Turndown 4.23 MiB/s median (72.55 ms, 66.93-109.75) | Candidate 10.12 MiB/s median (30.32 ms, 22.29-83.55) | Keep: 2.39x throughput |
|
||||
| Safe fences | Fence length derived from code content prevents embedded backticks from closing blocks. | Turndown emitted triple fences around embedded triples | Candidate expands to four backticks | Keep |
|
||||
| Tables | Row/cell events retain tabular relationships better than flattened cell blocks. | Turndown flattened cells | Candidate emits GFM-readable tables | Keep |
|
||||
| Malformed inline blocks | Delimiters spanning implied block closes produce malformed Markdown. | Candidate left open emphasis | Candidate drops the delimiter and preserves visible text | Keep |
|
||||
|
||||
## Evaluation
|
||||
|
||||
Temporary snapshots from Example Domain, MDN's table reference, Python asyncio documentation, RFC 9110, and W3C's forms tutorial were evaluated on 2026-08-12. Candidate output retained the same heading counts on four sites and one additional visible MDN heading, the same link counts on three sites, two additional Python links, and the same fenced-code counts where Turndown recognized fences. Candidate output was 0-3.6% smaller; tables and preformatted code were more explicit. Snapshots and generated output are not committed.
|
||||
|
||||
The minified isolated evaluation bundle containing Turndown, Domino, htmlparser2, and both renderers was 311,557 bytes (98,680 gzip). The candidate renderer with htmlparser2 was 61,293 bytes (26,922 gzip). Installed Turndown plus Domino occupied 9,028 KiB; htmlparser2 was already required by Core.
|
||||
|
||||
The same-commit macOS arm64 CLI executable was 87,338,978 bytes with Turndown and 87,091,298 bytes with the candidate, a 247,680-byte reduction.
|
||||
|
||||
Real-site HTML is temporary evaluation data and is not committed.
|
||||
@@ -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,361 +0,0 @@
|
||||
import { Parser } from "htmlparser2"
|
||||
|
||||
const omitted = new Set(["script", "style", "noscript", "iframe", "object", "embed", "meta", "link", "template"])
|
||||
const blocks = new Set([
|
||||
"address",
|
||||
"article",
|
||||
"aside",
|
||||
"details",
|
||||
"dialog",
|
||||
"div",
|
||||
"dl",
|
||||
"fieldset",
|
||||
"figcaption",
|
||||
"figure",
|
||||
"footer",
|
||||
"form",
|
||||
"header",
|
||||
"main",
|
||||
"nav",
|
||||
"p",
|
||||
"section",
|
||||
"summary",
|
||||
])
|
||||
|
||||
type Frame = {
|
||||
tag: string
|
||||
suppressed: boolean
|
||||
link?: { href: string; title?: string }
|
||||
marker?: { index: number; block: number; value: string }
|
||||
code?: { inline: boolean; text: string; language?: string }
|
||||
list?: { ordered: boolean; next: number }
|
||||
table?: { cells: number; header: boolean; rows: number }
|
||||
cell?: { start: number }
|
||||
}
|
||||
|
||||
export function convertHTMLToMarkdown(html: string) {
|
||||
if (hasPathologicalDepth(html)) return extractPathologicalText(html)
|
||||
const output: string[] = []
|
||||
const stack: Frame[] = []
|
||||
let pendingSpace = false
|
||||
let last = ""
|
||||
let quoteDepth = 0
|
||||
let needsQuotePrefix = false
|
||||
let blockCount = 0
|
||||
let listDepth = 0
|
||||
let activeCode: NonNullable<Frame["code"]> | undefined
|
||||
let activeTable: NonNullable<Frame["table"]> | undefined
|
||||
let tableDepth = 0
|
||||
const raw: string[] = []
|
||||
|
||||
const append = (value: string) => {
|
||||
if (!value) return
|
||||
output.push(value)
|
||||
last = value.at(-1) ?? last
|
||||
}
|
||||
const prefixQuote = () => {
|
||||
if (!needsQuotePrefix || quoteDepth === 0) return
|
||||
append(`${"> ".repeat(Math.min(8, quoteDepth))}`)
|
||||
needsQuotePrefix = false
|
||||
}
|
||||
const flushSpace = () => {
|
||||
if (!pendingSpace) return
|
||||
const marker = stack.at(-1)?.marker
|
||||
if (marker && output.length === marker.index + 1 && last !== " " && last !== "\n") {
|
||||
output[marker.index] = ` ${output[marker.index]}`
|
||||
pendingSpace = false
|
||||
return
|
||||
}
|
||||
if (last && last !== "\n" && last !== " ") append(" ")
|
||||
pendingSpace = false
|
||||
}
|
||||
const inline = (value: string, open = false) => {
|
||||
if (open) flushSpace()
|
||||
prefixQuote()
|
||||
append(value)
|
||||
}
|
||||
const block = () => {
|
||||
pendingSpace = false
|
||||
append("\n\n")
|
||||
blockCount++
|
||||
needsQuotePrefix = quoteDepth > 0
|
||||
}
|
||||
const text = (value: string) => {
|
||||
if (activeCode) {
|
||||
activeCode.text += value
|
||||
return
|
||||
}
|
||||
for (const part of value.split(/([\t\n\f\r ]+)/)) {
|
||||
if (!part) continue
|
||||
if (/^[\t\n\f\r ]+$/.test(part)) {
|
||||
pendingSpace = true
|
||||
continue
|
||||
}
|
||||
flushSpace()
|
||||
prefixQuote()
|
||||
append(
|
||||
part
|
||||
.replace(/([\\`*_[\]<>|])/g, "\\$1")
|
||||
.replace(/~/g, "\\~")
|
||||
.replace(/^([#+-])/, "\\$1")
|
||||
.replace(/^(\d+)\./, "$1\\."),
|
||||
)
|
||||
}
|
||||
}
|
||||
const destination = (value: string) => value.replace(/([\\()])/g, "\\$1").replace(/[\t\n\r ]+/g, "%20")
|
||||
const title = (value: string | undefined) => (value ? ` "${value.replace(/([\\"])/g, "\\$1")}"` : "")
|
||||
const finishCode = (code: NonNullable<Frame["code"]>) => {
|
||||
let longest = 0
|
||||
let current = 0
|
||||
for (const character of code.text) {
|
||||
current = character === "`" ? current + 1 : 0
|
||||
longest = Math.max(longest, current)
|
||||
}
|
||||
const fence = "`".repeat(Math.max(code.inline ? 1 : 3, longest + 1))
|
||||
if (code.inline) {
|
||||
const padding = /^ | $/.test(code.text) && !/^ +$/.test(code.text) ? " " : ""
|
||||
flushSpace()
|
||||
inline(`${fence}${padding}${code.text}${padding}${fence}`)
|
||||
return
|
||||
}
|
||||
block()
|
||||
const value = `${fence}${code.language ?? ""}\n${code.text}${code.text.endsWith("\n") ? "" : "\n"}${fence}`
|
||||
const quoted = quoteDepth > 0 ? value.replace(/^/gm, `${"> ".repeat(Math.min(8, quoteDepth))}`) : value
|
||||
const placeholder = `\u0000${raw.length}\u0000`
|
||||
raw.push(quoted)
|
||||
append(placeholder)
|
||||
block()
|
||||
}
|
||||
|
||||
const parser = new Parser({
|
||||
onopentag(name, attributes) {
|
||||
const suppressed = (stack.at(-1)?.suppressed ?? false) || omitted.has(name)
|
||||
const frame: Frame = { tag: name, suppressed }
|
||||
stack.push(frame)
|
||||
if (suppressed) return
|
||||
|
||||
if (activeCode && !activeCode.inline) {
|
||||
if (name === "code" && attributes.class) activeCode.language = attributes.class.match(/(?:language-|lang-)([^\s]+)/)?.[1]
|
||||
return
|
||||
}
|
||||
if (name === "pre") {
|
||||
frame.code = { inline: false, text: "" }
|
||||
activeCode = frame.code
|
||||
return
|
||||
}
|
||||
if (name === "code") {
|
||||
frame.code = { inline: true, text: "" }
|
||||
activeCode = frame.code
|
||||
return
|
||||
}
|
||||
if (/^h[1-6]$/.test(name)) {
|
||||
block()
|
||||
inline(`${"#".repeat(Number(name[1]))} `)
|
||||
return
|
||||
}
|
||||
if (blocks.has(name)) {
|
||||
if (name === "p" && last === " ") return
|
||||
block()
|
||||
return
|
||||
}
|
||||
if (name === "br") {
|
||||
pendingSpace = false
|
||||
inline(" \n")
|
||||
needsQuotePrefix = quoteDepth > 0
|
||||
return
|
||||
}
|
||||
if (name === "hr") {
|
||||
block()
|
||||
inline("---")
|
||||
block()
|
||||
return
|
||||
}
|
||||
if (name === "strong" || name === "b") {
|
||||
inline("**", true)
|
||||
frame.marker = { index: output.length - 1, block: blockCount, value: "**" }
|
||||
return
|
||||
}
|
||||
if (name === "em" || name === "i") {
|
||||
inline("*", true)
|
||||
frame.marker = { index: output.length - 1, block: blockCount, value: "*" }
|
||||
return
|
||||
}
|
||||
if (name === "s" || name === "strike" || name === "del") {
|
||||
inline("~~", true)
|
||||
frame.marker = { index: output.length - 1, block: blockCount, value: "~~" }
|
||||
return
|
||||
}
|
||||
if (name === "a") {
|
||||
frame.link = { href: attributes.href ?? "", title: attributes.title }
|
||||
return inline(`[`, true)
|
||||
}
|
||||
if (name === "img") {
|
||||
inline(`![${(attributes.alt ?? "").replace(/([\\\]])/g, "\\$1")}](${destination(attributes.src ?? "")}${title(attributes.title)})`, true)
|
||||
return
|
||||
}
|
||||
if (name === "blockquote") {
|
||||
block()
|
||||
quoteDepth++
|
||||
needsQuotePrefix = true
|
||||
return
|
||||
}
|
||||
if (name === "ul" || name === "ol") {
|
||||
frame.list = { ordered: name === "ol", next: Number.parseInt(attributes.start ?? "1") || 1 }
|
||||
listDepth++
|
||||
block()
|
||||
return
|
||||
}
|
||||
if (name === "li") {
|
||||
block()
|
||||
const list = stack.findLast((item) => item.list)?.list
|
||||
const marker = list?.ordered ? `${list.next++}.` : "-"
|
||||
inline(`${" ".repeat(Math.min(8, Math.max(0, listDepth - 1)))}${marker} `)
|
||||
return
|
||||
}
|
||||
if (name === "table") {
|
||||
tableDepth++
|
||||
if (tableDepth === 1) {
|
||||
frame.table = { cells: 0, header: false, rows: 0 }
|
||||
activeTable = frame.table
|
||||
block()
|
||||
} else pendingSpace = true
|
||||
return
|
||||
}
|
||||
if (name === "tr") {
|
||||
if (tableDepth !== 1) {
|
||||
pendingSpace = true
|
||||
return
|
||||
}
|
||||
const table = activeTable
|
||||
pendingSpace = false
|
||||
if (table && table.rows > 0) {
|
||||
append("\n")
|
||||
needsQuotePrefix = quoteDepth > 0
|
||||
}
|
||||
inline("|")
|
||||
return
|
||||
}
|
||||
if (name === "th" || name === "td") {
|
||||
if (tableDepth !== 1) {
|
||||
pendingSpace = true
|
||||
return
|
||||
}
|
||||
const table = activeTable
|
||||
if (table) {
|
||||
table.cells++
|
||||
table.header ||= name === "th"
|
||||
}
|
||||
inline(" ")
|
||||
frame.cell = { start: output.length }
|
||||
}
|
||||
},
|
||||
ontext(value) {
|
||||
if (stack.at(-1)?.suppressed) return
|
||||
text(value)
|
||||
},
|
||||
onclosetag(name) {
|
||||
const frame = stack.pop()
|
||||
if (!frame || frame.suppressed) return
|
||||
if (frame.code) {
|
||||
activeCode = undefined
|
||||
return finishCode(frame.code)
|
||||
}
|
||||
if (name === "strong" || name === "b" || name === "em" || name === "i" || name === "s" || name === "strike" || name === "del") {
|
||||
const value = name === "strong" || name === "b" ? "**" : name === "em" || name === "i" ? "*" : "~~"
|
||||
if (frame.marker && frame.marker.block !== blockCount) {
|
||||
output[frame.marker.index] = ""
|
||||
return
|
||||
}
|
||||
if (frame.marker && output.length === frame.marker.index + 1) {
|
||||
output[frame.marker.index] = ""
|
||||
return
|
||||
}
|
||||
return inline(value)
|
||||
}
|
||||
if (name === "a") {
|
||||
return inline(`](${destination(frame.link?.href ?? "")}${title(frame.link?.title)})`)
|
||||
}
|
||||
if (/^h[1-6]$/.test(name) || blocks.has(name)) return block()
|
||||
if (name === "blockquote") {
|
||||
quoteDepth--
|
||||
return block()
|
||||
}
|
||||
if (name === "li") return block()
|
||||
if (name === "ul" || name === "ol") {
|
||||
listDepth--
|
||||
return block()
|
||||
}
|
||||
if ((name === "th" || name === "td") && tableDepth === 1) {
|
||||
if (frame.cell) {
|
||||
const value = output
|
||||
.splice(frame.cell.start)
|
||||
.join("")
|
||||
.replace(/[\t\r\n ]+/g, " ")
|
||||
.trim()
|
||||
.replace(/(?<!\\)\|/g, "\\|")
|
||||
append(value)
|
||||
}
|
||||
return inline(" |")
|
||||
}
|
||||
if (name === "tr") {
|
||||
if (tableDepth !== 1) return
|
||||
const table = activeTable
|
||||
if (table && table.rows === 0) {
|
||||
inline("\n")
|
||||
needsQuotePrefix = quoteDepth > 0
|
||||
inline(`|${" --- |".repeat(table.cells)}`)
|
||||
}
|
||||
if (table) {
|
||||
table.rows++
|
||||
table.cells = 0
|
||||
}
|
||||
return
|
||||
}
|
||||
if (name === "table") {
|
||||
tableDepth--
|
||||
if (tableDepth === 0) {
|
||||
activeTable = undefined
|
||||
return block()
|
||||
}
|
||||
pendingSpace = true
|
||||
}
|
||||
},
|
||||
})
|
||||
parser.write(html)
|
||||
parser.end()
|
||||
return output
|
||||
.join("")
|
||||
.replace(/[ \t]+\n/g, (value) => (value.startsWith(" ") ? " \n" : "\n"))
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim()
|
||||
.replace(/\u0000(\d+)\u0000/g, (_, index) => raw[Number(index)] ?? "")
|
||||
}
|
||||
|
||||
function hasPathologicalDepth(html: string) {
|
||||
let depth = 0
|
||||
for (const match of html.matchAll(/<\s*(\/)?\s*([a-z][\w:-]*)\b[^>]*>/gi)) {
|
||||
if (match[1]) depth = Math.max(0, depth - 1)
|
||||
else if (!/\/$/.test(match[0].slice(0, -1).trim()) && !["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"].includes(match[2].toLowerCase())) depth++
|
||||
if (depth > 10_000) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function extractPathologicalText(html: string) {
|
||||
let output = ""
|
||||
let suppressed = 0
|
||||
const parser = new Parser({
|
||||
onopentag(name) {
|
||||
if (suppressed > 0 || omitted.has(name)) suppressed++
|
||||
},
|
||||
ontext(value) {
|
||||
if (suppressed === 0) output += value
|
||||
},
|
||||
onclosetag() {
|
||||
if (suppressed > 0) suppressed--
|
||||
},
|
||||
})
|
||||
parser.write(html.replace(/<\/?(?:[^>]+)>/g, (tag) => (omitted.has(tag.match(/^<\/?\s*([^\s/>]+)/)?.[1]?.toLowerCase() ?? "") ? tag : " ")))
|
||||
parser.end()
|
||||
return output.replace(/[\t\n\f\r ]+/g, " ").trim()
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { Duration, Effect, Layer, Schema } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { Parser } from "htmlparser2"
|
||||
import TurndownService from "turndown"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { convertHTMLToMarkdown } from "./html-markdown"
|
||||
import { collectBoundedResponseBody } from "./http-body"
|
||||
import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
@@ -196,4 +196,14 @@ export function extractTextFromHTML(html: string) {
|
||||
return text.trim()
|
||||
}
|
||||
|
||||
export { convertHTMLToMarkdown }
|
||||
export function convertHTMLToMarkdown(html: string) {
|
||||
const turndown = new TurndownService({
|
||||
headingStyle: "atx",
|
||||
hr: "---",
|
||||
bulletListMarker: "-",
|
||||
codeBlockStyle: "fenced",
|
||||
emDelimiter: "*",
|
||||
})
|
||||
turndown.remove(["script", "style", "meta", "link"])
|
||||
return turndown.turndown(html)
|
||||
}
|
||||
|
||||
@@ -66,109 +66,9 @@ describe("WebFetchTool helpers", () => {
|
||||
})
|
||||
|
||||
test("ports HTML text and markdown conversions without active content", () => {
|
||||
const html =
|
||||
"<h1>Hello</h1><script>bad()</script><p>world <strong>wide</strong> <product-name>today</product-name></p><style>.bad {}</style>"
|
||||
expect(WebFetchTool.extractTextFromHTML(html)).toBe("Helloworld wide today")
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe("# Hello\n\nworld **wide** today")
|
||||
})
|
||||
|
||||
test("renders headings, inline semantics, links, images, breaks, and thematic breaks", () => {
|
||||
const html = `<h2>Read <em>this</em></h2><p><a href="https://example.com/a (b)" title="Example">docs</a><br><img src="diagram.png" alt="a ] b"></p><hr><p><del>old</del></p>`
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
|
||||
`## Read *this*\n\n[docs](https://example.com/a%20\\(b\\) "Example") \n![a \\] b](diagram.png)\n\n---\n\n~~old~~`,
|
||||
)
|
||||
})
|
||||
|
||||
test("preserves inline and preformatted code verbatim with safe fences", () => {
|
||||
const html = `<p>Use <code>say(\`hello\`)</code> now.</p><pre><code class="language-ts">const fence = \`\`\`\n& stays decoded</code></pre>`
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
|
||||
`Use \`\`say(\`hello\`)\`\` now.\n\n\`\`\`\`ts\nconst fence = \`\`\`\n& stays decoded\n\`\`\`\``,
|
||||
)
|
||||
})
|
||||
|
||||
test("keeps nested ordered and unordered lists structurally readable", () => {
|
||||
const html = `<ol start="3"><li>alpha<ul><li>nested <strong>item</strong></li></ul></li><li><p>beta first</p><p>beta second</p></li></ol>`
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
|
||||
`3. alpha\n\n - nested **item**\n\n4. beta first\n\nbeta second`,
|
||||
)
|
||||
})
|
||||
|
||||
test("renders blockquotes and tables as readable Markdown", () => {
|
||||
const html = `<blockquote><p>quoted <em>text</em></p><ul><li>point</li></ul></blockquote><table><thead><tr><th>Name</th><th>Value</th></tr></thead><tbody><tr><td>one</td><td><code>1</code></td></tr></tbody></table>`
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
|
||||
`> quoted *text*\n\n> - point\n\n| Name | Value |\n| --- | --- |\n| one | \`1\` |`,
|
||||
)
|
||||
})
|
||||
|
||||
test("decodes entities and normalizes prose whitespace without joining words", () => {
|
||||
const html = `<p>alpha\n <span>& beta</span> <unknown>café</unknown> gamma 😀</p><p>delta</p>`
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(`alpha & beta café gamma 😀\n\ndelta`)
|
||||
})
|
||||
|
||||
test("omits active and fallback content while retaining surrounding prose", () => {
|
||||
const html = `<p>before <script><b>bad</b></script><style>bad</style><noscript>bad</noscript><iframe>bad</iframe><object>bad</object><embed src="bad"><meta content="bad"><link href="bad"><template>bad</template> after</p>`
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe("before after")
|
||||
})
|
||||
|
||||
test("is deterministic and bounded for malformed maximum-size input", () => {
|
||||
const html = `<main><p>${"visible & text ".repeat(250_000)}</main></p></unknown>`
|
||||
const first = WebFetchTool.convertHTMLToMarkdown(html)
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(first)
|
||||
expect(first.startsWith("visible & text visible & text")).toBe(true)
|
||||
expect(first.length).toBeLessThanOrEqual(html.length)
|
||||
})
|
||||
|
||||
test("bounds deeply nested list output and fragmented code fences", () => {
|
||||
const lists = `${"<ul><li>item".repeat(2_000)}${"</li></ul>".repeat(2_000)}`
|
||||
const quotes = `${"<blockquote><p>item".repeat(2_000)}${"</p></blockquote>".repeat(2_000)}`
|
||||
const code = `<pre>${"` x ".repeat(250_000)}</pre>`
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(lists).length).toBeLessThan(lists.length * 4)
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(quotes).length).toBeLessThan(quotes.length * 4)
|
||||
expect(() => WebFetchTool.convertHTMLToMarkdown(code)).not.toThrow()
|
||||
expect(
|
||||
WebFetchTool.convertHTMLToMarkdown(
|
||||
"<div>".repeat(20_000) + "safe<script><b>bad</b>&</script><p>tail &</p>",
|
||||
),
|
||||
).toBe("safe tail &")
|
||||
})
|
||||
|
||||
test("escapes prose that would otherwise become Markdown structure", () => {
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(`<p># heading</p><p>1. item</p><p>---</p><p>a | b</p>`)).toBe(
|
||||
`\\# heading\n\n1\\. item\n\n\\---\n\na \\| b`,
|
||||
)
|
||||
})
|
||||
|
||||
test("preserves code whitespace and quotes every line of multiline blocks", () => {
|
||||
const html = `<blockquote><pre>line \n\n\nnext</pre><table><tr><td>a|b</td><td>c</td></tr></table></blockquote>`
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
|
||||
`> \`\`\`\n> line \n> \n> \n> next\n> \`\`\`\n\n> | a\\|b | c |\n> | --- | --- |`,
|
||||
)
|
||||
})
|
||||
|
||||
test("keeps visible whitespace around inline emphasis", () => {
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(`<p>a<strong> b</strong> c a <em>b </em>c</p>`)).toBe(
|
||||
`a **b** c a *b* c`,
|
||||
)
|
||||
})
|
||||
|
||||
test("normalizes multiline table cells without changing their columns", () => {
|
||||
const html = `<table><tr><td>x<br>y</td><td><code>a|b</code></td><td><p>first</p><p>second</p></td></tr></table>`
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
|
||||
`| x y | \`a\\|b\` | first second |\n| --- | --- | --- |`,
|
||||
)
|
||||
})
|
||||
|
||||
test("flattens nested tables without corrupting the outer table", () => {
|
||||
const html = `<table><tr><th>Parent</th><th>Sibling</th></tr><tr><td>Before<table><tr><th>Key</th><th>Value</th></tr><tr><td>A</td><td>1</td></tr></table>After</td><td>Tail</td></tr></table>`
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
|
||||
`| Parent | Sibling |\n| --- | --- |\n| Before Key Value A 1 After | Tail |`,
|
||||
)
|
||||
})
|
||||
|
||||
test("escapes tilde fences and removes empty emphasis markers", () => {
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(`<p>~~~</p><p><strong></strong>content</p><p>~~~</p>`)).toBe(
|
||||
`\\~\\~\\~\n\ncontent\n\n\\~\\~\\~`,
|
||||
)
|
||||
const html = "<h1>Hello</h1><script>bad()</script><p>world <strong>wide</strong></p><style>.bad {}</style>"
|
||||
expect(WebFetchTool.extractTextFromHTML(html)).toBe("Helloworld wide")
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe("# Hello\n\nworld **wide**")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -276,7 +176,7 @@ describe("WebFetchTool registration", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("converts deeply nested HTML without overflowing", () =>
|
||||
it.effect("returns an error result when HTML-to-Markdown conversion throws", () =>
|
||||
Effect.gen(function* () {
|
||||
reset()
|
||||
respond = () =>
|
||||
@@ -289,8 +189,8 @@ describe("WebFetchTool registration", () => {
|
||||
const url = "https://1.1.1.1/deep-html"
|
||||
|
||||
expect(yield* executeTool(registry, call({ url, format: "markdown" }))).toEqual({
|
||||
type: "text",
|
||||
value: "content",
|
||||
type: "error",
|
||||
value: `Unable to fetch ${url}`,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -394,29 +394,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 +414,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 () => {
|
||||
|
||||
@@ -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>",
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+1
-1
@@ -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,
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { checksum } from "@opencode-ai/core/util/encode"
|
||||
import DOMPurify from "dompurify"
|
||||
import type { MarkdownToken } from "./markdown-worker-protocol"
|
||||
import { project } from "./markdown-stream"
|
||||
|
||||
export type MarkdownCacheEntry = {
|
||||
@@ -8,8 +9,18 @@ export type MarkdownCacheEntry = {
|
||||
html: string
|
||||
}
|
||||
|
||||
export type MarkdownCodeCacheEntry = {
|
||||
raw: string
|
||||
hash: string
|
||||
language: string
|
||||
generation: number
|
||||
stable: MarkdownToken[]
|
||||
unstable: MarkdownToken[]
|
||||
}
|
||||
|
||||
const max = 200
|
||||
const cache = new Map<string, MarkdownCacheEntry>()
|
||||
const codeCache = new Map<string, MarkdownCodeCacheEntry>()
|
||||
const config = {
|
||||
USE_PROFILES: { html: true, mathMl: true },
|
||||
SANITIZE_NAMED_PROPS: true,
|
||||
@@ -52,6 +63,21 @@ export function touchCachedMarkdown(key: string, value: MarkdownCacheEntry) {
|
||||
cache.delete(first)
|
||||
}
|
||||
|
||||
export function getCachedMarkdownCode(key: string) {
|
||||
return codeCache.get(key)
|
||||
}
|
||||
|
||||
export function touchCachedMarkdownCode(key: string, value: MarkdownCodeCacheEntry) {
|
||||
codeCache.delete(key)
|
||||
codeCache.set(key, value)
|
||||
|
||||
if (codeCache.size <= max) return
|
||||
|
||||
const first = codeCache.keys().next().value
|
||||
if (!first) return
|
||||
codeCache.delete(first)
|
||||
}
|
||||
|
||||
export async function preloadMarkdown(
|
||||
text: string,
|
||||
cacheKey: string,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { preloadMarkdown } from "./markdown-cache"
|
||||
import { getCachedMarkdownCode, preloadMarkdown, touchCachedMarkdownCode } from "./markdown-cache"
|
||||
|
||||
test("preloads completed markdown into the render cache", async () => {
|
||||
const parsed: string[] = []
|
||||
@@ -16,3 +16,24 @@ test("preloads completed markdown into the render cache", async () => {
|
||||
|
||||
expect(parsed).toEqual(["prepared response"])
|
||||
})
|
||||
|
||||
test("keeps completed code highlights by stable block key", () => {
|
||||
const key = `markdown-code-${crypto.randomUUID()}:0:code`
|
||||
touchCachedMarkdownCode(key, {
|
||||
raw: "```ts\nconst value = 1\n```",
|
||||
hash: "23",
|
||||
language: "ts",
|
||||
generation: 1,
|
||||
stable: [["const", "color: red"]],
|
||||
unstable: [],
|
||||
})
|
||||
|
||||
expect(getCachedMarkdownCode(key)).toEqual({
|
||||
raw: "```ts\nconst value = 1\n```",
|
||||
hash: "23",
|
||||
language: "ts",
|
||||
generation: 1,
|
||||
stable: [["const", "color: red"]],
|
||||
unstable: [],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,7 +24,14 @@ import {
|
||||
} from "./markdown-worker"
|
||||
import { markdownBlockKey, type MarkdownToken } from "./markdown-worker-protocol"
|
||||
import { shouldResetCodeTokens, type RenderedCodeState } from "./markdown-code-state"
|
||||
import { getCachedMarkdown, sanitizeMarkdown, touchCachedMarkdown, type MarkdownCacheEntry } from "./markdown-cache"
|
||||
import {
|
||||
getCachedMarkdown,
|
||||
getCachedMarkdownCode,
|
||||
sanitizeMarkdown,
|
||||
touchCachedMarkdown,
|
||||
touchCachedMarkdownCode,
|
||||
type MarkdownCacheEntry,
|
||||
} from "./markdown-cache"
|
||||
import { inlineCodeKind } from "./markdown-inline-code-kind"
|
||||
|
||||
type RenderedBlock =
|
||||
@@ -66,8 +73,12 @@ function fallback(markdown: string) {
|
||||
return escape(markdown).replace(/\r\n?/g, "\n").replace(/\n/g, "<br>")
|
||||
}
|
||||
|
||||
function highlightLanguage(language: string | undefined) {
|
||||
return language && language in bundledLanguages ? language : "text"
|
||||
}
|
||||
|
||||
async function code(text: string, language: string | undefined, key: string, complete = false) {
|
||||
const name = language && language in bundledLanguages ? language : "text"
|
||||
const name = highlightLanguage(language)
|
||||
try {
|
||||
const result = await highlightStreamingCode(key, text, name, complete)
|
||||
return { language: name, generation: result.generation, stable: result.stable, unstable: result.unstable }
|
||||
@@ -297,9 +308,15 @@ function initialResult(text: string, key: string | undefined, projection: Projec
|
||||
if (!text) return { text, blocks: [] }
|
||||
const base = key ?? checksum(text)
|
||||
if (base) {
|
||||
const blocks = projection.blocks.flatMap((block, index) => {
|
||||
if (block.mode === "code") return []
|
||||
const blocks = projection.blocks.flatMap((block, index): RenderedBlock[] => {
|
||||
const cacheKey = `${base}:${index}:${block.mode}`
|
||||
if (block.mode === "code") {
|
||||
if (!block.complete) return []
|
||||
const cached = getCachedMarkdownCode(cacheKey)
|
||||
if (cached?.raw !== block.raw) return []
|
||||
touchCachedMarkdownCode(cacheKey, cached)
|
||||
return [{ key: `${owner}:${cacheKey}`, mode: block.mode, complete: true, ...cached }]
|
||||
}
|
||||
const cached = getCachedMarkdown(cacheKey)
|
||||
if (cached?.raw !== block.raw) return []
|
||||
return [{ key: `${owner}:${cacheKey}`, mode: block.mode, ...cached }]
|
||||
@@ -370,9 +387,17 @@ export function Markdown(
|
||||
const blockKey = markdownBlockKey(owner, src.key, index, block.mode)
|
||||
|
||||
if (block.mode === "code") {
|
||||
const language = highlightLanguage(block.language)
|
||||
if (block.complete && key) {
|
||||
const shared = getCachedMarkdownCode(key)
|
||||
if (shared?.raw === block.raw && shared.language === language) {
|
||||
touchCachedMarkdownCode(key, shared)
|
||||
return { key: blockKey, mode: block.mode, complete: true, ...shared }
|
||||
}
|
||||
}
|
||||
const cached = completedCode.get(blockKey)
|
||||
if (block.complete && cached?.raw === block.raw) return cached
|
||||
const result = await code(block.src, block.language, blockKey, block.complete)
|
||||
const result = await code(block.src, language, blockKey, block.complete)
|
||||
const rendered = {
|
||||
key: blockKey,
|
||||
mode: block.mode,
|
||||
@@ -381,7 +406,18 @@ export function Markdown(
|
||||
complete: !!block.complete,
|
||||
...result,
|
||||
}
|
||||
if (block.complete) completedCode.set(blockKey, rendered)
|
||||
if (block.complete) {
|
||||
completedCode.set(blockKey, rendered)
|
||||
if (key && rendered.generation > 0)
|
||||
touchCachedMarkdownCode(key, {
|
||||
raw: rendered.raw,
|
||||
hash: rendered.hash,
|
||||
language: rendered.language,
|
||||
generation: rendered.generation,
|
||||
stable: rendered.stable,
|
||||
unstable: rendered.unstable,
|
||||
})
|
||||
}
|
||||
return rendered
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
type ModelCatalogEntry,
|
||||
} from "../model-catalog"
|
||||
import { runStatsEffect } from "../../stats-runtime"
|
||||
import { setStatsPageCacheHeaders } from "../stats-cache"
|
||||
import {
|
||||
applyThemePreference,
|
||||
Footer,
|
||||
@@ -87,7 +86,7 @@ export default function StatsModel() {
|
||||
const i18n = useI18n()
|
||||
const language = useLanguage()
|
||||
const event = getRequestEvent()
|
||||
setStatsPageCacheHeaders(event?.response.headers)
|
||||
event?.response.headers.set("Cache-Control", "public, max-age=60, s-maxage=300, stale-while-revalidate=86400")
|
||||
const params = useParams()
|
||||
const labParam = createMemo(() => params.lab ?? "")
|
||||
const modelParam = createMemo(() => params.model ?? "")
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
type ModelCatalogLab,
|
||||
} from "../model-catalog"
|
||||
import { runStatsEffect } from "../../stats-runtime"
|
||||
import { setStatsPageCacheHeaders } from "../stats-cache"
|
||||
import {
|
||||
applyThemePreference,
|
||||
Footer,
|
||||
@@ -44,7 +43,7 @@ export default function StatsLab() {
|
||||
const i18n = useI18n()
|
||||
const language = useLanguage()
|
||||
const event = getRequestEvent()
|
||||
setStatsPageCacheHeaders(event?.response.headers)
|
||||
event?.response.headers.set("Cache-Control", "public, max-age=60, s-maxage=300, stale-while-revalidate=86400")
|
||||
const params = useParams()
|
||||
const labParam = createMemo(() => params.lab ?? "")
|
||||
const catalog = createAsync(() => getModelCatalog())
|
||||
|
||||
@@ -32,7 +32,6 @@ import { useI18n } from "../context/i18n"
|
||||
import { useLanguage } from "../context/language"
|
||||
import { localizedUrl } from "../lib/language"
|
||||
import { findModelCatalogEntry, getModelCatalog, type ModelCatalog } from "./model-catalog"
|
||||
import { setStatsPageCacheHeaders } from "./stats-cache"
|
||||
import {
|
||||
applyThemePreference,
|
||||
Footer,
|
||||
@@ -122,7 +121,7 @@ export default function StatsHome() {
|
||||
const i18n = useI18n()
|
||||
const language = useLanguage()
|
||||
const event = getRequestEvent()
|
||||
setStatsPageCacheHeaders(event?.response.headers)
|
||||
event?.response.headers.set("Cache-Control", "public, max-age=60, s-maxage=300, stale-while-revalidate=86400")
|
||||
const statsHomeUrl = localizedUrl(language.locale(), "/data/")
|
||||
const statsUnfurlUrl = new URL(statsUnfurlPath, localizedUrl("en", "/data/")).toString()
|
||||
const data = createAsync(() => getData())
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
const statsPageCacheControl = "public, max-age=60, s-maxage=300, stale-while-revalidate=86400"
|
||||
|
||||
export function setStatsPageCacheHeaders(headers: Headers | undefined) {
|
||||
if (!headers) return
|
||||
|
||||
headers.set("Cache-Control", statsPageCacheControl)
|
||||
appendVary(headers, "Accept-Language", "Cookie")
|
||||
}
|
||||
|
||||
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(", "),
|
||||
)
|
||||
}
|
||||
@@ -53,22 +53,6 @@ const icons = {
|
||||
viewBox: "0 0 16 16",
|
||||
body: `<path d="M3.53613 8.17857L6.39328 11.75L12.4647 4.25" stroke="currentColor"/>`,
|
||||
},
|
||||
monitor: {
|
||||
viewBox: "0 0 16 16",
|
||||
body: `<path d="M4.05559 9.38889H0.500007C0.500007 9.38889 0.500017 8.59298 0.500017 7.61112V2.27778C0.500017 1.29594 0.500102 0.5 0.500102 0.5H13.3889C13.3889 0.5 13.3889 1.29594 13.3889 2.27778V7.61112C13.3889 8.59298 13.3889 9.38889 13.3889 9.38889H9.83336M4.05559 9.38889V11.6111H6.94448H9.83336V9.38889M4.05559 9.38889H9.83336" transform="translate(1.05556 1.94444)" stroke="currentColor"/>`,
|
||||
},
|
||||
"workspace-new": {
|
||||
viewBox: "0 0 16 16",
|
||||
body: `<path d="M2 10.7578V14.0011H5.24324M13.9991 5.24324V2H10.7559M13.9991 10.7578V14.0011H10.7559M2 5.24324V2H5.24324" stroke="currentColor" stroke-miterlimit="10" stroke-linecap="square"/><path d="M8 4.5V11.5M4.5 8H11.5" stroke="currentColor" stroke-linejoin="round"/>`,
|
||||
},
|
||||
"workspace-isolated": {
|
||||
viewBox: "0 0 16 16",
|
||||
body: `<path d="M10.5 10.5V5.5H5.5V10.5H10.5Z" fill="currentColor"/><rect x="2.5" y="2.5" width="11" height="11" stroke="currentColor"/>`,
|
||||
},
|
||||
workspace: {
|
||||
viewBox: "0 0 16 16",
|
||||
body: `<path d="M2 10.668V14.0013H10.6667M13.9974 10.6667V2H2.66406M13.9974 10.668V14.0013H10.6641M2 10V2H5.33333" stroke="currentColor" stroke-miterlimit="10" stroke-linecap="square"/><path d="M10.6693 10.6654V5.33203H5.33594V10.6654H10.6693Z" fill="currentColor"/>`,
|
||||
},
|
||||
close: {
|
||||
viewBox: "0 0 20 20",
|
||||
body: `<path d="M14.4446 5.55566L5.55566 14.4446M5.55566 5.55566L14.4446 14.4446" stroke="currentColor" stroke-linejoin="round"/>`,
|
||||
|
||||
@@ -3,7 +3,8 @@ title: Enterprise
|
||||
description: Using OpenCode securely in your organization.
|
||||
---
|
||||
|
||||
export const enterprise = "https://opencode.ai/enterprise"
|
||||
import config from "../../../config.mjs"
|
||||
export const email = `mailto:${config.email}`
|
||||
|
||||
OpenCode Enterprise is for organizations that want to ensure that their code and data never leaves their infrastructure. It can do this by using a centralized config that integrates with your SSO and internal AI gateway.
|
||||
|
||||
@@ -14,7 +15,7 @@ OpenCode does not store any of your code or context data.
|
||||
To get started with OpenCode Enterprise:
|
||||
|
||||
1. Do a trial internally with your team.
|
||||
2. **<a href={enterprise}>Contact us</a>** to discuss pricing and implementation options.
|
||||
2. **<a href={email}>Contact us</a>** to discuss pricing and implementation options.
|
||||
|
||||
---
|
||||
|
||||
@@ -62,14 +63,14 @@ We recommend you disable this for your trial.
|
||||
|
||||
## Pricing
|
||||
|
||||
We use a per-seat model for OpenCode Enterprise. If you have your own LLM gateway, we do not charge for tokens used. For further details about pricing and implementation options, **<a href={enterprise}>contact us</a>**.
|
||||
We use a per-seat model for OpenCode Enterprise. If you have your own LLM gateway, we do not charge for tokens used. For further details about pricing and implementation options, **<a href={email}>contact us</a>**.
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
Once you have completed your trial and you are ready to use OpenCode at
|
||||
your organization, you can **<a href={enterprise}>contact us</a>** to discuss
|
||||
your organization, you can **<a href={email}>contact us</a>** to discuss
|
||||
pricing and implementation options.
|
||||
|
||||
---
|
||||
@@ -103,7 +104,7 @@ You can also disable all other AI providers, ensuring all requests go through yo
|
||||
While we recommend disabling the share pages to ensure your data never leaves
|
||||
your organization, we can also help you self-host them on your infrastructure.
|
||||
|
||||
This is currently on our roadmap. If you're interested, **<a href={enterprise}>let us know</a>**.
|
||||
This is currently on our roadmap. If you're interested, **<a href={email}>let us know</a>**.
|
||||
|
||||
---
|
||||
|
||||
@@ -121,14 +122,14 @@ OpenCode Enterprise is for organizations that want to ensure that their code and
|
||||
|
||||
Simply start with an internal trial with your team. OpenCode by default does not store your code or context data, making it easy to get started.
|
||||
|
||||
Then **<a href={enterprise}>contact us</a>** to discuss pricing and implementation options.
|
||||
Then **<a href={email}>contact us</a>** to discuss pricing and implementation options.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>How does enterprise pricing work?</summary>
|
||||
|
||||
We offer per-seat enterprise pricing. If you have your own LLM gateway, we do not charge for tokens used. For further details, **<a href={enterprise}>contact us</a>** for a custom quote based on your organization's needs.
|
||||
We offer per-seat enterprise pricing. If you have your own LLM gateway, we do not charge for tokens used. For further details, **<a href={email}>contact us</a>** for a custom quote based on your organization's needs.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -48,42 +48,6 @@ You can customize the base URL for any provider by setting the `baseURL` option.
|
||||
|
||||
---
|
||||
|
||||
#### Hiding models
|
||||
|
||||
You can hide specific models from the `/models` picker for a provider using the `blacklist` option. This is useful when a provider exposes models you don't want to use or select.
|
||||
|
||||
```json title="opencode.json" {6}
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {
|
||||
"anthropic": {
|
||||
"blacklist": ["claude-opus-4-20250514"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The inverse `whitelist` option hides every model except the ones listed.
|
||||
|
||||
```json title="opencode.json" {6}
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {
|
||||
"anthropic": {
|
||||
"whitelist": ["claude-sonnet-4-20250514"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Both options take an array of model IDs — the same IDs shown in the `/models` picker.
|
||||
|
||||
- `blacklist` removes the listed models from the picker.
|
||||
- `whitelist` keeps only the listed models and hides the rest.
|
||||
- You can combine them: `whitelist` narrows the set, then `blacklist` removes entries from it.
|
||||
|
||||
---
|
||||
|
||||
## OpenCode Zen
|
||||
|
||||
OpenCode Zen is a list of models provided by the OpenCode team that have been
|
||||
|
||||
Reference in New Issue
Block a user