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